@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,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
|