stravinsky 0.4.18__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 (184) 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 +0 -1
  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/__init__.py +2 -2
  11. mcp_bridge/config/hook_config.py +3 -5
  12. mcp_bridge/config/rate_limits.py +108 -13
  13. mcp_bridge/hooks/HOOKS_SETTINGS.json +17 -4
  14. mcp_bridge/hooks/__init__.py +14 -4
  15. mcp_bridge/hooks/agent_reminder.py +4 -4
  16. mcp_bridge/hooks/auto_slash_command.py +5 -5
  17. mcp_bridge/hooks/budget_optimizer.py +2 -2
  18. mcp_bridge/hooks/claude_limits_hook.py +114 -0
  19. mcp_bridge/hooks/comment_checker.py +3 -4
  20. mcp_bridge/hooks/compaction.py +2 -2
  21. mcp_bridge/hooks/context.py +2 -1
  22. mcp_bridge/hooks/context_monitor.py +2 -2
  23. mcp_bridge/hooks/delegation_policy.py +85 -0
  24. mcp_bridge/hooks/directory_context.py +3 -3
  25. mcp_bridge/hooks/edit_recovery.py +3 -2
  26. mcp_bridge/hooks/edit_recovery_policy.py +49 -0
  27. mcp_bridge/hooks/empty_message_sanitizer.py +2 -2
  28. mcp_bridge/hooks/events.py +160 -0
  29. mcp_bridge/hooks/git_noninteractive.py +4 -4
  30. mcp_bridge/hooks/keyword_detector.py +8 -10
  31. mcp_bridge/hooks/manager.py +35 -22
  32. mcp_bridge/hooks/notification_hook.py +13 -6
  33. mcp_bridge/hooks/parallel_enforcement_policy.py +67 -0
  34. mcp_bridge/hooks/parallel_enforcer.py +5 -5
  35. mcp_bridge/hooks/parallel_execution.py +22 -10
  36. mcp_bridge/hooks/post_tool/parallel_validation.py +103 -0
  37. mcp_bridge/hooks/pre_compact.py +8 -9
  38. mcp_bridge/hooks/pre_tool/agent_spawn_validator.py +115 -0
  39. mcp_bridge/hooks/preemptive_compaction.py +2 -3
  40. mcp_bridge/hooks/routing_notifications.py +80 -0
  41. mcp_bridge/hooks/rules_injector.py +11 -19
  42. mcp_bridge/hooks/session_idle.py +4 -4
  43. mcp_bridge/hooks/session_notifier.py +4 -4
  44. mcp_bridge/hooks/session_recovery.py +4 -5
  45. mcp_bridge/hooks/stravinsky_mode.py +1 -1
  46. mcp_bridge/hooks/subagent_stop.py +1 -3
  47. mcp_bridge/hooks/task_validator.py +2 -2
  48. mcp_bridge/hooks/tmux_manager.py +7 -8
  49. mcp_bridge/hooks/todo_delegation.py +4 -1
  50. mcp_bridge/hooks/todo_enforcer.py +180 -10
  51. mcp_bridge/hooks/truncation_policy.py +37 -0
  52. mcp_bridge/hooks/truncator.py +1 -2
  53. mcp_bridge/metrics/cost_tracker.py +115 -0
  54. mcp_bridge/native_search.py +93 -0
  55. mcp_bridge/native_watcher.py +118 -0
  56. mcp_bridge/notifications.py +3 -4
  57. mcp_bridge/orchestrator/enums.py +11 -0
  58. mcp_bridge/orchestrator/router.py +165 -0
  59. mcp_bridge/orchestrator/state.py +32 -0
  60. mcp_bridge/orchestrator/visualization.py +14 -0
  61. mcp_bridge/orchestrator/wisdom.py +34 -0
  62. mcp_bridge/prompts/__init__.py +1 -8
  63. mcp_bridge/prompts/dewey.py +1 -1
  64. mcp_bridge/prompts/planner.py +2 -4
  65. mcp_bridge/prompts/stravinsky.py +53 -31
  66. mcp_bridge/proxy/__init__.py +0 -0
  67. mcp_bridge/proxy/client.py +70 -0
  68. mcp_bridge/proxy/model_server.py +157 -0
  69. mcp_bridge/routing/__init__.py +43 -0
  70. mcp_bridge/routing/config.py +250 -0
  71. mcp_bridge/routing/model_tiers.py +135 -0
  72. mcp_bridge/routing/provider_state.py +261 -0
  73. mcp_bridge/routing/task_classifier.py +190 -0
  74. mcp_bridge/server.py +363 -34
  75. mcp_bridge/server_tools.py +298 -6
  76. mcp_bridge/tools/__init__.py +19 -8
  77. mcp_bridge/tools/agent_manager.py +549 -799
  78. mcp_bridge/tools/background_tasks.py +13 -17
  79. mcp_bridge/tools/code_search.py +54 -51
  80. mcp_bridge/tools/continuous_loop.py +0 -1
  81. mcp_bridge/tools/dashboard.py +19 -0
  82. mcp_bridge/tools/find_code.py +296 -0
  83. mcp_bridge/tools/init.py +1 -0
  84. mcp_bridge/tools/list_directory.py +42 -0
  85. mcp_bridge/tools/lsp/__init__.py +8 -8
  86. mcp_bridge/tools/lsp/manager.py +51 -28
  87. mcp_bridge/tools/lsp/tools.py +98 -65
  88. mcp_bridge/tools/model_invoke.py +1047 -152
  89. mcp_bridge/tools/mux_client.py +75 -0
  90. mcp_bridge/tools/project_context.py +1 -2
  91. mcp_bridge/tools/query_classifier.py +132 -49
  92. mcp_bridge/tools/read_file.py +84 -0
  93. mcp_bridge/tools/replace.py +45 -0
  94. mcp_bridge/tools/run_shell_command.py +38 -0
  95. mcp_bridge/tools/search_enhancements.py +347 -0
  96. mcp_bridge/tools/semantic_search.py +677 -92
  97. mcp_bridge/tools/session_manager.py +0 -2
  98. mcp_bridge/tools/skill_loader.py +0 -1
  99. mcp_bridge/tools/task_runner.py +5 -7
  100. mcp_bridge/tools/templates.py +3 -3
  101. mcp_bridge/tools/tool_search.py +331 -0
  102. mcp_bridge/tools/write_file.py +29 -0
  103. mcp_bridge/update_manager.py +33 -37
  104. mcp_bridge/update_manager_pypi.py +6 -8
  105. mcp_bridge/utils/cache.py +82 -0
  106. mcp_bridge/utils/process.py +71 -0
  107. mcp_bridge/utils/session_state.py +51 -0
  108. mcp_bridge/utils/truncation.py +76 -0
  109. {stravinsky-0.4.18.dist-info → stravinsky-0.4.66.dist-info}/METADATA +84 -35
  110. stravinsky-0.4.66.dist-info/RECORD +198 -0
  111. {stravinsky-0.4.18.dist-info → stravinsky-0.4.66.dist-info}/entry_points.txt +1 -0
  112. stravinsky_claude_assets/HOOKS_INTEGRATION.md +316 -0
  113. stravinsky_claude_assets/agents/HOOKS.md +437 -0
  114. stravinsky_claude_assets/agents/code-reviewer.md +210 -0
  115. stravinsky_claude_assets/agents/comment_checker.md +580 -0
  116. stravinsky_claude_assets/agents/debugger.md +254 -0
  117. stravinsky_claude_assets/agents/delphi.md +495 -0
  118. stravinsky_claude_assets/agents/dewey.md +248 -0
  119. stravinsky_claude_assets/agents/explore.md +1198 -0
  120. stravinsky_claude_assets/agents/frontend.md +472 -0
  121. stravinsky_claude_assets/agents/implementation-lead.md +164 -0
  122. stravinsky_claude_assets/agents/momus.md +464 -0
  123. stravinsky_claude_assets/agents/research-lead.md +141 -0
  124. stravinsky_claude_assets/agents/stravinsky.md +730 -0
  125. stravinsky_claude_assets/commands/delphi.md +9 -0
  126. stravinsky_claude_assets/commands/dewey.md +54 -0
  127. stravinsky_claude_assets/commands/git-master.md +112 -0
  128. stravinsky_claude_assets/commands/index.md +49 -0
  129. stravinsky_claude_assets/commands/publish.md +86 -0
  130. stravinsky_claude_assets/commands/review.md +73 -0
  131. stravinsky_claude_assets/commands/str/agent_cancel.md +70 -0
  132. stravinsky_claude_assets/commands/str/agent_list.md +56 -0
  133. stravinsky_claude_assets/commands/str/agent_output.md +92 -0
  134. stravinsky_claude_assets/commands/str/agent_progress.md +74 -0
  135. stravinsky_claude_assets/commands/str/agent_retry.md +94 -0
  136. stravinsky_claude_assets/commands/str/cancel.md +51 -0
  137. stravinsky_claude_assets/commands/str/clean.md +97 -0
  138. stravinsky_claude_assets/commands/str/continue.md +38 -0
  139. stravinsky_claude_assets/commands/str/index.md +199 -0
  140. stravinsky_claude_assets/commands/str/list_watchers.md +96 -0
  141. stravinsky_claude_assets/commands/str/search.md +205 -0
  142. stravinsky_claude_assets/commands/str/start_filewatch.md +136 -0
  143. stravinsky_claude_assets/commands/str/stats.md +71 -0
  144. stravinsky_claude_assets/commands/str/stop_filewatch.md +89 -0
  145. stravinsky_claude_assets/commands/str/unwatch.md +42 -0
  146. stravinsky_claude_assets/commands/str/watch.md +45 -0
  147. stravinsky_claude_assets/commands/strav.md +53 -0
  148. stravinsky_claude_assets/commands/stravinsky.md +292 -0
  149. stravinsky_claude_assets/commands/verify.md +60 -0
  150. stravinsky_claude_assets/commands/version.md +5 -0
  151. stravinsky_claude_assets/hooks/README.md +248 -0
  152. stravinsky_claude_assets/hooks/comment_checker.py +193 -0
  153. stravinsky_claude_assets/hooks/context.py +38 -0
  154. stravinsky_claude_assets/hooks/context_monitor.py +153 -0
  155. stravinsky_claude_assets/hooks/dependency_tracker.py +73 -0
  156. stravinsky_claude_assets/hooks/edit_recovery.py +46 -0
  157. stravinsky_claude_assets/hooks/execution_state_tracker.py +68 -0
  158. stravinsky_claude_assets/hooks/notification_hook.py +103 -0
  159. stravinsky_claude_assets/hooks/notification_hook_v2.py +96 -0
  160. stravinsky_claude_assets/hooks/parallel_execution.py +241 -0
  161. stravinsky_claude_assets/hooks/parallel_reinforcement.py +106 -0
  162. stravinsky_claude_assets/hooks/parallel_reinforcement_v2.py +112 -0
  163. stravinsky_claude_assets/hooks/pre_compact.py +123 -0
  164. stravinsky_claude_assets/hooks/ralph_loop.py +173 -0
  165. stravinsky_claude_assets/hooks/session_recovery.py +263 -0
  166. stravinsky_claude_assets/hooks/stop_hook.py +89 -0
  167. stravinsky_claude_assets/hooks/stravinsky_metrics.py +164 -0
  168. stravinsky_claude_assets/hooks/stravinsky_mode.py +146 -0
  169. stravinsky_claude_assets/hooks/subagent_stop.py +98 -0
  170. stravinsky_claude_assets/hooks/todo_continuation.py +111 -0
  171. stravinsky_claude_assets/hooks/todo_delegation.py +96 -0
  172. stravinsky_claude_assets/hooks/tool_messaging.py +281 -0
  173. stravinsky_claude_assets/hooks/truncator.py +23 -0
  174. stravinsky_claude_assets/rules/deployment_safety.md +51 -0
  175. stravinsky_claude_assets/rules/integration_wiring.md +89 -0
  176. stravinsky_claude_assets/rules/pypi_deployment.md +220 -0
  177. stravinsky_claude_assets/rules/stravinsky_orchestrator.md +32 -0
  178. stravinsky_claude_assets/settings.json +152 -0
  179. stravinsky_claude_assets/skills/chrome-devtools/SKILL.md +81 -0
  180. stravinsky_claude_assets/skills/sqlite/SKILL.md +77 -0
  181. stravinsky_claude_assets/skills/supabase/SKILL.md +74 -0
  182. stravinsky_claude_assets/task_dependencies.json +34 -0
  183. stravinsky-0.4.18.dist-info/RECORD +0 -88
  184. {stravinsky-0.4.18.dist-info → stravinsky-0.4.66.dist-info}/WHEEL +0 -0
