stravinsky 0.2.67__py3-none-any.whl → 0.4.66__py3-none-any.whl

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.

Potentially problematic release.


This version of stravinsky might be problematic. Click here for more details.

Files changed (190) hide show
  1. mcp_bridge/__init__.py +1 -1
  2. mcp_bridge/auth/__init__.py +16 -6
  3. mcp_bridge/auth/cli.py +202 -11
  4. mcp_bridge/auth/oauth.py +1 -2
  5. mcp_bridge/auth/openai_oauth.py +4 -7
  6. mcp_bridge/auth/token_store.py +112 -11
  7. mcp_bridge/cli/__init__.py +1 -1
  8. mcp_bridge/cli/install_hooks.py +503 -107
  9. mcp_bridge/cli/session_report.py +0 -3
  10. mcp_bridge/config/MANIFEST_SCHEMA.md +305 -0
  11. mcp_bridge/config/README.md +276 -0
  12. mcp_bridge/config/__init__.py +2 -2
  13. mcp_bridge/config/hook_config.py +247 -0
  14. mcp_bridge/config/hooks_manifest.json +138 -0
  15. mcp_bridge/config/rate_limits.py +317 -0
  16. mcp_bridge/config/skills_manifest.json +128 -0
  17. mcp_bridge/hooks/HOOKS_SETTINGS.json +17 -4
  18. mcp_bridge/hooks/__init__.py +19 -4
  19. mcp_bridge/hooks/agent_reminder.py +4 -4
  20. mcp_bridge/hooks/auto_slash_command.py +5 -5
  21. mcp_bridge/hooks/budget_optimizer.py +2 -2
  22. mcp_bridge/hooks/claude_limits_hook.py +114 -0
  23. mcp_bridge/hooks/comment_checker.py +3 -4
  24. mcp_bridge/hooks/compaction.py +2 -2
  25. mcp_bridge/hooks/context.py +2 -1
  26. mcp_bridge/hooks/context_monitor.py +2 -2
  27. mcp_bridge/hooks/delegation_policy.py +85 -0
  28. mcp_bridge/hooks/directory_context.py +3 -3
  29. mcp_bridge/hooks/edit_recovery.py +3 -2
  30. mcp_bridge/hooks/edit_recovery_policy.py +49 -0
  31. mcp_bridge/hooks/empty_message_sanitizer.py +2 -2
  32. mcp_bridge/hooks/events.py +160 -0
  33. mcp_bridge/hooks/git_noninteractive.py +4 -4
  34. mcp_bridge/hooks/keyword_detector.py +8 -10
  35. mcp_bridge/hooks/manager.py +43 -22
  36. mcp_bridge/hooks/notification_hook.py +13 -6
  37. mcp_bridge/hooks/parallel_enforcement_policy.py +67 -0
  38. mcp_bridge/hooks/parallel_enforcer.py +5 -5
  39. mcp_bridge/hooks/parallel_execution.py +22 -10
  40. mcp_bridge/hooks/post_tool/parallel_validation.py +103 -0
  41. mcp_bridge/hooks/pre_compact.py +8 -9
  42. mcp_bridge/hooks/pre_tool/agent_spawn_validator.py +115 -0
  43. mcp_bridge/hooks/preemptive_compaction.py +2 -3
  44. mcp_bridge/hooks/routing_notifications.py +80 -0
  45. mcp_bridge/hooks/rules_injector.py +11 -19
  46. mcp_bridge/hooks/session_idle.py +4 -4
  47. mcp_bridge/hooks/session_notifier.py +4 -4
  48. mcp_bridge/hooks/session_recovery.py +4 -5
  49. mcp_bridge/hooks/stravinsky_mode.py +1 -1
  50. mcp_bridge/hooks/subagent_stop.py +1 -3
  51. mcp_bridge/hooks/task_validator.py +2 -2
  52. mcp_bridge/hooks/tmux_manager.py +7 -8
  53. mcp_bridge/hooks/todo_delegation.py +4 -1
  54. mcp_bridge/hooks/todo_enforcer.py +180 -10
  55. mcp_bridge/hooks/tool_messaging.py +113 -10
  56. mcp_bridge/hooks/truncation_policy.py +37 -0
  57. mcp_bridge/hooks/truncator.py +1 -2
  58. mcp_bridge/metrics/cost_tracker.py +115 -0
  59. mcp_bridge/native_search.py +93 -0
  60. mcp_bridge/native_watcher.py +118 -0
  61. mcp_bridge/notifications.py +150 -0
  62. mcp_bridge/orchestrator/enums.py +11 -0
  63. mcp_bridge/orchestrator/router.py +165 -0
  64. mcp_bridge/orchestrator/state.py +32 -0
  65. mcp_bridge/orchestrator/visualization.py +14 -0
  66. mcp_bridge/orchestrator/wisdom.py +34 -0
  67. mcp_bridge/prompts/__init__.py +1 -8
  68. mcp_bridge/prompts/dewey.py +1 -1
  69. mcp_bridge/prompts/planner.py +2 -4
  70. mcp_bridge/prompts/stravinsky.py +53 -31
  71. mcp_bridge/proxy/__init__.py +0 -0
  72. mcp_bridge/proxy/client.py +70 -0
  73. mcp_bridge/proxy/model_server.py +157 -0
  74. mcp_bridge/routing/__init__.py +43 -0
  75. mcp_bridge/routing/config.py +250 -0
  76. mcp_bridge/routing/model_tiers.py +135 -0
  77. mcp_bridge/routing/provider_state.py +261 -0
  78. mcp_bridge/routing/task_classifier.py +190 -0
  79. mcp_bridge/server.py +542 -59
  80. mcp_bridge/server_tools.py +738 -6
  81. mcp_bridge/tools/__init__.py +40 -25
  82. mcp_bridge/tools/agent_manager.py +616 -697
  83. mcp_bridge/tools/background_tasks.py +13 -17
  84. mcp_bridge/tools/code_search.py +70 -53
  85. mcp_bridge/tools/continuous_loop.py +0 -1
  86. mcp_bridge/tools/dashboard.py +19 -0
  87. mcp_bridge/tools/find_code.py +296 -0
  88. mcp_bridge/tools/init.py +1 -0
  89. mcp_bridge/tools/list_directory.py +42 -0
  90. mcp_bridge/tools/lsp/__init__.py +12 -5
  91. mcp_bridge/tools/lsp/manager.py +471 -0
  92. mcp_bridge/tools/lsp/tools.py +723 -207
  93. mcp_bridge/tools/model_invoke.py +1195 -273
  94. mcp_bridge/tools/mux_client.py +75 -0
  95. mcp_bridge/tools/project_context.py +1 -2
  96. mcp_bridge/tools/query_classifier.py +406 -0
  97. mcp_bridge/tools/read_file.py +84 -0
  98. mcp_bridge/tools/replace.py +45 -0
  99. mcp_bridge/tools/run_shell_command.py +38 -0
  100. mcp_bridge/tools/search_enhancements.py +347 -0
  101. mcp_bridge/tools/semantic_search.py +3627 -0
  102. mcp_bridge/tools/session_manager.py +0 -2
  103. mcp_bridge/tools/skill_loader.py +0 -1
  104. mcp_bridge/tools/task_runner.py +5 -7
  105. mcp_bridge/tools/templates.py +3 -3
  106. mcp_bridge/tools/tool_search.py +331 -0
  107. mcp_bridge/tools/write_file.py +29 -0
  108. mcp_bridge/update_manager.py +585 -0
  109. mcp_bridge/update_manager_pypi.py +297 -0
  110. mcp_bridge/utils/cache.py +82 -0
  111. mcp_bridge/utils/process.py +71 -0
  112. mcp_bridge/utils/session_state.py +51 -0
  113. mcp_bridge/utils/truncation.py +76 -0
  114. stravinsky-0.4.66.dist-info/METADATA +517 -0
  115. stravinsky-0.4.66.dist-info/RECORD +198 -0
  116. {stravinsky-0.2.67.dist-info → stravinsky-0.4.66.dist-info}/entry_points.txt +1 -0
  117. stravinsky_claude_assets/HOOKS_INTEGRATION.md +316 -0
  118. stravinsky_claude_assets/agents/HOOKS.md +437 -0
  119. stravinsky_claude_assets/agents/code-reviewer.md +210 -0
  120. stravinsky_claude_assets/agents/comment_checker.md +580 -0
  121. stravinsky_claude_assets/agents/debugger.md +254 -0
  122. stravinsky_claude_assets/agents/delphi.md +495 -0
  123. stravinsky_claude_assets/agents/dewey.md +248 -0
  124. stravinsky_claude_assets/agents/explore.md +1198 -0
  125. stravinsky_claude_assets/agents/frontend.md +472 -0
  126. stravinsky_claude_assets/agents/implementation-lead.md +164 -0
  127. stravinsky_claude_assets/agents/momus.md +464 -0
  128. stravinsky_claude_assets/agents/research-lead.md +141 -0
  129. stravinsky_claude_assets/agents/stravinsky.md +730 -0
  130. stravinsky_claude_assets/commands/delphi.md +9 -0
  131. stravinsky_claude_assets/commands/dewey.md +54 -0
  132. stravinsky_claude_assets/commands/git-master.md +112 -0
  133. stravinsky_claude_assets/commands/index.md +49 -0
  134. stravinsky_claude_assets/commands/publish.md +86 -0
  135. stravinsky_claude_assets/commands/review.md +73 -0
  136. stravinsky_claude_assets/commands/str/agent_cancel.md +70 -0
  137. stravinsky_claude_assets/commands/str/agent_list.md +56 -0
  138. stravinsky_claude_assets/commands/str/agent_output.md +92 -0
  139. stravinsky_claude_assets/commands/str/agent_progress.md +74 -0
  140. stravinsky_claude_assets/commands/str/agent_retry.md +94 -0
  141. stravinsky_claude_assets/commands/str/cancel.md +51 -0
  142. stravinsky_claude_assets/commands/str/clean.md +97 -0
  143. stravinsky_claude_assets/commands/str/continue.md +38 -0
  144. stravinsky_claude_assets/commands/str/index.md +199 -0
  145. stravinsky_claude_assets/commands/str/list_watchers.md +96 -0
  146. stravinsky_claude_assets/commands/str/search.md +205 -0
  147. stravinsky_claude_assets/commands/str/start_filewatch.md +136 -0
  148. stravinsky_claude_assets/commands/str/stats.md +71 -0
  149. stravinsky_claude_assets/commands/str/stop_filewatch.md +89 -0
  150. stravinsky_claude_assets/commands/str/unwatch.md +42 -0
  151. stravinsky_claude_assets/commands/str/watch.md +45 -0
  152. stravinsky_claude_assets/commands/strav.md +53 -0
  153. stravinsky_claude_assets/commands/stravinsky.md +292 -0
  154. stravinsky_claude_assets/commands/verify.md +60 -0
  155. stravinsky_claude_assets/commands/version.md +5 -0
  156. stravinsky_claude_assets/hooks/README.md +248 -0
  157. stravinsky_claude_assets/hooks/comment_checker.py +193 -0
  158. stravinsky_claude_assets/hooks/context.py +38 -0
  159. stravinsky_claude_assets/hooks/context_monitor.py +153 -0
  160. stravinsky_claude_assets/hooks/dependency_tracker.py +73 -0
  161. stravinsky_claude_assets/hooks/edit_recovery.py +46 -0
  162. stravinsky_claude_assets/hooks/execution_state_tracker.py +68 -0
  163. stravinsky_claude_assets/hooks/notification_hook.py +103 -0
  164. stravinsky_claude_assets/hooks/notification_hook_v2.py +96 -0
  165. stravinsky_claude_assets/hooks/parallel_execution.py +241 -0
  166. stravinsky_claude_assets/hooks/parallel_reinforcement.py +106 -0
  167. stravinsky_claude_assets/hooks/parallel_reinforcement_v2.py +112 -0
  168. stravinsky_claude_assets/hooks/pre_compact.py +123 -0
  169. stravinsky_claude_assets/hooks/ralph_loop.py +173 -0
  170. stravinsky_claude_assets/hooks/session_recovery.py +263 -0
  171. stravinsky_claude_assets/hooks/stop_hook.py +89 -0
  172. stravinsky_claude_assets/hooks/stravinsky_metrics.py +164 -0
  173. stravinsky_claude_assets/hooks/stravinsky_mode.py +146 -0
  174. stravinsky_claude_assets/hooks/subagent_stop.py +98 -0
  175. stravinsky_claude_assets/hooks/todo_continuation.py +111 -0
  176. stravinsky_claude_assets/hooks/todo_delegation.py +96 -0
  177. stravinsky_claude_assets/hooks/tool_messaging.py +281 -0
  178. stravinsky_claude_assets/hooks/truncator.py +23 -0
  179. stravinsky_claude_assets/rules/deployment_safety.md +51 -0
  180. stravinsky_claude_assets/rules/integration_wiring.md +89 -0
  181. stravinsky_claude_assets/rules/pypi_deployment.md +220 -0
  182. stravinsky_claude_assets/rules/stravinsky_orchestrator.md +32 -0
  183. stravinsky_claude_assets/settings.json +152 -0
  184. stravinsky_claude_assets/skills/chrome-devtools/SKILL.md +81 -0
  185. stravinsky_claude_assets/skills/sqlite/SKILL.md +77 -0
  186. stravinsky_claude_assets/skills/supabase/SKILL.md +74 -0
  187. stravinsky_claude_assets/task_dependencies.json +34 -0
  188. stravinsky-0.2.67.dist-info/METADATA +0 -284
  189. stravinsky-0.2.67.dist-info/RECORD +0 -76
  190. {stravinsky-0.2.67.dist-info → stravinsky-0.4.66.dist-info}/WHEEL +0 -0
