@jaguilar87/gaia-ops 2.2.0 → 2.2.2

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 (41) hide show
  1. package/CHANGELOG.md +137 -1
  2. package/README.en.md +29 -23
  3. package/README.md +24 -17
  4. package/agents/{claude-architect.md → gaia.md} +6 -6
  5. package/commands/{architect.md → gaia.md} +6 -6
  6. package/config/AGENTS.md +5 -5
  7. package/config/agent-catalog.md +14 -14
  8. package/config/context-contracts.md +4 -4
  9. package/config/embeddings_info.json +14 -0
  10. package/config/intent_embeddings.json +2002 -0
  11. package/config/intent_embeddings.npy +0 -0
  12. package/index.js +3 -1
  13. package/package.json +3 -2
  14. package/speckit/README.en.md +20 -69
  15. package/templates/CLAUDE.template.md +5 -13
  16. package/tests/README.en.md +224 -0
  17. package/tests/README.md +338 -0
  18. package/tests/fixtures/project-context.aws.json +53 -0
  19. package/tests/fixtures/project-context.gcp.json +53 -0
  20. package/tests/integration/RUN_TESTS.md +185 -0
  21. package/tests/integration/__init__.py +0 -0
  22. package/tests/integration/test_hooks_integration.py +473 -0
  23. package/tests/integration/test_hooks_workflow.py +397 -0
  24. package/tests/permissions-validation/MANUAL_VALIDATION.md +434 -0
  25. package/tests/permissions-validation/test_permissions_validation.py +527 -0
  26. package/tests/system/__init__.py +0 -0
  27. package/tests/system/permissions_helpers.py +318 -0
  28. package/tests/system/test_agent_definitions.py +166 -0
  29. package/tests/system/test_configuration_files.py +121 -0
  30. package/tests/system/test_directory_structure.py +231 -0
  31. package/tests/system/test_permissions_system.py +1006 -0
  32. package/tests/tools/__init__.py +0 -0
  33. package/tests/tools/test_agent_router.py +266 -0
  34. package/tests/tools/test_clarify_engine.py +413 -0
  35. package/tests/tools/test_context_provider.py +157 -0
  36. package/tests/validators/__init__.py +0 -0
  37. package/tests/validators/test_approval_gate.py +415 -0
  38. package/tests/validators/test_commit_validator.py +446 -0
  39. package/tools/context_provider.py +28 -7
  40. package/tools/generate_embeddings.py +3 -3
  41. package/tools/semantic_matcher.py +2 -2