@@ -8,8 +8,6 @@ Optionally uses Gemini for session summarization.
8
8
 
9
9
  import argparse
10
10
  import json
11
- import os
12
- import re
13
11
  import sys
14
12
  from collections import Counter
15
13
  from datetime import datetime
@@ -23,7 +21,6 @@ from rich.table import Table
23
21
  from rich.text import Text
24
22
  from rich.tree import Tree
25
23
 
26
-
27
24
  console = Console()
28
25
 
29
26
 
@@ -1,9 +1,9 @@
1
1
  # Configuration module
2
2
  from .hooks import (
3
- get_hooks_config,
4
- list_hook_scripts,
5
3
  configure_hook,
6
4
  get_hook_documentation,
5
+ get_hooks_config,
6
+ list_hook_scripts,
7
7
  )
8
8
 
9
9
  __all__ = [
@@ -5,10 +5,8 @@ Provides batteries-included defaults with user-configurable overrides.
5
5
  Users can disable specific hooks via ~/.stravinsky/disable_hooks.txt
6
6
  """
7
7
 
8
- import os
9
- from pathlib import Path
10
- from typing import Set, Optional
11
8
  import logging
9
+ from pathlib import Path
12
10
 
13
11
  logger = logging.getLogger(__name__)
14
12
 
@@ -20,7 +18,7 @@ DISABLE_HOOKS_PATHS = [
20
18
  ]
21
19
 
22
20
 
23
- def get_disabled_hooks() -> Set[str]:
21
+ def get_disabled_hooks() -> set[str]:
24
22
  """
25
23
  Load disabled hooks from config files.
26
24
 
@@ -74,7 +72,7 @@ def get_hook_config_path() -> Path:
74
72
  return config_dir
75
73
 
76
74
 
77
- def create_sample_disable_hooks() -> Optional[Path]:
75
+ def create_sample_disable_hooks() -> Path | None:
78
76
  """
79
77
  Create a sample disable_hooks.txt file with documentation.
80
78
 
@@ -17,14 +17,13 @@ Configuration file: ~/.stravinsky/config.json
17
17
  }
18
18
  """
19
19
 
20
- import asyncio
21
20
  import json
21
+ import logging
22
+ import sys
22
23
  import threading
24
+ import time
25
+ from collections import defaultdict, deque
23
26
  from pathlib import Path
24
- from typing import Dict, Optional
25
- from collections import defaultdict
26
- from datetime import datetime
27
- import logging
28
27
 
29
28
  logger = logging.getLogger(__name__)
30
29
 
@@ -56,13 +55,13 @@ class RateLimiter:
56
55
  """
57
56
 
58
57
  def __init__(self):
59
- self._semaphores: Dict[str, threading.Semaphore] = {}
58
+ self._semaphores: dict[str, threading.Semaphore] = {}
60
59
  self._lock = threading.Lock()
61
60
  self._limits = self._load_limits()
62
- self._active_counts: Dict[str, int] = defaultdict(int)
63
- self._queue_counts: Dict[str, int] = defaultdict(int)
61
+ self._active_counts: dict[str, int] = defaultdict(int)
62
+ self._queue_counts: dict[str, int] = defaultdict(int)
64
63
 
65
- def _load_limits(self) -> Dict[str, int]:
64
+ def _load_limits(self) -> dict[str, int]:
66
65
  """Load rate limits from config file or use defaults."""
67
66
  limits = DEFAULT_RATE_LIMITS.copy()
68
67
 
@@ -73,7 +72,7 @@ class RateLimiter:
73
72
  if "rate_limits" in config:
74
73
  limits.update(config["rate_limits"])
75
74
  logger.info(f"[RateLimiter] Loaded custom limits from {CONFIG_FILE}")
76
- except (json.JSONDecodeError, IOError) as e:
75
+ except (OSError, json.JSONDecodeError) as e:
77
76
  logger.warning(f"[RateLimiter] Failed to load config: {e}")
78
77
 
79
78
  return limits
@@ -150,7 +149,7 @@ class RateLimiter:
150
149
  semaphore.release()
151
150
  logger.debug(f"[RateLimiter] Released slot for {normalized}")
152
151
 
153
- def get_status(self) -> Dict[str, Dict[str, int]]:
152
+ def get_status(self) -> dict[str, dict[str, int]]:
154
153
  """Get current rate limiter status."""
155
154
  with self._lock:
156
155
  return {
@@ -162,7 +161,7 @@ class RateLimiter:
162
161
  for model in set(list(self._active_counts.keys()) + list(self._queue_counts.keys()))
163
162
  }
164
163
 
165
- def update_limits(self, new_limits: Dict[str, int]):
164
+ def update_limits(self, new_limits: dict[str, int]):
166
165
  """
167
166
  Update rate limits dynamically.
168
167
 
@@ -196,7 +195,7 @@ class RateLimitContext:
196
195
 
197
196
 
198
197
  # Global rate limiter instance
199
- _rate_limiter: Optional[RateLimiter] = None
198
+ _rate_limiter: RateLimiter | None = None
200
199
  _rate_limiter_lock = threading.Lock()
201
200
 
202
201
 
@@ -220,3 +219,99 @@ def rate_limited(model: str, timeout: float = 60.0) -> RateLimitContext:
220
219
  pass
221
220
  """
222
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
@@ -47,6 +47,11 @@
47
47
  "type": "command",
48
48
  "command": "python3 ~/.claude/hooks/stravinsky_mode.py",
49
49
  "description": "Hard blocking of direct tools in stravinsky mode"
50
+ },
51
+ {
52
+ "type": "command",
53
+ "command": "python3 ~/.claude/hooks/pre_tool/agent_spawn_validator.py",
54
+ "description": "Blocks sequential tool usage when parallel delegation is required"
50
55
  }
51
56
  ]
52
57
  }
@@ -111,6 +116,11 @@
111
116
  "type": "command",
112
117
  "command": "python3 ~/.claude/hooks/todo_delegation.py",
113
118
  "description": "Parallel execution enforcer after TodoWrite"
119
+ },
120
+ {
121
+ "type": "command",
122
+ "command": "python3 ~/.claude/hooks/post_tool/parallel_validation.py",
123
+ "description": "Tracks pending tasks for hard enforcement"
114
124
  }
115
125
  ]
116
126
  }
@@ -139,7 +149,9 @@
139
149
  "execution_control": {
140
150
  "parallel_execution.py": "Detects implementation tasks and injects parallel execution instructions before response generation. Activates stravinsky mode on /stravinsky invocation.",
141
151
  "stravinsky_mode.py": "Blocks native file tools (Read, Grep, Bash, Edit) when stravinsky mode is active, forcing Task delegation.",
142
- "todo_delegation.py": "Hard blocks response completion after TodoWrite if 2+ pending items exist without spawning Task agents."
152
+ "todo_delegation.py": "Hard blocks response completion after TodoWrite if 2+ pending items exist without spawning Task agents.",
153
+ "parallel_validation.py": "Tracks pending tasks count after TodoWrite to enable hard enforcement state.",
154
+ "agent_spawn_validator.py": "Blocks sequential tool usage (Read, Grep, etc.) if parallel delegation is required and enabled."
143
155
  },
144
156
  "context_management": {
145
157
  "context.py": "Auto-injects CLAUDE.md, README.md, or AGENTS.md content into prompts for project-specific context.",
@@ -166,10 +178,11 @@
166
178
  "state_files": {
167
179
  "~/.stravinsky_mode": "Marker file indicating stravinsky orchestrator mode is active (enables hard blocking)",
168
180
  "~/.claude/state/compaction.jsonl": "Audit log of context compaction events with preserved items",
169
- ".claude/todo_state.json": "Cached todo state for continuation enforcement"
181
+ ".claude/todo_state.json": "Cached todo state for continuation enforcement",
182
+ ".claude/parallel_state.json": "Transient state tracking pending tasks for hard enforcement"
170
183
  },
171
184
 
172
- "version": "0.2.63",
185
+ "version": "0.2.64",
173
186
  "package": "stravinsky",
174
187
  "documentation": "https://github.com/GratefulDave/stravinsky"
175
- }
188
+ }
@@ -115,11 +115,21 @@ __all__ = [
115
115
 
116
116
  def initialize_hooks():
117
117
  """Initialize and register all hooks with the HookManager."""
118
- # Currently hooks are primarily external scripts or lazy-loaded.
119
- # This entry point allows for future internal hook registration.
120
- pass
118
+ from .delegation_policy import DelegationReminderPolicy
119
+ from .edit_recovery_policy import EditRecoveryPolicy
120
+ from .manager import get_hook_manager
121
+ from .parallel_enforcement_policy import ParallelEnforcementPolicy
122
+ from .truncation_policy import TruncationPolicy
121
123
 
124
+ manager = get_hook_manager()
122
125
 
123
- __version__ = "0.2.63"
126
+ # Register unified policies
127
+ manager.register_policy(TruncationPolicy())
128
+ manager.register_policy(DelegationReminderPolicy())
129
+ manager.register_policy(EditRecoveryPolicy())
130
+ manager.register_policy(ParallelEnforcementPolicy())
131
+
132
+
133
+ __version__ = "0.4.60"
124
134
  __author__ = "David Andrews"
125
135
  __description__ = "Claude Code hooks for Stravinsky MCP parallel execution"
@@ -6,7 +6,7 @@ suggests using background agents for more comprehensive results.
6
6
  """
7
7
 
8
8
  import logging
9
- from typing import Any, Dict, Optional
9
+ from typing import Any
10
10
 
11
11
  logger = logging.getLogger(__name__)
12
12
 
@@ -28,8 +28,8 @@ SEARCH_TOOLS = {"grep", "glob", "rg", "find", "Grep", "Glob", "grep_search", "gl
28
28
 
29
29
 
30
30
  async def agent_reminder_hook(
31
- tool_name: str, arguments: Dict[str, Any], output: str
32
- ) -> Optional[str]:
31
+ tool_name: str, arguments: dict[str, Any], output: str
32
+ ) -> str | None:
33
33
  """
34
34
  Post-tool call hook that suggests background agents after direct search tool usage.
35
35
  """
@@ -51,7 +51,7 @@ async def agent_reminder_hook(
51
51
  return None
52
52
 
53
53
 
54
- def _extract_search_context(arguments: Dict[str, Any]) -> str:
54
+ def _extract_search_context(arguments: dict[str, Any]) -> str:
55
55
  """Extract search context from tool arguments."""
56
56
  for key in ("pattern", "query", "search", "name", "path"):
57
57
  if key in arguments:
@@ -11,7 +11,7 @@ Detects and auto-processes slash commands in user input:
11
11
  import logging
12
12
  import re
13
13
  from pathlib import Path
14
- from typing import Any, Dict, List, Optional, Tuple
14
+ from typing import Any
15
15
 
16
16
  logger = logging.getLogger(__name__)
17
17
 
@@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
19
19
  SLASH_COMMAND_PATTERN = re.compile(r'(?:^|(?<=\s))\/([a-zA-Z][a-zA-Z0-9_-]*)\b', re.MULTILINE)
20
20
 
21
21
 
22
- def extract_slash_commands(text: str) -> List[str]:
22
+ def extract_slash_commands(text: str) -> list[str]:
23
23
  """
24
24
  Extract all slash command names from text.
25
25
 
@@ -40,7 +40,7 @@ def extract_slash_commands(text: str) -> List[str]:
40
40
  return unique
41
41
 
42
42
 
43
- def load_skill_content(command_name: str, project_path: Optional[str] = None) -> Optional[Tuple[str, str]]:
43
+ def load_skill_content(command_name: str, project_path: str | None = None) -> tuple[str, str] | None:
44
44
  """
45
45
  Load skill content by command name.
46
46
 
@@ -92,7 +92,7 @@ def load_skill_content(command_name: str, project_path: Optional[str] = None) ->
92
92
  return None
93
93
 
94
94
 
95
- def get_project_path_from_prompt(prompt: str) -> Optional[str]:
95
+ def get_project_path_from_prompt(prompt: str) -> str | None:
96
96
  """
97
97
  Try to extract project path from prompt context.
98
98
  Looks for common patterns that indicate the working directory.
@@ -126,7 +126,7 @@ SKILL_NOT_FOUND_WARNING = """
126
126
  """
127
127
 
128
128
 
129
- async def auto_slash_command_hook(params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
129
+ async def auto_slash_command_hook(params: dict[str, Any]) -> dict[str, Any] | None:
130
130
  """
131
131
  Pre-model invoke hook that detects slash commands and injects skill content.
132
132
 
@@ -3,14 +3,14 @@ Thinking budget optimizer hook.
3
3
  Analyzes prompt complexity and adjusts thinking_budget for models that support it.
4
4
  """
5
5
 
6
- from typing import Any, Dict, Optional
6
+ from typing import Any
7
7
 
8
8
  REASONING_KEYWORDS = [
9
9
  "architect", "design", "refactor", "debug", "complex", "optimize",
10
10
  "summarize", "analyze", "explain", "why", "review", "strangler"
11
11
  ]
12
12
 
13
- async def budget_optimizer_hook(params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
13
+ async def budget_optimizer_hook(params: dict[str, Any]) -> dict[str, Any] | None:
14
14
  """
15
15
  Adjusts the thinking_budget based on presence of reasoning-heavy keywords.
16
16
  """
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ PostToolUse hook for Claude rate limit detection.
4
+
5
+ Monitors model invocation responses for Claude-specific rate limit indicators
6
+ and updates the provider state tracker accordingly.
7
+ """
8
+
9
+ import json
10
+ import logging
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))
16
+
17
+ try:
18
+ from routing import get_provider_tracker
19
+ except ImportError:
20
+ get_provider_tracker = None # type: ignore
21
+
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # Claude rate limit indicators
26
+ CLAUDE_RATE_LIMIT_PATTERNS = [
27
+ "rate limit",
28
+ "rate_limit_error",
29
+ "too many requests",
30
+ "429",
31
+ "quota exceeded",
32
+ "rate-limited",
33
+ "overloaded_error",
34
+ ]
35
+
36
+
37
+ def detect_claude_rate_limit(tool_result: str | dict) -> bool:
38
+ """
39
+ Detect if a tool result indicates Claude rate limiting.
40
+
41
+ Args:
42
+ tool_result: Tool result string or dict
43
+
44
+ Returns:
45
+ True if rate limit detected, False otherwise
46
+ """
47
+ # Convert result to searchable string
48
+ if isinstance(tool_result, dict):
49
+ search_text = json.dumps(tool_result).lower()
50
+ else:
51
+ search_text = str(tool_result).lower()
52
+
53
+ # Check for rate limit patterns
54
+ for pattern in CLAUDE_RATE_LIMIT_PATTERNS:
55
+ if pattern in search_text:
56
+ logger.info(f"[ClaudeLimitsHook] Detected rate limit pattern: {pattern}")
57
+ return True
58
+
59
+ return False
60
+
61
+
62
+ def main() -> None:
63
+ """Process PostToolUse hook event."""
64
+ try:
65
+ # Read hook input from stdin
66
+ hook_input = json.loads(sys.stdin.read())
67
+
68
+ tool_name = hook_input.get("tool_name", "")
69
+ tool_result = hook_input.get("tool_result", "")
70
+
71
+ # Only monitor invoke tools (Claude uses default Claude models)
72
+ # We're looking for rate limits from the main Claude context
73
+ # This is different from invoke_openai/invoke_gemini which are explicit
74
+ if not any(
75
+ keyword in tool_name.lower() for keyword in ["invoke", "chat", "generate", "complete"]
76
+ ):
77
+ # Not a model invocation tool - skip
78
+ sys.exit(0)
79
+
80
+ # Check for rate limit indicators
81
+ if detect_claude_rate_limit(tool_result):
82
+ logger.warning("[ClaudeLimitsHook] Claude rate limit detected")
83
+
84
+ if get_provider_tracker and callable(get_provider_tracker):
85
+ tracker = get_provider_tracker()
86
+ if tracker:
87
+ tracker.mark_rate_limited("claude", duration=300, reason="Claude rate limit")
88
+ logger.info("[ClaudeLimitsHook] Marked Claude as rate-limited (300s cooldown)")
89
+
90
+ print(
91
+ "\n⚠️ Claude Rate Limit Detected\n"
92
+ "→ Routing future requests to OpenAI/Gemini for 5 minutes\n",
93
+ file=sys.stderr,
94
+ )
95
+ else:
96
+ logger.debug(
97
+ "[ClaudeLimitsHook] Provider tracker not available, skipping state update"
98
+ )
99
+
100
+ sys.exit(0)
101
+
102
+ except Exception as e:
103
+ # Log error but don't block
104
+ logger.error(f"[ClaudeLimitsHook] Error: {e}", exc_info=True)
105
+ print(f"[ClaudeLimitsHook] Warning: {e}", file=sys.stderr)
106
+ sys.exit(0) # Exit 0 to not block execution
107
+
108
+
109
+ if __name__ == "__main__":
110
+ # Configure logging
111
+ logging.basicConfig(
112
+ level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
113
+ )
114
+ main()
@@ -6,8 +6,7 @@ Code should be self-documenting; excessive comments indicate AI slop.
6
6
  """
7
7
 
8
8
  import logging
9
- import re
10
- from typing import Any, Dict, Optional
9
+ from typing import Any
11
10
 
12
11
  logger = logging.getLogger(__name__)
13
12
 
@@ -53,8 +52,8 @@ CODE_EXTENSIONS = {
53
52
 
54
53
 
55
54
  async def comment_checker_hook(
56
- tool_name: str, arguments: Dict[str, Any], output: str
57
- ) -> Optional[str]:
55
+ tool_name: str, arguments: dict[str, Any], output: str
56
+ ) -> str | None:
58
57
  """
