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,9 +8,10 @@ when implementation tasks are detected. Eliminates timing ambiguity.
8
8
  CRITICAL: Also activates stravinsky mode marker when /stravinsky is invoked,
9
9
  enabling hard blocking of direct tools (Read, Grep, Bash) via stravinsky_mode.py.
10
10
  """
11
+
11
12
  import json
12
- import sys
13
13
  import re
14
+ import sys
14
15
  from pathlib import Path
15
16
 
16
17
  # Marker file that enables hard blocking of direct tools
@@ -20,11 +21,10 @@ STRAVINSKY_MODE_FILE = Path.home() / ".stravinsky_mode"
20
21
  def detect_stravinsky_invocation(prompt):
21
22
  """Detect if /stravinsky skill is being invoked."""
22
23
  patterns = [
23
- r'/stravinsky',
24
- r'<command-name>/stravinsky</command-name>',
25
- r'stravinsky orchestrator',
26
- r'ultrawork',
27
- r'ultrathink',
24
+ r"/stravinsky",
25
+ r"<command-name>/stravinsky</command-name>",
26
+ r"stravinsky orchestrator",
27
+ r"\bultrawork\b",
28
28
  ]
29
29
  prompt_lower = prompt.lower()
30
30
  return any(re.search(p, prompt_lower) for p in patterns)
@@ -36,16 +36,28 @@ def activate_stravinsky_mode():
36
36
  config = {"active": True, "reason": "invoked via /stravinsky skill"}
37
37
  STRAVINSKY_MODE_FILE.write_text(json.dumps(config))
38
38
  return True
39
- except IOError:
39
+ except OSError:
40
40
  return False
41
41
 
42
42
 
43
43
  def detect_implementation_task(prompt):
44
44
  """Detect if prompt is an implementation task requiring parallel execution."""
45
45
  keywords = [
46
- 'implement', 'add', 'create', 'build', 'refactor', 'fix',
47
- 'update', 'modify', 'change', 'develop', 'write code',
48
- 'feature', 'bug fix', 'enhancement', 'integrate'
46
+ "implement",
47
+ "add",
48
+ "create",
49
+ "build",
50
+ "refactor",
51
+ "fix",
52
+ "update",
53
+ "modify",
54
+ "change",
55
+ "develop",
56
+ "write code",
57
+ "feature",
58
+ "bug fix",
59
+ "enhancement",
60
+ "integrate",
49
61
  ]
50
62
 
51
63
  prompt_lower = prompt.lower()
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ PostToolUse hook: Parallel Validation.
4
+
5
+ Tracks pending tasks after TodoWrite and sets a state flag if parallel
6
+ delegation is required. This state is consumed by the PreToolUse validator.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import sys
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Any, Dict
15
+
16
+ # State file base location (will be suffixed with session ID)
17
+ STATE_DIR = Path(".claude")
18
+
19
+
20
+ def get_state_file() -> Path:
21
+ """Get path to state file, respecting CLAUDE_CWD and CLAUDE_SESSION_ID."""
22
+ # Use environment variable passed by Claude Code
23
+ cwd = Path(os.environ.get("CLAUDE_CWD", "."))
24
+ session_id = os.environ.get("CLAUDE_SESSION_ID", "default")
25
+ # Sanitize session ID
26
+ session_id = "".join(c for c in session_id if c.isalnum() or c in "-_")
27
+
28
+ return cwd / ".claude" / f"parallel_state_{session_id}.json"
29
+
30
+
31
+ def save_state(state: Dict[str, Any]) -> None:
32
+ """Save state to file."""
33
+ path = get_state_file()
34
+ path.parent.mkdir(parents=True, exist_ok=True)
35
+ path.write_text(json.dumps(state, indent=2))
36
+
37
+
38
+ def load_state() -> Dict[str, Any]:
39
+ """Load state from file."""
40
+ path = get_state_file()
41
+ if not path.exists():
42
+ return {}
43
+ try:
44
+ return json.loads(path.read_text())
45
+ except Exception:
46
+ return {}
47
+
48
+
49
+ def process_hook(hook_input: Dict[str, Any]) -> int:
50
+ """Process the hook input and update state."""
51
+ tool_name = hook_input.get("tool_name", "")
52
+
53
+ # We only care about TodoWrite (creating tasks)
54
+ # or maybe Task/agent_spawn (resetting the requirement)
55
+
56
+ if tool_name == "TodoWrite":
57
+ tool_input = hook_input.get("tool_input", {})
58
+ todos = tool_input.get("todos", [])
59
+
60
+ # Count independent pending todos
61
+ # Conservative: assume all pending are independent for now
62
+ pending_count = sum(1 for t in todos if t.get("status") == "pending")
63
+
64
+ if pending_count >= 2:
65
+ state = load_state()
66
+ state.update({
67
+ "delegation_required": True,
68
+ "pending_count": pending_count,
69
+ "last_todo_write": time.time(),
70
+ "reason": f"TodoWrite created {pending_count} pending items. Parallel delegation required."
71
+ })
72
+ save_state(state)
73
+
74
+ elif tool_name in ["Task", "agent_spawn"]:
75
+ # If a task is spawned, we might be satisfying the requirement
76
+ # But we need to spawn ONE for EACH independent task.
77
+ # For now, let's just note that a spawn happened.
78
+ # The PreToolUse validator will decide if it's enough (maybe checking count?)
79
+ # Or we can just decrement a counter?
80
+ # Simpler: If ANY delegation happens, we assume the user is complying for now.
81
+ # Strict implementation: We'd track how many spawned vs required.
82
+
83
+ state = load_state()
84
+ if state.get("delegation_required"):
85
+ # Update state to reflect compliance
86
+ state["delegation_required"] = False
87
+ state["last_delegation"] = time.time()
88
+ save_state(state)
89
+
90
+ return 0
91
+
92
+
93
+ def main():
94
+ try:
95
+ hook_input = json.load(sys.stdin)
96
+ exit_code = process_hook(hook_input)
97
+ sys.exit(exit_code)
98
+ except (json.JSONDecodeError, EOFError):
99
+ sys.exit(0)
100
+
101
+
102
+ if __name__ == "__main__":
103
+ main()
@@ -13,10 +13,9 @@ Cannot block compaction (exit 2 only shows error).
13
13
 
14
14
  import json
15
15
  import sys
16
- from pathlib import Path
17
16
  from datetime import datetime
18
- from typing import List, Dict, Any
19
-
17
+ from pathlib import Path
18
+ from typing import Any
20
19
 
21
20
  STRAVINSKY_MODE_FILE = Path.home() / ".stravinsky_mode"
22
21
  STATE_DIR = Path.home() / ".claude" / "state"
@@ -43,18 +42,18 @@ def ensure_state_dir():
43
42
  STATE_DIR.mkdir(parents=True, exist_ok=True)
44
43
 
45
44
 
46
- def get_stravinsky_mode_state() -> Dict[str, Any]:
45
+ def get_stravinsky_mode_state() -> dict[str, Any]:
47
46
  """Read stravinsky mode state."""
48
47
  if not STRAVINSKY_MODE_FILE.exists():
49
48
  return {"active": False}
50
49
  try:
51
50
  content = STRAVINSKY_MODE_FILE.read_text().strip()
52
51
  return json.loads(content) if content else {"active": True}
53
- except (json.JSONDecodeError, IOError):
52
+ except (OSError, json.JSONDecodeError):
54
53
  return {"active": True}
55
54
 
56
55
 
57
- def extract_preserved_context(prompt: str) -> List[str]:
56
+ def extract_preserved_context(prompt: str) -> list[str]:
58
57
  """Extract context matching preservation patterns."""
59
58
  preserved = []
60
59
  lines = prompt.split("\n")
@@ -70,12 +69,12 @@ def extract_preserved_context(prompt: str) -> List[str]:
70
69
  return preserved[:15] # Max 15 items
71
70
 
72
71
 
73
- def log_compaction(preserved: List[str], stravinsky_active: bool):
72
+ def log_compaction(preserved: list[str], stravinsky_active: bool):
74
73
  """Log compaction event for audit."""
75
74
  ensure_state_dir()
76
75
 
77
76
  entry = {
78
- "timestamp": datetime.utcnow().isoformat(),
77
+ "timestamp": datetime.now(timezone.utc).isoformat(),
79
78
  "preserved_count": len(preserved),
80
79
  "stravinsky_mode": stravinsky_active,
81
80
  "preview": [p[:50] for p in preserved[:3]],
@@ -84,7 +83,7 @@ def log_compaction(preserved: List[str], stravinsky_active: bool):
84
83
  try:
85
84
  with COMPACTION_LOG.open("a") as f:
86
85
  f.write(json.dumps(entry) + "\n")
87
- except IOError:
86
+ except OSError:
88
87
  pass
89
88
 
90
89
 
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ PreToolUse hook: Agent Spawn Validator.
4
+
5
+ Blocks direct tools (Read, Grep, etc.) if parallel delegation is required
6
+ but hasn't happened yet.
7
+
8
+ Triggers when:
9
+ 1. parallel_validation.py (PostToolUse) has set 'delegation_required=True'
10
+ 2. User tries to use a non-delegation tool
11
+ 3. Hard enforcement is enabled (opt-in)
12
+ """
13
+
14
+ import json
15
+ import os
16
+ import sys
17
+ from pathlib import Path
18
+ from typing import Any, Dict
19
+
20
+ # State file location
21
+ STATE_FILE = Path(".claude/parallel_state.json")
22
+ CONFIG_FILE = Path(".stravinsky/config.json") # Faster than yaml
23
+
24
+
25
+ def get_project_dir() -> Path:
26
+ return Path(os.environ.get("CLAUDE_CWD", "."))
27
+
28
+
29
+ def get_state_file() -> Path:
30
+ cwd = get_project_dir()
31
+ session_id = os.environ.get("CLAUDE_SESSION_ID", "default")
32
+ # Sanitize session ID
33
+ session_id = "".join(c for c in session_id if c.isalnum() or c in "-_")
34
+ return cwd / ".claude" / f"parallel_state_{session_id}.json"
35
+
36
+
37
+ def load_state() -> Dict[str, Any]:
38
+ path = get_state_file()
39
+ if not path.exists():
40
+ return {}
41
+ try:
42
+ return json.loads(path.read_text())
43
+ except Exception:
44
+ return {}
45
+
46
+
47
+ def is_enforcement_enabled() -> bool:
48
+ """Check if hard enforcement is enabled."""
49
+ # Check env var override
50
+ if os.environ.get("STRAVINSKY_ALLOW_SEQUENTIAL", "").lower() == "true":
51
+ return False
52
+
53
+ # Check config file (default: false for now)
54
+ config_path = get_project_dir() / ".stravinsky/config.json"
55
+ if config_path.exists():
56
+ try:
57
+ config = json.loads(config_path.read_text())
58
+ return config.get("enforce_parallel_delegation", False)
59
+ except Exception:
60
+ pass
61
+
62
+ return False
63
+
64
+
65
+ def process_hook(hook_input: Dict[str, Any]) -> int:
66
+ """Process hook input."""
67
+ tool_name = hook_input.get("toolName", "")
68
+
69
+ # Allowed tools during delegation phase
70
+ ALLOWED_TOOLS = ["Task", "agent_spawn", "TodoWrite", "TodoRead"]
71
+
72
+ if tool_name in ALLOWED_TOOLS:
73
+ return 0
74
+
75
+ state = load_state()
76
+
77
+ # If delegation is not required, allow
78
+ if not state.get("delegation_required"):
79
+ return 0
80
+
81
+ # If hard enforcement is not enabled, allow (maybe warn? but PreToolUse warnings aren't visible usually)
82
+ if not is_enforcement_enabled():
83
+ # TODO: Ideally print a warning to stderr?
84
+ return 0
85
+
86
+ # BLOCK
87
+ print(f"""
88
+ 🛑 BLOCKED: PARALLEL DELEGATION REQUIRED
89
+
90
+ You have {state.get("pending_todos", "multiple")} pending tasks that require parallel execution.
91
+ You are attempting to use '{tool_name}' sequentially.
92
+
93
+ REQUIRED ACTION:
94
+ Spawn agents for ALL independent tasks in THIS response using:
95
+ - Task(subagent_type="...", prompt="...")
96
+ - agent_spawn(agent_type="...", prompt="...")
97
+
98
+ To override (if tasks are truly dependent):
99
+ - Set STRAVINSKY_ALLOW_SEQUENTIAL=true
100
+ - Or use TodoWrite to update tasks to dependent state
101
+ """)
102
+ return 2
103
+
104
+
105
+ def main():
106
+ try:
107
+ hook_input = json.load(sys.stdin)
108
+ exit_code = process_hook(hook_input)
109
+ sys.exit(exit_code)
110
+ except (json.JSONDecodeError, EOFError):
111
+ sys.exit(0)
112
+
113
+
114
+ if __name__ == "__main__":
115
+ main()
@@ -9,8 +9,7 @@ Proactively compresses context BEFORE hitting limits by:
9
9
  """
10
10
 
11
11
  import logging
12
- import re
13
- from typing import Any, Dict, Optional
12
+ from typing import Any
14
13
 
15
14
  logger = logging.getLogger(__name__)
16
15
 
@@ -170,7 +169,7 @@ async def summarize_with_gemini(token_store: Any, content: str) -> str:
170
169
  _in_summarization = False
171
170
 
172
171
 
173
- async def preemptive_compaction_hook(params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
172
+ async def preemptive_compaction_hook(params: dict[str, Any]) -> dict[str, Any] | None:
174
173
  """
