stravinsky 0.2.40__py3-none-any.whl → 0.3.4__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.
- mcp_bridge/__init__.py +1 -1
- mcp_bridge/auth/token_refresh.py +130 -0
- mcp_bridge/cli/__init__.py +6 -0
- mcp_bridge/cli/install_hooks.py +1265 -0
- mcp_bridge/cli/session_report.py +585 -0
- mcp_bridge/hooks/HOOKS_SETTINGS.json +175 -0
- mcp_bridge/hooks/README.md +215 -0
- mcp_bridge/hooks/__init__.py +119 -43
- mcp_bridge/hooks/edit_recovery.py +42 -37
- mcp_bridge/hooks/git_noninteractive.py +89 -0
- mcp_bridge/hooks/keyword_detector.py +30 -0
- mcp_bridge/hooks/manager.py +50 -0
- mcp_bridge/hooks/notification_hook.py +103 -0
- mcp_bridge/hooks/parallel_enforcer.py +127 -0
- mcp_bridge/hooks/parallel_execution.py +111 -0
- mcp_bridge/hooks/pre_compact.py +123 -0
- mcp_bridge/hooks/preemptive_compaction.py +81 -7
- mcp_bridge/hooks/rules_injector.py +507 -0
- mcp_bridge/hooks/session_idle.py +116 -0
- mcp_bridge/hooks/session_notifier.py +125 -0
- mcp_bridge/{native_hooks → hooks}/stravinsky_mode.py +51 -16
- mcp_bridge/hooks/subagent_stop.py +98 -0
- mcp_bridge/hooks/task_validator.py +73 -0
- mcp_bridge/hooks/tmux_manager.py +141 -0
- mcp_bridge/hooks/todo_continuation.py +90 -0
- mcp_bridge/hooks/todo_delegation.py +88 -0
- mcp_bridge/hooks/tool_messaging.py +164 -0
- mcp_bridge/hooks/truncator.py +21 -17
- mcp_bridge/notifications.py +151 -0
- mcp_bridge/prompts/__init__.py +3 -1
- mcp_bridge/prompts/dewey.py +30 -20
- mcp_bridge/prompts/explore.py +46 -8
- mcp_bridge/prompts/multimodal.py +24 -3
- mcp_bridge/prompts/planner.py +222 -0
- mcp_bridge/prompts/stravinsky.py +107 -28
- mcp_bridge/server.py +170 -10
- mcp_bridge/server_tools.py +554 -32
- mcp_bridge/tools/agent_manager.py +316 -106
- mcp_bridge/tools/background_tasks.py +2 -1
- mcp_bridge/tools/code_search.py +97 -11
- mcp_bridge/tools/lsp/__init__.py +7 -0
- mcp_bridge/tools/lsp/manager.py +448 -0
- mcp_bridge/tools/lsp/tools.py +637 -150
- mcp_bridge/tools/model_invoke.py +270 -47
- mcp_bridge/tools/semantic_search.py +2492 -0
- mcp_bridge/tools/templates.py +32 -18
- stravinsky-0.3.4.dist-info/METADATA +420 -0
- stravinsky-0.3.4.dist-info/RECORD +79 -0
- stravinsky-0.3.4.dist-info/entry_points.txt +5 -0
- mcp_bridge/native_hooks/edit_recovery.py +0 -46
- mcp_bridge/native_hooks/truncator.py +0 -23
- stravinsky-0.2.40.dist-info/METADATA +0 -204
- stravinsky-0.2.40.dist-info/RECORD +0 -57
- stravinsky-0.2.40.dist-info/entry_points.txt +0 -3
- /mcp_bridge/{native_hooks → hooks}/context.py +0 -0
- {stravinsky-0.2.40.dist-info → stravinsky-0.3.4.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session Idle Hook - Stop Hook Implementation.
|
|
3
|
+
|
|
4
|
+
Detects when session becomes idle with incomplete todos and injects
|
|
5
|
+
a continuation prompt to force task completion.
|
|
6
|
+
|
|
7
|
+
Based on oh-my-opencode's todo-continuation-enforcer pattern.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
# Continuation prompt injected when session is idle with incomplete todos
|
|
16
|
+
TODO_CONTINUATION_PROMPT = """
|
|
17
|
+
[SYSTEM REMINDER - TODO CONTINUATION]
|
|
18
|
+
|
|
19
|
+
You have incomplete tasks in your todo list. Continue working on the next pending task.
|
|
20
|
+
|
|
21
|
+
RULES:
|
|
22
|
+
- Proceed immediately without asking for permission
|
|
23
|
+
- Mark the current task as in_progress before starting
|
|
24
|
+
- Mark each task complete when finished
|
|
25
|
+
- Do NOT stop until all tasks are done
|
|
26
|
+
- If blocked, create a new task describing what needs to be resolved
|
|
27
|
+
|
|
28
|
+
STATUS CHECK:
|
|
29
|
+
Use TodoWrite to check your current task status and continue with the next pending item.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
# Track sessions to prevent duplicate injections
|
|
33
|
+
_idle_sessions: Dict[str, bool] = {}
|
|
34
|
+
_last_activity: Dict[str, float] = {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def session_idle_hook(params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
38
|
+
"""
|
|
39
|
+
Pre-model-invoke hook that detects idle sessions with incomplete todos.
|
|
40
|
+
|
|
41
|
+
Checks if:
|
|
42
|
+
1. The conversation has pending todos
|
|
43
|
+
2. The session has been idle (no recent tool calls)
|
|
44
|
+
3. A continuation hasn't already been injected
|
|
45
|
+
|
|
46
|
+
If all conditions met, injects TODO_CONTINUATION_PROMPT.
|
|
47
|
+
"""
|
|
48
|
+
import time
|
|
49
|
+
|
|
50
|
+
prompt = params.get("prompt", "")
|
|
51
|
+
session_id = params.get("session_id", "default")
|
|
52
|
+
|
|
53
|
+
# Skip if already contains continuation reminder
|
|
54
|
+
if "[SYSTEM REMINDER - TODO CONTINUATION]" in prompt:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
# Skip if this is a fresh prompt (user just typed something)
|
|
58
|
+
if params.get("is_user_message", False):
|
|
59
|
+
_last_activity[session_id] = time.time()
|
|
60
|
+
_idle_sessions[session_id] = False
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
# Check for pending todos in the prompt/context
|
|
64
|
+
has_pending_todos = _detect_pending_todos(prompt)
|
|
65
|
+
|
|
66
|
+
if not has_pending_todos:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
# Check idle threshold (2 seconds of no activity)
|
|
70
|
+
current_time = time.time()
|
|
71
|
+
last_activity = _last_activity.get(session_id, current_time)
|
|
72
|
+
idle_seconds = current_time - last_activity
|
|
73
|
+
|
|
74
|
+
if idle_seconds < 2.0:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
# Check if already injected for this idle period
|
|
78
|
+
if _idle_sessions.get(session_id, False):
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
# Mark as injected and inject continuation
|
|
82
|
+
_idle_sessions[session_id] = True
|
|
83
|
+
logger.info(f"[SessionIdleHook] Injecting TODO continuation for session {session_id}")
|
|
84
|
+
|
|
85
|
+
modified_prompt = prompt + "\n\n" + TODO_CONTINUATION_PROMPT
|
|
86
|
+
|
|
87
|
+
return {**params, "prompt": modified_prompt}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _detect_pending_todos(prompt: str) -> bool:
|
|
91
|
+
"""
|
|
92
|
+
Detect if there are pending todos in the conversation.
|
|
93
|
+
|
|
94
|
+
Looks for patterns like:
|
|
95
|
+
- [pending] or status: pending
|
|
96
|
+
- TodoWrite with pending items
|
|
97
|
+
- Incomplete task lists
|
|
98
|
+
"""
|
|
99
|
+
pending_patterns = [
|
|
100
|
+
"[pending]",
|
|
101
|
+
"status: pending",
|
|
102
|
+
'"status": "pending"',
|
|
103
|
+
"pending tasks",
|
|
104
|
+
"incomplete tasks",
|
|
105
|
+
"remaining todos",
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
prompt_lower = prompt.lower()
|
|
109
|
+
return any(pattern.lower() in prompt_lower for pattern in pending_patterns)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def reset_session(session_id: str = "default"):
|
|
113
|
+
"""Reset idle state for a session (call when user provides new input)."""
|
|
114
|
+
_idle_sessions[session_id] = False
|
|
115
|
+
import time
|
|
116
|
+
_last_activity[session_id] = time.time()
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session Notification Hook.
|
|
3
|
+
|
|
4
|
+
Provides OS-level desktop notifications when sessions are idle.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import platform
|
|
9
|
+
import subprocess
|
|
10
|
+
from typing import Any, Dict, Optional, Set
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
# Track which sessions have been notified (avoid duplicates)
|
|
15
|
+
_notified_sessions: Set[str] = set()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_notification_command(title: str, message: str, sound: bool = True) -> Optional[list]:
|
|
19
|
+
"""
|
|
20
|
+
Get platform-specific notification command.
|
|
21
|
+
|
|
22
|
+
Returns command as list of args, or None if platform not supported.
|
|
23
|
+
"""
|
|
24
|
+
system = platform.system()
|
|
25
|
+
|
|
26
|
+
if system == "Darwin": # macOS
|
|
27
|
+
# Use osascript for macOS notifications
|
|
28
|
+
script = f'display notification "{message}" with title "{title}"'
|
|
29
|
+
if sound:
|
|
30
|
+
script += ' sound name "Glass"'
|
|
31
|
+
return ["osascript", "-e", script]
|
|
32
|
+
|
|
33
|
+
elif system == "Linux":
|
|
34
|
+
# Use notify-send for Linux
|
|
35
|
+
cmd = ["notify-send", title, message]
|
|
36
|
+
if sound:
|
|
37
|
+
cmd.extend(["--urgency=normal"])
|
|
38
|
+
return cmd
|
|
39
|
+
|
|
40
|
+
elif system == "Windows":
|
|
41
|
+
# Use PowerShell for Windows notifications
|
|
42
|
+
ps_script = f"""
|
|
43
|
+
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
|
44
|
+
[Windows.UI.Notifications.ToastNotification, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
|
45
|
+
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
|
|
46
|
+
|
|
47
|
+
$template = @"
|
|
48
|
+
<toast>
|
|
49
|
+
<visual>
|
|
50
|
+
<binding template="ToastGeneric">
|
|
51
|
+
<text>{title}</text>
|
|
52
|
+
<text>{message}</text>
|
|
53
|
+
</binding>
|
|
54
|
+
</visual>
|
|
55
|
+
</toast>
|
|
56
|
+
"@
|
|
57
|
+
|
|
58
|
+
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
|
|
59
|
+
$xml.LoadXml($template)
|
|
60
|
+
$toast = New-Object Windows.UI.Notifications.ToastNotification $xml
|
|
61
|
+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("Stravinsky").Show($toast)
|
|
62
|
+
"""
|
|
63
|
+
return ["powershell", "-Command", ps_script]
|
|
64
|
+
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def session_notifier_hook(
|
|
69
|
+
session_id: str,
|
|
70
|
+
has_pending_todos: bool,
|
|
71
|
+
idle_seconds: float,
|
|
72
|
+
params: Dict[str, Any]
|
|
73
|
+
) -> None:
|
|
74
|
+
"""
|
|
75
|
+
Session idle hook that sends desktop notification.
|
|
76
|
+
|
|
77
|
+
Called when session becomes idle with pending work.
|
|
78
|
+
"""
|
|
79
|
+
# Skip if already notified for this session
|
|
80
|
+
if session_id in _notified_sessions:
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
# Skip if no pending work
|
|
84
|
+
if not has_pending_todos:
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
# Skip if idle time is too short (< 5 seconds)
|
|
88
|
+
if idle_seconds < 5.0:
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
# Prepare notification
|
|
92
|
+
title = "Stravinsky Session Idle"
|
|
93
|
+
message = f"Session has pending todos and has been idle for {int(idle_seconds)}s"
|
|
94
|
+
|
|
95
|
+
# Get platform-specific command
|
|
96
|
+
cmd = get_notification_command(title, message, sound=True)
|
|
97
|
+
|
|
98
|
+
if not cmd:
|
|
99
|
+
logger.warning(f"[SessionNotifier] Desktop notifications not supported on {platform.system()}")
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
# Send notification (non-blocking)
|
|
104
|
+
subprocess.Popen(
|
|
105
|
+
cmd,
|
|
106
|
+
stdout=subprocess.DEVNULL,
|
|
107
|
+
stderr=subprocess.DEVNULL,
|
|
108
|
+
start_new_session=True
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Mark as notified
|
|
112
|
+
_notified_sessions.add(session_id)
|
|
113
|
+
logger.info(f"[SessionNotifier] Sent desktop notification for session {session_id}")
|
|
114
|
+
|
|
115
|
+
except FileNotFoundError:
|
|
116
|
+
logger.warning(f"[SessionNotifier] Notification command not found: {cmd[0]}")
|
|
117
|
+
except Exception as e:
|
|
118
|
+
logger.error(f"[SessionNotifier] Failed to send notification: {e}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def clear_notification_state(session_id: str) -> None:
|
|
122
|
+
"""
|
|
123
|
+
Clear notification state for a session (called when session resumes activity).
|
|
124
|
+
"""
|
|
125
|
+
_notified_sessions.discard(session_id)
|
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
Stravinsky Mode Enforcer Hook
|
|
4
4
|
|
|
5
5
|
This PreToolUse hook blocks native file reading tools (Read, Search, Grep, Bash)
|
|
6
|
-
when stravinsky orchestrator mode is active, forcing use of
|
|
6
|
+
when stravinsky orchestrator mode is active, forcing use of Task tool for native
|
|
7
|
+
subagent delegation.
|
|
7
8
|
|
|
8
9
|
Stravinsky mode is activated by creating a marker file:
|
|
9
10
|
~/.stravinsky_mode
|
|
10
11
|
|
|
11
|
-
The /
|
|
12
|
+
The /stravinsky command should create this file, and it should be
|
|
12
13
|
removed when the task is complete.
|
|
13
14
|
|
|
14
15
|
Exit codes:
|
|
@@ -27,7 +28,7 @@ STRAVINSKY_MODE_FILE = Path.home() / ".stravinsky_mode"
|
|
|
27
28
|
# Tools to block when in stravinsky mode
|
|
28
29
|
BLOCKED_TOOLS = {
|
|
29
30
|
"Read",
|
|
30
|
-
"Search",
|
|
31
|
+
"Search",
|
|
31
32
|
"Grep",
|
|
32
33
|
"Bash",
|
|
33
34
|
"MultiEdit",
|
|
@@ -38,8 +39,18 @@ BLOCKED_TOOLS = {
|
|
|
38
39
|
ALLOWED_TOOLS = {
|
|
39
40
|
"TodoRead",
|
|
40
41
|
"TodoWrite",
|
|
41
|
-
"Task",
|
|
42
|
-
"Agent", # MCP agent tools
|
|
42
|
+
"Task", # Native subagent delegation
|
|
43
|
+
"Agent", # MCP agent tools
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# Agent routing recommendations
|
|
47
|
+
AGENT_ROUTES = {
|
|
48
|
+
"Read": "explore",
|
|
49
|
+
"Grep": "explore",
|
|
50
|
+
"Search": "explore",
|
|
51
|
+
"Bash": "explore",
|
|
52
|
+
"Edit": "code-reviewer",
|
|
53
|
+
"MultiEdit": "code-reviewer",
|
|
43
54
|
}
|
|
44
55
|
|
|
45
56
|
|
|
@@ -65,33 +76,57 @@ def main():
|
|
|
65
76
|
except json.JSONDecodeError:
|
|
66
77
|
# If we can't parse input, allow the tool
|
|
67
78
|
sys.exit(0)
|
|
68
|
-
|
|
69
|
-
tool_name = hook_input.get("tool_name", "")
|
|
70
|
-
|
|
79
|
+
|
|
80
|
+
tool_name = hook_input.get("toolName", hook_input.get("tool_name", ""))
|
|
81
|
+
params = hook_input.get("params", {})
|
|
82
|
+
|
|
71
83
|
# Always allow certain tools
|
|
72
84
|
if tool_name in ALLOWED_TOOLS:
|
|
73
85
|
sys.exit(0)
|
|
74
|
-
|
|
86
|
+
|
|
75
87
|
# Check if stravinsky mode is active
|
|
76
88
|
if not is_stravinsky_mode_active():
|
|
77
89
|
# Not in stravinsky mode, allow all tools
|
|
78
90
|
sys.exit(0)
|
|
79
|
-
|
|
91
|
+
|
|
80
92
|
config = read_stravinsky_mode_config()
|
|
81
|
-
|
|
93
|
+
|
|
82
94
|
# Check if this tool should be blocked
|
|
83
95
|
if tool_name in BLOCKED_TOOLS:
|
|
96
|
+
# Determine which agent to delegate to
|
|
97
|
+
agent = AGENT_ROUTES.get(tool_name, "explore")
|
|
98
|
+
|
|
99
|
+
# Get tool context for better messaging
|
|
100
|
+
context = ""
|
|
101
|
+
if tool_name == "Grep":
|
|
102
|
+
pattern = params.get("pattern", "")
|
|
103
|
+
context = f" (searching for '{pattern[:30]}')"
|
|
104
|
+
elif tool_name == "Read":
|
|
105
|
+
file_path = params.get("file_path", "")
|
|
106
|
+
context = f" (reading {os.path.basename(file_path)})" if file_path else ""
|
|
107
|
+
|
|
108
|
+
# User-friendly delegation message
|
|
109
|
+
print(f"🎭 {agent}('Delegating {tool_name}{context}')", file=sys.stderr)
|
|
110
|
+
|
|
84
111
|
# Block the tool and tell Claude why
|
|
85
112
|
reason = f"""⚠️ STRAVINSKY MODE ACTIVE - {tool_name} BLOCKED
|
|
86
113
|
|
|
87
114
|
You are in Stravinsky orchestrator mode. Native tools are disabled.
|
|
88
115
|
|
|
89
|
-
Instead of using {tool_name}, you MUST use:
|
|
90
|
-
-
|
|
91
|
-
-
|
|
116
|
+
Instead of using {tool_name}, you MUST use Task tool for native subagent delegation:
|
|
117
|
+
- Task(subagent_type="explore", ...) for file reading/searching
|
|
118
|
+
- Task(subagent_type="dewey", ...) for documentation research
|
|
119
|
+
- Task(subagent_type="code-reviewer", ...) for code analysis
|
|
120
|
+
- Task(subagent_type="debugger", ...) for error investigation
|
|
121
|
+
- Task(subagent_type="frontend", ...) for UI/UX work
|
|
122
|
+
- Task(subagent_type="delphi", ...) for strategic architecture decisions
|
|
92
123
|
|
|
93
124
|
Example:
|
|
94
|
-
|
|
125
|
+
Task(
|
|
126
|
+
subagent_type="explore",
|
|
127
|
+
prompt="Read and analyze the authentication module",
|
|
128
|
+
description="Analyze auth"
|
|
129
|
+
)
|
|
95
130
|
|
|
96
131
|
To exit stravinsky mode, run:
|
|
97
132
|
rm ~/.stravinsky_mode
|
|
@@ -100,7 +135,7 @@ To exit stravinsky mode, run:
|
|
|
100
135
|
print(reason, file=sys.stderr)
|
|
101
136
|
# Exit with code 2 to block the tool
|
|
102
137
|
sys.exit(2)
|
|
103
|
-
|
|
138
|
+
|
|
104
139
|
# Tool not in block list, allow it
|
|
105
140
|
sys.exit(0)
|
|
106
141
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
SubagentStop hook: Handler for agent/subagent completion events.
|
|
4
|
+
|
|
5
|
+
Fires when a Claude Code subagent (Task tool) finishes to:
|
|
6
|
+
1. Output completion status messages
|
|
7
|
+
2. Verify agent produced expected output
|
|
8
|
+
3. Block completion if critical validation fails
|
|
9
|
+
4. Integrate with TODO tracking
|
|
10
|
+
|
|
11
|
+
Exit codes:
|
|
12
|
+
0 = Allow completion
|
|
13
|
+
2 = Block completion (force continuation)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Optional, Tuple
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
STRAVINSKY_MODE_FILE = Path.home() / ".stravinsky_mode"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_stravinsky_mode() -> bool:
|
|
26
|
+
"""Check if stravinsky mode is active."""
|
|
27
|
+
return STRAVINSKY_MODE_FILE.exists()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def extract_subagent_info(hook_input: dict) -> Tuple[str, str, str]:
|
|
31
|
+
"""
|
|
32
|
+
Extract subagent information from hook input.
|
|
33
|
+
|
|
34
|
+
Returns: (agent_type, description, status)
|
|
35
|
+
"""
|
|
36
|
+
# Try to get from tool parameters or response
|
|
37
|
+
params = hook_input.get("tool_input", hook_input.get("params", {}))
|
|
38
|
+
response = hook_input.get("tool_response", "")
|
|
39
|
+
|
|
40
|
+
agent_type = params.get("subagent_type", "unknown")
|
|
41
|
+
description = params.get("description", "")[:50]
|
|
42
|
+
|
|
43
|
+
# Determine status from response
|
|
44
|
+
status = "completed"
|
|
45
|
+
response_lower = response.lower() if isinstance(response, str) else ""
|
|
46
|
+
if "error" in response_lower or "failed" in response_lower:
|
|
47
|
+
status = "failed"
|
|
48
|
+
elif "timeout" in response_lower:
|
|
49
|
+
status = "timeout"
|
|
50
|
+
|
|
51
|
+
return agent_type, description, status
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def format_completion_message(agent_type: str, description: str, status: str) -> str:
|
|
55
|
+
"""Format user-friendly completion message."""
|
|
56
|
+
icon = "✓" if status == "completed" else "✗"
|
|
57
|
+
return f"{icon} Subagent {agent_type} {status}: {description}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def should_block(status: str, agent_type: str) -> bool:
|
|
61
|
+
"""
|
|
62
|
+
Determine if we should block completion.
|
|
63
|
+
|
|
64
|
+
Block if:
|
|
65
|
+
- Agent failed AND stravinsky mode active AND critical agent type
|
|
66
|
+
"""
|
|
67
|
+
if status != "completed" and is_stravinsky_mode():
|
|
68
|
+
critical_agents = {"delphi", "code-reviewer", "debugger"}
|
|
69
|
+
if agent_type in critical_agents:
|
|
70
|
+
return True
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main():
|
|
75
|
+
"""Main hook entry point."""
|
|
76
|
+
try:
|
|
77
|
+
hook_input = json.load(sys.stdin)
|
|
78
|
+
except (json.JSONDecodeError, EOFError):
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
# Extract subagent info
|
|
82
|
+
agent_type, description, status = extract_subagent_info(hook_input)
|
|
83
|
+
|
|
84
|
+
# Output completion message
|
|
85
|
+
message = format_completion_message(agent_type, description, status)
|
|
86
|
+
print(message, file=sys.stderr)
|
|
87
|
+
|
|
88
|
+
# Check if we should block
|
|
89
|
+
if should_block(status, agent_type):
|
|
90
|
+
print(f"\n⚠️ CRITICAL SUBAGENT FAILURE - {agent_type} failed", file=sys.stderr)
|
|
91
|
+
print("Review the error and retry or delegate to delphi.", file=sys.stderr)
|
|
92
|
+
return 2
|
|
93
|
+
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
sys.exit(main())
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Task Validator Hook (empty-task-response-detector equivalent).
|
|
3
|
+
|
|
4
|
+
Detects and warns about empty or failed Task tool execution results.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
EMPTY_PATTERNS = [
|
|
14
|
+
r"^\s*$", # Completely empty
|
|
15
|
+
r"^null$", # Null response
|
|
16
|
+
r"^None$", # Python None
|
|
17
|
+
r"^undefined$", # JavaScript undefined
|
|
18
|
+
r"^{}$", # Empty JSON object
|
|
19
|
+
r"^\[\]$", # Empty array
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
TASK_FAILURE_WARNING = """
|
|
23
|
+
[TASK EXECUTION WARNING]
|
|
24
|
+
The background task completed but returned empty or invalid output.
|
|
25
|
+
|
|
26
|
+
Possible causes:
|
|
27
|
+
- Agent terminated prematurely
|
|
28
|
+
- Tool execution failed silently
|
|
29
|
+
- Output was not captured properly
|
|
30
|
+
- Task encountered an unhandled exception
|
|
31
|
+
|
|
32
|
+
Recommended actions:
|
|
33
|
+
1. Check task logs for errors
|
|
34
|
+
2. Verify agent has proper tool access
|
|
35
|
+
3. Re-run task with explicit error handling
|
|
36
|
+
4. Use agent_progress(task_id) to monitor execution
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def task_validator_hook(
|
|
41
|
+
tool_name: str, tool_input: Dict[str, Any], tool_response: str
|
|
42
|
+
) -> str:
|
|
43
|
+
"""
|
|
44
|
+
Post-tool-call hook that validates Task tool responses.
|
|
45
|
+
|
|
46
|
+
Detects empty/failed responses and injects diagnostic warning.
|
|
47
|
+
"""
|
|
48
|
+
# Only validate Task tool responses
|
|
49
|
+
if tool_name not in ["Task", "agent_spawn"]:
|
|
50
|
+
return tool_response
|
|
51
|
+
|
|
52
|
+
# Skip if response is non-empty and meaningful
|
|
53
|
+
if tool_response and len(tool_response.strip()) > 50:
|
|
54
|
+
return tool_response
|
|
55
|
+
|
|
56
|
+
# Check for empty patterns
|
|
57
|
+
response_stripped = tool_response.strip()
|
|
58
|
+
for pattern in EMPTY_PATTERNS:
|
|
59
|
+
if re.match(pattern, response_stripped, re.IGNORECASE):
|
|
60
|
+
logger.warning(
|
|
61
|
+
f"[TaskValidator] Empty/invalid response detected for {tool_name}"
|
|
62
|
+
)
|
|
63
|
+
# Inject warning but preserve original response
|
|
64
|
+
return tool_response + "\n" + TASK_FAILURE_WARNING
|
|
65
|
+
|
|
66
|
+
# Check for suspiciously short responses (< 10 chars)
|
|
67
|
+
if len(response_stripped) < 10:
|
|
68
|
+
logger.warning(
|
|
69
|
+
f"[TaskValidator] Suspiciously short response ({len(response_stripped)} chars)"
|
|
70
|
+
)
|
|
71
|
+
return tool_response + "\n" + TASK_FAILURE_WARNING
|
|
72
|
+
|
|
73
|
+
return tool_response
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interactive Bash Session Hook (Tmux Manager).
|
|
3
|
+
|
|
4
|
+
Manages persistent tmux sessions and cleanup.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
import shlex
|
|
10
|
+
import subprocess
|
|
11
|
+
from typing import Any, Dict, List, Optional, Set
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
# Track tmux sessions created by Stravinsky
|
|
16
|
+
_tracked_sessions: Set[str] = set()
|
|
17
|
+
SESSION_PREFIX = "stravinsky-"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_tmux_command(command: str) -> Optional[str]:
|
|
21
|
+
"""
|
|
22
|
+
Parse tmux command to extract session name.
|
|
23
|
+
|
|
24
|
+
Handles quote/escape properly using shlex.
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
# Use shlex to properly parse quoted arguments
|
|
28
|
+
parts = shlex.split(command)
|
|
29
|
+
|
|
30
|
+
# Look for tmux new-session or attach-session
|
|
31
|
+
if "tmux" not in parts:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
# Find session name after -t or -s flags
|
|
35
|
+
for i, part in enumerate(parts):
|
|
36
|
+
if part in ["-s", "-t"] and i + 1 < len(parts):
|
|
37
|
+
session_name = parts[i + 1]
|
|
38
|
+
return normalize_session_name(session_name)
|
|
39
|
+
|
|
40
|
+
# Check for inline session name (tmux new -s name)
|
|
41
|
+
for i, part in enumerate(parts):
|
|
42
|
+
if part == "new" or part == "new-session":
|
|
43
|
+
# Look for -s in following parts
|
|
44
|
+
for j in range(i + 1, len(parts)):
|
|
45
|
+
if parts[j] == "-s" and j + 1 < len(parts):
|
|
46
|
+
return normalize_session_name(parts[j + 1])
|
|
47
|
+
|
|
48
|
+
except Exception as e:
|
|
49
|
+
logger.error(f"[TmuxManager] Failed to parse tmux command: {e}")
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def normalize_session_name(name: str) -> str:
|
|
56
|
+
"""
|
|
57
|
+
Normalize tmux session name (strip window/pane suffixes).
|
|
58
|
+
|
|
59
|
+
Examples:
|
|
60
|
+
"session:0" -> "session"
|
|
61
|
+
"session:window.pane" -> "session"
|
|
62
|
+
"""
|
|
63
|
+
# Split on : to remove window/pane references
|
|
64
|
+
return name.split(":")[0]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def tmux_manager_hook(
|
|
68
|
+
tool_name: str, tool_input: Dict[str, Any], tool_output: Optional[str] = None
|
|
69
|
+
) -> Optional[str]:
|
|
70
|
+
"""
|
|
71
|
+
Post-tool-call hook that tracks tmux sessions.
|
|
72
|
+
|
|
73
|
+
Monitors Bash tool for tmux commands and tracks session names.
|
|
74
|
+
"""
|
|
75
|
+
# Only process Bash tool
|
|
76
|
+
if tool_name != "Bash":
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
command = tool_input.get("command", "")
|
|
80
|
+
if not command or "tmux" not in command:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
# Parse session name from tmux command
|
|
84
|
+
session_name = parse_tmux_command(command)
|
|
85
|
+
|
|
86
|
+
if session_name:
|
|
87
|
+
# Track with Stravinsky prefix
|
|
88
|
+
if not session_name.startswith(SESSION_PREFIX):
|
|
89
|
+
session_name = SESSION_PREFIX + session_name
|
|
90
|
+
|
|
91
|
+
_tracked_sessions.add(session_name)
|
|
92
|
+
logger.info(f"[TmuxManager] Tracking tmux session: {session_name}")
|
|
93
|
+
|
|
94
|
+
# Append reminder about active sessions
|
|
95
|
+
if tool_output:
|
|
96
|
+
reminder = f"\n\n[TMUX SESSION] Active session tracked: {session_name}\n" \
|
|
97
|
+
f"Cleanup on session end: kill-session -t {session_name}"
|
|
98
|
+
return tool_output + reminder
|
|
99
|
+
|
|
100
|
+
return tool_output
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def cleanup_tmux_sessions() -> List[str]:
|
|
104
|
+
"""
|
|
105
|
+
Kill all tracked tmux sessions.
|
|
106
|
+
|
|
107
|
+
Returns list of killed session names.
|
|
108
|
+
"""
|
|
109
|
+
killed = []
|
|
110
|
+
|
|
111
|
+
for session_name in _tracked_sessions:
|
|
112
|
+
try:
|
|
113
|
+
# Kill tmux session
|
|
114
|
+
subprocess.run(
|
|
115
|
+
["tmux", "kill-session", "-t", session_name],
|
|
116
|
+
stdout=subprocess.DEVNULL,
|
|
117
|
+
stderr=subprocess.DEVNULL,
|
|
118
|
+
timeout=5
|
|
119
|
+
)
|
|
120
|
+
killed.append(session_name)
|
|
121
|
+
logger.info(f"[TmuxManager] Killed tmux session: {session_name}")
|
|
122
|
+
|
|
123
|
+
except FileNotFoundError:
|
|
124
|
+
logger.warning("[TmuxManager] tmux command not found")
|
|
125
|
+
break
|
|
126
|
+
except subprocess.TimeoutExpired:
|
|
127
|
+
logger.warning(f"[TmuxManager] Timeout killing session: {session_name}")
|
|
128
|
+
except Exception as e:
|
|
129
|
+
logger.error(f"[TmuxManager] Failed to kill session {session_name}: {e}")
|
|
130
|
+
|
|
131
|
+
# Clear tracked sessions
|
|
132
|
+
_tracked_sessions.clear()
|
|
133
|
+
|
|
134
|
+
return killed
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def get_tracked_sessions() -> Set[str]:
|
|
138
|
+
"""
|
|
139
|
+
Get set of currently tracked tmux sessions.
|
|
140
|
+
"""
|
|
141
|
+
return _tracked_sessions.copy()
|