anvil-dev-framework 0.1.7 → 0.1.9

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 (143) hide show
  1. package/README.md +71 -22
  2. package/VERSION +1 -1
  3. package/docs/ANV-263-hook-logging-investigation.md +116 -0
  4. package/docs/command-reference.md +398 -17
  5. package/docs/session-workflow.md +62 -9
  6. package/docs/system-architecture.md +584 -0
  7. package/global/api/__pycache__/ralph_api.cpython-314.pyc +0 -0
  8. package/global/api/openapi.yaml +357 -0
  9. package/global/api/ralph_api.py +528 -0
  10. package/global/commands/anvil-settings.md +47 -19
  11. package/global/commands/audit.md +163 -0
  12. package/global/commands/checklist.md +180 -0
  13. package/global/commands/coderabbit-fix.md +282 -0
  14. package/global/commands/efficiency.md +356 -0
  15. package/global/commands/evidence.md +117 -33
  16. package/global/commands/hud.md +24 -0
  17. package/global/commands/insights.md +101 -3
  18. package/global/commands/orient.md +22 -21
  19. package/global/commands/patterns.md +115 -0
  20. package/global/commands/ralph.md +47 -1
  21. package/global/commands/token-budget.md +214 -0
  22. package/global/commands/weekly-review.md +21 -1
  23. package/global/config/notifications.yaml.template +50 -0
  24. package/global/hooks/ralph_stop.sh +33 -1
  25. package/global/hooks/statusline.sh +67 -2
  26. package/global/lib/__pycache__/coderabbit_metrics.cpython-314.pyc +0 -0
  27. package/global/lib/__pycache__/command_tracker.cpython-314.pyc +0 -0
  28. package/global/lib/__pycache__/context_optimizer.cpython-314.pyc +0 -0
  29. package/global/lib/__pycache__/git_utils.cpython-314.pyc +0 -0
  30. package/global/lib/__pycache__/issue_models.cpython-314.pyc +0 -0
  31. package/global/lib/__pycache__/linear_provider.cpython-314.pyc +0 -0
  32. package/global/lib/__pycache__/optimization_applier.cpython-314.pyc +0 -0
  33. package/global/lib/__pycache__/ralph_state.cpython-314.pyc +0 -0
  34. package/global/lib/__pycache__/ralph_webhooks.cpython-314.pyc +0 -0
  35. package/global/lib/__pycache__/state_manager.cpython-314.pyc +0 -0
  36. package/global/lib/__pycache__/token_analyzer.cpython-314.pyc +0 -0
  37. package/global/lib/__pycache__/token_metrics.cpython-314.pyc +0 -0
  38. package/global/lib/coderabbit_metrics.py +647 -0
  39. package/global/lib/command_tracker.py +147 -0
  40. package/global/lib/context_optimizer.py +323 -0
  41. package/global/lib/linear_provider.py +210 -16
  42. package/global/lib/log_rotation.py +287 -0
  43. package/global/lib/optimization_applier.py +582 -0
  44. package/global/lib/ralph_events.py +398 -0
  45. package/global/lib/ralph_notifier.py +366 -0
  46. package/global/lib/ralph_state.py +264 -24
  47. package/global/lib/ralph_webhooks.py +470 -0
  48. package/global/lib/state_manager.py +121 -0
  49. package/global/lib/token_analyzer.py +1383 -0
  50. package/global/lib/token_metrics.py +919 -0
  51. package/global/tests/__pycache__/test_command_tracker.cpython-314-pytest-9.0.2.pyc +0 -0
  52. package/global/tests/__pycache__/test_context_optimizer.cpython-314-pytest-9.0.2.pyc +0 -0
  53. package/global/tests/__pycache__/test_doc_coverage.cpython-314-pytest-9.0.2.pyc +0 -0
  54. package/global/tests/__pycache__/test_git_utils.cpython-314-pytest-9.0.2.pyc +0 -0
  55. package/global/tests/__pycache__/test_issue_models.cpython-314-pytest-9.0.2.pyc +0 -0
  56. package/global/tests/__pycache__/test_linear_filtering.cpython-314-pytest-9.0.2.pyc +0 -0
  57. package/global/tests/__pycache__/test_linear_provider.cpython-314-pytest-9.0.2.pyc +0 -0
  58. package/global/tests/__pycache__/test_local_provider.cpython-314-pytest-9.0.2.pyc +0 -0
  59. package/global/tests/__pycache__/test_optimization_applier.cpython-314-pytest-9.0.2.pyc +0 -0
  60. package/global/tests/__pycache__/test_token_analyzer.cpython-314-pytest-9.0.2.pyc +0 -0
  61. package/global/tests/__pycache__/test_token_analyzer_phase6.cpython-314-pytest-9.0.2.pyc +0 -0
  62. package/global/tests/__pycache__/test_token_metrics.cpython-314-pytest-9.0.2.pyc +0 -0
  63. package/global/tests/test_command_tracker.py +172 -0
  64. package/global/tests/test_context_optimizer.py +321 -0
  65. package/global/tests/test_linear_filtering.py +319 -0
  66. package/global/tests/test_linear_provider.py +40 -1
  67. package/global/tests/test_optimization_applier.py +508 -0
  68. package/global/tests/test_token_analyzer.py +735 -0
  69. package/global/tests/test_token_analyzer_phase6.py +537 -0
  70. package/global/tests/test_token_metrics.py +829 -0
  71. package/global/tools/README.md +153 -0
  72. package/global/tools/__pycache__/anvil-hud.cpython-314.pyc +0 -0
  73. package/global/tools/__pycache__/orient_linear.cpython-314.pyc +0 -0
  74. package/global/tools/__pycache__/ralph-watchcpython-314.pyc +0 -0
  75. package/global/tools/anvil-hud.py +86 -1
  76. package/global/tools/anvil-memory/src/__tests__/ccs/context-monitor.test.ts +472 -0
  77. package/global/tools/anvil-memory/src/__tests__/ccs/fixtures.ts +405 -0
  78. package/global/tools/anvil-memory/src/__tests__/ccs/index.ts +36 -0
  79. package/global/tools/anvil-memory/src/__tests__/ccs/prompt-generator.test.ts +653 -0
  80. package/global/tools/anvil-memory/src/__tests__/ccs/ralph-stop.test.ts +727 -0
  81. package/global/tools/anvil-memory/src/__tests__/ccs/test-utils.ts +340 -0
  82. package/global/tools/anvil-memory/src/__tests__/commands.test.ts +218 -0
  83. package/global/tools/anvil-memory/src/commands/context.ts +322 -0
  84. package/global/tools/anvil-memory/src/db.ts +108 -0
  85. package/global/tools/anvil-memory/src/index.ts +2 -8
  86. package/global/tools/orient_linear.py +159 -0
  87. package/global/tools/ralph-watch +423 -0
  88. package/package.json +2 -1
  89. package/project/.anvil-project.yaml.template +93 -0
  90. package/project/CLAUDE.md.template +343 -0
  91. package/project/agents/README.md +119 -0
  92. package/project/agents/cross-layer-debugger.md +217 -0
  93. package/project/agents/security-code-reviewer.md +162 -0
  94. package/project/constitution.md.template +235 -0
  95. package/project/coordination.md +103 -0
  96. package/project/docs/background-tasks.md +258 -0
  97. package/project/docs/skills-frontmatter.md +243 -0
  98. package/project/examples/README.md +106 -0
  99. package/project/examples/api-route-template.ts +171 -0
  100. package/project/examples/component-template.tsx +110 -0
  101. package/project/examples/hook-template.ts +152 -0
  102. package/project/examples/service-template.ts +207 -0
  103. package/project/examples/test-template.test.tsx +249 -0
  104. package/project/hooks/README.md +491 -0
  105. package/project/hooks/__pycache__/notification.cpython-314.pyc +0 -0
  106. package/project/hooks/__pycache__/post_tool_use.cpython-314.pyc +0 -0
  107. package/project/hooks/__pycache__/pre_tool_use.cpython-314.pyc +0 -0
  108. package/project/hooks/__pycache__/session_start.cpython-314.pyc +0 -0
  109. package/project/hooks/__pycache__/stop.cpython-314.pyc +0 -0
  110. package/project/hooks/notification.py +183 -0
  111. package/project/hooks/permission_request.py +438 -0
  112. package/project/hooks/post_tool_use.py +397 -0
  113. package/project/hooks/pre_compact.py +126 -0
  114. package/project/hooks/pre_tool_use.py +454 -0
  115. package/project/hooks/session_start.py +656 -0
  116. package/project/hooks/stop.py +356 -0
  117. package/project/hooks/subagent_start.py +223 -0
  118. package/project/hooks/subagent_stop.py +215 -0
  119. package/project/hooks/user_prompt_submit.py +110 -0
  120. package/project/hooks/utils/llm/anth.py +114 -0
  121. package/project/hooks/utils/llm/oai.py +114 -0
  122. package/project/hooks/utils/tts/elevenlabs_tts.py +63 -0
  123. package/project/hooks/utils/tts/mlx_audio_tts.py +86 -0
  124. package/project/hooks/utils/tts/openai_tts.py +92 -0
  125. package/project/hooks/utils/tts/pyttsx3_tts.py +75 -0
  126. package/project/linear.yaml.template +23 -0
  127. package/project/product.md.template +238 -0
  128. package/project/retros/README.md +126 -0
  129. package/project/rules/README.md +90 -0
  130. package/project/rules/debugging.md +139 -0
  131. package/project/rules/security-review.md +115 -0
  132. package/project/settings.yaml.template +185 -0
  133. package/project/specs/SPEC-ANV-72-hud-kanban.md +525 -0
  134. package/project/templates/api-python/CLAUDE.md +547 -0
  135. package/project/templates/generic/CLAUDE.md +260 -0
  136. package/project/templates/saas/CLAUDE.md +478 -0
  137. package/project/tests/README.md +140 -0
  138. package/project/tests/__pycache__/test_transcript_parser.cpython-314-pytest-9.0.2.pyc +0 -0
  139. package/project/tests/fixtures/sample-transcript.jsonl +21 -0
  140. package/project/tests/test-hooks.sh +259 -0
  141. package/project/tests/test-lib.sh +248 -0
  142. package/project/tests/test-statusline.sh +165 -0
  143. package/project/tests/test_transcript_parser.py +323 -0
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Unit tests for command_tracker.py (ANV-293)
4
+
5
+ Run with: python3 -m pytest global/tests/test_command_tracker.py -v
6
+ """
7
+
8
+ import sys
9
+ import os
10
+ import tempfile
11
+ from pathlib import Path
12
+
13
+ # Add parent directory to path for imports
14
+ sys.path.insert(0, str(Path(__file__).parent.parent / "lib"))
15
+
16
+ from token_metrics import TokenMetrics
17
+ from command_tracker import (
18
+ track_command_load,
19
+ get_command_token_estimate,
20
+ _find_command_content,
21
+ )
22
+
23
+
24
+ class TestCommandTracker:
25
+ """Tests for command tracking functionality."""
26
+
27
+ def setup_method(self):
28
+ """Create a fresh metrics instance with temp database."""
29
+ self.temp_dir = tempfile.mkdtemp()
30
+ self.db_path = Path(self.temp_dir) / "test_metrics.db"
31
+
32
+ # Create a temp command file
33
+ self.cmd_dir = Path(self.temp_dir) / ".claude" / "commands"
34
+ self.cmd_dir.mkdir(parents=True)
35
+ self.test_command = self.cmd_dir / "test-cmd.md"
36
+ self.test_command.write_text("# Test Command\n\nThis is a test command for tracking.")
37
+
38
+ # Change to temp dir so command finding works
39
+ self.original_cwd = os.getcwd()
40
+ os.chdir(self.temp_dir)
41
+
42
+ # Initialize metrics with temp database
43
+ self.metrics = TokenMetrics(db_path=self.db_path)
44
+ self.metrics.start_session()
45
+
46
+ # Patch get_metrics to return our test instance
47
+ import command_tracker
48
+ self._original_get_metrics = command_tracker.get_metrics
49
+ command_tracker.get_metrics = lambda: self.metrics
50
+
51
+ def teardown_method(self):
52
+ """Clean up temp files and restore state."""
53
+ self.metrics.end_session()
54
+ os.chdir(self.original_cwd)
55
+
56
+ # Restore get_metrics
57
+ import command_tracker
58
+ command_tracker.get_metrics = self._original_get_metrics
59
+
60
+ # Clean up temp files
61
+ if self.db_path.exists():
62
+ self.db_path.unlink()
63
+ if self.test_command.exists():
64
+ self.test_command.unlink()
65
+ self.cmd_dir.rmdir()
66
+ (Path(self.temp_dir) / ".claude").rmdir()
67
+ os.rmdir(self.temp_dir)
68
+
69
+ def test_track_command_load_with_source_path(self):
70
+ """Should track command load when source path is provided."""
71
+ tokens = track_command_load("test-cmd", source_path=str(self.test_command))
72
+
73
+ assert tokens is not None
74
+ assert tokens > 0
75
+
76
+ # Verify it was recorded in the database
77
+ summary = self.metrics.get_session_summary()
78
+ assert summary.total_tokens >= tokens
79
+ assert "command" in summary.component_breakdown
80
+
81
+ def test_track_command_load_with_content(self):
82
+ """Should track command load when content is provided directly."""
83
+ content = "# Direct Content\n\nThis is test content."
84
+ tokens = track_command_load("direct-cmd", content=content)
85
+
86
+ assert tokens is not None
87
+ assert tokens > 0
88
+
89
+ summary = self.metrics.get_session_summary()
90
+ assert summary.total_tokens >= tokens
91
+
92
+ def test_track_command_load_finds_file_automatically(self):
93
+ """Should find command file in known locations."""
94
+ tokens = track_command_load("test-cmd")
95
+
96
+ assert tokens is not None
97
+ assert tokens > 0
98
+
99
+ def test_track_command_load_nonexistent_command(self):
100
+ """Should handle nonexistent commands gracefully."""
101
+ tokens = track_command_load("nonexistent-cmd")
102
+
103
+ # Should return 0 or None when content can't be found
104
+ assert tokens is None or tokens == 0
105
+
106
+ def test_find_command_content(self):
107
+ """Should find command content from known locations."""
108
+ content = _find_command_content("test-cmd")
109
+ assert content is not None
110
+ assert "Test Command" in content
111
+
112
+ def test_find_command_content_not_found(self):
113
+ """Should return None for commands that don't exist."""
114
+ content = _find_command_content("nonexistent-cmd")
115
+ assert content is None
116
+
117
+ def test_get_command_token_estimate(self):
118
+ """Should estimate tokens without recording."""
119
+ estimate = get_command_token_estimate("test-cmd")
120
+
121
+ assert estimate is not None
122
+ assert estimate > 0
123
+
124
+ # Verify nothing was recorded (no change to session)
125
+ summary = self.metrics.get_session_summary()
126
+ assert summary.total_tokens == 0
127
+
128
+ def test_track_command_multiple_loads(self):
129
+ """Should track multiple command loads."""
130
+ tokens1 = track_command_load("test-cmd")
131
+ tokens2 = track_command_load("test-cmd")
132
+
133
+ assert tokens1 is not None
134
+ assert tokens2 is not None
135
+
136
+ summary = self.metrics.get_session_summary()
137
+ assert summary.total_tokens >= tokens1 + tokens2
138
+
139
+
140
+ class TestCommandTrackerNoSession:
141
+ """Tests for command tracking without active session."""
142
+
143
+ def setup_method(self):
144
+ """Create metrics instance without starting session."""
145
+ self.temp_dir = tempfile.mkdtemp()
146
+ self.db_path = Path(self.temp_dir) / "test_metrics.db"
147
+
148
+ self.metrics = TokenMetrics(db_path=self.db_path)
149
+ # Note: NOT starting session
150
+
151
+ import command_tracker
152
+ self._original_get_metrics = command_tracker.get_metrics
153
+ command_tracker.get_metrics = lambda: self.metrics
154
+
155
+ def teardown_method(self):
156
+ """Clean up."""
157
+ import command_tracker
158
+ command_tracker.get_metrics = self._original_get_metrics
159
+
160
+ if self.db_path.exists():
161
+ self.db_path.unlink()
162
+ os.rmdir(self.temp_dir)
163
+
164
+ def test_track_without_session_returns_none(self):
165
+ """Should return None when no session is active."""
166
+ tokens = track_command_load("test-cmd", content="Test content")
167
+ assert tokens is None
168
+
169
+
170
+ if __name__ == "__main__":
171
+ import pytest
172
+ pytest.main([__file__, "-v"])
@@ -0,0 +1,321 @@
1
+ """
2
+ Tests for Context Optimizer Service (Phase 5).
3
+
4
+ Tests the trigger keyword detection and context loading optimization.
5
+ """
6
+
7
+ import pytest
8
+ from datetime import datetime, timezone
9
+
10
+ # Import the module under test
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ # Add parent directory to path for imports
15
+ sys.path.insert(0, str(Path(__file__).parent.parent / "lib"))
16
+
17
+ from context_optimizer import (
18
+ ContextOptimizer,
19
+ TriggerRule,
20
+ LoadSuggestion,
21
+ DEFAULT_TRIGGER_RULES,
22
+ get_optimizer,
23
+ detect_triggers,
24
+ format_trigger_table,
25
+ )
26
+
27
+
28
+ class TestTriggerRule:
29
+ """Tests for TriggerRule dataclass."""
30
+
31
+ def test_trigger_rule_creation(self):
32
+ """Test creating a trigger rule."""
33
+ rule = TriggerRule(
34
+ command="/test",
35
+ keywords=["test", "testing"],
36
+ description="Test command",
37
+ estimated_tokens=100,
38
+ priority=1
39
+ )
40
+ assert rule.command == "/test"
41
+ assert rule.keywords == ["test", "testing"]
42
+ assert rule.estimated_tokens == 100
43
+ assert rule.priority == 1
44
+
45
+ def test_trigger_rule_default_priority(self):
46
+ """Test default priority is 2."""
47
+ rule = TriggerRule(
48
+ command="/cmd",
49
+ keywords=["kw"],
50
+ description="desc",
51
+ estimated_tokens=50
52
+ )
53
+ assert rule.priority == 2
54
+
55
+
56
+ class TestLoadSuggestion:
57
+ """Tests for LoadSuggestion dataclass."""
58
+
59
+ def test_load_suggestion_creation(self):
60
+ """Test creating a load suggestion."""
61
+ suggestion = LoadSuggestion(
62
+ command="/patterns",
63
+ reason="Anti-patterns and code patterns",
64
+ estimated_tokens=1500,
65
+ priority=1,
66
+ keywords_matched=["anti-pattern", "avoid"]
67
+ )
68
+ assert suggestion.command == "/patterns"
69
+ assert len(suggestion.keywords_matched) == 2
70
+
71
+
72
+ class TestContextOptimizer:
73
+ """Tests for ContextOptimizer class."""
74
+
75
+ def test_init_with_default_rules(self):
76
+ """Test initialization with default rules."""
77
+ optimizer = ContextOptimizer()
78
+ assert len(optimizer.rules) == len(DEFAULT_TRIGGER_RULES)
79
+
80
+ def test_init_with_custom_rules(self):
81
+ """Test initialization with custom rules."""
82
+ custom_rules = [
83
+ TriggerRule(
84
+ command="/custom",
85
+ keywords=["custom"],
86
+ description="Custom command",
87
+ estimated_tokens=100
88
+ )
89
+ ]
90
+ optimizer = ContextOptimizer(custom_rules=custom_rules)
91
+ assert len(optimizer.rules) == 1
92
+
93
+ def test_detect_triggers_finds_patterns(self):
94
+ """Test detecting triggers for patterns command."""
95
+ optimizer = ContextOptimizer()
96
+ suggestions = optimizer.detect_triggers("I need help avoiding anti-patterns")
97
+
98
+ # Should find /patterns command
99
+ commands = [s.command for s in suggestions]
100
+ assert "/patterns" in commands
101
+
102
+ def test_detect_triggers_finds_checklist(self):
103
+ """Test detecting triggers for checklist command."""
104
+ optimizer = ContextOptimizer()
105
+ suggestions = optimizer.detect_triggers("I need the quality checklist before PR")
106
+
107
+ commands = [s.command for s in suggestions]
108
+ assert "/checklist" in commands
109
+
110
+ def test_detect_triggers_finds_audit(self):
111
+ """Test detecting triggers for audit command."""
112
+ optimizer = ContextOptimizer()
113
+ suggestions = optimizer.detect_triggers("Show me my token usage")
114
+
115
+ commands = [s.command for s in suggestions]
116
+ assert "/audit" in commands
117
+
118
+ def test_detect_triggers_case_insensitive(self):
119
+ """Test that trigger detection is case insensitive."""
120
+ optimizer = ContextOptimizer()
121
+
122
+ lower_result = optimizer.detect_triggers("avoid bad practice")
123
+ upper_result = optimizer.detect_triggers("AVOID BAD PRACTICE")
124
+
125
+ assert len(lower_result) == len(upper_result)
126
+
127
+ def test_detect_triggers_excludes_loaded(self):
128
+ """Test that already-loaded commands are excluded by default."""
129
+ optimizer = ContextOptimizer()
130
+ optimizer.mark_loaded("/patterns")
131
+
132
+ suggestions = optimizer.detect_triggers("help with anti-patterns")
133
+ commands = [s.command for s in suggestions]
134
+
135
+ assert "/patterns" not in commands
136
+
137
+ def test_detect_triggers_includes_loaded_when_requested(self):
138
+ """Test including loaded commands when explicitly requested."""
139
+ optimizer = ContextOptimizer()
140
+ optimizer.mark_loaded("/patterns")
141
+
142
+ suggestions = optimizer.detect_triggers(
143
+ "help with anti-patterns",
144
+ include_loaded=True
145
+ )
146
+ commands = [s.command for s in suggestions]
147
+
148
+ assert "/patterns" in commands
149
+
150
+ def test_mark_loaded_normalizes_command(self):
151
+ """Test that mark_loaded normalizes command names."""
152
+ optimizer = ContextOptimizer()
153
+
154
+ optimizer.mark_loaded("patterns") # Without /
155
+ assert optimizer.is_loaded("/patterns")
156
+
157
+ optimizer.mark_loaded("/checklist") # With /
158
+ assert optimizer.is_loaded("checklist")
159
+
160
+ def test_get_loaded_commands(self):
161
+ """Test getting list of loaded commands."""
162
+ optimizer = ContextOptimizer()
163
+ optimizer.mark_loaded("/patterns")
164
+ optimizer.mark_loaded("/checklist")
165
+
166
+ loaded = optimizer.get_loaded_commands()
167
+ assert len(loaded) == 2
168
+ assert "/patterns" in loaded
169
+ assert "/checklist" in loaded
170
+
171
+ def test_get_estimated_savings(self):
172
+ """Test calculating estimated token savings."""
173
+ optimizer = ContextOptimizer()
174
+
175
+ # Initially, nothing is loaded
176
+ savings = optimizer.get_estimated_savings()
177
+ assert savings["commands_loaded"] == 0
178
+ assert savings["deferred_tokens"] > 0
179
+ assert savings["savings_percent"] == 100.0
180
+
181
+ # Load one command
182
+ optimizer.mark_loaded("/patterns")
183
+ savings = optimizer.get_estimated_savings()
184
+ assert savings["commands_loaded"] == 1
185
+ assert savings["loaded_tokens"] > 0
186
+
187
+ def test_format_suggestions_empty(self):
188
+ """Test formatting empty suggestions returns empty string."""
189
+ optimizer = ContextOptimizer()
190
+ result = optimizer.format_suggestions([])
191
+ assert result == ""
192
+
193
+ def test_format_suggestions_with_items(self):
194
+ """Test formatting suggestions produces markdown."""
195
+ optimizer = ContextOptimizer()
196
+ suggestions = [
197
+ LoadSuggestion(
198
+ command="/test",
199
+ reason="Test reason",
200
+ estimated_tokens=100,
201
+ priority=1,
202
+ keywords_matched=["test"]
203
+ )
204
+ ]
205
+ result = optimizer.format_suggestions(suggestions)
206
+
207
+ assert "/test" in result
208
+ assert "Test reason" in result
209
+ assert "100 tokens" in result
210
+
211
+ def test_format_suggestions_respects_max(self):
212
+ """Test that format_suggestions respects max_suggestions."""
213
+ optimizer = ContextOptimizer()
214
+ suggestions = optimizer.detect_triggers(
215
+ "anti-pattern avoid mistake checklist quality validate token budget"
216
+ )
217
+
218
+ # Format with max of 2
219
+ result = optimizer.format_suggestions(suggestions, max_suggestions=2)
220
+
221
+ # Should mention "and X more" if there are more
222
+ if len(suggestions) > 2:
223
+ assert "more" in result
224
+
225
+ def test_add_rule(self):
226
+ """Test adding a custom rule."""
227
+ optimizer = ContextOptimizer()
228
+ initial_count = len(optimizer.rules)
229
+
230
+ new_rule = TriggerRule(
231
+ command="/newcmd",
232
+ keywords=["newkeyword"],
233
+ description="New command",
234
+ estimated_tokens=200
235
+ )
236
+ optimizer.add_rule(new_rule)
237
+
238
+ assert len(optimizer.rules) == initial_count + 1
239
+
240
+ def test_reset_session(self):
241
+ """Test resetting session state."""
242
+ optimizer = ContextOptimizer()
243
+ optimizer.mark_loaded("/patterns")
244
+ optimizer.mark_loaded("/checklist")
245
+
246
+ assert len(optimizer.get_loaded_commands()) == 2
247
+
248
+ optimizer.reset_session()
249
+
250
+ assert len(optimizer.get_loaded_commands()) == 0
251
+
252
+ def test_suggestions_sorted_by_priority(self):
253
+ """Test that suggestions are sorted by priority."""
254
+ optimizer = ContextOptimizer()
255
+ suggestions = optimizer.detect_triggers(
256
+ "ralph autonomous migration anti-pattern avoid"
257
+ )
258
+
259
+ # Should be sorted by priority (1, 2, 3)
260
+ priorities = [s.priority for s in suggestions]
261
+ assert priorities == sorted(priorities)
262
+
263
+
264
+ class TestGlobalFunctions:
265
+ """Tests for module-level convenience functions."""
266
+
267
+ def test_get_optimizer_returns_singleton(self):
268
+ """Test that get_optimizer returns a singleton."""
269
+ opt1 = get_optimizer()
270
+ opt2 = get_optimizer()
271
+ assert opt1 is opt2
272
+
273
+ def test_detect_triggers_convenience(self):
274
+ """Test the convenience detect_triggers function."""
275
+ suggestions = detect_triggers("help with anti-patterns")
276
+ assert len(suggestions) > 0
277
+
278
+ def test_format_trigger_table(self):
279
+ """Test formatting trigger table for CLAUDE.md."""
280
+ table = format_trigger_table()
281
+
282
+ assert "| Trigger | Command | Description |" in table
283
+ assert "/patterns" in table
284
+ assert "/checklist" in table
285
+
286
+
287
+ class TestDefaultTriggerRules:
288
+ """Tests for the default trigger rules configuration."""
289
+
290
+ def test_default_rules_exist(self):
291
+ """Test that default rules are defined."""
292
+ assert len(DEFAULT_TRIGGER_RULES) > 0
293
+
294
+ def test_patterns_rule_exists(self):
295
+ """Test that /patterns rule exists with correct keywords."""
296
+ patterns_rule = next(
297
+ (r for r in DEFAULT_TRIGGER_RULES if r.command == "/patterns"),
298
+ None
299
+ )
300
+ assert patterns_rule is not None
301
+ assert "anti-pattern" in patterns_rule.keywords
302
+ assert patterns_rule.priority == 1
303
+
304
+ def test_checklist_rule_exists(self):
305
+ """Test that /checklist rule exists with correct keywords."""
306
+ checklist_rule = next(
307
+ (r for r in DEFAULT_TRIGGER_RULES if r.command == "/checklist"),
308
+ None
309
+ )
310
+ assert checklist_rule is not None
311
+ assert "checklist" in checklist_rule.keywords
312
+ assert "quality" in checklist_rule.keywords
313
+
314
+ def test_all_rules_have_required_fields(self):
315
+ """Test that all default rules have required fields."""
316
+ for rule in DEFAULT_TRIGGER_RULES:
317
+ assert rule.command.startswith("/")
318
+ assert len(rule.keywords) > 0
319
+ assert rule.description
320
+ assert rule.estimated_tokens > 0
321
+ assert rule.priority in [1, 2, 3]