stravinsky 0.2.52__py3-none-any.whl → 0.2.67__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/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 +117 -63
- 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/notification_hook.py +103 -0
- mcp_bridge/hooks/parallel_execution.py +111 -0
- mcp_bridge/hooks/pre_compact.py +82 -183
- mcp_bridge/hooks/rules_injector.py +507 -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/prompts/multimodal.py +24 -3
- mcp_bridge/server.py +12 -1
- mcp_bridge/server_tools.py +5 -0
- mcp_bridge/tools/agent_manager.py +30 -11
- mcp_bridge/tools/code_search.py +81 -9
- mcp_bridge/tools/lsp/tools.py +6 -2
- mcp_bridge/tools/model_invoke.py +76 -1
- mcp_bridge/tools/templates.py +32 -18
- stravinsky-0.2.67.dist-info/METADATA +284 -0
- {stravinsky-0.2.52.dist-info → stravinsky-0.2.67.dist-info}/RECORD +36 -23
- stravinsky-0.2.67.dist-info/entry_points.txt +5 -0
- mcp_bridge/native_hooks/edit_recovery.py +0 -46
- mcp_bridge/native_hooks/todo_delegation.py +0 -54
- mcp_bridge/native_hooks/truncator.py +0 -23
- stravinsky-0.2.52.dist-info/METADATA +0 -204
- stravinsky-0.2.52.dist-info/entry_points.txt +0 -3
- /mcp_bridge/{native_hooks → hooks}/context.py +0 -0
- {stravinsky-0.2.52.dist-info → stravinsky-0.2.67.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,1265 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Stravinsky Hook Installer
|
|
4
|
+
|
|
5
|
+
Installs Stravinsky hooks to ~/.claude/hooks/ directory and updates settings.json.
|
|
6
|
+
Hooks provide enhanced Claude Code behavior including:
|
|
7
|
+
- Parallel execution enforcement
|
|
8
|
+
- Tool output truncation
|
|
9
|
+
- Edit error recovery
|
|
10
|
+
- Context injection
|
|
11
|
+
- Todo continuation
|
|
12
|
+
- Tool messaging
|
|
13
|
+
- Stravinsky mode (orchestrator blocking)
|
|
14
|
+
- Notification handling
|
|
15
|
+
- Subagent completion tracking
|
|
16
|
+
- Pre-compaction context preservation
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import shutil
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Dict, List, Any
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Hook file contents - these will be written to ~/.claude/hooks/
|
|
28
|
+
HOOKS = {
|
|
29
|
+
"truncator.py": '''import os
|
|
30
|
+
import sys
|
|
31
|
+
import json
|
|
32
|
+
|
|
33
|
+
MAX_CHARS = 30000
|
|
34
|
+
|
|
35
|
+
def main():
|
|
36
|
+
try:
|
|
37
|
+
data = json.load(sys.stdin)
|
|
38
|
+
tool_response = data.get("tool_response", "")
|
|
39
|
+
except Exception:
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
if len(tool_response) > MAX_CHARS:
|
|
43
|
+
header = f"[TRUNCATED - {len(tool_response)} chars reduced to {MAX_CHARS}]\\n"
|
|
44
|
+
footer = "\\n...[TRUNCATED]"
|
|
45
|
+
truncated = tool_response[:MAX_CHARS]
|
|
46
|
+
print(header + truncated + footer)
|
|
47
|
+
else:
|
|
48
|
+
print(tool_response)
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
main()
|
|
52
|
+
''',
|
|
53
|
+
|
|
54
|
+
"edit_recovery.py": '''#!/usr/bin/env python3
|
|
55
|
+
"""Edit error recovery hook - detects edit failures and forces file reading."""
|
|
56
|
+
import json
|
|
57
|
+
import os
|
|
58
|
+
import sys
|
|
59
|
+
|
|
60
|
+
EDIT_ERROR_PATTERNS = [
|
|
61
|
+
"oldString and newString must be different",
|
|
62
|
+
"oldString not found",
|
|
63
|
+
"oldString found multiple times",
|
|
64
|
+
"Target content not found",
|
|
65
|
+
"Multiple occurrences of target content found",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
def main():
|
|
69
|
+
try:
|
|
70
|
+
data = json.load(sys.stdin)
|
|
71
|
+
tool_response = data.get("tool_response", "")
|
|
72
|
+
except Exception:
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
# Check for edit errors
|
|
76
|
+
is_edit_error = any(pattern in tool_response for pattern in EDIT_ERROR_PATTERNS)
|
|
77
|
+
|
|
78
|
+
if is_edit_error:
|
|
79
|
+
error_msg = """
|
|
80
|
+
> **[EDIT ERROR - IMMEDIATE ACTION REQUIRED]**
|
|
81
|
+
> You made an Edit mistake. STOP and do this NOW:
|
|
82
|
+
> 1. **READ** the file immediately to see its ACTUAL current state.
|
|
83
|
+
> 2. **VERIFY** what the content really looks like (your assumption was wrong).
|
|
84
|
+
> 3. **APOLOGIZE** briefly to the user for the error.
|
|
85
|
+
> 4. **CONTINUE** with corrected action based on the real file content.
|
|
86
|
+
> **DO NOT** attempt another edit until you've read and verified the file state.
|
|
87
|
+
"""
|
|
88
|
+
print(tool_response + error_msg)
|
|
89
|
+
else:
|
|
90
|
+
print(tool_response)
|
|
91
|
+
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
sys.exit(main())
|
|
96
|
+
''',
|
|
97
|
+
|
|
98
|
+
"context.py": '''import os
|
|
99
|
+
import sys
|
|
100
|
+
import json
|
|
101
|
+
from pathlib import Path
|
|
102
|
+
|
|
103
|
+
def main():
|
|
104
|
+
try:
|
|
105
|
+
data = json.load(sys.stdin)
|
|
106
|
+
prompt = data.get("prompt", "")
|
|
107
|
+
except Exception:
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
cwd = Path(os.environ.get("CLAUDE_CWD", "."))
|
|
111
|
+
|
|
112
|
+
# Files to look for
|
|
113
|
+
context_files = ["AGENTS.md", "README.md", "CLAUDE.md"]
|
|
114
|
+
found_context = ""
|
|
115
|
+
|
|
116
|
+
for f in context_files:
|
|
117
|
+
path = cwd / f
|
|
118
|
+
if path.exists():
|
|
119
|
+
try:
|
|
120
|
+
content = path.read_text()
|
|
121
|
+
found_context += f"\\n\\n--- LOCAL CONTEXT: {f} ---\\n{content}\\n"
|
|
122
|
+
break # Only use one for brevity
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
if found_context:
|
|
127
|
+
# Prepend context to prompt
|
|
128
|
+
# We wrap the user prompt to distinguish it
|
|
129
|
+
new_prompt = f"{found_context}\\n\\n[USER PROMPT]\\n{prompt}"
|
|
130
|
+
print(new_prompt)
|
|
131
|
+
else:
|
|
132
|
+
print(prompt)
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
main()
|
|
136
|
+
''',
|
|
137
|
+
|
|
138
|
+
"parallel_execution.py": '''#!/usr/bin/env python3
|
|
139
|
+
"""
|
|
140
|
+
UserPromptSubmit hook: Pre-emptive parallel execution enforcement.
|
|
141
|
+
|
|
142
|
+
Fires BEFORE response generation to inject parallel execution instructions
|
|
143
|
+
when implementation tasks are detected. Eliminates timing ambiguity.
|
|
144
|
+
|
|
145
|
+
CRITICAL: Also activates stravinsky mode marker when /stravinsky is invoked,
|
|
146
|
+
enabling hard blocking of direct tools (Read, Grep, Bash) via stravinsky_mode.py.
|
|
147
|
+
"""
|
|
148
|
+
import json
|
|
149
|
+
import sys
|
|
150
|
+
import re
|
|
151
|
+
from pathlib import Path
|
|
152
|
+
|
|
153
|
+
# Marker file that enables hard blocking of direct tools
|
|
154
|
+
STRAVINSKY_MODE_FILE = Path.home() / ".stravinsky_mode"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def detect_stravinsky_invocation(prompt):
|
|
158
|
+
"""Detect if /stravinsky skill is being invoked."""
|
|
159
|
+
patterns = [
|
|
160
|
+
r'/stravinsky',
|
|
161
|
+
r'<command-name>/stravinsky</command-name>',
|
|
162
|
+
r'stravinsky orchestrator',
|
|
163
|
+
r'ultrawork',
|
|
164
|
+
r'ultrathink',
|
|
165
|
+
]
|
|
166
|
+
prompt_lower = prompt.lower()
|
|
167
|
+
return any(re.search(p, prompt_lower) for p in patterns)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def activate_stravinsky_mode():
|
|
171
|
+
"""Create marker file to enable hard blocking of direct tools."""
|
|
172
|
+
try:
|
|
173
|
+
config = {"active": True, "reason": "invoked via /stravinsky skill"}
|
|
174
|
+
STRAVINSKY_MODE_FILE.write_text(json.dumps(config))
|
|
175
|
+
return True
|
|
176
|
+
except IOError:
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def detect_implementation_task(prompt):
|
|
181
|
+
"""Detect if prompt is an implementation task requiring parallel execution."""
|
|
182
|
+
keywords = [
|
|
183
|
+
'implement', 'add', 'create', 'build', 'refactor', 'fix',
|
|
184
|
+
'update', 'modify', 'change', 'develop', 'write code',
|
|
185
|
+
'feature', 'bug fix', 'enhancement', 'integrate'
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
prompt_lower = prompt.lower()
|
|
189
|
+
return any(kw in prompt_lower for kw in keywords)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def main():
|
|
193
|
+
try:
|
|
194
|
+
hook_input = json.load(sys.stdin)
|
|
195
|
+
except (json.JSONDecodeError, EOFError):
|
|
196
|
+
return 0
|
|
197
|
+
|
|
198
|
+
prompt = hook_input.get("prompt", "")
|
|
199
|
+
|
|
200
|
+
# CRITICAL: Activate stravinsky mode if /stravinsky is invoked
|
|
201
|
+
# This creates the marker file that enables hard blocking of direct tools
|
|
202
|
+
is_stravinsky = detect_stravinsky_invocation(prompt)
|
|
203
|
+
if is_stravinsky:
|
|
204
|
+
activate_stravinsky_mode()
|
|
205
|
+
|
|
206
|
+
# Only inject for implementation tasks OR stravinsky invocation
|
|
207
|
+
if not detect_implementation_task(prompt) and not is_stravinsky:
|
|
208
|
+
print(prompt)
|
|
209
|
+
return 0
|
|
210
|
+
|
|
211
|
+
# Inject parallel execution instruction BEFORE prompt
|
|
212
|
+
instruction = """
|
|
213
|
+
[🔄 PARALLEL EXECUTION MODE ACTIVE]
|
|
214
|
+
|
|
215
|
+
When you create a TodoWrite with 2+ pending items:
|
|
216
|
+
|
|
217
|
+
✅ IMMEDIATELY in THIS SAME RESPONSE (do NOT end response after TodoWrite):
|
|
218
|
+
1. Spawn Task() for EACH independent pending TODO
|
|
219
|
+
2. Use: Task(subagent_type="explore"|"Plan"|etc., prompt="...", description="...", run_in_background=true)
|
|
220
|
+
3. Fire ALL Task calls in ONE response block
|
|
221
|
+
4. Do NOT mark any TODO as in_progress until Task results return
|
|
222
|
+
|
|
223
|
+
❌ DO NOT:
|
|
224
|
+
- End your response after TodoWrite
|
|
225
|
+
- Mark TODOs in_progress before spawning Tasks
|
|
226
|
+
- Spawn only ONE Task (spawn ALL independent tasks)
|
|
227
|
+
- Wait for "next response" to spawn Tasks
|
|
228
|
+
|
|
229
|
+
Example pattern (all in SAME response):
|
|
230
|
+
```
|
|
231
|
+
TodoWrite([task1, task2, task3])
|
|
232
|
+
Task(subagent_type="Explore", prompt="Task 1 details", description="Task 1", run_in_background=true)
|
|
233
|
+
Task(subagent_type="Plan", prompt="Task 2 details", description="Task 2", run_in_background=true)
|
|
234
|
+
Task(subagent_type="Explore", prompt="Task 3 details", description="Task 3", run_in_background=true)
|
|
235
|
+
# Continue response - collect results with TaskOutput
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
modified_prompt = instruction + prompt
|
|
243
|
+
print(modified_prompt)
|
|
244
|
+
return 0
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
if __name__ == "__main__":
|
|
248
|
+
sys.exit(main())
|
|
249
|
+
''',
|
|
250
|
+
|
|
251
|
+
"todo_continuation.py": '''#!/usr/bin/env python3
|
|
252
|
+
"""
|
|
253
|
+
UserPromptSubmit hook: Todo Continuation Enforcer
|
|
254
|
+
|
|
255
|
+
Checks if there are incomplete todos (in_progress or pending) and injects
|
|
256
|
+
a reminder to continue working on them before starting new work.
|
|
257
|
+
|
|
258
|
+
Aligned with oh-my-opencode's [SYSTEM REMINDER - TODO CONTINUATION] pattern.
|
|
259
|
+
"""
|
|
260
|
+
import json
|
|
261
|
+
import os
|
|
262
|
+
import sys
|
|
263
|
+
from pathlib import Path
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def get_todo_state() -> dict:
|
|
267
|
+
"""Try to get current todo state from Claude Code session or local cache."""
|
|
268
|
+
# Claude Code stores todo state - we can check via session files
|
|
269
|
+
# For now, we'll use a simple file-based approach
|
|
270
|
+
cwd = Path(os.environ.get("CLAUDE_CWD", "."))
|
|
271
|
+
todo_cache = cwd / ".claude" / "todo_state.json"
|
|
272
|
+
|
|
273
|
+
if todo_cache.exists():
|
|
274
|
+
try:
|
|
275
|
+
return json.loads(todo_cache.read_text())
|
|
276
|
+
except Exception:
|
|
277
|
+
pass
|
|
278
|
+
|
|
279
|
+
return {"todos": []}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def main():
|
|
283
|
+
try:
|
|
284
|
+
data = json.load(sys.stdin)
|
|
285
|
+
prompt = data.get("prompt", "")
|
|
286
|
+
except Exception:
|
|
287
|
+
return 0
|
|
288
|
+
|
|
289
|
+
# Get current todo state
|
|
290
|
+
state = get_todo_state()
|
|
291
|
+
todos = state.get("todos", [])
|
|
292
|
+
|
|
293
|
+
if not todos:
|
|
294
|
+
# No todos tracked, pass through
|
|
295
|
+
print(prompt)
|
|
296
|
+
return 0
|
|
297
|
+
|
|
298
|
+
# Count incomplete todos
|
|
299
|
+
in_progress = [t for t in todos if t.get("status") == "in_progress"]
|
|
300
|
+
pending = [t for t in todos if t.get("status") == "pending"]
|
|
301
|
+
|
|
302
|
+
if not in_progress and not pending:
|
|
303
|
+
# All todos complete, pass through
|
|
304
|
+
print(prompt)
|
|
305
|
+
return 0
|
|
306
|
+
|
|
307
|
+
# Build reminder
|
|
308
|
+
reminder_parts = ["[SYSTEM REMINDER - TODO CONTINUATION]", ""]
|
|
309
|
+
|
|
310
|
+
if in_progress:
|
|
311
|
+
reminder_parts.append(f"IN PROGRESS ({len(in_progress)} items):")
|
|
312
|
+
for t in in_progress:
|
|
313
|
+
reminder_parts.append(f" - {t.get('content', 'Unknown task')}")
|
|
314
|
+
reminder_parts.append("")
|
|
315
|
+
|
|
316
|
+
if pending:
|
|
317
|
+
reminder_parts.append(f"PENDING ({len(pending)} items):")
|
|
318
|
+
for t in pending[:5]: # Show max 5 pending
|
|
319
|
+
reminder_parts.append(f" - {t.get('content', 'Unknown task')}")
|
|
320
|
+
if len(pending) > 5:
|
|
321
|
+
reminder_parts.append(f" ... and {len(pending) - 5} more")
|
|
322
|
+
reminder_parts.append("")
|
|
323
|
+
|
|
324
|
+
reminder_parts.extend([
|
|
325
|
+
"IMPORTANT: You have incomplete work. Before starting anything new:",
|
|
326
|
+
"1. Continue working on IN_PROGRESS todos first",
|
|
327
|
+
"2. If blocked, explain why and move to next PENDING item",
|
|
328
|
+
"3. Only start NEW work if all todos are complete or explicitly abandoned",
|
|
329
|
+
"",
|
|
330
|
+
"---",
|
|
331
|
+
"",
|
|
332
|
+
])
|
|
333
|
+
|
|
334
|
+
reminder = "\\n".join(reminder_parts)
|
|
335
|
+
print(reminder + prompt)
|
|
336
|
+
return 0
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
if __name__ == "__main__":
|
|
340
|
+
sys.exit(main())
|
|
341
|
+
''',
|
|
342
|
+
|
|
343
|
+
"todo_delegation.py": '''#!/usr/bin/env python3
|
|
344
|
+
"""
|
|
345
|
+
PostToolUse hook for TodoWrite: CRITICAL parallel execution enforcer.
|
|
346
|
+
|
|
347
|
+
This hook fires AFTER TodoWrite completes. If there are 2+ pending items,
|
|
348
|
+
it outputs a STRONG reminder that Task agents must be spawned immediately.
|
|
349
|
+
|
|
350
|
+
Exit code 2 is used to signal a HARD BLOCK - Claude should see this as
|
|
351
|
+
a failure condition requiring immediate correction.
|
|
352
|
+
|
|
353
|
+
Works in tandem with:
|
|
354
|
+
- parallel_execution.py (UserPromptSubmit): Pre-emptive instruction injection
|
|
355
|
+
- stravinsky_mode.py (PreToolUse): Hard blocking of Read/Grep/Bash tools
|
|
356
|
+
"""
|
|
357
|
+
import json
|
|
358
|
+
import sys
|
|
359
|
+
from pathlib import Path
|
|
360
|
+
|
|
361
|
+
# Check if stravinsky mode is active (hard blocking enabled)
|
|
362
|
+
STRAVINSKY_MODE_FILE = Path.home() / ".stravinsky_mode"
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def is_stravinsky_mode():
|
|
366
|
+
"""Check if hard blocking mode is active."""
|
|
367
|
+
return STRAVINSKY_MODE_FILE.exists()
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def main():
|
|
371
|
+
# Read hook input from stdin
|
|
372
|
+
try:
|
|
373
|
+
hook_input = json.load(sys.stdin)
|
|
374
|
+
except (json.JSONDecodeError, EOFError):
|
|
375
|
+
return 0
|
|
376
|
+
|
|
377
|
+
tool_name = hook_input.get("tool_name", "")
|
|
378
|
+
|
|
379
|
+
if tool_name != "TodoWrite":
|
|
380
|
+
return 0
|
|
381
|
+
|
|
382
|
+
# Get the todos that were just written
|
|
383
|
+
tool_input = hook_input.get("tool_input", {})
|
|
384
|
+
todos = tool_input.get("todos", [])
|
|
385
|
+
|
|
386
|
+
# Count pending todos
|
|
387
|
+
pending_count = sum(1 for t in todos if t.get("status") == "pending")
|
|
388
|
+
|
|
389
|
+
if pending_count < 2:
|
|
390
|
+
return 0
|
|
391
|
+
|
|
392
|
+
# Check if stravinsky mode is active
|
|
393
|
+
stravinsky_active = is_stravinsky_mode()
|
|
394
|
+
|
|
395
|
+
# CRITICAL: Output urgent reminder for parallel Task spawning
|
|
396
|
+
mode_warning = ""
|
|
397
|
+
if stravinsky_active:
|
|
398
|
+
mode_warning = """
|
|
399
|
+
⚠️ STRAVINSKY MODE ACTIVE - Direct tools (Read, Grep, Bash) are BLOCKED.
|
|
400
|
+
You MUST use Task(subagent_type="explore", ...) for ALL file operations.
|
|
401
|
+
"""
|
|
402
|
+
|
|
403
|
+
error_message = f"""
|
|
404
|
+
🚨 PARALLEL DELEGATION REQUIRED 🚨
|
|
405
|
+
|
|
406
|
+
TodoWrite created {pending_count} pending items.
|
|
407
|
+
{mode_warning}
|
|
408
|
+
You MUST spawn Task agents for ALL independent TODOs in THIS SAME RESPONSE.
|
|
409
|
+
|
|
410
|
+
Required pattern (IMMEDIATELY after this message):
|
|
411
|
+
Task(subagent_type="explore", prompt="TODO 1...", description="TODO 1", run_in_background=true)
|
|
412
|
+
Task(subagent_type="explore", prompt="TODO 2...", description="TODO 2", run_in_background=true)
|
|
413
|
+
...
|
|
414
|
+
|
|
415
|
+
DO NOT:
|
|
416
|
+
- End your response without spawning Tasks
|
|
417
|
+
- Mark TODOs in_progress before spawning Tasks
|
|
418
|
+
- Use Read/Grep/Bash directly (BLOCKED in stravinsky mode)
|
|
419
|
+
|
|
420
|
+
Your NEXT action MUST be multiple Task() calls, one for each independent TODO.
|
|
421
|
+
"""
|
|
422
|
+
print(error_message, file=sys.stderr)
|
|
423
|
+
|
|
424
|
+
# Exit code 2 = HARD BLOCK in stravinsky mode
|
|
425
|
+
# Exit code 1 = WARNING otherwise
|
|
426
|
+
return 2 if stravinsky_active else 1
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
if __name__ == "__main__":
|
|
430
|
+
sys.exit(main())
|
|
431
|
+
''',
|
|
432
|
+
|
|
433
|
+
"stravinsky_mode.py": '''#!/usr/bin/env python3
|
|
434
|
+
"""
|
|
435
|
+
Stravinsky Mode Enforcer Hook
|
|
436
|
+
|
|
437
|
+
This PreToolUse hook blocks native file reading tools (Read, Search, Grep, Bash)
|
|
438
|
+
when stravinsky orchestrator mode is active, forcing use of Task tool for native
|
|
439
|
+
subagent delegation.
|
|
440
|
+
|
|
441
|
+
Stravinsky mode is activated by creating a marker file:
|
|
442
|
+
~/.stravinsky_mode
|
|
443
|
+
|
|
444
|
+
The /stravinsky command should create this file, and it should be
|
|
445
|
+
removed when the task is complete.
|
|
446
|
+
|
|
447
|
+
Exit codes:
|
|
448
|
+
0 = Allow the tool to execute
|
|
449
|
+
2 = Block the tool (reason sent via stderr)
|
|
450
|
+
"""
|
|
451
|
+
|
|
452
|
+
import json
|
|
453
|
+
import os
|
|
454
|
+
import sys
|
|
455
|
+
from pathlib import Path
|
|
456
|
+
|
|
457
|
+
# Marker file that indicates stravinsky mode is active
|
|
458
|
+
STRAVINSKY_MODE_FILE = Path.home() / ".stravinsky_mode"
|
|
459
|
+
|
|
460
|
+
# Tools to block when in stravinsky mode
|
|
461
|
+
BLOCKED_TOOLS = {
|
|
462
|
+
"Read",
|
|
463
|
+
"Search",
|
|
464
|
+
"Grep",
|
|
465
|
+
"Bash",
|
|
466
|
+
"MultiEdit",
|
|
467
|
+
"Edit",
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
# Tools that are always allowed
|
|
471
|
+
ALLOWED_TOOLS = {
|
|
472
|
+
"TodoRead",
|
|
473
|
+
"TodoWrite",
|
|
474
|
+
"Task", # Native subagent delegation
|
|
475
|
+
"Agent", # MCP agent tools
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
# Agent routing recommendations
|
|
479
|
+
AGENT_ROUTES = {
|
|
480
|
+
"Read": "explore",
|
|
481
|
+
"Grep": "explore",
|
|
482
|
+
"Search": "explore",
|
|
483
|
+
"Bash": "explore",
|
|
484
|
+
"Edit": "code-reviewer",
|
|
485
|
+
"MultiEdit": "code-reviewer",
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def is_stravinsky_mode_active() -> bool:
|
|
490
|
+
"""Check if stravinsky orchestrator mode is active."""
|
|
491
|
+
return STRAVINSKY_MODE_FILE.exists()
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def read_stravinsky_mode_config() -> dict:
|
|
495
|
+
"""Read the stravinsky mode configuration if it exists."""
|
|
496
|
+
if not STRAVINSKY_MODE_FILE.exists():
|
|
497
|
+
return {}
|
|
498
|
+
try:
|
|
499
|
+
return json.loads(STRAVINSKY_MODE_FILE.read_text())
|
|
500
|
+
except (json.JSONDecodeError, IOError):
|
|
501
|
+
return {"active": True}
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def main():
|
|
505
|
+
# Read hook input from stdin
|
|
506
|
+
try:
|
|
507
|
+
hook_input = json.loads(sys.stdin.read())
|
|
508
|
+
except json.JSONDecodeError:
|
|
509
|
+
# If we can't parse input, allow the tool
|
|
510
|
+
sys.exit(0)
|
|
511
|
+
|
|
512
|
+
tool_name = hook_input.get("toolName", hook_input.get("tool_name", ""))
|
|
513
|
+
params = hook_input.get("params", {})
|
|
514
|
+
|
|
515
|
+
# Always allow certain tools
|
|
516
|
+
if tool_name in ALLOWED_TOOLS:
|
|
517
|
+
sys.exit(0)
|
|
518
|
+
|
|
519
|
+
# Check if stravinsky mode is active
|
|
520
|
+
if not is_stravinsky_mode_active():
|
|
521
|
+
# Not in stravinsky mode, allow all tools
|
|
522
|
+
sys.exit(0)
|
|
523
|
+
|
|
524
|
+
config = read_stravinsky_mode_config()
|
|
525
|
+
|
|
526
|
+
# Check if this tool should be blocked
|
|
527
|
+
if tool_name in BLOCKED_TOOLS:
|
|
528
|
+
# Determine which agent to delegate to
|
|
529
|
+
agent = AGENT_ROUTES.get(tool_name, "explore")
|
|
530
|
+
|
|
531
|
+
# Get tool context for better messaging
|
|
532
|
+
context = ""
|
|
533
|
+
if tool_name == "Grep":
|
|
534
|
+
pattern = params.get("pattern", "")
|
|
535
|
+
context = f" (searching for '{pattern[:30]}')"
|
|
536
|
+
elif tool_name == "Read":
|
|
537
|
+
file_path = params.get("file_path", "")
|
|
538
|
+
context = f" (reading {os.path.basename(file_path)})" if file_path else ""
|
|
539
|
+
|
|
540
|
+
# User-friendly delegation message
|
|
541
|
+
print(f"🎭 {agent}('Delegating {tool_name}{context}')", file=sys.stderr)
|
|
542
|
+
|
|
543
|
+
# Block the tool and tell Claude why
|
|
544
|
+
reason = f"""⚠️ STRAVINSKY MODE ACTIVE - {tool_name} BLOCKED
|
|
545
|
+
|
|
546
|
+
You are in Stravinsky orchestrator mode. Native tools are disabled.
|
|
547
|
+
|
|
548
|
+
Instead of using {tool_name}, you MUST use Task tool for native subagent delegation:
|
|
549
|
+
- Task(subagent_type="explore", ...) for file reading/searching
|
|
550
|
+
- Task(subagent_type="dewey", ...) for documentation research
|
|
551
|
+
- Task(subagent_type="code-reviewer", ...) for code analysis
|
|
552
|
+
- Task(subagent_type="debugger", ...) for error investigation
|
|
553
|
+
- Task(subagent_type="frontend", ...) for UI/UX work
|
|
554
|
+
- Task(subagent_type="delphi", ...) for strategic architecture decisions
|
|
555
|
+
|
|
556
|
+
Example:
|
|
557
|
+
Task(
|
|
558
|
+
subagent_type="explore",
|
|
559
|
+
prompt="Read and analyze the authentication module",
|
|
560
|
+
description="Analyze auth"
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
To exit stravinsky mode, run:
|
|
564
|
+
rm ~/.stravinsky_mode
|
|
565
|
+
"""
|
|
566
|
+
# Send reason to stderr (Claude sees this)
|
|
567
|
+
print(reason, file=sys.stderr)
|
|
568
|
+
# Exit with code 2 to block the tool
|
|
569
|
+
sys.exit(2)
|
|
570
|
+
|
|
571
|
+
# Tool not in block list, allow it
|
|
572
|
+
sys.exit(0)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
if __name__ == "__main__":
|
|
576
|
+
main()
|
|
577
|
+
''',
|
|
578
|
+
|
|
579
|
+
"tool_messaging.py": '''#!/usr/bin/env python3
|
|
580
|
+
"""
|
|
581
|
+
PostToolUse hook for user-friendly tool messaging.
|
|
582
|
+
|
|
583
|
+
Outputs concise messages about which agent/tool was used and what it did.
|
|
584
|
+
Format examples:
|
|
585
|
+
- ast-grep('Searching for authentication patterns')
|
|
586
|
+
- delphi:openai/gpt-5.2-medium('Analyzing architecture trade-offs')
|
|
587
|
+
- explore:gemini-3-flash('Finding all API endpoints')
|
|
588
|
+
"""
|
|
589
|
+
|
|
590
|
+
import json
|
|
591
|
+
import os
|
|
592
|
+
import sys
|
|
593
|
+
|
|
594
|
+
# Agent model mappings
|
|
595
|
+
AGENT_MODELS = {
|
|
596
|
+
"explore": "gemini-3-flash",
|
|
597
|
+
"dewey": "gemini-3-flash",
|
|
598
|
+
"code-reviewer": "sonnet",
|
|
599
|
+
"debugger": "sonnet",
|
|
600
|
+
"frontend": "gemini-3-pro-high",
|
|
601
|
+
"delphi": "gpt-5.2-medium",
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
# Tool display names
|
|
605
|
+
TOOL_NAMES = {
|
|
606
|
+
"mcp__stravinsky__ast_grep_search": "ast-grep",
|
|
607
|
+
"mcp__stravinsky__grep_search": "grep",
|
|
608
|
+
"mcp__stravinsky__glob_files": "glob",
|
|
609
|
+
"mcp__stravinsky__lsp_diagnostics": "lsp-diagnostics",
|
|
610
|
+
"mcp__stravinsky__lsp_hover": "lsp-hover",
|
|
611
|
+
"mcp__stravinsky__lsp_goto_definition": "lsp-goto-def",
|
|
612
|
+
"mcp__stravinsky__lsp_find_references": "lsp-find-refs",
|
|
613
|
+
"mcp__stravinsky__lsp_document_symbols": "lsp-symbols",
|
|
614
|
+
"mcp__stravinsky__lsp_workspace_symbols": "lsp-workspace-symbols",
|
|
615
|
+
"mcp__stravinsky__invoke_gemini": "gemini",
|
|
616
|
+
"mcp__stravinsky__invoke_openai": "openai",
|
|
617
|
+
"mcp__grep-app__searchCode": "grep.app",
|
|
618
|
+
"mcp__grep-app__github_file": "github-file",
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def extract_description(tool_name: str, params: dict) -> str:
|
|
623
|
+
"""Extract a concise description of what the tool did."""
|
|
624
|
+
|
|
625
|
+
# AST-grep
|
|
626
|
+
if "ast_grep" in tool_name:
|
|
627
|
+
pattern = params.get("pattern", "")
|
|
628
|
+
directory = params.get("directory", ".")
|
|
629
|
+
return f"Searching AST in {directory} for '{pattern[:40]}...'"
|
|
630
|
+
|
|
631
|
+
# Grep/search
|
|
632
|
+
if "grep_search" in tool_name or "searchCode" in tool_name:
|
|
633
|
+
pattern = params.get("pattern", params.get("query", ""))
|
|
634
|
+
return f"Searching for '{pattern[:40]}...'"
|
|
635
|
+
|
|
636
|
+
# Glob
|
|
637
|
+
if "glob_files" in tool_name:
|
|
638
|
+
pattern = params.get("pattern", "")
|
|
639
|
+
return f"Finding files matching '{pattern}'"
|
|
640
|
+
|
|
641
|
+
# LSP diagnostics
|
|
642
|
+
if "lsp_diagnostics" in tool_name:
|
|
643
|
+
file_path = params.get("file_path", "")
|
|
644
|
+
filename = os.path.basename(file_path) if file_path else "file"
|
|
645
|
+
return f"Checking {filename} for errors"
|
|
646
|
+
|
|
647
|
+
# LSP hover
|
|
648
|
+
if "lsp_hover" in tool_name:
|
|
649
|
+
file_path = params.get("file_path", "")
|
|
650
|
+
line = params.get("line", "")
|
|
651
|
+
filename = os.path.basename(file_path) if file_path else "file"
|
|
652
|
+
return f"Type info for {filename}:{line}"
|
|
653
|
+
|
|
654
|
+
# LSP goto definition
|
|
655
|
+
if "lsp_goto" in tool_name:
|
|
656
|
+
file_path = params.get("file_path", "")
|
|
657
|
+
filename = os.path.basename(file_path) if file_path else "symbol"
|
|
658
|
+
return f"Finding definition in {filename}"
|
|
659
|
+
|
|
660
|
+
# LSP find references
|
|
661
|
+
if "lsp_find_references" in tool_name:
|
|
662
|
+
file_path = params.get("file_path", "")
|
|
663
|
+
filename = os.path.basename(file_path) if file_path else "symbol"
|
|
664
|
+
return f"Finding all references to symbol in {filename}"
|
|
665
|
+
|
|
666
|
+
# LSP symbols
|
|
667
|
+
if "lsp_symbols" in tool_name or "lsp_document_symbols" in tool_name:
|
|
668
|
+
file_path = params.get("file_path", "")
|
|
669
|
+
filename = os.path.basename(file_path) if file_path else "file"
|
|
670
|
+
return f"Getting symbols from {filename}"
|
|
671
|
+
|
|
672
|
+
if "lsp_workspace_symbols" in tool_name:
|
|
673
|
+
query = params.get("query", "")
|
|
674
|
+
return f"Searching workspace for symbol '{query}'"
|
|
675
|
+
|
|
676
|
+
# Gemini invocation
|
|
677
|
+
if "invoke_gemini" in tool_name:
|
|
678
|
+
prompt = params.get("prompt", "")
|
|
679
|
+
# Extract first meaningful line
|
|
680
|
+
first_line = prompt.split('\\n')[0][:50] if prompt else "Processing"
|
|
681
|
+
return first_line
|
|
682
|
+
|
|
683
|
+
# OpenAI invocation
|
|
684
|
+
if "invoke_openai" in tool_name:
|
|
685
|
+
prompt = params.get("prompt", "")
|
|
686
|
+
first_line = prompt.split('\\n')[0][:50] if prompt else "Strategic analysis"
|
|
687
|
+
return first_line
|
|
688
|
+
|
|
689
|
+
# GitHub file fetch
|
|
690
|
+
if "github_file" in tool_name:
|
|
691
|
+
path = params.get("path", "")
|
|
692
|
+
repo = params.get("repo", "")
|
|
693
|
+
return f"Fetching {path} from {repo}"
|
|
694
|
+
|
|
695
|
+
# Task delegation
|
|
696
|
+
if tool_name == "Task":
|
|
697
|
+
subagent_type = params.get("subagent_type", "unknown")
|
|
698
|
+
description = params.get("description", "")
|
|
699
|
+
model = AGENT_MODELS.get(subagent_type, "unknown")
|
|
700
|
+
return f"{subagent_type}:{model}('{description}')"
|
|
701
|
+
|
|
702
|
+
return "Processing"
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def main():
|
|
706
|
+
try:
|
|
707
|
+
# Read hook input from stdin
|
|
708
|
+
hook_input = json.loads(sys.stdin.read())
|
|
709
|
+
|
|
710
|
+
tool_name = hook_input.get("toolName", hook_input.get("tool_name", ""))
|
|
711
|
+
params = hook_input.get("params", hook_input.get("tool_input", {}))
|
|
712
|
+
|
|
713
|
+
# Only output messages for MCP tools and Task delegations
|
|
714
|
+
if not (tool_name.startswith("mcp__") or tool_name == "Task"):
|
|
715
|
+
sys.exit(0)
|
|
716
|
+
|
|
717
|
+
# Get tool display name
|
|
718
|
+
display_name = TOOL_NAMES.get(tool_name, tool_name)
|
|
719
|
+
|
|
720
|
+
# Special handling for Task delegations
|
|
721
|
+
if tool_name == "Task":
|
|
722
|
+
subagent_type = params.get("subagent_type", "unknown")
|
|
723
|
+
description = params.get("description", "")
|
|
724
|
+
model = AGENT_MODELS.get(subagent_type, "unknown")
|
|
725
|
+
|
|
726
|
+
# Show full agent delegation message
|
|
727
|
+
print(f"🎯 {subagent_type}:{model}('{description}')", file=sys.stderr)
|
|
728
|
+
else:
|
|
729
|
+
# Regular tool usage
|
|
730
|
+
description = extract_description(tool_name, params)
|
|
731
|
+
print(f"🔧 {display_name}('{description}')", file=sys.stderr)
|
|
732
|
+
|
|
733
|
+
sys.exit(0)
|
|
734
|
+
|
|
735
|
+
except Exception as e:
|
|
736
|
+
# On error, fail silently (don't disrupt workflow)
|
|
737
|
+
print(f"Tool messaging hook error: {e}", file=sys.stderr)
|
|
738
|
+
sys.exit(0)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
if __name__ == "__main__":
|
|
742
|
+
main()
|
|
743
|
+
''',
|
|
744
|
+
|
|
745
|
+
"notification_hook.py": '''#!/usr/bin/env python3
|
|
746
|
+
"""
|
|
747
|
+
Notification hook for agent spawn messages.
|
|
748
|
+
|
|
749
|
+
Fires on Notification events to output user-friendly messages about
|
|
750
|
+
which agent was spawned, what model it uses, and what task it's doing.
|
|
751
|
+
|
|
752
|
+
Format: spawned {agent_type}:{model}('{description}')
|
|
753
|
+
Example: spawned delphi:gpt-5.2-medium('Debug xyz code')
|
|
754
|
+
"""
|
|
755
|
+
|
|
756
|
+
import json
|
|
757
|
+
import sys
|
|
758
|
+
from typing import Optional, Dict, Any
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
# Agent display model mappings
|
|
762
|
+
AGENT_DISPLAY_MODELS = {
|
|
763
|
+
"explore": "gemini-3-flash",
|
|
764
|
+
"dewey": "gemini-3-flash",
|
|
765
|
+
"document_writer": "gemini-3-flash",
|
|
766
|
+
"multimodal": "gemini-3-flash",
|
|
767
|
+
"frontend": "gemini-3-pro-high",
|
|
768
|
+
"delphi": "gpt-5.2-medium",
|
|
769
|
+
"planner": "opus-4.5",
|
|
770
|
+
"code-reviewer": "sonnet-4.5",
|
|
771
|
+
"debugger": "sonnet-4.5",
|
|
772
|
+
"_default": "sonnet-4.5",
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def extract_agent_info(message: str) -> Optional[Dict[str, str]]:
|
|
777
|
+
"""
|
|
778
|
+
Extract agent spawn information from notification message.
|
|
779
|
+
|
|
780
|
+
Looks for patterns like:
|
|
781
|
+
- "Agent explore spawned for task..."
|
|
782
|
+
- "Spawned delphi agent: description"
|
|
783
|
+
- Task tool delegation messages
|
|
784
|
+
"""
|
|
785
|
+
message_lower = message.lower()
|
|
786
|
+
|
|
787
|
+
# Try to extract agent type from message
|
|
788
|
+
agent_type = None
|
|
789
|
+
description = ""
|
|
790
|
+
|
|
791
|
+
for agent in AGENT_DISPLAY_MODELS.keys():
|
|
792
|
+
if agent == "_default":
|
|
793
|
+
continue
|
|
794
|
+
if agent in message_lower:
|
|
795
|
+
agent_type = agent
|
|
796
|
+
# Extract description after agent name
|
|
797
|
+
idx = message_lower.find(agent)
|
|
798
|
+
description = message[idx + len(agent):].strip()[:60]
|
|
799
|
+
break
|
|
800
|
+
|
|
801
|
+
if not agent_type:
|
|
802
|
+
return None
|
|
803
|
+
|
|
804
|
+
# Clean up description
|
|
805
|
+
description = description.strip(":-() ")
|
|
806
|
+
if not description:
|
|
807
|
+
description = "task delegated"
|
|
808
|
+
|
|
809
|
+
display_model = AGENT_DISPLAY_MODELS.get(agent_type, AGENT_DISPLAY_MODELS["_default"])
|
|
810
|
+
|
|
811
|
+
return {
|
|
812
|
+
"agent_type": agent_type,
|
|
813
|
+
"model": display_model,
|
|
814
|
+
"description": description,
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def main():
|
|
819
|
+
"""Main hook entry point."""
|
|
820
|
+
try:
|
|
821
|
+
hook_input = json.load(sys.stdin)
|
|
822
|
+
except (json.JSONDecodeError, EOFError):
|
|
823
|
+
return 0
|
|
824
|
+
|
|
825
|
+
# Get notification message
|
|
826
|
+
message = hook_input.get("message", "")
|
|
827
|
+
notification_type = hook_input.get("notification_type", "")
|
|
828
|
+
|
|
829
|
+
# Only process agent-related notifications
|
|
830
|
+
agent_keywords = ["agent", "spawn", "delegat", "task"]
|
|
831
|
+
if not any(kw in message.lower() for kw in agent_keywords):
|
|
832
|
+
return 0
|
|
833
|
+
|
|
834
|
+
# Extract agent info
|
|
835
|
+
agent_info = extract_agent_info(message)
|
|
836
|
+
if not agent_info:
|
|
837
|
+
return 0
|
|
838
|
+
|
|
839
|
+
# Format and output
|
|
840
|
+
output = f"spawned {agent_info['agent_type']}:{agent_info['model']}('{agent_info['description']}')"
|
|
841
|
+
print(output, file=sys.stderr)
|
|
842
|
+
|
|
843
|
+
return 0
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
if __name__ == "__main__":
|
|
847
|
+
sys.exit(main())
|
|
848
|
+
''',
|
|
849
|
+
|
|
850
|
+
"subagent_stop.py": '''#!/usr/bin/env python3
|
|
851
|
+
"""
|
|
852
|
+
SubagentStop hook: Handler for agent/subagent completion events.
|
|
853
|
+
|
|
854
|
+
Fires when a Claude Code subagent (Task tool) finishes to:
|
|
855
|
+
1. Output completion status messages
|
|
856
|
+
2. Verify agent produced expected output
|
|
857
|
+
3. Block completion if critical validation fails
|
|
858
|
+
4. Integrate with TODO tracking
|
|
859
|
+
|
|
860
|
+
Exit codes:
|
|
861
|
+
0 = Allow completion
|
|
862
|
+
2 = Block completion (force continuation)
|
|
863
|
+
"""
|
|
864
|
+
|
|
865
|
+
import json
|
|
866
|
+
import sys
|
|
867
|
+
from pathlib import Path
|
|
868
|
+
from typing import Optional, Tuple
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
STRAVINSKY_MODE_FILE = Path.home() / ".stravinsky_mode"
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def is_stravinsky_mode() -> bool:
|
|
875
|
+
"""Check if stravinsky mode is active."""
|
|
876
|
+
return STRAVINSKY_MODE_FILE.exists()
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def extract_subagent_info(hook_input: dict) -> Tuple[str, str, str]:
|
|
880
|
+
"""
|
|
881
|
+
Extract subagent information from hook input.
|
|
882
|
+
|
|
883
|
+
Returns: (agent_type, description, status)
|
|
884
|
+
"""
|
|
885
|
+
# Try to get from tool parameters or response
|
|
886
|
+
params = hook_input.get("tool_input", hook_input.get("params", {}))
|
|
887
|
+
response = hook_input.get("tool_response", "")
|
|
888
|
+
|
|
889
|
+
agent_type = params.get("subagent_type", "unknown")
|
|
890
|
+
description = params.get("description", "")[:50]
|
|
891
|
+
|
|
892
|
+
# Determine status from response
|
|
893
|
+
status = "completed"
|
|
894
|
+
response_lower = response.lower() if isinstance(response, str) else ""
|
|
895
|
+
if "error" in response_lower or "failed" in response_lower:
|
|
896
|
+
status = "failed"
|
|
897
|
+
elif "timeout" in response_lower:
|
|
898
|
+
status = "timeout"
|
|
899
|
+
|
|
900
|
+
return agent_type, description, status
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def format_completion_message(agent_type: str, description: str, status: str) -> str:
|
|
904
|
+
"""Format user-friendly completion message."""
|
|
905
|
+
icon = "✓" if status == "completed" else "✗"
|
|
906
|
+
return f"{icon} Subagent {agent_type} {status}: {description}"
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def should_block(status: str, agent_type: str) -> bool:
|
|
910
|
+
"""
|
|
911
|
+
Determine if we should block completion.
|
|
912
|
+
|
|
913
|
+
Block if:
|
|
914
|
+
- Agent failed AND stravinsky mode active AND critical agent type
|
|
915
|
+
"""
|
|
916
|
+
if status != "completed" and is_stravinsky_mode():
|
|
917
|
+
critical_agents = {"delphi", "code-reviewer", "debugger"}
|
|
918
|
+
if agent_type in critical_agents:
|
|
919
|
+
return True
|
|
920
|
+
return False
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def main():
|
|
924
|
+
"""Main hook entry point."""
|
|
925
|
+
try:
|
|
926
|
+
hook_input = json.load(sys.stdin)
|
|
927
|
+
except (json.JSONDecodeError, EOFError):
|
|
928
|
+
return 0
|
|
929
|
+
|
|
930
|
+
# Extract subagent info
|
|
931
|
+
agent_type, description, status = extract_subagent_info(hook_input)
|
|
932
|
+
|
|
933
|
+
# Output completion message
|
|
934
|
+
message = format_completion_message(agent_type, description, status)
|
|
935
|
+
print(message, file=sys.stderr)
|
|
936
|
+
|
|
937
|
+
# Check if we should block
|
|
938
|
+
if should_block(status, agent_type):
|
|
939
|
+
print(f"\\n⚠️ CRITICAL SUBAGENT FAILURE - {agent_type} failed", file=sys.stderr)
|
|
940
|
+
print("Review the error and retry or delegate to delphi.", file=sys.stderr)
|
|
941
|
+
return 2
|
|
942
|
+
|
|
943
|
+
return 0
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
if __name__ == "__main__":
|
|
947
|
+
sys.exit(main())
|
|
948
|
+
''',
|
|
949
|
+
|
|
950
|
+
"pre_compact.py": '''#!/usr/bin/env python3
|
|
951
|
+
"""
|
|
952
|
+
PreCompact hook: Context preservation before compaction.
|
|
953
|
+
|
|
954
|
+
Fires before Claude Code compacts conversation context to:
|
|
955
|
+
1. Preserve critical context patterns
|
|
956
|
+
2. Maintain stravinsky mode state
|
|
957
|
+
3. Warn about information loss
|
|
958
|
+
4. Save state for recovery
|
|
959
|
+
|
|
960
|
+
Cannot block compaction (exit 2 only shows error).
|
|
961
|
+
"""
|
|
962
|
+
|
|
963
|
+
import json
|
|
964
|
+
import sys
|
|
965
|
+
from pathlib import Path
|
|
966
|
+
from datetime import datetime
|
|
967
|
+
from typing import List, Dict, Any
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
STRAVINSKY_MODE_FILE = Path.home() / ".stravinsky_mode"
|
|
971
|
+
STATE_DIR = Path.home() / ".claude" / "state"
|
|
972
|
+
COMPACTION_LOG = STATE_DIR / "compaction.jsonl"
|
|
973
|
+
|
|
974
|
+
# Patterns to preserve
|
|
975
|
+
PRESERVE_PATTERNS = [
|
|
976
|
+
"ARCHITECTURE:",
|
|
977
|
+
"DESIGN DECISION:",
|
|
978
|
+
"CONSTRAINT:",
|
|
979
|
+
"REQUIREMENT:",
|
|
980
|
+
"MUST NOT:",
|
|
981
|
+
"NEVER:",
|
|
982
|
+
"CRITICAL ERROR:",
|
|
983
|
+
"CURRENT TASK:",
|
|
984
|
+
"BLOCKED BY:",
|
|
985
|
+
"[STRAVINSKY MODE]",
|
|
986
|
+
"PARALLEL_DELEGATION:",
|
|
987
|
+
]
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def ensure_state_dir():
|
|
991
|
+
"""Ensure state directory exists."""
|
|
992
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def get_stravinsky_mode_state() -> Dict[str, Any]:
|
|
996
|
+
"""Read stravinsky mode state."""
|
|
997
|
+
if not STRAVINSKY_MODE_FILE.exists():
|
|
998
|
+
return {"active": False}
|
|
999
|
+
try:
|
|
1000
|
+
content = STRAVINSKY_MODE_FILE.read_text().strip()
|
|
1001
|
+
return json.loads(content) if content else {"active": True}
|
|
1002
|
+
except (json.JSONDecodeError, IOError):
|
|
1003
|
+
return {"active": True}
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def extract_preserved_context(prompt: str) -> List[str]:
|
|
1007
|
+
"""Extract context matching preservation patterns."""
|
|
1008
|
+
preserved = []
|
|
1009
|
+
lines = prompt.split("\\n")
|
|
1010
|
+
|
|
1011
|
+
for i, line in enumerate(lines):
|
|
1012
|
+
for pattern in PRESERVE_PATTERNS:
|
|
1013
|
+
if pattern in line:
|
|
1014
|
+
# Capture line + 2 more for context
|
|
1015
|
+
context = "\\n".join(lines[i:min(i+3, len(lines))])
|
|
1016
|
+
preserved.append(context)
|
|
1017
|
+
break
|
|
1018
|
+
|
|
1019
|
+
return preserved[:15] # Max 15 items
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def log_compaction(preserved: List[str], stravinsky_active: bool):
|
|
1023
|
+
"""Log compaction event for audit."""
|
|
1024
|
+
ensure_state_dir()
|
|
1025
|
+
|
|
1026
|
+
entry = {
|
|
1027
|
+
"timestamp": datetime.utcnow().isoformat(),
|
|
1028
|
+
"preserved_count": len(preserved),
|
|
1029
|
+
"stravinsky_mode": stravinsky_active,
|
|
1030
|
+
"preview": [p[:50] for p in preserved[:3]],
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
try:
|
|
1034
|
+
with COMPACTION_LOG.open("a") as f:
|
|
1035
|
+
f.write(json.dumps(entry) + "\\n")
|
|
1036
|
+
except IOError:
|
|
1037
|
+
pass
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def main():
|
|
1041
|
+
"""Main hook entry point."""
|
|
1042
|
+
try:
|
|
1043
|
+
hook_input = json.load(sys.stdin)
|
|
1044
|
+
except (json.JSONDecodeError, EOFError):
|
|
1045
|
+
return 0
|
|
1046
|
+
|
|
1047
|
+
prompt = hook_input.get("prompt", "")
|
|
1048
|
+
trigger = hook_input.get("trigger", "auto")
|
|
1049
|
+
|
|
1050
|
+
# Get stravinsky mode state
|
|
1051
|
+
strav_state = get_stravinsky_mode_state()
|
|
1052
|
+
stravinsky_active = strav_state.get("active", False)
|
|
1053
|
+
|
|
1054
|
+
# Extract preserved context
|
|
1055
|
+
preserved = extract_preserved_context(prompt)
|
|
1056
|
+
|
|
1057
|
+
# Log compaction event
|
|
1058
|
+
log_compaction(preserved, stravinsky_active)
|
|
1059
|
+
|
|
1060
|
+
# Output preservation warning
|
|
1061
|
+
if preserved or stravinsky_active:
|
|
1062
|
+
print(f"\\n[PreCompact] Context compaction triggered ({trigger})", file=sys.stderr)
|
|
1063
|
+
print(f" Preserved items: {len(preserved)}", file=sys.stderr)
|
|
1064
|
+
if stravinsky_active:
|
|
1065
|
+
print(" [STRAVINSKY MODE ACTIVE] - State will persist", file=sys.stderr)
|
|
1066
|
+
print(" Audit log: ~/.claude/state/compaction.jsonl", file=sys.stderr)
|
|
1067
|
+
|
|
1068
|
+
return 0
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
if __name__ == "__main__":
|
|
1072
|
+
sys.exit(main())
|
|
1073
|
+
''',
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
# Hook registration configuration for settings.json
|
|
1078
|
+
# IMPORTANT: Uses ~/.claude/hooks/ (global) paths so hooks work in ANY project
|
|
1079
|
+
HOOK_REGISTRATIONS = {
|
|
1080
|
+
"Notification": [
|
|
1081
|
+
{
|
|
1082
|
+
"matcher": "*",
|
|
1083
|
+
"hooks": [
|
|
1084
|
+
{
|
|
1085
|
+
"type": "command",
|
|
1086
|
+
"command": "python3 ~/.claude/hooks/notification_hook.py"
|
|
1087
|
+
}
|
|
1088
|
+
]
|
|
1089
|
+
}
|
|
1090
|
+
],
|
|
1091
|
+
"SubagentStop": [
|
|
1092
|
+
{
|
|
1093
|
+
"matcher": "*",
|
|
1094
|
+
"hooks": [
|
|
1095
|
+
{
|
|
1096
|
+
"type": "command",
|
|
1097
|
+
"command": "python3 ~/.claude/hooks/subagent_stop.py"
|
|
1098
|
+
}
|
|
1099
|
+
]
|
|
1100
|
+
}
|
|
1101
|
+
],
|
|
1102
|
+
"PreCompact": [
|
|
1103
|
+
{
|
|
1104
|
+
"matcher": "*",
|
|
1105
|
+
"hooks": [
|
|
1106
|
+
{
|
|
1107
|
+
"type": "command",
|
|
1108
|
+
"command": "python3 ~/.claude/hooks/pre_compact.py"
|
|
1109
|
+
}
|
|
1110
|
+
]
|
|
1111
|
+
}
|
|
1112
|
+
],
|
|
1113
|
+
"PreToolUse": [
|
|
1114
|
+
{
|
|
1115
|
+
"matcher": "Read,Search,Grep,Bash,Edit,MultiEdit",
|
|
1116
|
+
"hooks": [
|
|
1117
|
+
{
|
|
1118
|
+
"type": "command",
|
|
1119
|
+
"command": "python3 ~/.claude/hooks/stravinsky_mode.py"
|
|
1120
|
+
}
|
|
1121
|
+
]
|
|
1122
|
+
}
|
|
1123
|
+
],
|
|
1124
|
+
"UserPromptSubmit": [
|
|
1125
|
+
{
|
|
1126
|
+
"matcher": "*",
|
|
1127
|
+
"hooks": [
|
|
1128
|
+
{
|
|
1129
|
+
"type": "command",
|
|
1130
|
+
"command": "python3 ~/.claude/hooks/parallel_execution.py"
|
|
1131
|
+
},
|
|
1132
|
+
{
|
|
1133
|
+
"type": "command",
|
|
1134
|
+
"command": "python3 ~/.claude/hooks/context.py"
|
|
1135
|
+
},
|
|
1136
|
+
{
|
|
1137
|
+
"type": "command",
|
|
1138
|
+
"command": "python3 ~/.claude/hooks/todo_continuation.py"
|
|
1139
|
+
}
|
|
1140
|
+
]
|
|
1141
|
+
}
|
|
1142
|
+
],
|
|
1143
|
+
"PostToolUse": [
|
|
1144
|
+
{
|
|
1145
|
+
"matcher": "*",
|
|
1146
|
+
"hooks": [
|
|
1147
|
+
{
|
|
1148
|
+
"type": "command",
|
|
1149
|
+
"command": "python3 ~/.claude/hooks/truncator.py"
|
|
1150
|
+
}
|
|
1151
|
+
]
|
|
1152
|
+
},
|
|
1153
|
+
{
|
|
1154
|
+
"matcher": "mcp__stravinsky__*,mcp__grep-app__*,Task",
|
|
1155
|
+
"hooks": [
|
|
1156
|
+
{
|
|
1157
|
+
"type": "command",
|
|
1158
|
+
"command": "python3 ~/.claude/hooks/tool_messaging.py"
|
|
1159
|
+
}
|
|
1160
|
+
]
|
|
1161
|
+
},
|
|
1162
|
+
{
|
|
1163
|
+
"matcher": "Edit,MultiEdit",
|
|
1164
|
+
"hooks": [
|
|
1165
|
+
{
|
|
1166
|
+
"type": "command",
|
|
1167
|
+
"command": "python3 ~/.claude/hooks/edit_recovery.py"
|
|
1168
|
+
}
|
|
1169
|
+
]
|
|
1170
|
+
},
|
|
1171
|
+
{
|
|
1172
|
+
"matcher": "TodoWrite",
|
|
1173
|
+
"hooks": [
|
|
1174
|
+
{
|
|
1175
|
+
"type": "command",
|
|
1176
|
+
"command": "python3 ~/.claude/hooks/todo_delegation.py"
|
|
1177
|
+
}
|
|
1178
|
+
]
|
|
1179
|
+
}
|
|
1180
|
+
]
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def install_hooks():
|
|
1185
|
+
"""Install Stravinsky hooks to ~/.claude/hooks/"""
|
|
1186
|
+
|
|
1187
|
+
# Get home directory
|
|
1188
|
+
home = Path.home()
|
|
1189
|
+
claude_dir = home / ".claude"
|
|
1190
|
+
hooks_dir = claude_dir / "hooks"
|
|
1191
|
+
settings_file = claude_dir / "settings.json"
|
|
1192
|
+
|
|
1193
|
+
print("🚀 Stravinsky Hook Installer")
|
|
1194
|
+
print("=" * 60)
|
|
1195
|
+
|
|
1196
|
+
# Create hooks directory if it doesn't exist
|
|
1197
|
+
if not hooks_dir.exists():
|
|
1198
|
+
print(f"📁 Creating hooks directory: {hooks_dir}")
|
|
1199
|
+
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
1200
|
+
else:
|
|
1201
|
+
print(f"📁 Hooks directory exists: {hooks_dir}")
|
|
1202
|
+
|
|
1203
|
+
# Install each hook file
|
|
1204
|
+
print(f"\\n📝 Installing {len(HOOKS)} hook files...")
|
|
1205
|
+
for filename, content in HOOKS.items():
|
|
1206
|
+
hook_path = hooks_dir / filename
|
|
1207
|
+
print(f" ✓ {filename}")
|
|
1208
|
+
hook_path.write_text(content)
|
|
1209
|
+
hook_path.chmod(0o755) # Make executable
|
|
1210
|
+
|
|
1211
|
+
# Merge hook registrations into settings.json
|
|
1212
|
+
print(f"\\n⚙️ Updating settings.json...")
|
|
1213
|
+
|
|
1214
|
+
# Load existing settings or create new
|
|
1215
|
+
if settings_file.exists():
|
|
1216
|
+
print(f" 📖 Reading existing settings: {settings_file}")
|
|
1217
|
+
with settings_file.open('r') as f:
|
|
1218
|
+
settings = json.load(f)
|
|
1219
|
+
else:
|
|
1220
|
+
print(f" 📝 Creating new settings file: {settings_file}")
|
|
1221
|
+
settings = {}
|
|
1222
|
+
|
|
1223
|
+
# Merge hooks configuration
|
|
1224
|
+
if "hooks" not in settings:
|
|
1225
|
+
settings["hooks"] = {}
|
|
1226
|
+
|
|
1227
|
+
for hook_type, registrations in HOOK_REGISTRATIONS.items():
|
|
1228
|
+
settings["hooks"][hook_type] = registrations
|
|
1229
|
+
print(f" ✓ Registered {hook_type} hooks")
|
|
1230
|
+
|
|
1231
|
+
# Write updated settings
|
|
1232
|
+
with settings_file.open('w') as f:
|
|
1233
|
+
json.dump(settings, f, indent=2)
|
|
1234
|
+
|
|
1235
|
+
print(f"\\n✅ Installation complete!")
|
|
1236
|
+
print("=" * 60)
|
|
1237
|
+
print(f"\\n📋 Installed hooks:")
|
|
1238
|
+
for filename in HOOKS.keys():
|
|
1239
|
+
print(f" • {filename}")
|
|
1240
|
+
|
|
1241
|
+
print(f"\\n🔧 Hook types registered:")
|
|
1242
|
+
for hook_type in HOOK_REGISTRATIONS.keys():
|
|
1243
|
+
print(f" • {hook_type}")
|
|
1244
|
+
|
|
1245
|
+
print(f"\\n📁 Installation directory: {hooks_dir}")
|
|
1246
|
+
print(f"⚙️ Settings file: {settings_file}")
|
|
1247
|
+
print("\\n🎉 Stravinsky hooks are now active!")
|
|
1248
|
+
print("\\n💡 Tip: Run '/stravinsky' to activate orchestrator mode")
|
|
1249
|
+
|
|
1250
|
+
return 0
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
def main():
|
|
1254
|
+
"""CLI entry point."""
|
|
1255
|
+
try:
|
|
1256
|
+
return install_hooks()
|
|
1257
|
+
except Exception as e:
|
|
1258
|
+
print(f"\\n❌ Installation failed: {e}", file=sys.stderr)
|
|
1259
|
+
import traceback
|
|
1260
|
+
traceback.print_exc()
|
|
1261
|
+
return 1
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
if __name__ == "__main__":
|
|
1265
|
+
sys.exit(main())
|