@@ -0,0 +1,247 @@
1
+ """
2
+ Hook configuration with selective disabling support.
3
+
4
+ Provides batteries-included defaults with user-configurable overrides.
5
+ Users can disable specific hooks via ~/.stravinsky/disable_hooks.txt
6
+ """
7
+
8
+ import logging
9
+ from pathlib import Path
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Default locations for disable hooks config
14
+ DISABLE_HOOKS_PATHS = [
15
+ Path.home() / ".stravinsky" / "disable_hooks.txt",
16
+ Path(".stravinsky") / "disable_hooks.txt",
17
+ Path(".claude") / "disable_hooks.txt",
18
+ ]
19
+
20
+
21
+ def get_disabled_hooks() -> set[str]:
22
+ """
23
+ Load disabled hooks from config files.
24
+
25
+ Checks (in order):
26
+ 1. ~/.stravinsky/disable_hooks.txt (user global)
27
+ 2. .stravinsky/disable_hooks.txt (project local)
28
+ 3. .claude/disable_hooks.txt (claude project local)
29
+
30
+ Returns:
31
+ Set of hook names that should be disabled.
32
+ """
33
+ disabled = set()
34
+
35
+ for path in DISABLE_HOOKS_PATHS:
36
+ if path.exists():
37
+ try:
38
+ content = path.read_text()
39
+ for line in content.splitlines():
40
+ line = line.strip()
41
+ # Skip comments and empty lines
42
+ if line and not line.startswith("#"):
43
+ disabled.add(line)
44
+ logger.debug(f"Loaded disabled hooks from {path}: {disabled}")
45
+ except Exception as e:
46
+ logger.warning(f"Failed to read {path}: {e}")
47
+
48
+ return disabled
49
+
50
+
51
+ def is_hook_enabled(hook_name: str) -> bool:
52
+ """
53
+ Check if a specific hook is enabled.
54
+
55
+ Args:
56
+ hook_name: Name of the hook (e.g., 'comment_checker', 'session_recovery')
57
+
58
+ Returns:
59
+ True if the hook is enabled (not in disable list), False otherwise.
60
+ """
61
+ disabled = get_disabled_hooks()
62
+ return hook_name not in disabled
63
+
64
+
65
+ def get_hook_config_path() -> Path:
66
+ """
67
+ Get the path to the user's hook config directory.
68
+ Creates it if it doesn't exist.
69
+ """
70
+ config_dir = Path.home() / ".stravinsky"
71
+ config_dir.mkdir(parents=True, exist_ok=True)
72
+ return config_dir
73
+
74
+
75
+ def create_sample_disable_hooks() -> Path | None:
76
+ """
77
+ Create a sample disable_hooks.txt file with documentation.
78
+
79
+ Returns:
80
+ Path to the created file, or None if it already exists.
81
+ """
82
+ config_dir = get_hook_config_path()
83
+ disable_file = config_dir / "disable_hooks.txt"
84
+
85
+ if disable_file.exists():
86
+ return None
87
+
88
+ sample_content = """# Stravinsky Hook Disabling Configuration
89
+ # Add hook names (one per line) to disable them.
90
+ # Lines starting with # are comments.
91
+ #
92
+ # Available hooks:
93
+ # ================
94
+ #
95
+ # PreToolUse Hooks:
96
+ # - comment_checker (checks git commit comments for quality)
97
+ # - stravinsky_mode (blocks direct tool calls, forces delegation)
98
+ # - notification_hook (displays agent spawn notifications)
99
+ #
100
+ # PostToolUse Hooks:
101
+ # - session_recovery (detects API errors and logs recovery info)
102
+ # - parallel_execution (injects parallel execution instructions)
103
+ # - todo_delegation (enforces parallel Task spawning for todos)
104
+ # - tool_messaging (user-friendly MCP tool messages)
105
+ # - edit_recovery (suggests recovery for Edit failures)
106
+ # - truncator (truncates long responses)
107
+ # - subagent_stop (handles subagent completion)
108
+ #
109
+ # UserPromptSubmit Hooks:
110
+ # - context (injects CLAUDE.md content)
111
+ # - todo_continuation (reminds about incomplete todos)
112
+ #
113
+ # PreCompact Hooks:
114
+ # - pre_compact (preserves critical context before compaction)
115
+ #
116
+ # Example - to disable the comment checker:
117
+ # comment_checker
118
+ #
119
+ # Example - to disable ultrawork mode detection:
120
+ # parallel_execution
121
+ """
122
+
123
+ disable_file.write_text(sample_content)
124
+ logger.info(f"Created sample disable_hooks.txt at {disable_file}")
125
+ return disable_file
126
+
127
+
128
+ # Hook metadata for batteries-included config
129
+ HOOK_DEFAULTS = {
130
+ # PreToolUse hooks
131
+ "comment_checker": {
132
+ "type": "PreToolUse",
133
+ "description": "Checks git commit comments for quality issues",
134
+ "default_enabled": True,
135
+ "exit_on_block": 0, # Warn but don't block
136
+ },
137
+ "stravinsky_mode": {
138
+ "type": "PreToolUse",
139
+ "description": "Blocks direct tool calls, forces Task delegation",
140
+ "default_enabled": True,
141
+ "exit_on_block": 2, # Hard block
142
+ },
143
+ "notification_hook": {
144
+ "type": "PreToolUse",
145
+ "description": "Displays agent spawn notifications",
146
+ "default_enabled": True,
147
+ "exit_on_block": 0,
148
+ },
149
+ # PostToolUse hooks
150
+ "session_recovery": {
151
+ "type": "PostToolUse",
152
+ "description": "Detects API errors and logs recovery suggestions",
153
+ "default_enabled": True,
154
+ "exit_on_block": 0,
155
+ },
156
+ "parallel_execution": {
157
+ "type": "PostToolUse",
158
+ "description": "Injects parallel execution and ULTRAWORK mode",
159
+ "default_enabled": True,
160
+ "exit_on_block": 0,
161
+ },
162
+ "todo_delegation": {
163
+ "type": "PostToolUse",
164
+ "description": "Enforces parallel Task spawning for 2+ todos",
165
+ "default_enabled": True,
166
+ "exit_on_block": 2, # Hard block in stravinsky mode
167
+ },
168
+ "tool_messaging": {
169
+ "type": "PostToolUse",
170
+ "description": "User-friendly messages for MCP tools",
171
+ "default_enabled": True,
172
+ "exit_on_block": 0,
173
+ },
174
+ "edit_recovery": {
175
+ "type": "PostToolUse",
176
+ "description": "Suggests recovery for Edit failures",
177
+ "default_enabled": True,
178
+ "exit_on_block": 0,
179
+ },
180
+ "truncator": {
181
+ "type": "PostToolUse",
182
+ "description": "Truncates responses longer than 30k chars",
183
+ "default_enabled": True,
184
+ "exit_on_block": 0,
185
+ },
186
+ "subagent_stop": {
187
+ "type": "SubagentStop",
188
+ "description": "Handles subagent completion events",
189
+ "default_enabled": True,
190
+ "exit_on_block": 0,
191
+ },
192
+ # UserPromptSubmit hooks
193
+ "context": {
194
+ "type": "UserPromptSubmit",
195
+ "description": "Injects CLAUDE.md content to prompts",
196
+ "default_enabled": True,
197
+ "exit_on_block": 0,
198
+ },
199
+ "todo_continuation": {
200
+ "type": "UserPromptSubmit",
201
+ "description": "Reminds about incomplete todos",
202
+ "default_enabled": True,
203
+ "exit_on_block": 0,
204
+ },
205
+ # PreCompact hooks
206
+ "pre_compact": {
207
+ "type": "PreCompact",
208
+ "description": "Preserves critical context before compaction",
209
+ "default_enabled": True,
210
+ "exit_on_block": 0,
211
+ },
212
+ }
213
+
214
+
215
+ def get_enabled_hooks() -> dict:
216
+ """
217
+ Get all enabled hooks with their configuration.
218
+
219
+ Returns:
220
+ Dict of hook_name -> hook_config for enabled hooks only.
221
+ """
222
+ disabled = get_disabled_hooks()
223
+ enabled = {}
224
+
225
+ for hook_name, config in HOOK_DEFAULTS.items():
226
+ if hook_name not in disabled and config.get("default_enabled", True):
227
+ enabled[hook_name] = config
228
+
229
+ return enabled
230
+
231
+
232
+ def list_hooks() -> str:
233
+ """
234
+ List all hooks with their status.
235
+
236
+ Returns:
237
+ Formatted string showing hook status.
238
+ """
239
+ disabled = get_disabled_hooks()
240
+ lines = ["# Stravinsky Hooks Status", ""]
241
+
242
+ for hook_name, config in sorted(HOOK_DEFAULTS.items()):
243
+ status = "DISABLED" if hook_name in disabled else "enabled"
244
+ icon = "" if hook_name in disabled else ""
245
+ lines.append(f"{icon} {hook_name}: {status} - {config['description']}")
246
+
247
+ return "\n".join(lines)
@@ -0,0 +1,138 @@
1
+ {
2
+ "schema_version": "1.0.0",
3
+ "manifest_version": "0.3.9",
4
+ "description": "Stravinsky hooks for Claude Code integration",
5
+ "generated_date": "2026-01-08T23:53:08.836595Z",
6
+ "hooks": {
7
+ "context.py": {
8
+ "version": "0.3.9",
9
+ "source": "mcp_bridge/hooks/context.py",
10
+ "description": "Project context injection from local files",
11
+ "checksum": "2411ea9d7ef9",
12
+ "lines_of_code": 38,
13
+ "updatable": true,
14
+ "priority": "high",
15
+ "required": true
16
+ },
17
+ "context_monitor.py": {
18
+ "version": "0.3.9",
19
+ "source": "mcp_bridge/hooks/context_monitor.py",
20
+ "description": "Pre-emptive context optimization at 70% threshold",
21
+ "checksum": "7a8d0615af4f",
22
+ "lines_of_code": 153,
23
+ "updatable": true,
24
+ "priority": "high",
25
+ "required": true
26
+ },
27
+ "edit_recovery.py": {
28
+ "version": "0.3.9",
29
+ "source": "mcp_bridge/hooks/edit_recovery.py",
30
+ "description": "Edit/MultiEdit error recovery helper",
31
+ "checksum": "d4e5a96f7bfc",
32
+ "lines_of_code": 46,
33
+ "updatable": true,
34
+ "priority": "high",
35
+ "required": true
36
+ },
37
+ "notification_hook.py": {
38
+ "version": "0.3.9",
39
+ "source": "mcp_bridge/hooks/notification_hook.py",
40
+ "description": "Agent spawn message formatting",
41
+ "checksum": "184947c5a227",
42
+ "lines_of_code": 103,
43
+ "updatable": true,
44
+ "priority": "high",
45
+ "required": true
46
+ },
47
+ "parallel_execution.py": {
48
+ "version": "0.3.9",
49
+ "source": "mcp_bridge/hooks/parallel_execution.py",
50
+ "description": "Pre-emptive parallel execution enforcement",
51
+ "checksum": "9c820d3d19be",
52
+ "lines_of_code": 111,
53
+ "updatable": true,
54
+ "priority": "high",
55
+ "required": true
56
+ },
57
+ "pre_compact.py": {
58
+ "version": "0.3.9",
59
+ "source": "mcp_bridge/hooks/pre_compact.py",
60
+ "description": "Context preservation before compaction",
61
+ "checksum": "4177023bd901",
62
+ "lines_of_code": 123,
63
+ "updatable": true,
64
+ "priority": "high",
65
+ "required": true
66
+ },
67
+ "stop_hook.py": {
68
+ "version": "0.3.9",
69
+ "source": "mcp_bridge/hooks/stop_hook.py",
70
+ "description": "Continuation loop handler",
71
+ "checksum": "820aef797e2e",
72
+ "lines_of_code": 234,
73
+ "updatable": true,
74
+ "priority": "high",
75
+ "required": true
76
+ },
77
+ "stravinsky_mode.py": {
78
+ "version": "0.3.9",
79
+ "source": "mcp_bridge/hooks/stravinsky_mode.py",
80
+ "description": "Hard blocking of native tools",
81
+ "checksum": "5968a95ebcbe",
82
+ "lines_of_code": 146,
83
+ "updatable": true,
84
+ "priority": "high",
85
+ "required": true
86
+ },
87
+ "subagent_stop.py": {
88
+ "version": "0.3.9",
89
+ "source": "mcp_bridge/hooks/subagent_stop.py",
90
+ "description": "Subagent completion handler",
91
+ "checksum": "1943d8dc5355",
92
+ "lines_of_code": 98,
93
+ "updatable": true,
94
+ "priority": "high",
95
+ "required": true
96
+ },
97
+ "todo_continuation.py": {
98
+ "version": "0.3.9",
99
+ "source": "mcp_bridge/hooks/todo_continuation.py",
100
+ "description": "Todo continuation enforcer",
101
+ "checksum": "b6685355f319",
102
+ "lines_of_code": 90,
103
+ "updatable": true,
104
+ "priority": "high",
105
+ "required": true
106
+ },
107
+ "todo_delegation.py": {
108
+ "version": "0.3.9",
109
+ "source": "mcp_bridge/hooks/todo_delegation.py",
110
+ "description": "Parallel task spawning enforcement",
111
+ "checksum": "b4e004d51600",
112
+ "lines_of_code": 88,
113
+ "updatable": true,
114
+ "priority": "high",
115
+ "required": true
116
+ },
117
+ "tool_messaging.py": {
118
+ "version": "0.3.9",
119
+ "source": "mcp_bridge/hooks/tool_messaging.py",
120
+ "description": "User-friendly tool messaging",
121
+ "checksum": "04a10e76f890",
122
+ "lines_of_code": 263,
123
+ "updatable": true,
124
+ "priority": "high",
125
+ "required": true
126
+ },
127
+ "truncator.py": {
128
+ "version": "0.3.9",
129
+ "source": "mcp_bridge/hooks/truncator.py",
130
+ "description": "Tool response truncation at 30k chars",
131
+ "checksum": "87785bf2c657",
132
+ "lines_of_code": 23,
133
+ "updatable": true,
134
+ "priority": "high",
135
+ "required": true
136
+ }
137
+ }
138
+ }
@@ -0,0 +1,317 @@
1
+ """
2
+ Rate Limiting Configuration for Stravinsky Agent Manager.
3
+
4
+ Provides per-model concurrency limits to prevent API overload.
5
+ Implements semaphore-based rate limiting with configurable limits
6
+ per model family.
7
+
8
+ Configuration file: ~/.stravinsky/config.json
9
+ {
10
+ "rate_limits": {
11
+ "claude-opus-4": 2,
12
+ "claude-sonnet-4.5": 5,
13
+ "gemini-3-flash": 10,
14
+ "gemini-3-pro-high": 5,
15
+ "gpt-5.2": 3
16
+ }
17
+ }
18
+ """
19
+
20
+ import json
21
+ import logging
22
+ import sys
23
+ import threading
24
+ import time
25
+ from collections import defaultdict, deque
26
+ from pathlib import Path
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # Default rate limits per model (conservative defaults)
31
+ DEFAULT_RATE_LIMITS = {
32
+ # Claude models via CLI
33
+ "opus": 2, # Expensive, limit parallel calls
34
+ "sonnet": 5, # Moderate cost
35
+ "haiku": 10, # Cheap, allow more
36
+ # Gemini models via MCP
37
+ "gemini-3-flash": 10, # Free/cheap, allow many
38
+ "gemini-3-pro-high": 5, # Medium cost
39
+ # OpenAI models via MCP
40
+ "gpt-5.2": 3, # Expensive
41
+ # Default for unknown models
42
+ "_default": 5,
43
+ }
44
+
45
+ # Config file location
46
+ CONFIG_FILE = Path.home() / ".stravinsky" / "config.json"
47
+
48
+
49
+ class RateLimiter:
50
+ """
51
+ Semaphore-based rate limiter for model concurrency.
52
+
53
+ Thread-safe implementation that limits concurrent requests
54
+ per model family to prevent API overload.
55
+ """
56
+
57
+ def __init__(self):
58
+ self._semaphores: dict[str, threading.Semaphore] = {}
59
+ self._lock = threading.Lock()
60
+ self._limits = self._load_limits()
61
+ self._active_counts: dict[str, int] = defaultdict(int)
62
+ self._queue_counts: dict[str, int] = defaultdict(int)
63
+
64
+ def _load_limits(self) -> dict[str, int]:
65
+ """Load rate limits from config file or use defaults."""
66
+ limits = DEFAULT_RATE_LIMITS.copy()
67
+
68
+ if CONFIG_FILE.exists():
69
+ try:
70
+ with open(CONFIG_FILE) as f:
71
+ config = json.load(f)
72
+ if "rate_limits" in config:
73
+ limits.update(config["rate_limits"])
74
+ logger.info(f"[RateLimiter] Loaded custom limits from {CONFIG_FILE}")
75
+ except (OSError, json.JSONDecodeError) as e:
76
+ logger.warning(f"[RateLimiter] Failed to load config: {e}")
77
+
78
+ return limits
79
+
80
+ def _get_semaphore(self, model: str) -> threading.Semaphore:
81
+ """Get or create a semaphore for a model."""
82
+ with self._lock:
83
+ if model not in self._semaphores:
84
+ limit = self._limits.get(model, self._limits.get("_default", 5))
85
+ self._semaphores[model] = threading.Semaphore(limit)
86
+ logger.debug(f"[RateLimiter] Created semaphore for {model} with limit {limit}")
87
+ return self._semaphores[model]
88
+
89
+ def _normalize_model(self, model: str) -> str:
90
+ """Normalize model name to match config keys."""
91
+ model_lower = model.lower()
92
+
93
+ # Match known patterns
94
+ if "opus" in model_lower:
95
+ return "opus"
96
+ elif "sonnet" in model_lower:
97
+ return "sonnet"
98
+ elif "haiku" in model_lower:
99
+ return "haiku"
100
+ elif "gemini" in model_lower and "flash" in model_lower:
101
+ return "gemini-3-flash"
102
+ elif "gemini" in model_lower and ("pro" in model_lower or "high" in model_lower):
103
+ return "gemini-3-pro-high"
104
+ elif "gpt" in model_lower:
105
+ return "gpt-5.2"
106
+
107
+ return model_lower
108
+
109
+ def acquire(self, model: str, timeout: float = 60.0) -> bool:
110
+ """
111
+ Acquire a slot for the given model.
112
+
113
+ Args:
114
+ model: Model name to acquire slot for
115
+ timeout: Maximum time to wait in seconds
116
+
117
+ Returns:
118
+ True if slot acquired, False if timed out
119
+ """
120
+ normalized = self._normalize_model(model)
121
+ semaphore = self._get_semaphore(normalized)
122
+
123
+ with self._lock:
124
+ self._queue_counts[normalized] += 1
125
+
126
+ logger.debug(f"[RateLimiter] Acquiring slot for {normalized}")
127
+ acquired = semaphore.acquire(blocking=True, timeout=timeout)
128
+
129
+ with self._lock:
130
+ self._queue_counts[normalized] -= 1
131
+ if acquired:
132
+ self._active_counts[normalized] += 1
133
+
134
+ if acquired:
135
+ logger.debug(f"[RateLimiter] Acquired slot for {normalized}")
136
+ else:
137
+ logger.warning(f"[RateLimiter] Timeout waiting for slot for {normalized}")
138
+
139
+ return acquired
140
+
141
+ def release(self, model: str):
142
+ """Release a slot for the given model."""
143
+ normalized = self._normalize_model(model)
144
+ semaphore = self._get_semaphore(normalized)
145
+
146
+ with self._lock:
147
+ self._active_counts[normalized] = max(0, self._active_counts[normalized] - 1)
148
+
149
+ semaphore.release()
150
+ logger.debug(f"[RateLimiter] Released slot for {normalized}")
151
+
152
+ def get_status(self) -> dict[str, dict[str, int]]:
153
+ """Get current rate limiter status."""
154
+ with self._lock:
155
+ return {
156
+ model: {
157
+ "limit": self._limits.get(model, self._limits.get("_default", 5)),
158
+ "active": self._active_counts[model],
159
+ "queued": self._queue_counts[model],
160
+ }
161
+ for model in set(list(self._active_counts.keys()) + list(self._queue_counts.keys()))
162
+ }
163
+
164
+ def update_limits(self, new_limits: dict[str, int]):
165
+ """
166
+ Update rate limits dynamically.
167
+
168
+ Note: This only affects new semaphores. Existing ones
169
+ will continue with their original limits until recreated.
170
+ """
171
+ with self._lock:
172
+ self._limits.update(new_limits)
173
+ logger.info(f"[RateLimiter] Updated limits: {new_limits}")
174
+
175
+
176
+ class RateLimitContext:
177
+ """Context manager for rate-limited model access."""
178
+
179
+ def __init__(self, limiter: RateLimiter, model: str, timeout: float = 60.0):
180
+ self.limiter = limiter
181
+ self.model = model
182
+ self.timeout = timeout
183
+ self.acquired = False
184
+
185
+ def __enter__(self):
186
+ self.acquired = self.limiter.acquire(self.model, self.timeout)
187
+ if not self.acquired:
188
+ raise TimeoutError(f"Rate limit timeout for model {self.model}")
189
+ return self
190
+
191
+ def __exit__(self, exc_type, exc_val, exc_tb):
192
+ if self.acquired:
193
+ self.limiter.release(self.model)
194
+ return False
195
+
196
+
197
+ # Global rate limiter instance
198
+ _rate_limiter: RateLimiter | None = None
199
+ _rate_limiter_lock = threading.Lock()
200
+
201
+
202
+ def get_rate_limiter() -> RateLimiter:
203
+ """Get or create the global RateLimiter instance."""
204
+ global _rate_limiter
205
+ if _rate_limiter is None:
206
+ with _rate_limiter_lock:
207
+ if _rate_limiter is None:
208
+ _rate_limiter = RateLimiter()
209
+ return _rate_limiter
210
+
211
+
212
+ def rate_limited(model: str, timeout: float = 60.0) -> RateLimitContext:
213
+ """
214
+ Get a rate-limited context for a model.
215
+
216
+ Usage:
217
+ with rate_limited("gemini-3-flash") as ctx:
218
+ # Make API call
219
+ pass
220
+ """
221
+ return RateLimitContext(get_rate_limiter(), model, timeout)
222
+
223
+
224
+ class TimeWindowRateLimiter:
225
+ """
226
+ Time-window rate limiter (30 requests/minute) with user-visible feedback.
227
+
228
+ Implements sliding window algorithm for accurate rate limiting.
229
+ Complements the existing semaphore-based concurrency limiter.
230
+ """
231
+
232
+ def __init__(self, calls: int = 30, period: int = 60):
233
+ """
234
+ Initialize time-window rate limiter.
235
+
236
+ Args:
237
+ calls: Maximum number of calls per period (default: 30)
238
+ period: Time period in seconds (default: 60)
239
+ """
240
+ self.calls = calls
241
+ self.period = period
242
+ self._timestamps: deque = deque()
243
+ self._lock = threading.Lock()
244
+
245
+ def acquire_visible(self, provider: str, auth_mode: str) -> float:
246
+ """
247
+ Acquire slot with user-visible feedback.
248
+
249
+ Args:
250
+ provider: Provider name for logging (e.g., "GEMINI", "OPENAI")
251
+ auth_mode: Authentication method (e.g., "OAuth", "API key")
252
+
253
+ Returns:
254
+ wait_time: Time to wait in seconds (0 if no wait needed)
255
+
256
+ Note:
257
+ This method prints status to stderr for user visibility.
258
+ """
259
+ with self._lock:
260
+ now = time.time()
261
+
262
+ # Clean old timestamps (sliding window)
263
+ while self._timestamps and self._timestamps[0] < now - self.period:
264
+ self._timestamps.popleft()
265
+
266
+ current = len(self._timestamps)
267
+
268
+ if current < self.calls:
269
+ self._timestamps.append(now)
270
+ # Show current count in stderr for visibility
271
+ print(
272
+ f"🔮 {provider} ({auth_mode}): {current + 1}/{self.calls} this minute",
273
+ file=sys.stderr,
274
+ )
275
+ return 0.0
276
+
277
+ # Rate limit hit - calculate wait time
278
+ wait_time = self._timestamps[0] + self.period - now
279
+ print(
280
+ f"⏳ RATE LIMIT ({provider}): {self.calls}/min hit. Waiting {wait_time:.1f}s...",
281
+ file=sys.stderr,
282
+ )
283
+ logger.warning(
284
+ f"[RateLimit] {provider} hit {self.calls}/min limit. Waiting {wait_time:.1f}s ({auth_mode})"
285
+ )
286
+
287
+ return wait_time
288
+
289
+ def get_stats(self) -> dict[str, int]:
290
+ """Get current rate limiter statistics."""
291
+ with self._lock:
292
+ now = time.time()
293
+ # Clean old timestamps
294
+ while self._timestamps and self._timestamps[0] < now - self.period:
295
+ self._timestamps.popleft()
296
+
297
+ return {
298
+ "current_count": len(self._timestamps),
299
+ "limit": self.calls,
300
+ "period_seconds": self.period,
301
+ }
302
+
303
+
304
+ # Global time-window rate limiter for Gemini (30 req/min)
305
+ _gemini_time_limiter: TimeWindowRateLimiter | None = None
306
+ _gemini_time_limiter_lock = threading.Lock()
307
+
308
+
309
+ def get_gemini_time_limiter() -> TimeWindowRateLimiter:
310
+ """Get or create the global Gemini time-window rate limiter."""
311
+ global _gemini_time_limiter
312
+ if _gemini_time_limiter is None:
313
+ with _gemini_time_limiter_lock:
314
+ if _gemini_time_limiter is None:
315
+ _gemini_time_limiter = TimeWindowRateLimiter(calls=30, period=60)
316
+ logger.info("[TimeWindowRateLimiter] Created Gemini rate limiter (30 req/min)")
317
+ return _gemini_time_limiter