@@ -0,0 +1,446 @@
1
+ """
2
+ Unit tests for commit_validator.py
3
+
4
+ Tests the Git Commit Message Validator that prevents commits with
5
+ forbidden footers or incorrect format from being executed.
6
+ """
7
+
8
+ import unittest
9
+ import json
10
+ import os
11
+ import tempfile
12
+ import sys
13
+
14
+ # Add parent directory to path to import commit_validator
15
+ test_dir = os.path.dirname(os.path.abspath(__file__))
16
+ claude_tools_path = os.path.join(test_dir, '../../../.claude/tools')
17
+ sys.path.insert(0, claude_tools_path)
18
+
19
+ from commit_validator import (
20
+ CommitMessageValidator,
21
+ ValidationResult,
22
+ validate_commit_message,
23
+ safe_validate_before_commit
24
+ )
25
+
26
+
27
+ class TestCommitMessageValidator(unittest.TestCase):
28
+ """Test cases for CommitMessageValidator class."""
29
+
30
+ def setUp(self):
31
+ """Set up test fixtures."""
32
+ self.validator = CommitMessageValidator()
33
+
34
+ def test_valid_feat_commit(self):
35
+ """Test validation of valid feat commit."""
36
+ message = "feat(helmrelease): add Phase 3.3 services"
37
+ validation = self.validator.validate(message)
38
+
39
+ self.assertTrue(validation.valid)
40
+ self.assertEqual(len(validation.errors), 0)
41
+
42
+ def test_valid_fix_commit(self):
43
+ """Test validation of valid fix commit."""
44
+ message = "fix(pg-non-prod): correct API key environment variable mappings"
45
+ validation = self.validator.validate(message)
46
+
47
+ self.assertTrue(validation.valid)
48
+ self.assertEqual(len(validation.errors), 0)
49
+
50
+ def test_valid_commit_without_scope(self):
51
+ """Test validation of valid commit without scope."""
52
+ message = "refactor: simplify context provider logic"
53
+ validation = self.validator.validate(message)
54
+
55
+ self.assertTrue(validation.valid)
56
+ self.assertEqual(len(validation.errors), 0)
57
+
58
+ def test_valid_commit_with_body(self):
59
+ """Test validation of valid commit with body."""
60
+ message = """feat(helmrelease): add Phase 3.3 services
61
+
62
+ Deploys 6 microservices for PG RAG Agent:
63
+ - admin-ui
64
+ - query-api
65
+ - admin-api
66
+ - embedding-worker
67
+ - ingestion-worker
68
+ - query-worker"""
69
+ validation = self.validator.validate(message)
70
+
71
+ self.assertTrue(validation.valid)
72
+ self.assertEqual(len(validation.errors), 0)
73
+
74
+ def test_forbidden_footer_claude_code(self):
75
+ """Test rejection of commit with Claude Code footer."""
76
+ message = """feat: add new feature
77
+
78
+ 🤖 Generated with [Claude Code](https://claude.com/claude-code)"""
79
+
80
+ validation = self.validator.validate(message)
81
+
82
+ self.assertFalse(validation.valid)
83
+ # Should detect at least one forbidden footer (either "Claude Code" or "🤖 Generated with")
84
+ self.assertGreaterEqual(len(validation.errors), 1)
85
+ self.assertEqual(validation.errors[0]['type'], 'FORBIDDEN_FOOTER')
86
+ # Check that message contains one of the forbidden terms
87
+ error_msg = validation.errors[0]['message']
88
+ self.assertTrue(
89
+ 'Claude Code' in error_msg or 'Generated with' in error_msg,
90
+ f"Expected forbidden footer in error message, got: {error_msg}"
91
+ )
92
+
93
+ def test_forbidden_footer_co_authored(self):
94
+ """Test rejection of commit with Co-Authored-By Claude footer."""
95
+ message = """fix: update secrets
96
+
97
+ Co-Authored-By: Claude <noreply@anthropic.com>"""
98
+
99
+ validation = self.validator.validate(message)
100
+
101
+ self.assertFalse(validation.valid)
102
+ self.assertEqual(len(validation.errors), 1)
103
+ self.assertEqual(validation.errors[0]['type'], 'FORBIDDEN_FOOTER')
104
+ self.assertIn('Co-Authored-By: Claude', validation.errors[0]['message'])
105
+
106
+ def test_multiple_forbidden_footers(self):
107
+ """Test rejection of commit with multiple forbidden footers."""
108
+ message = """feat: add feature
109
+
110
+ 🤖 Generated with [Claude Code](https://claude.com/claude-code)
111
+
112
+ Co-Authored-By: Claude <noreply@anthropic.com>"""
113
+
114
+ validation = self.validator.validate(message)
115
+
116
+ self.assertFalse(validation.valid)
117
+ self.assertEqual(len(validation.errors), 2)
118
+
119
+ error_types = [error['type'] for error in validation.errors]
120
+ self.assertEqual(error_types.count('FORBIDDEN_FOOTER'), 2)
121
+
122
+ def test_invalid_format_no_type(self):
123
+ """Test rejection of commit without type."""
124
+ message = "Added new feature"
125
+ validation = self.validator.validate(message)
126
+
127
+ self.assertFalse(validation.valid)
128
+ self.assertTrue(
129
+ any(error['type'] == 'INVALID_FORMAT' for error in validation.errors)
130
+ )
131
+
132
+ def test_invalid_format_wrong_type(self):
133
+ """Test rejection of commit with invalid type."""
134
+ message = "added: new feature"
135
+ validation = self.validator.validate(message)
136
+
137
+ self.assertFalse(validation.valid)
138
+ self.assertTrue(
139
+ any(error['type'] == 'INVALID_FORMAT' for error in validation.errors)
140
+ )
141
+
142
+ def test_invalid_format_missing_description(self):
143
+ """Test rejection of commit without description."""
144
+ message = "feat:"
145
+ validation = self.validator.validate(message)
146
+
147
+ self.assertFalse(validation.valid)
148
+
149
+ def test_subject_too_long(self):
150
+ """Test rejection of commit with subject exceeding max length."""
151
+ # Create a subject longer than 72 characters
152
+ long_description = "a" * 80
153
+ message = f"feat: {long_description}"
154
+ validation = self.validator.validate(message)
155
+
156
+ self.assertFalse(validation.valid)
157
+ self.assertTrue(
158
+ any(error['type'] == 'SUBJECT_TOO_LONG' for error in validation.errors)
159
+ )
160
+
161
+ def test_subject_ends_with_period(self):
162
+ """Test rejection of commit with period at end."""
163
+ message = "feat: add new feature."
164
+ validation = self.validator.validate(message)
165
+
166
+ self.assertFalse(validation.valid)
167
+ self.assertTrue(
168
+ any(error['type'] == 'SUBJECT_ENDS_WITH_PERIOD' for error in validation.errors)
169
+ )
170
+
171
+ def test_allowed_footer_breaking_change(self):
172
+ """Test that allowed footers are not rejected."""
173
+ message = """feat: add breaking change
174
+
175
+ BREAKING CHANGE: API endpoint changed from v1 to v2"""
176
+
177
+ validation = self.validator.validate(message)
178
+
179
+ # Should be valid (BREAKING CHANGE is allowed)
180
+ self.assertTrue(validation.valid)
181
+
182
+ def test_allowed_footer_closes(self):
183
+ """Test that Closes footer is allowed."""
184
+ message = """fix: resolve authentication bug
185
+
186
+ Closes: #123"""
187
+
188
+ validation = self.validator.validate(message)
189
+
190
+ self.assertTrue(validation.valid)
191
+
192
+ def test_case_insensitive_forbidden_footer_detection(self):
193
+ """Test that forbidden footer detection is case-insensitive."""
194
+ message = """feat: add feature
195
+
196
+ generated with claude code"""
197
+
198
+ validation = self.validator.validate(message)
199
+
200
+ self.assertFalse(validation.valid)
201
+ self.assertTrue(
202
+ any(error['type'] == 'FORBIDDEN_FOOTER' for error in validation.errors)
203
+ )
204
+
205
+ def test_all_allowed_types(self):
206
+ """Test that all allowed types are accepted."""
207
+ allowed_types = [
208
+ "feat", "fix", "refactor", "docs", "test",
209
+ "chore", "ci", "perf", "style", "build"
210
+ ]
211
+
212
+ for commit_type in allowed_types:
213
+ message = f"{commit_type}: do something"
214
+ validation = self.validator.validate(message)
215
+
216
+ self.assertTrue(
217
+ validation.valid,
218
+ f"Type '{commit_type}' should be valid"
219
+ )
220
+
221
+
222
+ class TestValidationResult(unittest.TestCase):
223
+ """Test cases for ValidationResult dataclass."""
224
+
225
+ def test_validation_result_valid(self):
226
+ """Test ValidationResult for valid message."""
227
+ result = ValidationResult(valid=True, errors=[])
228
+
229
+ self.assertTrue(result.valid)
230
+ self.assertEqual(len(result.errors), 0)
231
+ self.assertEqual(len(result.warnings), 0)
232
+
233
+ def test_validation_result_with_errors(self):
234
+ """Test ValidationResult with errors."""
235
+ errors = [
236
+ {'type': 'FORBIDDEN_FOOTER', 'message': 'Error message'}
237
+ ]
238
+ result = ValidationResult(valid=False, errors=errors)
239
+
240
+ self.assertFalse(result.valid)
241
+ self.assertEqual(len(result.errors), 1)
242
+
243
+ def test_validation_result_with_warnings(self):
244
+ """Test ValidationResult with warnings."""
245
+ warnings = [
246
+ {'type': 'BODY_LINE_TOO_LONG', 'message': 'Warning message'}
247
+ ]
248
+ result = ValidationResult(valid=True, errors=[], warnings=warnings)
249
+
250
+ self.assertTrue(result.valid)
251
+ self.assertEqual(len(result.warnings), 1)
252
+
253
+
254
+ class TestConvenienceFunctions(unittest.TestCase):
255
+ """Test cases for module-level convenience functions."""
256
+
257
+ def test_validate_commit_message_valid(self):
258
+ """Test validate_commit_message with valid message."""
259
+ message = "feat: add new feature"
260
+ validation = validate_commit_message(message)
261
+
262
+ self.assertTrue(validation.valid)
263
+
264
+ def test_validate_commit_message_invalid(self):
265
+ """Test validate_commit_message with invalid message."""
266
+ message = "Added new feature"
267
+ validation = validate_commit_message(message)
268
+
269
+ self.assertFalse(validation.valid)
270
+
271
+ def test_safe_validate_before_commit_valid(self):
272
+ """Test safe_validate_before_commit with valid message."""
273
+ message = "fix: correct bug"
274
+ result = safe_validate_before_commit(message)
275
+
276
+ self.assertTrue(result)
277
+
278
+ def test_safe_validate_before_commit_invalid(self):
279
+ """Test safe_validate_before_commit with invalid message."""
280
+ message = "Fixed bug"
281
+ result = safe_validate_before_commit(message)
282
+
283
+ self.assertFalse(result)
284
+
285
+
286
+ class TestHelperMethods(unittest.TestCase):
287
+ """Test cases for helper methods."""
288
+
289
+ def setUp(self):
290
+ """Set up test fixtures."""
291
+ self.validator = CommitMessageValidator()
292
+
293
+ def test_get_examples(self):
294
+ """Test get_examples returns valid and invalid examples."""
295
+ examples = self.validator.get_examples()
296
+
297
+ self.assertIn('valid', examples)
298
+ self.assertIn('invalid', examples)
299
+ self.assertIsInstance(examples['valid'], list)
300
+ self.assertIsInstance(examples['invalid'], list)
301
+
302
+ def test_get_allowed_types(self):
303
+ """Test get_allowed_types returns list of types."""
304
+ types = self.validator.get_allowed_types()
305
+
306
+ self.assertIsInstance(types, list)
307
+ self.assertIn('feat', types)
308
+ self.assertIn('fix', types)
309
+ self.assertIn('refactor', types)
310
+
311
+ def test_format_error_message_valid(self):
312
+ """Test format_error_message for valid result."""
313
+ validation = ValidationResult(valid=True, errors=[])
314
+ formatted = self.validator.format_error_message(validation)
315
+
316
+ self.assertIn("✅", formatted)
317
+ self.assertIn("valid", formatted.lower())
318
+
319
+ def test_format_error_message_invalid(self):
320
+ """Test format_error_message for invalid result."""
321
+ errors = [{
322
+ 'type': 'FORBIDDEN_FOOTER',
323
+ 'message': 'Contains forbidden footer',
324
+ 'fix': 'Remove the footer'
325
+ }]
326
+ validation = ValidationResult(valid=False, errors=errors)
327
+ formatted = self.validator.format_error_message(validation)
328
+
329
+ self.assertIn("❌", formatted)
330
+ self.assertIn("FORBIDDEN_FOOTER", formatted)
331
+ self.assertIn("Contains forbidden footer", formatted)
332
+ self.assertIn("Fix:", formatted)
333
+
334
+
335
+ class TestLogging(unittest.TestCase):
336
+ """Test cases for violation logging."""
337
+
338
+ def test_log_violation(self):
339
+ """Test that violations are logged when enabled."""
340
+ # Create temporary config with logging enabled
341
+ temp_config = tempfile.NamedTemporaryFile(
342
+ mode='w', suffix='.json', delete=False
343
+ )
344
+ temp_log = tempfile.NamedTemporaryFile(
345
+ mode='w', suffix='.jsonl', delete=False
346
+ )
347
+
348
+ config = {
349
+ "commit_message": {
350
+ "format": "conventional_commits",
351
+ "type_allowed": ["feat", "fix"],
352
+ "footer_forbidden": ["Generated with Claude"]
353
+ },
354
+ "enforcement": {
355
+ "enabled": True,
356
+ "log_violations": True,
357
+ "log_path": temp_log.name
358
+ }
359
+ }
360
+
361
+ json.dump(config, temp_config)
362
+ temp_config.close()
363
+ temp_log.close()
364
+
365
+ try:
366
+ # Create validator with custom config
367
+ validator = CommitMessageValidator(config_path=temp_config.name)
368
+
369
+ # Validate invalid message (should log)
370
+ message = "Invalid commit message"
371
+ validation = validator.validate(message)
372
+
373
+ self.assertFalse(validation.valid)
374
+
375
+ # Check that log file was created and contains entry
376
+ with open(temp_log.name, 'r') as f:
377
+ log_entries = f.readlines()
378
+
379
+ self.assertGreater(len(log_entries), 0)
380
+
381
+ # Parse first log entry
382
+ log_entry = json.loads(log_entries[0])
383
+ self.assertIn('timestamp', log_entry)
384
+ self.assertIn('message', log_entry)
385
+ self.assertIn('errors', log_entry)
386
+
387
+ finally:
388
+ # Clean up temp files
389
+ os.remove(temp_config.name)
390
+ os.remove(temp_log.name)
391
+
392
+
393
+ class TestRealWorldCommits(unittest.TestCase):
394
+ """Test with real-world commit message examples."""
395
+
396
+ def setUp(self):
397
+ """Set up test fixtures."""
398
+ self.validator = CommitMessageValidator()
399
+
400
+ def test_real_commit_from_log_forbidden(self):
401
+ """Test the actual commit from the log that was interrupted."""
402
+ message = """fix(pg-non-prod): add explicit API key environment variable mappings
403
+
404
+ 🤖 Generated with [Claude Code](https://claude.com/claude-code)
405
+
406
+ Co-Authored-By: Claude <noreply@anthropic.com>"""
407
+
408
+ validation = self.validator.validate(message)
409
+
410
+ # Should be INVALID due to forbidden footers
411
+ self.assertFalse(validation.valid)
412
+ self.assertGreaterEqual(len(validation.errors), 2) # At least 2 forbidden footers
413
+
414
+ def test_real_commit_from_log_corrected(self):
415
+ """Test the corrected version without forbidden footers."""
416
+ message = """fix(pg-non-prod): add explicit API key environment variable mappings
417
+
418
+ Maps snake_case secret keys to SCREAMING_SNAKE_CASE env vars
419
+ required by NestJS applications."""
420
+
421
+ validation = self.validator.validate(message)
422
+
423
+ # Should be VALID
424
+ self.assertTrue(validation.valid)
425
+ self.assertEqual(len(validation.errors), 0)
426
+
427
+ def test_complex_commit_with_multiple_issues(self):
428
+ """Test commit with multiple validation issues."""
429
+ message = """Added new feature.
430
+
431
+ This is a long body without proper wrapping and it exceeds the maximum line length specified in the configuration file.
432
+
433
+ 🤖 Generated with Claude Code"""
434
+
435
+ validation = self.validator.validate(message)
436
+
437
+ self.assertFalse(validation.valid)
438
+
439
+ # Should have multiple errors
440
+ error_types = [error['type'] for error in validation.errors]
441
+ self.assertIn('INVALID_FORMAT', error_types)
442
+ self.assertIn('FORBIDDEN_FOOTER', error_types)
443
+
444
+
445
+ if __name__ == '__main__':
446
+ unittest.main()
@@ -6,9 +6,30 @@ from pathlib import Path
6
6
  from typing import Dict, List, Any, Optional