59
58
  Post-tool call hook that checks for excessive comments in code edits.
60
59
  """
@@ -3,7 +3,7 @@ Preemptive context compaction hook.
3
3
  Monitors context size and injects optimization reminders.
4
4
  """
5
5
 
6
- from typing import Any, Dict, Optional
6
+ from typing import Any
7
7
 
8
8
  THRESHOLD_CHARS = 100000 # Roughly 25k-30k tokens for typical LLM text
9
9
 
@@ -17,7 +17,7 @@ COMPACTION_REMINDER = """
17
17
  > 4. Keep your next responses concise and focused only on the current sub-task.
18
18
  """
19
19
 
20
- async def context_compaction_hook(params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
20
+ async def context_compaction_hook(params: dict[str, Any]) -> dict[str, Any] | None:
21
21
  """
22
22
  Checks prompt length and injects a compaction reminder if it's too large.
23
23
  """
@@ -1,8 +1,9 @@
1
+ import json
1
2
  import os
2
3
  import sys
3
- import json
4
4
  from pathlib import Path
5
5
 
6
+
6
7
  def main():
7
8
  try:
8
9
  data = json.load(sys.stdin)
@@ -7,7 +7,7 @@ At 85%, suggests compaction before hitting hard limits.
7
7
  """
8
8
 
9
9
  import logging
10
- from typing import Any, Dict, Optional
10
+ from typing import Any
11
11
 
12
12
  logger = logging.getLogger(__name__)
13
13
 
@@ -32,7 +32,7 @@ CONTEXT_THRESHOLD_WARNING = 0.85
32
32
  ESTIMATED_MAX_TOKENS = 200000
33
33
 
34
34
 
35
- async def context_monitor_hook(params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
35
+ async def context_monitor_hook(params: dict[str, Any]) -> dict[str, Any] | None:
36
36
  """
37
37
  Pre-model invoke hook that monitors context window usage.
38
38
  """
@@ -0,0 +1,85 @@
1
+ import os
2
+ import time
3
+ from pathlib import Path
4
+
5
+ from ..utils.session_state import get_current_session_id, update_session_state
6
+ from .events import EventType, HookPolicy, PolicyResult, ToolCallEvent
7
+
8
+ # Check if stravinsky mode is active (hard blocking enabled)
9
+ STRAVINSKY_MODE_FILE = Path.home() / ".stravinsky_mode"
10
+
11
+
12
+ def is_stravinsky_mode():
13
+ """Check if hard blocking mode is active."""
14
+ return STRAVINSKY_MODE_FILE.exists()
15
+
16
+
17
+ class DelegationReminderPolicy(HookPolicy):
18
+ """
19
+ Policy for TodoWrite: CRITICAL parallel execution enforcer.
20
+ """
21
+
22
+ @property
23
+ def event_type(self) -> EventType:
24
+ return EventType.POST_TOOL_CALL
25
+
26
+ async def evaluate(self, event: ToolCallEvent) -> PolicyResult:
27
+ if event.tool_name != "TodoWrite":
28
+ return PolicyResult(modified_data=event.output)
29
+
30
+ todos = event.arguments.get("todos", [])
31
+ pending_count = sum(1 for t in todos if t.get("status") == "pending")
32
+
33
+ # Update session state
34
+ session_id = event.metadata.get("session_id") or get_current_session_id()
35
+ update_session_state(
36
+ {
37
+ "last_todo_write_at": time.time(),
38
+ "pending_todo_count": pending_count,
39
+ },
40
+ session_id=session_id,
41
+ )
42
+
43
+ if pending_count < 2:
44
+ return PolicyResult(modified_data=event.output)
45
+
46
+ stravinsky_active = is_stravinsky_mode()
47
+
48
+ mode_warning = ""
49
+ if stravinsky_active:
50
+ mode_warning = """
51
+ ⚠️ STRAVINSKY MODE ACTIVE - Direct tools (Read, Grep, Bash) are BLOCKED.
52
+ You MUST use Task(subagent_type="explore", ...) for ALL file operations.
53
+ """
54
+
55
+ error_message = f"""
56
+ ╔══════════════════════════════════════════════════════════════════════════╗
57
+ ║ 🚨 PARALLEL DELEGATION REQUIRED 🚨 ║
58
+ ╠══════════════════════════════════════════════════════════════════════════╣
59
+ ║ ║
60
+ ║ TodoWrite created {pending_count} pending items. ║
61
+ ║ {mode_warning.strip()} ║
62
+ ║ ║
63
+ ║ You MUST spawn Task agents for ALL independent TODOs in this response. ║
64
+ ║ ║
65
+ ╠══════════════════════════════════════════════════════════════════════════╣
66
+ ║ REQUIRED PATTERN: ║
67
+ ║ Task(subagent_type="explore", prompt="TODO 1...", run_in_background=t) ║
68
+ ║ Task(subagent_type="explore", prompt="TODO 2...", run_in_background=t) ║
69
+ ║ ... ║
70
+ ╚══════════════════════════════════════════════════════════════════════════╝
71
+ """
72
+ # In PostToolUse, we often want to APPEND the reminder to the output
73
+ new_output = (event.output or "") + "\n" + error_message
74
+
75
+ return PolicyResult(
76
+ modified_data=new_output,
77
+ message=error_message, # For native hooks, we might just print the message
78
+ should_block=stravinsky_active,
79
+ exit_code=2 if stravinsky_active else 1,
80
+ )
81
+
82
+
83
+ if __name__ == "__main__":
84
+ policy = DelegationReminderPolicy()
85
+ policy.run_as_native()