@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,356 @@
1
+ """
2
+ Ambiguity Detection Patterns
3
+
4
+ Defines keyword patterns and heuristics for detecting
5
+ ambiguous user prompts that require clarification.
6
+ """
7
+
8
+ from typing import List, Dict, Any, Optional
9
+ import re
10
+
11
+
12
+ class AmbiguityPattern:
13
+ """Base class for ambiguity detection patterns."""
14
+
15
+ def __init__(self, name: str, keywords: List[str], weight: int):
16
+ """
17
+ Args:
18
+ name: Pattern name (e.g., "service_ambiguity")
19
+ keywords: Keywords that trigger this pattern
20
+ weight: Ambiguity weight (0-100)
21
+ """
22
+ self.name = name
23
+ self.keywords = keywords
24
+ self.weight = weight
25
+
26
+ def detect(self, prompt: str, project_context: Dict[str, Any]) -> Optional[Dict[str, Any]]:
27
+ """
28
+ Detect ambiguity in prompt.
29
+
30
+ Returns:
31
+ None if no ambiguity, otherwise:
32
+ {
33
+ "pattern": str,
34
+ "detected_keyword": str,
35
+ "ambiguity_reason": str,
36
+ "available_options": List[str],
37
+ "suggested_question": str,
38
+ "weight": int,
39
+ "allow_multiple": bool
40
+ }
41
+ """
42
+ raise NotImplementedError
43
+
44
+
45
+ class ServiceAmbiguityPattern(AmbiguityPattern):
46
+ """Detects ambiguous service references."""
47
+
48
+ def __init__(self):
49
+ super().__init__(
50
+ name="service_ambiguity",
51
+ keywords=[
52
+ "the api", "el api", "la api", "api service", "the service",
53
+ "el servicio", "the app", "la app", "the web", "web service",
54
+ "the bot", "el bot", "the jobs", "los jobs", "el worker",
55
+ "check service", "chequea servicio", "deploy service",
56
+ "service status", "estado del servicio"
57
+ ],
58
+ weight=80 # High weight - very ambiguous
59
+ )
60
+
61
+ def detect(self, prompt: str, project_context: Dict[str, Any]) -> Optional[Dict[str, Any]]:
62
+ prompt_lower = prompt.lower()
63
+
64
+ # Check if any keyword matches
65
+ detected_keyword = None
66
+ for keyword in self.keywords:
67
+ if keyword in prompt_lower:
68
+ detected_keyword = keyword
69
+ break
70
+
71
+ if not detected_keyword:
72
+ return None
73
+
74
+ # Extract available services from project_context
75
+ services = []
76
+ services_metadata = {}
77
+
78
+ if "sections" in project_context and "application_services" in project_context["sections"]:
79
+ for svc in project_context["sections"]["application_services"]:
80
+ service_name = svc.get("name", "unknown")
81
+ services.append(service_name)
82
+ services_metadata[service_name] = svc
83
+
84
+ # Check if user already specified a service name
85
+ for service_name in services:
86
+ if service_name in prompt_lower:
87
+ # User already specified which service, no ambiguity
88
+ return None
89
+
90
+ # If multiple services, this is ambiguous
91
+ if len(services) > 1:
92
+ return {
93
+ "pattern": self.name,
94
+ "detected_keyword": detected_keyword,
95
+ "ambiguity_reason": f"Mencionaste '{detected_keyword}', pero hay {len(services)} servicios en este proyecto.",
96
+ "available_options": services,
97
+ "services_metadata": services_metadata,
98
+ "suggested_question": f"¿Qué servicio quieres {self._infer_action(prompt)}?",
99
+ "weight": self.weight,
100
+ "allow_multiple": False
101
+ }
102
+
103
+ return None
104
+
105
+ def _infer_action(self, prompt: str) -> str:
106
+ """Infer user action from prompt for better question phrasing."""
107
+ prompt_lower = prompt.lower()
108
+
109
+ if any(word in prompt_lower for word in ["check", "chequea", "revisar", "status"]):
110
+ return "revisar"
111
+ elif any(word in prompt_lower for word in ["deploy", "desplegar", "update", "actualizar"]):
112
+ return "desplegar"
113
+ elif any(word in prompt_lower for word in ["fix", "arreglar", "debug"]):
114
+ return "debuggear"
115
+ else:
116
+ return "trabajar con"
117
+
118
+
119
+ class NamespaceAmbiguityPattern(AmbiguityPattern):
120
+ """Detects ambiguous namespace references."""
121
+
122
+ def __init__(self):
123
+ super().__init__(
124
+ name="namespace_ambiguity",
125
+ keywords=[
126
+ "deploy to cluster", "desplegar en cluster", "check cluster",
127
+ "chequear cluster", "the namespace", "el namespace",
128
+ "to the cluster", "en el cluster", "in the cluster",
129
+ "al cluster", "cluster status", "estado del cluster"
130
+ ],
131
+ weight=60 # Medium-high weight
132
+ )
133
+
134
+ def detect(self, prompt: str, project_context: Dict[str, Any]) -> Optional[Dict[str, Any]]:
135
+ prompt_lower = prompt.lower()
136
+
137
+ detected_keyword = None
138
+ for keyword in self.keywords:
139
+ if keyword in prompt_lower:
140
+ detected_keyword = keyword
141
+ break
142
+
143
+ if not detected_keyword:
144
+ return None
145
+
146
+ # Extract namespaces
147
+ namespaces = []
148
+ namespace_metadata = {}
149
+
150
+ if "sections" in project_context and "cluster_details" in project_context["sections"]:
151
+ cluster_details = project_context["sections"]["cluster_details"]
152
+ namespaces = cluster_details.get("primary_namespaces", [])
153
+
154
+ # Build metadata: count services per namespace
155
+ if "application_services" in project_context["sections"]:
156
+ for ns in namespaces:
157
+ services_in_ns = [
158
+ svc["name"] for svc in project_context["sections"]["application_services"]
159
+ if svc.get("namespace") == ns
160
+ ]
161
+ namespace_metadata[ns] = {
162
+ "services": services_in_ns,
163
+ "service_count": len(services_in_ns)
164
+ }
165
+
166
+ if len(namespaces) > 1:
167
+ return {
168
+ "pattern": self.name,
169
+ "detected_keyword": detected_keyword,
170
+ "ambiguity_reason": f"El cluster tiene {len(namespaces)} namespaces.",
171
+ "available_options": namespaces,
172
+ "namespace_metadata": namespace_metadata,
173
+ "suggested_question": "¿En qué namespace debería trabajar?",
174
+ "weight": self.weight,
175
+ "allow_multiple": False
176
+ }
177
+
178
+ return None
179
+
180
+
181
+ class EnvironmentAmbiguityPattern(AmbiguityPattern):
182
+ """Detects environment confusion."""
183
+
184
+ def __init__(self):
185
+ super().__init__(
186
+ name="environment_ambiguity",
187
+ keywords=["production", "prod", "staging", "producción"],
188
+ weight=90 # Very high weight - critical
189
+ )
190
+
191
+ def detect(self, prompt: str, project_context: Dict[str, Any]) -> Optional[Dict[str, Any]]:
192
+ prompt_lower = prompt.lower()
193
+
194
+ detected_keyword = None
195
+ for keyword in self.keywords:
196
+ # Use word boundaries to avoid matching "non-prod" when looking for "prod"
197
+ import re
198
+ pattern = r'\b' + re.escape(keyword) + r'\b'
199
+ if re.search(pattern, prompt_lower):
200
+ # Additional check: don't match "prod" if it's part of "non-prod"
201
+ if keyword == "prod" or keyword == "production":
202
+ # Check if "non-prod" or "nonprod" is in the prompt
203
+ if re.search(r'non[-\s]?prod', prompt_lower):
204
+ continue # Skip this keyword, it's part of "non-prod"
205
+ detected_keyword = keyword
206
+ break
207
+
208
+ if not detected_keyword:
209
+ return None
210
+
211
+ # Check current environment
212
+ current_env = "unknown"
213
+ if "sections" in project_context and "project_details" in project_context["sections"]:
214
+ current_env = project_context["sections"]["project_details"].get("environment", "unknown")
215
+
216
+ # If user says "prod" but environment is "non-prod", warn
217
+ if detected_keyword in ["production", "prod", "producción"] and current_env == "non-prod":
218
+ return {
219
+ "pattern": self.name,
220
+ "detected_keyword": detected_keyword,
221
+ "ambiguity_reason": f"⚠️ Mencionaste '{detected_keyword}', pero project-context.json muestra environment='{current_env}'.",
222
+ "available_options": [
223
+ f"Continuar en {current_env}",
224
+ "Detener (voy a cambiar configuración)",
225
+ "Forzar producción (PELIGROSO)"
226
+ ],
227
+ "current_environment": current_env,
228
+ "requested_environment": detected_keyword,
229
+ "suggested_question": f"Mencionaste '{detected_keyword}', pero el proyecto está configurado como '{current_env}'. ¿Cómo proceder?",
230
+ "weight": self.weight,
231
+ "allow_multiple": False
232
+ }
233
+
234
+ return None
235
+
236
+
237
+ class ResourceAmbiguityPattern(AmbiguityPattern):
238
+ """Detects ambiguous infrastructure resource references."""
239
+
240
+ def __init__(self):
241
+ super().__init__(
242
+ name="resource_ambiguity",
243
+ keywords=[
244
+ "the redis", "el redis", "redis instance", "instancia redis",
245
+ "the database", "la database", "the postgres", "el postgres",
246
+ "the sql", "cloud sql", "postgres instance", "instancia postgres"
247
+ ],
248
+ weight=70 # Medium-high weight
249
+ )
250
+
251
+ def detect(self, prompt: str, project_context: Dict[str, Any]) -> Optional[Dict[str, Any]]:
252
+ prompt_lower = prompt.lower()
253
+
254
+ detected_keyword = None
255
+ resource_type = None
256
+ for keyword in self.keywords:
257
+ if keyword in prompt_lower:
258
+ detected_keyword = keyword
259
+ # Infer resource type
260
+ if "redis" in keyword:
261
+ resource_type = "Redis"
262
+ elif any(db in keyword for db in ["postgres", "sql", "database"]):
263
+ resource_type = "Database"
264
+ break
265
+
266
+ if not detected_keyword:
267
+ return None
268
+
269
+ # Extract resources from terraform_infrastructure or application_services
270
+ resources = []
271
+ resource_metadata = {}
272
+
273
+ # Check terraform infrastructure
274
+ if "sections" in project_context and "terraform_infrastructure" in project_context["sections"]:
275
+ infra = project_context["sections"]["terraform_infrastructure"]
276
+ if "modules" in infra:
277
+ for module_name, module_info in infra["modules"].items():
278
+ # Check if module matches resource type
279
+ if resource_type == "Redis" and "redis" in module_name.lower():
280
+ resources.append(module_name)
281
+ resource_metadata[module_name] = {
282
+ "type": module_info.get("resources", "Memorystore Redis"),
283
+ "status": module_info.get("status", "unknown"),
284
+ "tier": module_info.get("tier", "N/A")
285
+ }
286
+ elif resource_type == "Database" and any(db in module_name.lower() for db in ["sql", "postgres", "database"]):
287
+ resources.append(module_name)
288
+ resource_metadata[module_name] = {
289
+ "type": module_info.get("resources", "Cloud SQL PostgreSQL"),
290
+ "status": module_info.get("status", "unknown"),
291
+ "tier": module_info.get("tier", "N/A")
292
+ }
293
+
294
+ # Also check application_services for Redis/DB references
295
+ if "sections" in project_context and "application_services" in project_context["sections"]:
296
+ for svc in project_context["sections"]["application_services"]:
297
+ service_name = svc.get("name", "")
298
+ if resource_type and resource_type.lower() in service_name.lower():
299
+ if service_name not in resources:
300
+ resources.append(service_name)
301
+ resource_metadata[service_name] = {
302
+ "type": "Application Service",
303
+ "status": svc.get("status", "unknown"),
304
+ "namespace": svc.get("namespace", "N/A")
305
+ }
306
+
307
+ if len(resources) > 1:
308
+ return {
309
+ "pattern": self.name,
310
+ "detected_keyword": detected_keyword,
311
+ "ambiguity_reason": f"Encontré {len(resources)} recursos de {resource_type} en el proyecto.",
312
+ "available_options": resources,
313
+ "resource_metadata": resource_metadata,
314
+ "resource_type": resource_type,
315
+ "suggested_question": f"¿Qué recurso de {resource_type} quieres usar?",
316
+ "weight": self.weight,
317
+ "allow_multiple": False
318
+ }
319
+
320
+ return None
321
+
322
+
323
+ # ============================================================================
324
+ # PATTERN REGISTRY
325
+ # ============================================================================
326
+
327
+ AMBIGUITY_PATTERNS = [
328
+ ServiceAmbiguityPattern(),
329
+ NamespaceAmbiguityPattern(),
330
+ EnvironmentAmbiguityPattern(),
331
+ ResourceAmbiguityPattern()
332
+ ]
333
+
334
+
335
+ def detect_all_ambiguities(prompt: str, project_context: Dict[str, Any]) -> List[Dict[str, Any]]:
336
+ """
337
+ Run all ambiguity patterns and return detected ambiguities.
338
+
339
+ Args:
340
+ prompt: User prompt to analyze
341
+ project_context: Loaded project-context.json
342
+
343
+ Returns:
344
+ List of detected ambiguities (sorted by weight, descending)
345
+ """
346
+ ambiguities = []
347
+
348
+ for pattern in AMBIGUITY_PATTERNS:
349
+ result = pattern.detect(prompt, project_context)
350
+ if result:
351
+ ambiguities.append(result)
352
+
353
+ # Sort by weight (highest first)
354
+ ambiguities.sort(key=lambda x: x["weight"], reverse=True)
355
+
356
+ return ambiguities
@@ -0,0 +1,338 @@
1
+ """
2
+ Git Commit Message Validator
3
+
4
+ Validates commit messages against project standards before execution.
5
+ This prevents commits with forbidden footers or incorrect format from
6
+ being pushed to the repository.
7
+
8
+ Usage:
9
+ from commit_validator import CommitMessageValidator
10
+
11
+ validator = CommitMessageValidator()
12
+ validation = validator.validate(commit_message)
13
+
14
+ if not validation.valid:
15
+ for error in validation.errors:
16
+ print(f"Error: {error['message']}")
17
+ # Do not proceed with commit
18
+ else:
19
+ # Safe to commit
20
+ git commit -m "$commit_message"
21
+ """
22
+
23
+ import json
24
+ import os
25
+ import re
26
+ from typing import Dict, List, Any, Optional
27
+ from datetime import datetime
28
+ from dataclasses import dataclass
29
+
30
+
31
+ @dataclass
32
+ class ValidationResult:
33
+ """Result of commit message validation."""
34
+ valid: bool
35
+ errors: List[Dict[str, str]]
36
+ warnings: List[Dict[str, str]] = None
37
+
38
+ def __post_init__(self):
39
+ if self.warnings is None:
40
+ self.warnings = []
41
+
42
+
43
+ class CommitMessageValidator:
44
+ """
45
+ Validates git commit messages against project standards.
46
+
47
+ Standards are defined in .claude/config/git_standards.json
48
+ """
49
+
50
+ def __init__(self, config_path: Optional[str] = None):
51
+ """
52
+ Initialize validator with configuration.
53
+
54
+ Args:
55
+ config_path: Optional path to git_standards.json
56
+ If None, uses default location
57
+ """
58
+ if config_path is None:
59
+ # Default path relative to this file
60
+ base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
61
+ config_path = os.path.join(base_path, 'config', 'git_standards.json')
62
+
63
+ self.config_path = config_path
64
+ self.config = self._load_config()
65
+ self.standards = self.config.get('commit_message', {})
66
+ self.enforcement = self.config.get('enforcement', {})
67
+
68
+ def _load_config(self) -> Dict[str, Any]:
69
+ """Load git standards configuration from JSON file."""
70
+ if not os.path.exists(self.config_path):
71
+ raise FileNotFoundError(
72
+ f"Git standards configuration not found at: {self.config_path}"
73
+ )
74
+
75
+ with open(self.config_path, 'r') as f:
76
+ return json.load(f)
77
+
78
+ def validate(self, message: str) -> ValidationResult:
79
+ """
80
+ Validate a commit message against all standards.
81
+
82
+ Args:
83
+ message: The commit message to validate
84
+
85
+ Returns:
86
+ ValidationResult with valid status and any errors/warnings
87
+ """
88
+ errors = []
89
+ warnings = []
90
+
91
+ # 1. Check for forbidden footers (CRITICAL)
92
+ footer_errors = self._check_forbidden_footers(message)
93
+ errors.extend(footer_errors)
94
+
95
+ # 2. Check conventional commits format
96
+ format_errors = self._check_conventional_format(message)
97
+ errors.extend(format_errors)
98
+
99
+ # 3. Check subject line rules
100
+ subject_errors = self._check_subject_rules(message)
101
+ errors.extend(subject_errors)
102
+
103
+ # 4. Check body rules (warnings only)
104
+ body_warnings = self._check_body_rules(message)
105
+ warnings.extend(body_warnings)
106
+
107
+ # Log violations if configured
108
+ if errors and self.enforcement.get('log_violations', False):
109
+ self._log_violation(message, errors)
110
+
111
+ return ValidationResult(
112
+ valid=len(errors) == 0,
113
+ errors=errors,
114
+ warnings=warnings
115
+ )
116
+
117
+ def _check_forbidden_footers(self, message: str) -> List[Dict[str, str]]:
118
+ """Check for forbidden footers in commit message."""
119
+ errors = []
120
+ forbidden = self.standards.get('footer_forbidden', [])
121
+
122
+ for forbidden_text in forbidden:
123
+ if forbidden_text.lower() in message.lower():
124
+ errors.append({
125
+ 'type': 'FORBIDDEN_FOOTER',
126
+ 'message': f"Commit message contains forbidden footer: '{forbidden_text}'",
127
+ 'fix': f"Remove all occurrences of '{forbidden_text}'",
128
+ 'severity': 'error'
129
+ })
130
+
131
+ return errors
132
+
133
+ def _check_conventional_format(self, message: str) -> List[Dict[str, str]]:
134
+ """Check if message follows Conventional Commits format."""
135
+ errors = []
136
+
137
+ # Get first line (subject)
138
+ lines = message.split('\n')
139
+ subject = lines[0].strip()
140
+
141
+ # Pattern: type(scope)?: description
142
+ # Examples: feat: add feature, fix(api): correct bug
143
+ allowed_types = '|'.join(self.standards.get('type_allowed', []))
144
+ pattern = rf'^({allowed_types})(\(.+?\))?: .+$'
145
+
146
+ if not re.match(pattern, subject):
147
+ errors.append({
148
+ 'type': 'INVALID_FORMAT',
149
+ 'message': 'Commit message does not follow Conventional Commits format',
150
+ 'fix': f"Use format: type(scope): description\nAllowed types: {', '.join(self.standards.get('type_allowed', []))}",
151
+ 'severity': 'error',
152
+ 'examples': self.standards.get('examples_valid', [])
153
+ })
154
+
155
+ return errors
156
+
157
+ def _check_subject_rules(self, message: str) -> List[Dict[str, str]]:
158
+ """Check subject line specific rules."""
159
+ errors = []
160
+
161
+ lines = message.split('\n')
162
+ subject = lines[0].strip()
163
+
164
+ # Extract description part (after type and scope)
165
+ # Example: "feat(scope): description" -> "description"
166
+ match = re.match(r'^[a-z]+(\(.+?\))?: (.+)$', subject)
167
+ if match:
168
+ description = match.group(2)
169
+
170
+ # Check max length
171
+ max_length = self.standards.get('subject_max_length', 72)
172
+ if len(subject) > max_length:
173
+ errors.append({
174
+ 'type': 'SUBJECT_TOO_LONG',
175
+ 'message': f'Subject line exceeds {max_length} characters (current: {len(subject)})',
176
+ 'fix': f'Shorten subject to {max_length} characters or less',
177
+ 'severity': 'error'
178
+ })
179
+
180
+ # Check for period at end
181
+ rules = self.standards.get('subject_rules', {})
182
+ if rules.get('no_period_at_end', True) and description.endswith('.'):
183
+ errors.append({
184
+ 'type': 'SUBJECT_ENDS_WITH_PERIOD',
185
+ 'message': 'Subject line should not end with a period',
186
+ 'fix': 'Remove the period at the end of the subject',
187
+ 'severity': 'error'
188
+ })
189
+
190
+ return errors
191
+
192
+ def _check_body_rules(self, message: str) -> List[Dict[str, str]]:
193
+ """Check body rules (returns warnings, not errors)."""
194
+ warnings = []
195
+
196
+ lines = message.split('\n')
197
+
198
+ # Check if there's a body (more than just subject)
199
+ if len(lines) <= 1:
200
+ return warnings
201
+
202
+ # Check blank line after subject
203
+ if len(lines) > 1 and lines[1].strip() != '':
204
+ warnings.append({
205
+ 'type': 'MISSING_BLANK_LINE',
206
+ 'message': 'Missing blank line between subject and body',
207
+ 'fix': 'Add a blank line after the subject line',
208
+ 'severity': 'warning'
209
+ })
210
+
211
+ # Check body line length
212
+ max_length = self.standards.get('body_max_line_length', 72)
213
+ for i, line in enumerate(lines[2:], start=3): # Skip subject and blank line
214
+ if len(line) > max_length and not line.startswith('http'):
215
+ warnings.append({
216
+ 'type': 'BODY_LINE_TOO_LONG',
217
+ 'message': f'Body line {i} exceeds {max_length} characters',
218
+ 'fix': f'Wrap line to {max_length} characters',
219
+ 'severity': 'warning'
220
+ })
221
+
222
+ return warnings
223
+
224
+ def _log_violation(self, message: str, errors: List[Dict[str, str]]):
225
+ """Log commit message violation for audit trail."""
226
+ log_path = self.enforcement.get('log_path', '.claude/logs/commit-violations.jsonl')
227
+
228
+ # Ensure log directory exists
229
+ log_dir = os.path.dirname(log_path)
230
+ if log_dir and not os.path.exists(log_dir):
231
+ os.makedirs(log_dir, exist_ok=True)
232
+
233
+ log_entry = {
234
+ 'timestamp': datetime.now().isoformat(),
235
+ 'message': message[:100] + ('...' if len(message) > 100 else ''),
236
+ 'errors': errors,
237
+ 'error_count': len(errors)
238
+ }
239
+
240
+ with open(log_path, 'a') as f:
241
+ f.write(json.dumps(log_entry) + '\n')
242
+
243
+ def get_examples(self) -> Dict[str, List[str]]:
244
+ """Get example commit messages (valid and invalid)."""
245
+ return {
246
+ 'valid': self.standards.get('examples_valid', []),
247
+ 'invalid': self.standards.get('examples_invalid', [])
248
+ }
249
+
250
+ def get_allowed_types(self) -> List[str]:
251
+ """Get list of allowed commit types."""
252
+ return self.standards.get('type_allowed', [])
253
+
254
+ def format_error_message(self, validation: ValidationResult) -> str:
255
+ """
256
+ Format validation errors into human-readable message.
257
+
258
+ Args:
259
+ validation: ValidationResult from validate()
260
+
261
+ Returns:
262
+ Formatted error message string
263
+ """
264
+ if validation.valid:
265
+ return "✅ Commit message is valid"
266
+
267
+ lines = ["❌ Commit message validation failed:\n"]
268
+
269
+ for error in validation.errors:
270
+ lines.append(f" [{error['type']}]")
271
+ lines.append(f" {error['message']}")
272
+ lines.append(f" Fix: {error['fix']}")
273
+
274
+ if 'examples' in error:
275
+ lines.append(f" Examples:")
276
+ for example in error['examples'][:3]:
277
+ lines.append(f" - {example}")
278
+
279
+ lines.append("")
280
+
281
+ if validation.warnings:
282
+ lines.append("⚠️ Warnings:")
283
+ for warning in validation.warnings:
284
+ lines.append(f" - {warning['message']}")
285
+ lines.append("")
286
+
287
+ return "\n".join(lines)
288
+
289
+
290
+ # Convenience function for quick validation
291
+ def validate_commit_message(message: str) -> ValidationResult:
292
+ """
293
+ Quick validation function.
294
+
295
+ Args:
296
+ message: Commit message to validate
297
+
298
+ Returns:
299
+ ValidationResult
300
+
301
+ Example:
302
+ validation = validate_commit_message("feat: add new feature")
303
+ if not validation.valid:
304
+ print("Invalid commit message")
305
+ """
306
+ validator = CommitMessageValidator()
307
+ return validator.validate(message)
308
+
309
+
310
+ # Function for use in git commit workflow
311
+ def safe_validate_before_commit(message: str) -> bool:
312
+ """
313
+ Validate commit message and print errors if invalid.
314
+
315
+ This is the primary function that agents should call before git commit.
316
+
317
+ Args:
318
+ message: Commit message to validate
319
+
320
+ Returns:
321
+ True if valid, False if invalid (with errors printed)
322
+
323
+ Example:
324
+ if not safe_validate_before_commit(commit_message):
325
+ return {"status": "failed", "reason": "commit_validation_failed"}
326
+
327
+ # Safe to commit
328
+ Bash(f'git commit -m "{commit_message}"')
329
+ """
330
+ validator = CommitMessageValidator()
331
+ validation = validator.validate(message)
332
+
333
+ if not validation.valid:
334
+ error_message = validator.format_error_message(validation)
335
+ print(error_message)
336
+ return False
337
+
338
+ return True