175
174
  Pre-model invoke hook that proactively compresses context before hitting limits.
176
175
 
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ PostToolUse hook for routing fallback notifications.
4
+
5
+ Monitors provider state changes and notifies users when routing decisions
6
+ are made due to rate limits or provider unavailability.
7
+ """
8
+
9
+ import json
10
+ import logging
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ sys.path.insert(0, str(Path(__file__).parent.parent))
15
+
16
+ try:
17
+ from routing import get_provider_tracker
18
+ except ImportError:
19
+ get_provider_tracker = None # type: ignore
20
+
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def format_cooldown_time(seconds: float) -> str:
26
+ """Format cooldown duration for user display."""
27
+ if seconds < 60:
28
+ return f"{int(seconds)}s"
29
+ minutes = int(seconds / 60)
30
+ remaining_seconds = int(seconds % 60)
31
+ if remaining_seconds == 0:
32
+ return f"{minutes}m"
33
+ return f"{minutes}m {remaining_seconds}s"
34
+
35
+
36
+ def check_and_notify_routing_state() -> None:
37
+ """Check current routing state and notify if providers are unavailable."""
38
+ if not get_provider_tracker or not callable(get_provider_tracker):
39
+ return
40
+
41
+ tracker = get_provider_tracker()
42
+ if not tracker:
43
+ return
44
+
45
+ status = tracker.get_status()
46
+
47
+ unavailable_providers = []
48
+ for provider_name, provider_status in status.items():
49
+ if not provider_status["available"] and provider_status["cooldown_remaining"]:
50
+ cooldown_str = format_cooldown_time(provider_status["cooldown_remaining"])
51
+ unavailable_providers.append((provider_name, cooldown_str))
52
+
53
+ if unavailable_providers:
54
+ print("\n📊 Provider Status:", file=sys.stderr)
55
+ for provider, cooldown in unavailable_providers:
56
+ print(f" ⏳ {provider.title()}: Cooldown ({cooldown} remaining)", file=sys.stderr)
57
+
58
+
59
+ def main() -> None:
60
+ """Process PostToolUse hook event."""
61
+ try:
62
+ hook_input = json.loads(sys.stdin.read())
63
+
64
+ tool_name = hook_input.get("tool_name", "")
65
+
66
+ if "invoke" in tool_name.lower() or "agent_spawn" in tool_name.lower():
67
+ check_and_notify_routing_state()
68
+
69
+ sys.exit(0)
70
+
71
+ except Exception as e:
72
+ logger.error(f"[RoutingNotifications] Error: {e}", exc_info=True)
73
+ sys.exit(0)
74
+
75
+
76
+ if __name__ == "__main__":
77
+ logging.basicConfig(
78
+ level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
79
+ )
80
+ main()
@@ -35,7 +35,7 @@ import re
35
35
  from dataclasses import dataclass
36
36
  from fnmatch import fnmatch
37
37
  from pathlib import Path
38
- from typing import Any, Dict, Optional, Set
38
+ from typing import Any
39
39
 
40
40
  logger = logging.getLogger(__name__)
41
41
 
@@ -44,8 +44,8 @@ MAX_RULES_TOKENS = 4000 # Reserve max 4k tokens for rules
44
44
  TOKEN_ESTIMATE_RATIO = 4 # ~4 chars per token (conservative)
45
45
 
46
46
  # Session-scoped caches
47
- _rules_injection_cache: Dict[str, Set[str]] = {}
48
- _session_rules_cache: Dict[str, list] = {}
47
+ _rules_injection_cache: dict[str, set[str]] = {}
48
+ _session_rules_cache: dict[str, list] = {}
49
49
 
50
50
 
51
51
  @dataclass(frozen=True)
@@ -128,7 +128,7 @@ def parse_frontmatter(content: str) -> tuple[dict[str, Any], str]:
128
128
  return metadata, body
129
129
 
130
130
 
131
- def discover_rules(project_path: Optional[str] = None) -> list[RuleFile]:
131
+ def discover_rules(project_path: str | None = None) -> list[RuleFile]:
132
132
  """