7
7
 
8
8
  # This script is expected to be run from the root of the repository.
9
- # The project context file is located at `.claude/project-context.json`.
10
- # We construct the path relative to the script's assumed execution location.
11
- DEFAULT_CONTEXT_PATH = Path(".claude/project-context.json")
9
+ # Historically the project context lived at `.claude/project-context.json`, but
10
+ # newer installs keep it under `.claude/project-context/project-context.json`.
11
+ # We auto-detect the most common locations (and honor GAIA_CONTEXT_PATH) so agent
12
+ # routing never breaks if the file isn't symlinked to the legacy path.
13
+ def get_default_context_path() -> Path:
14
+ """Detects the project-context.json location, honoring GAIA_CONTEXT_PATH."""
15
+ env_override = os.environ.get("GAIA_CONTEXT_PATH")
16
+ if env_override:
17
+ return Path(env_override).expanduser()
18
+
19
+ candidates = [
20
+ Path(".claude/project-context.json"),
21
+ Path(".claude/project-context/project-context.json"),
22
+ Path("project-context.json"),
23
+ ]
24
+
25
+ for candidate in candidates:
26
+ if candidate.is_file():
27
+ return candidate
28
+
29
+ # Fall back to the legacy expectation; load_project_context will surface error
30
+ return candidates[0]
31
+
32
+ DEFAULT_CONTEXT_PATH = get_default_context_path()
12
33
 
