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.
- mcp_bridge/__init__.py +1 -1
- mcp_bridge/auth/__init__.py +16 -6
- mcp_bridge/auth/cli.py +202 -11
- mcp_bridge/auth/oauth.py +1 -2
- mcp_bridge/auth/openai_oauth.py +4 -7
- mcp_bridge/auth/token_store.py +0 -1
- mcp_bridge/cli/__init__.py +1 -1
- mcp_bridge/cli/install_hooks.py +503 -107
- mcp_bridge/cli/session_report.py +0 -3
- mcp_bridge/config/__init__.py +2 -2
- mcp_bridge/config/hook_config.py +3 -5
- mcp_bridge/config/rate_limits.py +108 -13
- mcp_bridge/hooks/HOOKS_SETTINGS.json +17 -4
- mcp_bridge/hooks/__init__.py +14 -4
- mcp_bridge/hooks/agent_reminder.py +4 -4
- mcp_bridge/hooks/auto_slash_command.py +5 -5
- mcp_bridge/hooks/budget_optimizer.py +2 -2
- mcp_bridge/hooks/claude_limits_hook.py +114 -0
- mcp_bridge/hooks/comment_checker.py +3 -4
- mcp_bridge/hooks/compaction.py +2 -2
- mcp_bridge/hooks/context.py +2 -1
- mcp_bridge/hooks/context_monitor.py +2 -2
- mcp_bridge/hooks/delegation_policy.py +85 -0
- mcp_bridge/hooks/directory_context.py +3 -3
- mcp_bridge/hooks/edit_recovery.py +3 -2
- mcp_bridge/hooks/edit_recovery_policy.py +49 -0
- mcp_bridge/hooks/empty_message_sanitizer.py +2 -2
- mcp_bridge/hooks/events.py +160 -0
- mcp_bridge/hooks/git_noninteractive.py +4 -4
- mcp_bridge/hooks/keyword_detector.py +8 -10
- mcp_bridge/hooks/manager.py +35 -22
- mcp_bridge/hooks/notification_hook.py +13 -6
- mcp_bridge/hooks/parallel_enforcement_policy.py +67 -0
- mcp_bridge/hooks/parallel_enforcer.py +5 -5
- mcp_bridge/hooks/parallel_execution.py +22 -10
- mcp_bridge/hooks/post_tool/parallel_validation.py +103 -0
- mcp_bridge/hooks/pre_compact.py +8 -9
- mcp_bridge/hooks/pre_tool/agent_spawn_validator.py +115 -0
- mcp_bridge/hooks/preemptive_compaction.py +2 -3
- mcp_bridge/hooks/routing_notifications.py +80 -0
- mcp_bridge/hooks/rules_injector.py +11 -19
- mcp_bridge/hooks/session_idle.py +4 -4
- mcp_bridge/hooks/session_notifier.py +4 -4
- mcp_bridge/hooks/session_recovery.py +4 -5
- mcp_bridge/hooks/stravinsky_mode.py +1 -1
- mcp_bridge/hooks/subagent_stop.py +1 -3
- mcp_bridge/hooks/task_validator.py +2 -2
- mcp_bridge/hooks/tmux_manager.py +7 -8
- mcp_bridge/hooks/todo_delegation.py +4 -1
- mcp_bridge/hooks/todo_enforcer.py +180 -10
- mcp_bridge/hooks/truncation_policy.py +37 -0
- mcp_bridge/hooks/truncator.py +1 -2
- mcp_bridge/metrics/cost_tracker.py +115 -0
- mcp_bridge/native_search.py +93 -0
- mcp_bridge/native_watcher.py +118 -0
- mcp_bridge/notifications.py +3 -4
- mcp_bridge/orchestrator/enums.py +11 -0
- mcp_bridge/orchestrator/router.py +165 -0
- mcp_bridge/orchestrator/state.py +32 -0
- mcp_bridge/orchestrator/visualization.py +14 -0
- mcp_bridge/orchestrator/wisdom.py +34 -0
- mcp_bridge/prompts/__init__.py +1 -8
- mcp_bridge/prompts/dewey.py +1 -1
- mcp_bridge/prompts/planner.py +2 -4
- mcp_bridge/prompts/stravinsky.py +53 -31
- mcp_bridge/proxy/__init__.py +0 -0
- mcp_bridge/proxy/client.py +70 -0
- mcp_bridge/proxy/model_server.py +157 -0
- mcp_bridge/routing/__init__.py +43 -0
- mcp_bridge/routing/config.py +250 -0
- mcp_bridge/routing/model_tiers.py +135 -0
- mcp_bridge/routing/provider_state.py +261 -0
- mcp_bridge/routing/task_classifier.py +190 -0
- mcp_bridge/server.py +363 -34
- mcp_bridge/server_tools.py +298 -6
- mcp_bridge/tools/__init__.py +19 -8
- mcp_bridge/tools/agent_manager.py +549 -799
- mcp_bridge/tools/background_tasks.py +13 -17
- mcp_bridge/tools/code_search.py +54 -51
- mcp_bridge/tools/continuous_loop.py +0 -1
- mcp_bridge/tools/dashboard.py +19 -0
- mcp_bridge/tools/find_code.py +296 -0
- mcp_bridge/tools/init.py +1 -0
- mcp_bridge/tools/list_directory.py +42 -0
- mcp_bridge/tools/lsp/__init__.py +8 -8
- mcp_bridge/tools/lsp/manager.py +51 -28
- mcp_bridge/tools/lsp/tools.py +98 -65
- mcp_bridge/tools/model_invoke.py +1047 -152
- mcp_bridge/tools/mux_client.py +75 -0
- mcp_bridge/tools/project_context.py +1 -2
- mcp_bridge/tools/query_classifier.py +132 -49
- mcp_bridge/tools/read_file.py +84 -0
- mcp_bridge/tools/replace.py +45 -0
- mcp_bridge/tools/run_shell_command.py +38 -0
- mcp_bridge/tools/search_enhancements.py +347 -0
- mcp_bridge/tools/semantic_search.py +677 -92
- mcp_bridge/tools/session_manager.py +0 -2
- mcp_bridge/tools/skill_loader.py +0 -1
- mcp_bridge/tools/task_runner.py +5 -7
- mcp_bridge/tools/templates.py +3 -3
- mcp_bridge/tools/tool_search.py +331 -0
- mcp_bridge/tools/write_file.py +29 -0
- mcp_bridge/update_manager.py +33 -37
- mcp_bridge/update_manager_pypi.py +6 -8
- mcp_bridge/utils/cache.py +82 -0
- mcp_bridge/utils/process.py +71 -0
- mcp_bridge/utils/session_state.py +51 -0
- mcp_bridge/utils/truncation.py +76 -0
- {stravinsky-0.4.18.dist-info → stravinsky-0.4.66.dist-info}/METADATA +84 -35
- stravinsky-0.4.66.dist-info/RECORD +198 -0
- {stravinsky-0.4.18.dist-info → stravinsky-0.4.66.dist-info}/entry_points.txt +1 -0
- stravinsky_claude_assets/HOOKS_INTEGRATION.md +316 -0
- stravinsky_claude_assets/agents/HOOKS.md +437 -0
- stravinsky_claude_assets/agents/code-reviewer.md +210 -0
- stravinsky_claude_assets/agents/comment_checker.md +580 -0
- stravinsky_claude_assets/agents/debugger.md +254 -0
- stravinsky_claude_assets/agents/delphi.md +495 -0
- stravinsky_claude_assets/agents/dewey.md +248 -0
- stravinsky_claude_assets/agents/explore.md +1198 -0
- stravinsky_claude_assets/agents/frontend.md +472 -0
- stravinsky_claude_assets/agents/implementation-lead.md +164 -0
- stravinsky_claude_assets/agents/momus.md +464 -0
- stravinsky_claude_assets/agents/research-lead.md +141 -0
- stravinsky_claude_assets/agents/stravinsky.md +730 -0
- stravinsky_claude_assets/commands/delphi.md +9 -0
- stravinsky_claude_assets/commands/dewey.md +54 -0
- stravinsky_claude_assets/commands/git-master.md +112 -0
- stravinsky_claude_assets/commands/index.md +49 -0
- stravinsky_claude_assets/commands/publish.md +86 -0
- stravinsky_claude_assets/commands/review.md +73 -0
- stravinsky_claude_assets/commands/str/agent_cancel.md +70 -0
- stravinsky_claude_assets/commands/str/agent_list.md +56 -0
- stravinsky_claude_assets/commands/str/agent_output.md +92 -0
- stravinsky_claude_assets/commands/str/agent_progress.md +74 -0
- stravinsky_claude_assets/commands/str/agent_retry.md +94 -0
- stravinsky_claude_assets/commands/str/cancel.md +51 -0
- stravinsky_claude_assets/commands/str/clean.md +97 -0
- stravinsky_claude_assets/commands/str/continue.md +38 -0
- stravinsky_claude_assets/commands/str/index.md +199 -0
- stravinsky_claude_assets/commands/str/list_watchers.md +96 -0
- stravinsky_claude_assets/commands/str/search.md +205 -0
- stravinsky_claude_assets/commands/str/start_filewatch.md +136 -0
- stravinsky_claude_assets/commands/str/stats.md +71 -0
- stravinsky_claude_assets/commands/str/stop_filewatch.md +89 -0
- stravinsky_claude_assets/commands/str/unwatch.md +42 -0
- stravinsky_claude_assets/commands/str/watch.md +45 -0
- stravinsky_claude_assets/commands/strav.md +53 -0
- stravinsky_claude_assets/commands/stravinsky.md +292 -0
- stravinsky_claude_assets/commands/verify.md +60 -0
- stravinsky_claude_assets/commands/version.md +5 -0
- stravinsky_claude_assets/hooks/README.md +248 -0
- stravinsky_claude_assets/hooks/comment_checker.py +193 -0
- stravinsky_claude_assets/hooks/context.py +38 -0
- stravinsky_claude_assets/hooks/context_monitor.py +153 -0
- stravinsky_claude_assets/hooks/dependency_tracker.py +73 -0
- stravinsky_claude_assets/hooks/edit_recovery.py +46 -0
- stravinsky_claude_assets/hooks/execution_state_tracker.py +68 -0
- stravinsky_claude_assets/hooks/notification_hook.py +103 -0
- stravinsky_claude_assets/hooks/notification_hook_v2.py +96 -0
- stravinsky_claude_assets/hooks/parallel_execution.py +241 -0
- stravinsky_claude_assets/hooks/parallel_reinforcement.py +106 -0
- stravinsky_claude_assets/hooks/parallel_reinforcement_v2.py +112 -0
- stravinsky_claude_assets/hooks/pre_compact.py +123 -0
- stravinsky_claude_assets/hooks/ralph_loop.py +173 -0
- stravinsky_claude_assets/hooks/session_recovery.py +263 -0
- stravinsky_claude_assets/hooks/stop_hook.py +89 -0
- stravinsky_claude_assets/hooks/stravinsky_metrics.py +164 -0
- stravinsky_claude_assets/hooks/stravinsky_mode.py +146 -0
- stravinsky_claude_assets/hooks/subagent_stop.py +98 -0
- stravinsky_claude_assets/hooks/todo_continuation.py +111 -0
- stravinsky_claude_assets/hooks/todo_delegation.py +96 -0
- stravinsky_claude_assets/hooks/tool_messaging.py +281 -0
- stravinsky_claude_assets/hooks/truncator.py +23 -0
- stravinsky_claude_assets/rules/deployment_safety.md +51 -0
- stravinsky_claude_assets/rules/integration_wiring.md +89 -0
- stravinsky_claude_assets/rules/pypi_deployment.md +220 -0
- stravinsky_claude_assets/rules/stravinsky_orchestrator.md +32 -0
- stravinsky_claude_assets/settings.json +152 -0
- stravinsky_claude_assets/skills/chrome-devtools/SKILL.md +81 -0
- stravinsky_claude_assets/skills/sqlite/SKILL.md +77 -0
- stravinsky_claude_assets/skills/supabase/SKILL.md +74 -0
- stravinsky_claude_assets/task_dependencies.json +34 -0
- stravinsky-0.4.18.dist-info/RECORD +0 -88
- {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
|
|
24
|
-
r
|
|
25
|
-
r
|
|
26
|
-
r
|
|
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
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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()
|
mcp_bridge/hooks/pre_compact.py
CHANGED
|
@@ -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
|
|
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() ->
|
|
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
|
|
52
|
+
except (OSError, json.JSONDecodeError):
|
|
54
53
|
return {"active": True}
|
|
55
54
|
|
|
56
55
|
|
|
57
|
-
def extract_preserved_context(prompt: 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:
|
|
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.
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
48
|
-
_session_rules_cache:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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) ->
|
|
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:
|
|
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
|
|
mcp_bridge/hooks/session_idle.py
CHANGED
|
@@ -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
|
|
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:
|
|
34
|
-
_last_activity:
|
|
33
|
+
_idle_sessions: dict[str, bool] = {}
|
|
34
|
+
_last_activity: dict[str, float] = {}
|
|
35
35
|
|
|
36
36
|
|
|
37
|
-
async def session_idle_hook(params:
|
|
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
|
|
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:
|
|
15
|
+
_notified_sessions: set[str] = set()
|
|
16
16
|
|
|
17
17
|
|
|
18
|
-
def get_notification_command(title: str, message: str, sound: bool = True) ->
|
|
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:
|
|
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
|
|
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) ->
|
|
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:
|
|
137
|
+
arguments: dict[str, Any],
|
|
139
138
|
output: str
|
|
140
|
-
) ->
|
|
139
|
+
) -> str | None:
|
|
141
140
|
"""
|
|
142
141
|
Post-tool call hook that detects corrupted results and injects recovery information.
|
|
143
142
|
|
|
@@ -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) ->
|
|
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
|
|
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:
|
|
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.
|