133
133
  Discover all rule files from .claude/rules/ directories.
134
134
 
@@ -212,7 +212,7 @@ def discover_rules(project_path: Optional[str] = None) -> list[RuleFile]:
212
212
  return rules
213
213
 
214
214
 
215
- def extract_file_paths_from_context(params: Dict[str, Any]) -> Set[str]:
215
+ def extract_file_paths_from_context(params: dict[str, Any]) -> set[str]:
216
216
  """
217
217
  Extract file paths from prompt context.
218
218
 
@@ -248,7 +248,7 @@ def extract_file_paths_from_context(params: Dict[str, Any]) -> Set[str]:
248
248
  return paths
249
249
 
250
250
 
251
- def match_rules_to_files(rules: list[RuleFile], file_paths: Set[str], project_path: str) -> list[RuleFile]:
251
+ def match_rules_to_files(rules: list[RuleFile], file_paths: set[str], project_path: str) -> list[RuleFile]:
252
252
  """
253
253
  Match discovered rules to active file paths using glob patterns.
254
254
 
@@ -282,15 +282,7 @@ def match_rules_to_files(rules: list[RuleFile], file_paths: Set[str], project_pa
282
282
  matched_this_pattern = False
283
283
 
284
284
  # Match absolute path
285
- if fnmatch(str(path), glob_pattern):
286
- matched_this_pattern = True
287
-
288
- # Match relative path
289
- elif relative_path and fnmatch(relative_path, glob_pattern):
290
- matched_this_pattern = True
291
-
292
- # Match filename only (for patterns like "*.py")
293
- elif fnmatch(path.name, glob_pattern):
285
+ if fnmatch(str(path), glob_pattern) or relative_path and fnmatch(relative_path, glob_pattern) or fnmatch(path.name, glob_pattern):
294
286
  matched_this_pattern = True
295
287
 
296
288
  if matched_this_pattern:
@@ -383,13 +375,13 @@ def format_rules_injection(rules: list[RuleFile]) -> str:
383
375
  return header + "\n".join(rules_blocks)
384
376
 
385
377
 
386
- def get_session_cache_key(session_id: str, file_paths: Set[str]) -> str:
378
+ def get_session_cache_key(session_id: str, file_paths: set[str]) -> str:
387
379
  """Generate cache key for session + file combination."""
388
380
  sorted_paths = "|".join(sorted(file_paths))
389
381
  return f"{session_id}:{sorted_paths}"
390
382
 
391
383
 
392
- def is_already_injected(session_id: str, file_paths: Set[str], rule_names: list[str]) -> bool:
384
+ def is_already_injected(session_id: str, file_paths: set[str], rule_names: list[str]) -> bool:
393
385
  """
394
386
  Check if rules have already been injected for this session + file combination.
395
387
 
@@ -434,7 +426,7 @@ def get_cached_rules(session_id: str, project_path: str) -> list[RuleFile]:
434
426
  return _session_rules_cache[cache_key]
435
427
 
436
428
 
437
- def get_project_path_from_prompt(prompt: str) -> Optional[str]:
429
+ def get_project_path_from_prompt(prompt: str) -> str | None:
438
430
  """Extract project path from prompt if available."""
439
431
  # Look for common working directory indicators
440
432
  patterns = [
@@ -452,7 +444,7 @@ def get_project_path_from_prompt(prompt: str) -> Optional[str]:
452
444
  return str(Path.cwd())
453
445
 
454
446
 
455
- async def rules_injector_hook(params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
447
+ async def rules_injector_hook(params: dict[str, Any]) -> dict[str, Any] | None:
456
448
  """
457
449
  Pre-model-invoke hook for automatic rules injection.
458
450
 
@@ -8,7 +8,7 @@ Based on oh-my-opencode's todo-continuation-enforcer pattern.
8
8
  """
9
9
 
10
10
  import logging
11
- from typing import Any, Dict, Optional
11
+ from typing import Any
12
12
 
13
13
  logger = logging.getLogger(__name__)
14
14
 
@@ -30,11 +30,11 @@ Use TodoWrite to check your current task status and continue with the next pendi
30
30
  """
31
31
 
32
32
  # Track sessions to prevent duplicate injections
33
- _idle_sessions: Dict[str, bool] = {}
34
- _last_activity: Dict[str, float] = {}
33
+ _idle_sessions: dict[str, bool] = {}
34
+ _last_activity: dict[str, float] = {}
35
35
 
36
36
 
37
- async def session_idle_hook(params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
37
+ async def session_idle_hook(params: dict[str, Any]) -> dict[str, Any] | None:
38
38
  """
39
39
  Pre-model-invoke hook that detects idle sessions with incomplete todos.
40
40
 
@@ -7,15 +7,15 @@ Provides OS-level desktop notifications when sessions are idle.
7
7
  import logging
8
8
  import platform
9
9
  import subprocess
10
- from typing import Any, Dict, Optional, Set
10
+ from typing import Any
11
11
 
12
12
  logger = logging.getLogger(__name__)
13
13
 
14
14
  # Track which sessions have been notified (avoid duplicates)
15
- _notified_sessions: Set[str] = set()
15
+ _notified_sessions: set[str] = set()
16
16
 
17
17
 
18
- def get_notification_command(title: str, message: str, sound: bool = True) -> Optional[list]:
18
+ def get_notification_command(title: str, message: str, sound: bool = True) -> list | None:
19
19
  """
20
20
  Get platform-specific notification command.
21
21
 
@@ -69,7 +69,7 @@ async def session_notifier_hook(
69
69
  session_id: str,
70
70
  has_pending_todos: bool,
71
71
  idle_seconds: float,
72
- params: Dict[str, Any]
72
+ params: dict[str, Any]
73
73
  ) -> None:
74
74
  """
75
75
  Session idle hook that sends desktop notification.
@@ -10,8 +10,7 @@ Detects and recovers from corrupted sessions:
10
10
 
11
11
  import logging
12
12
  import re
13
- import json
14
- from typing import Any, Dict, Optional
13
+ from typing import Any
15
14
 
16
15
  logger = logging.getLogger(__name__)
17
16
 
@@ -80,7 +79,7 @@ Recommended Actions:
80
79
  """
81
80
 
82
81
 
83
- def detect_corruption(output: str) -> Optional[str]:
82
+ def detect_corruption(output: str) -> str | None:
84
83
  """
85
84
  Detect if the output shows signs of corruption.
86
85
 
@@ -135,9 +134,9 @@ def get_recovery_hint(tool_name: str, issue: str) -> str:
135
134
 
136
135
  async def session_recovery_hook(
137
136
  tool_name: str,
138
- arguments: Dict[str, Any],
137
+ arguments: dict[str, Any],
139
138
  output: str
140
- ) -> Optional[str]:
139
+ ) -> str | None:
141
140
  """
142
141
  Post-tool call hook that detects corrupted results and injects recovery information.
143
142
 
@@ -65,7 +65,7 @@ def read_stravinsky_mode_config() -> dict:
65
65
  return {}
66
66
  try:
67
67
  return json.loads(STRAVINSKY_MODE_FILE.read_text())
68
- except (json.JSONDecodeError, IOError):
68
+ except (OSError, json.JSONDecodeError):
69
69
  return {"active": True}
70
70
 
71
71
 
@@ -16,8 +16,6 @@ Exit codes:
16
16
  import json
17
17
  import sys
18
18
  from pathlib import Path
19
- from typing import Optional, Tuple
20
-
21
19
 
22
20
  STRAVINSKY_MODE_FILE = Path.home() / ".stravinsky_mode"
23
21
 
@@ -27,7 +25,7 @@ def is_stravinsky_mode() -> bool:
27
25
  return STRAVINSKY_MODE_FILE.exists()
28
26
 
29
27
 
30
- def extract_subagent_info(hook_input: dict) -> Tuple[str, str, str]:
28
+ def extract_subagent_info(hook_input: dict) -> tuple[str, str, str]:
31
29
  """
32
30
  Extract subagent information from hook input.
33
31
 
@@ -6,7 +6,7 @@ Detects and warns about empty or failed Task tool execution results.
6
6
 
7
7
  import logging
8
8
  import re
9
- from typing import Any, Dict, Optional
9
+ from typing import Any
10
10
 
11
11
  logger = logging.getLogger(__name__)
12
12
 
@@ -38,7 +38,7 @@ Recommended actions:
38
38
 
39
39
 
40
40
  async def task_validator_hook(
41
- tool_name: str, tool_input: Dict[str, Any], tool_response: str
41
+ tool_name: str, tool_input: dict[str, Any], tool_response: str
42
42
  ) -> str:
43
43
  """
44
44
  Post-tool-call hook that validates Task tool responses.