13
34
  # Path to provider-specific contract files
14
35
  # When running from installed project: .claude/config (symlinked)
@@ -275,10 +296,10 @@ def get_contract_context(
275
296
  agent_contract = provider_contracts["agents"].get(agent_name)
276
297
  if not agent_contract:
277
298
  print(
278
- f"Warning: No contract found for agent '{agent_name}' in provider contracts. Returning empty contract.",
299
+ f"ERROR: Invalid agent '{agent_name}'. Agent not found in provider contracts.",
279
300
  file=sys.stderr,
280
301
  )
281
- return {}
302
+ sys.exit(1)
282
303
 
283
304
  contract_keys = agent_contract.get("required", [])
284
305
  else:
@@ -286,10 +307,10 @@ def get_contract_context(
286
307
  contract_keys = LEGACY_AGENT_CONTRACTS.get(agent_name)
287
308
  if not contract_keys:
288
309
  print(
289
- f"Warning: No contract found for agent '{agent_name}'. Returning empty contract.",
310
+ f"ERROR: Invalid agent '{agent_name}'. Agent not recognized.",
290
311
  file=sys.stderr,
291
312
  )
292
- return {}
313
+ sys.exit(1)
293
314
 
294
315
  sections = project_context.get("sections", {})
295
316
  if not sections:
@@ -9,8 +9,8 @@ Usage:
9
9
  python3 generate_embeddings.py
10
10
 
11
11
  Output:
12
- - .claude/configs/intent_embeddings.npy (binary, ~5MB)
13
- - .claude/configs/intent_embeddings.json (readable metadata)
12
+ - .claude/config/intent_embeddings.npy (binary, ~5MB)
13
+ - .claude/config/intent_embeddings.json (readable metadata)
14
14
  """
15
15
 
16
16
  import json
@@ -117,7 +117,7 @@ def generate_embeddings():
117
117
  print(f"\n Total examples processed: {total_examples}")
118
118
 
119
119
  # Save as JSON (readable metadata)
120
- output_dir = Path(__file__).parent.parent / "configs"
120
+ output_dir = Path(__file__).parent.parent / "config"
121
121
  output_dir.mkdir(parents=True, exist_ok=True)
122
122
 
123
123
  json_path = output_dir / "intent_embeddings.json"
@@ -32,10 +32,10 @@ class SemanticMatcher:
32
32
  Initialize semantic matcher
33
33
 
34
34
  Args:
35
- embeddings_dir: Directory containing embeddings (defaults to .claude/configs/)
35
+ embeddings_dir: Directory containing embeddings (defaults to .claude/config/)
36
36
  """
37
37
  if embeddings_dir is None:
38
- embeddings_dir = Path(__file__).parent.parent / "configs"
38
+ embeddings_dir = Path(__file__).parent.parent / "config"
39
39
 
40
40
  self.embeddings_dir = embeddings_dir
41
41
  self.embeddings: Dict[str, Any] = {}