stravinsky 0.1.12__tar.gz → 0.2.24__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of stravinsky might be problematic. Click here for more details.

Files changed (71) hide show
  1. stravinsky-0.2.24/.github/workflows/publish.yml +38 -0
  2. stravinsky-0.2.24/.stravinsky/agents/agent_8e68511c.out +1 -0
  3. stravinsky-0.2.24/.stravinsky/agents/agent_9b9fd4f0.log +0 -0
  4. stravinsky-0.2.24/.stravinsky/agents/agent_e76617db.log +0 -0
  5. stravinsky-0.2.24/.stravinsky/agents/agent_e76617db.out +1 -0
  6. stravinsky-0.2.24/.stravinsky/agents.json +34 -0
  7. {stravinsky-0.1.12 → stravinsky-0.2.24}/PKG-INFO +21 -9
  8. {stravinsky-0.1.12 → stravinsky-0.2.24}/README.md +18 -7
  9. stravinsky-0.2.24/error.log +4 -0
  10. stravinsky-0.2.24/install_native_hooks.py +53 -0
  11. stravinsky-0.2.24/mcp_bridge/__init__.py +1 -0
  12. {stravinsky-0.1.12 → stravinsky-0.2.24}/mcp_bridge/auth/cli.py +7 -0
  13. stravinsky-0.2.24/mcp_bridge/hooks/__init__.py +28 -0
  14. stravinsky-0.2.24/mcp_bridge/hooks/budget_optimizer.py +38 -0
  15. stravinsky-0.2.24/mcp_bridge/hooks/compaction.py +32 -0
  16. stravinsky-0.2.24/mcp_bridge/hooks/directory_context.py +40 -0
  17. stravinsky-0.2.24/mcp_bridge/hooks/edit_recovery.py +41 -0
  18. stravinsky-0.2.24/mcp_bridge/hooks/manager.py +77 -0
  19. stravinsky-0.2.24/mcp_bridge/hooks/truncator.py +19 -0
  20. stravinsky-0.2.24/mcp_bridge/native_hooks/context.py +38 -0
  21. stravinsky-0.2.24/mcp_bridge/native_hooks/edit_recovery.py +46 -0
  22. stravinsky-0.2.24/mcp_bridge/native_hooks/truncator.py +23 -0
  23. {stravinsky-0.1.12 → stravinsky-0.2.24}/mcp_bridge/prompts/stravinsky.py +51 -35
  24. stravinsky-0.2.24/mcp_bridge/server.py +519 -0
  25. stravinsky-0.1.12/mcp_bridge/server.py → stravinsky-0.2.24/mcp_bridge/server_tools.py +53 -372
  26. {stravinsky-0.1.12 → stravinsky-0.2.24}/mcp_bridge/tools/__init__.py +10 -3
  27. {stravinsky-0.1.12 → stravinsky-0.2.24}/mcp_bridge/tools/agent_manager.py +221 -107
  28. stravinsky-0.2.24/mcp_bridge/tools/init.py +50 -0
  29. {stravinsky-0.1.12 → stravinsky-0.2.24}/mcp_bridge/tools/lsp/tools.py +15 -15
  30. stravinsky-0.2.24/mcp_bridge/tools/model_invoke.py +561 -0
  31. {stravinsky-0.1.12 → stravinsky-0.2.24}/mcp_bridge/tools/task_runner.py +31 -5
  32. stravinsky-0.2.24/mcp_bridge/tools/templates.py +94 -0
  33. {stravinsky-0.1.12 → stravinsky-0.2.24}/pyproject.toml +3 -2
  34. stravinsky-0.2.24/repro_spawn.py +29 -0
  35. stravinsky-0.2.24/stdout_handshake_auditor.py +85 -0
  36. stravinsky-0.2.24/tests/manual_test_hooks.py +57 -0
  37. stravinsky-0.2.24/tests/test_hooks.py +56 -0
  38. {stravinsky-0.1.12 → stravinsky-0.2.24}/uv.lock +13 -33
  39. stravinsky-0.2.24/verify_tools.py +43 -0
  40. stravinsky-0.1.12/.stravinsky/agents.json +0 -17
  41. stravinsky-0.1.12/mcp_bridge/__init__.py +0 -5
  42. stravinsky-0.1.12/mcp_bridge/tools/model_invoke.py +0 -233
  43. {stravinsky-0.1.12 → stravinsky-0.2.24}/.gitignore +0 -0
  44. {stravinsky-0.1.12 → stravinsky-0.2.24}/.mcp.json +0 -0
  45. /stravinsky-0.1.12/.stravinsky/agents/agent_9b9fd4f0.log → /stravinsky-0.2.24/.stravinsky/agents/agent_8e68511c.log +0 -0
  46. {stravinsky-0.1.12 → stravinsky-0.2.24}/.stravinsky/agents/agent_9b9fd4f0.out +0 -0
  47. {stravinsky-0.1.12 → stravinsky-0.2.24}/CLAUDE.md +0 -0
  48. {stravinsky-0.1.12 → stravinsky-0.2.24}/assets/logo.png +0 -0
  49. {stravinsky-0.1.12 → stravinsky-0.2.24}/assets/logo.png.txt +0 -0
  50. {stravinsky-0.1.12 → stravinsky-0.2.24}/assets/logo_small.png +0 -0
  51. {stravinsky-0.1.12 → stravinsky-0.2.24}/mcp_bridge/auth/__init__.py +0 -0
  52. {stravinsky-0.1.12 → stravinsky-0.2.24}/mcp_bridge/auth/oauth.py +0 -0
  53. {stravinsky-0.1.12 → stravinsky-0.2.24}/mcp_bridge/auth/openai_oauth.py +0 -0
  54. {stravinsky-0.1.12 → stravinsky-0.2.24}/mcp_bridge/auth/token_store.py +0 -0
  55. {stravinsky-0.1.12 → stravinsky-0.2.24}/mcp_bridge/config/__init__.py +0 -0
  56. {stravinsky-0.1.12 → stravinsky-0.2.24}/mcp_bridge/config/hooks.py +0 -0
  57. {stravinsky-0.1.12 → stravinsky-0.2.24}/mcp_bridge/prompts/__init__.py +0 -0
  58. {stravinsky-0.1.12 → stravinsky-0.2.24}/mcp_bridge/prompts/delphi.py +0 -0
  59. {stravinsky-0.1.12 → stravinsky-0.2.24}/mcp_bridge/prompts/dewey.py +0 -0
  60. {stravinsky-0.1.12 → stravinsky-0.2.24}/mcp_bridge/prompts/document_writer.py +0 -0
  61. {stravinsky-0.1.12 → stravinsky-0.2.24}/mcp_bridge/prompts/explore.py +0 -0
  62. {stravinsky-0.1.12 → stravinsky-0.2.24}/mcp_bridge/prompts/frontend.py +0 -0
  63. {stravinsky-0.1.12 → stravinsky-0.2.24}/mcp_bridge/prompts/multimodal.py +0 -0
  64. {stravinsky-0.1.12 → stravinsky-0.2.24}/mcp_bridge/tools/background_tasks.py +0 -0
  65. {stravinsky-0.1.12 → stravinsky-0.2.24}/mcp_bridge/tools/code_search.py +0 -0
  66. {stravinsky-0.1.12 → stravinsky-0.2.24}/mcp_bridge/tools/continuous_loop.py +0 -0
  67. {stravinsky-0.1.12 → stravinsky-0.2.24}/mcp_bridge/tools/lsp/__init__.py +0 -0
  68. {stravinsky-0.1.12 → stravinsky-0.2.24}/mcp_bridge/tools/project_context.py +0 -0
  69. {stravinsky-0.1.12 → stravinsky-0.2.24}/mcp_bridge/tools/session_manager.py +0 -0
  70. {stravinsky-0.1.12 → stravinsky-0.2.24}/mcp_bridge/tools/skill_loader.py +0 -0
  71. {stravinsky-0.1.12 → stravinsky-0.2.24}/mcp_bridge/utils/__init__.py +0 -0
@@ -0,0 +1,38 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*" # Trigger on version tags like v0.2.9
7
+ workflow_dispatch: # Allow manual trigger
8
+
9
+ jobs:
10
+ build-and-publish:
11
+ runs-on: ubuntu-latest
12
+ permissions:
13
+ id-token: write # Required for trusted publishing
14
+ contents: read
15
+
16
+ steps:
17
+ - name: Checkout code
18
+ uses: actions/checkout@v4
19
+
20
+ - name: Set up Python
21
+ uses: actions/setup-python@v5
22
+ with:
23
+ python-version: "3.12"
24
+
25
+ - name: Install uv
26
+ uses: astral-sh/setup-uv@v4
27
+ with:
28
+ version: "latest"
29
+
30
+ - name: Build package
31
+ run: uv build
32
+
33
+ - name: Publish to PyPI
34
+ uses: pypa/gh-action-pypi-publish@release/v1
35
+ with:
36
+ # Uses trusted publishing - no token needed if configured on PyPI
37
+ # Fallback to token if trusted publishing not set up:
38
+ password: ${{ secrets.PYPI_API_TOKEN }}
@@ -0,0 +1 @@
1
+ API Error: 404 {"type":"error","error":{"type":"not_found_error","message":"model: gemini-3-flash"},"request_id":"req_011CWjjRp64NyZfRrodN8rfu"}
@@ -0,0 +1 @@
1
+ API Error: 404 {"type":"error","error":{"type":"not_found_error","message":"model: gemini-3-flash"},"request_id":"req_011CWjjHp9ZHrrpZ9nNCfXAF"}
@@ -0,0 +1,34 @@
1
+ {
2
+ "agent_e76617db": {
3
+ "id": "agent_e76617db",
4
+ "prompt": "Tell me a joke about robots.",
5
+ "agent_type": "explore",
6
+ "description": "Tell me a joke about robots.",
7
+ "status": "running",
8
+ "created_at": "2026-01-02T22:24:43.073103",
9
+ "parent_session_id": null,
10
+ "started_at": "2026-01-02T22:24:43.073419",
11
+ "completed_at": null,
12
+ "result": null,
13
+ "error": null,
14
+ "pid": 18206,
15
+ "timeout": 300,
16
+ "progress": null
17
+ },
18
+ "agent_8e68511c": {
19
+ "id": "agent_8e68511c",
20
+ "prompt": "Tell me a joke about robots.",
21
+ "agent_type": "explore",
22
+ "description": "Tell me a joke about robots.",
23
+ "status": "running",
24
+ "created_at": "2026-01-02T22:26:29.692048",
25
+ "parent_session_id": null,
26
+ "started_at": "2026-01-02T22:26:29.692586",
27
+ "completed_at": null,
28
+ "result": null,
29
+ "error": null,
30
+ "pid": 23792,
31
+ "timeout": 300,
32
+ "progress": null
33
+ }
34
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stravinsky
3
- Version: 0.1.12
3
+ Version: 0.2.24
4
4
  Summary: MCP Bridge for Claude Code with Multi-Model Support. Install globally: claude mcp add --scope user stravinsky -- uvx stravinsky. Add to CLAUDE.md: See https://pypi.org/project/stravinsky/
5
5
  Project-URL: Repository, https://github.com/GratefulDave/stravinsky
6
6
  Project-URL: Issues, https://github.com/GratefulDave/stravinsky/issues
@@ -15,13 +15,14 @@ Requires-Dist: google-auth>=2.20.0
15
15
  Requires-Dist: httpx>=0.24.0
16
16
  Requires-Dist: jedi>=0.19.2
17
17
  Requires-Dist: keyring>=25.7.0
18
- Requires-Dist: mcp>=1.0.0
18
+ Requires-Dist: mcp>=1.2.1
19
19
  Requires-Dist: openai>=1.0.0
20
20
  Requires-Dist: psutil>=5.9.0
21
21
  Requires-Dist: pydantic>=2.0.0
22
22
  Requires-Dist: python-dotenv>=1.0.0
23
23
  Requires-Dist: rich>=13.0.0
24
24
  Requires-Dist: ruff>=0.14.10
25
+ Requires-Dist: tenacity>=8.5.0
25
26
  Provides-Extra: dev
26
27
  Requires-Dist: mypy>=1.10.0; extra == 'dev'
27
28
  Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
@@ -76,7 +77,7 @@ claude mcp add stravinsky -- stravinsky
76
77
 
77
78
  ### Authentication
78
79
 
79
- ```bash
80
+ ````bash
80
81
  # Login to Google (Gemini)
81
82
  stravinsky-auth login gemini
82
83
 
@@ -88,7 +89,22 @@ stravinsky-auth status
88
89
 
89
90
  # Logout
90
91
  stravinsky-auth logout gemini
91
- ```
92
+
93
+ ### Repo Auto-Initialization
94
+
95
+ Bootstrap any repository for Stravinsky in one command:
96
+
97
+ ```bash
98
+ # In the root of your project:
99
+ stravinsky-auth init
100
+ ````
101
+
102
+ This will:
103
+
104
+ 1. Create/Update `CLAUDE.md` with Stravinsky parallel execution rules.
105
+ 2. Install standard slash commands into `.claude/commands/stra/`.
106
+
107
+ ````
92
108
 
93
109
  ## Add to Your CLAUDE.md
94
110
 
@@ -126,7 +142,7 @@ For ANY task with 2+ independent steps:
126
142
 
127
143
  - **ULTRATHINK**: Engage exhaustive deep reasoning, multi-dimensional analysis
128
144
  - **ULTRAWORK**: Maximum parallel execution - spawn agents aggressively for every subtask
129
- ```
145
+ ````
130
146
 
131
147
  ## Tools (31)
132
148
 
@@ -192,7 +208,3 @@ The Codex CLI uses the same port. Stop it with: `killall codex`
192
208
  MIT
193
209
 
194
210
  ---
195
-
196
- <div align="center">
197
- <small>Built with obsession by the Google Deepmind team.</small>
198
- </div>
@@ -45,7 +45,7 @@ claude mcp add stravinsky -- stravinsky
45
45
 
46
46
  ### Authentication
47
47
 
48
- ```bash
48
+ ````bash
49
49
  # Login to Google (Gemini)
50
50
  stravinsky-auth login gemini
51
51
 
@@ -57,7 +57,22 @@ stravinsky-auth status
57
57
 
58
58
  # Logout
59
59
  stravinsky-auth logout gemini
60
- ```
60
+
61
+ ### Repo Auto-Initialization
62
+
63
+ Bootstrap any repository for Stravinsky in one command:
64
+
65
+ ```bash
66
+ # In the root of your project:
67
+ stravinsky-auth init
68
+ ````
69
+
70
+ This will:
71
+
72
+ 1. Create/Update `CLAUDE.md` with Stravinsky parallel execution rules.
73
+ 2. Install standard slash commands into `.claude/commands/stra/`.
74
+
75
+ ````
61
76
 
62
77
  ## Add to Your CLAUDE.md
63
78
 
@@ -95,7 +110,7 @@ For ANY task with 2+ independent steps:
95
110
 
96
111
  - **ULTRATHINK**: Engage exhaustive deep reasoning, multi-dimensional analysis
97
112
  - **ULTRAWORK**: Maximum parallel execution - spawn agents aggressively for every subtask
98
- ```
113
+ ````
99
114
 
100
115
  ## Tools (31)
101
116
 
@@ -161,7 +176,3 @@ The Codex CLI uses the same port. Stop it with: `killall codex`
161
176
  MIT
162
177
 
163
178
  ---
164
-
165
- <div align="center">
166
- <small>Built with obsession by the Google Deepmind team.</small>
167
- </div>
@@ -0,0 +1,4 @@
1
+ Building stravinsky @ file:///Users/davidandrews/PycharmProjects/stravinsky
2
+ Built stravinsky @ file:///Users/davidandrews/PycharmProjects/stravinsky
3
+ Installed 61 packages in 150ms
4
+ INFO:mcp_bridge.server:Starting Stravinsky MCP Bridge Server...
@@ -0,0 +1,53 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+
5
+ def install():
6
+ project_root = Path.cwd()
7
+ settings_path = project_root / ".claude" / "settings.json"
8
+
9
+ # Create .claude directory if missing
10
+ settings_path.parent.mkdir(exist_ok=True)
11
+
12
+ # Initial settings
13
+ if settings_path.exists():
14
+ try:
15
+ settings = json.loads(settings_path.read_text())
16
+ except Exception:
17
+ settings = {}
18
+ else:
19
+ settings = {}
20
+
21
+ if "hooks" not in settings:
22
+ settings["hooks"] = {}
23
+
24
+ hooks = settings["hooks"]
25
+
26
+ # Hook 1: UserPromptSubmit (Context)
27
+ hooks["UserPromptSubmit"] = [
28
+ {
29
+ "type": "command",
30
+ "command": f"python3 {project_root}/mcp_bridge/native_hooks/context.py"
31
+ }
32
+ ]
33
+
34
+ # Hook 2: PostToolUse (Recovery & Truncation)
35
+ # We chain them or use a dispatcher. Chaining is easier in the JSON.
36
+ hooks["PostToolUse"] = [
37
+ {
38
+ "type": "command",
39
+ "command": f"python3 {project_root}/mcp_bridge/native_hooks/truncator.py"
40
+ },
41
+ {
42
+ "type": "command",
43
+ "command": f"python3 {project_root}/mcp_bridge/native_hooks/edit_recovery.py",
44
+ "matcher": ["Edit", "MultiEdit"]
45
+ }
46
+ ]
47
+
48
+ # Save
49
+ settings_path.write_text(json.dumps(settings, indent=2))
50
+ print(f"✅ Stravinsky native hooks installed to {settings_path}")
51
+
52
+ if __name__ == "__main__":
53
+ install()
@@ -0,0 +1 @@
1
+ __version__ = "0.2.24"
@@ -9,6 +9,7 @@ import sys
9
9
  import time
10
10
 
11
11
  from .token_store import TokenStore
12
+ from ..tools.init import bootstrap_repo
12
13
  from .oauth import perform_oauth_flow as gemini_oauth, refresh_access_token as gemini_refresh
13
14
  from .openai_oauth import (
14
15
  perform_oauth_flow as openai_oauth,
@@ -184,6 +185,9 @@ def main():
184
185
  help="Provider to refresh token for",
185
186
  )
186
187
 
188
+ # init command
189
+ subparsers.add_parser("init", help="Bootstrap current repository for Stravinsky")
190
+
187
191
  args = parser.parse_args()
188
192
 
189
193
  if not args.command:
@@ -200,6 +204,9 @@ def main():
200
204
  return cmd_status(token_store)
201
205
  elif args.command == "refresh":
202
206
  return cmd_refresh(args.provider, token_store)
207
+ elif args.command == "init":
208
+ print(bootstrap_repo())
209
+ return 0
203
210
 
204
211
  return 0
205
212
 
@@ -0,0 +1,28 @@
1
+ """
2
+ Hooks initialization.
3
+ Registers all Tier 1-3 hooks into the HookManager.
4
+ """
5
+
6
+ from .manager import get_hook_manager
7
+ from .truncator import output_truncator_hook
8
+ from .edit_recovery import edit_error_recovery_hook
9
+ from .directory_context import directory_context_hook
10
+ from .compaction import context_compaction_hook
11
+ from .budget_optimizer import budget_optimizer_hook
12
+
13
+ def initialize_hooks():
14
+ """Register all available hooks."""
15
+ manager = get_hook_manager()
16
+
17
+ # Tier 1
18
+ manager.register_post_tool_call(output_truncator_hook)
19
+ manager.register_post_tool_call(edit_error_recovery_hook)
20
+
21
+ # Tier 2
22
+ manager.register_pre_model_invoke(directory_context_hook)
23
+ manager.register_pre_model_invoke(context_compaction_hook)
24
+
25
+ # Tier 3
26
+ manager.register_pre_model_invoke(budget_optimizer_hook)
27
+
28
+ # initialize_hooks()
@@ -0,0 +1,38 @@
1
+ """
2
+ Thinking budget optimizer hook.
3
+ Analyzes prompt complexity and adjusts thinking_budget for models that support it.
4
+ """
5
+
6
+ from typing import Any, Dict, Optional
7
+
8
+ REASONING_KEYWORDS = [
9
+ "architect", "design", "refactor", "debug", "complex", "optimize",
10
+ "summarize", "analyze", "explain", "why", "review", "strangler"
11
+ ]
12
+
13
+ async def budget_optimizer_hook(params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
14
+ """
15
+ Adjusts the thinking_budget based on presence of reasoning-heavy keywords.
16
+ """
17
+ model = params.get("model", "")
18
+ # Only applies to models that typically support reasoning budgets (Gemini 2.0 Thinking, GPT-o1, etc.)
19
+ if not any(m in model for m in ["thinking", "flash-thinking", "o1", "o3"]):
20
+ return None
21
+
22
+ prompt = params.get("prompt", "").lower()
23
+
24
+ # Simple heuristic
25
+ is_complex = any(keyword in prompt for keyword in REASONING_KEYWORDS)
26
+
27
+ current_budget = params.get("thinking_budget", 0)
28
+
29
+ if is_complex and current_budget < 4000:
30
+ # Increase budget for complex tasks
31
+ params["thinking_budget"] = 16000
32
+ return params
33
+ elif not is_complex and current_budget > 2000:
34
+ # Lower budget for simple tasks to save time/cost
35
+ params["thinking_budget"] = 2000
36
+ return params
37
+
38
+ return None
@@ -0,0 +1,32 @@
1
+ """
2
+ Preemptive context compaction hook.
3
+ Monitors context size and injects optimization reminders.
4
+ """
5
+
6
+ from typing import Any, Dict, Optional
7
+
8
+ THRESHOLD_CHARS = 100000 # Roughly 25k-30k tokens for typical LLM text
9
+
10
+ COMPACTION_REMINDER = """
11
+ > **[SYSTEM ALERT - CONTEXT WINDOW NEAR LIMIT]**
12
+ > The current conversation history is reaching its limits. Performance may degrade.
13
+ > Please **STOP** and perform a **Session Compaction**:
14
+ > 1. Summarize all work completed so far in a `TASK_STATE.md` (if not already done).
15
+ > 2. List all pending todos.
16
+ > 3. Clear unnecessary tool outputs from your reasoning.
17
+ > 4. Keep your next responses concise and focused only on the current sub-task.
18
+ """
19
+
20
+ async def context_compaction_hook(params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
21
+ """
22
+ Checks prompt length and injects a compaction reminder if it's too large.
23
+ """
24
+ prompt = params.get("prompt", "")
25
+
26
+ if len(prompt) > THRESHOLD_CHARS:
27
+ # Check if we haven't already injected the reminder recently
28
+ if "CONTEXT WINDOW NEAR LIMIT" not in prompt:
29
+ params["prompt"] = COMPACTION_REMINDER + prompt
30
+ return params
31
+
32
+ return None
@@ -0,0 +1,40 @@
1
+ """
2
+ Directory context injector hook.
3
+ Automatically finds and injects local AGENTS.md or README.md content based on the current context.
4
+ """
5
+
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any, Dict, Optional
9
+
10
+ async def directory_context_hook(params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
11
+ """
12
+ Search for AGENTS.md or README.md in the current working directory and inject them.
13
+ """
14
+ cwd = Path.cwd()
15
+
16
+ # Check for AGENTS.md or README.md
17
+ target_files = ["AGENTS.md", "README.md"]
18
+ found_file = None
19
+ for filename in target_files:
20
+ if (cwd / filename).exists():
21
+ found_file = cwd / filename
22
+ break
23
+
24
+ if not found_file:
25
+ return None
26
+
27
+ try:
28
+ content = found_file.read_text()
29
+ # Injects as a special system reminder
30
+ injection = f"\n\n### Local Directory Context ({found_file.name}):\n{content}\n"
31
+
32
+ # Modify the prompt if it exists in params
33
+ if "prompt" in params:
34
+ # Add to the beginning of the prompt as a context block
35
+ params["prompt"] = injection + params["prompt"]
36
+ return params
37
+ except Exception:
38
+ pass
39
+
40
+ return None
@@ -0,0 +1,41 @@
1
+ """
2
+ Edit error recovery hook.
3
+ Detects common mistakes in file editing and injects high-priority corrective directives.
4
+ """
5
+
6
+ import re
7
+ from typing import Any, Dict, Optional
8
+
9
+ EDIT_ERROR_PATTERNS = [
10
+ r"oldString and newString must be different",
11
+ r"oldString not found",
12
+ r"oldString found multiple times",
13
+ r"Target content not found",
14
+ r"Multiple occurrences of target content found",
15
+ ]
16
+
17
+ EDIT_RECOVERY_PROMPT = """
18
+ > **[EDIT ERROR - IMMEDIATE ACTION REQUIRED]**
19
+ > You made an Edit mistake. STOP and do this NOW:
20
+ > 1. **READ** the file immediately to see its ACTUAL current state.
21
+ > 2. **VERIFY** what the content really looks like (your assumption was wrong).
22
+ > 3. **APOLOGIZE** briefly to the user for the error.
23
+ > 4. **CONTINUE** with corrected action based on the real file content.
24
+ > **DO NOT** attempt another edit until you've read and verified the file state.
25
+ """
26
+
27
+ async def edit_error_recovery_hook(tool_name: str, arguments: Dict[str, Any], output: str) -> Optional[str]:
28
+ """
29
+ Analyzes tool output for edit errors and appends corrective directives.
30
+ """
31
+ # Check if this is an edit-related tool (handling both built-in and common MCP tools)
32
+ edit_tools = ["replace_file_content", "multi_replace_file_content", "write_to_file", "edit_file", "Edit"]
33
+
34
+ # We also check the output content for common patterns even if the tool name doesn't match perfectly
35
+ is_edit_error = any(re.search(pattern, output, re.IGNORECASE) for pattern in EDIT_ERROR_PATTERNS)
36
+
37
+ if is_edit_error or any(tool in tool_name for tool in edit_tools):
38
+ if any(re.search(pattern, output, re.IGNORECASE) for pattern in EDIT_ERROR_PATTERNS):
39
+ return output + EDIT_RECOVERY_PROMPT
40
+
41
+ return None
@@ -0,0 +1,77 @@
1
+ """
2
+ Modular Hook System for Stravinsky.
3
+ Provides interception points for tool calls and model invocations.
4
+ """
5
+
6
+ import logging
7
+ from typing import Any, Callable, Dict, List, Optional, Awaitable
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ class HookManager:
12
+ """
13
+ Manages the registration and execution of hooks.
14
+ """
15
+ _instance = None
16
+
17
+ def __init__(self):
18
+ self.pre_tool_call_hooks: List[Callable[[str, Dict[str, Any]], Awaitable[Optional[Dict[str, Any]]]]] = []
19
+ self.post_tool_call_hooks: List[Callable[[str, Dict[str, Any], str], Awaitable[Optional[str]]]] = []
20
+ self.pre_model_invoke_hooks: List[Callable[[Dict[str, Any]], Awaitable[Optional[Dict[str, Any]]]]] = []
21
+
22
+ @classmethod
23
+ def get_instance(cls):
24
+ if cls._instance is None:
25
+ cls._instance = cls()
26
+ return cls._instance
27
+
28
+ def register_pre_tool_call(self, hook: Callable[[str, Dict[str, Any]], Awaitable[Optional[Dict[str, Any]]]]):
29
+ """Run before a tool is called. Can modify arguments or return early result."""
30
+ self.pre_tool_call_hooks.append(hook)
31
+
32
+ def register_post_tool_call(self, hook: Callable[[str, Dict[str, Any], str], Awaitable[Optional[str]]]):
33
+ """Run after a tool call. Can modify or recover from tool output/error."""
34
+ self.post_tool_call_hooks.append(hook)
35
+
36
+ def register_pre_model_invoke(self, hook: Callable[[Dict[str, Any]], Awaitable[Optional[Dict[str, Any]]]]):
37
+ """Run before model invocation. Can modify prompt or parameters."""
38
+ self.pre_model_invoke_hooks.append(hook)
39
+
40
+ async def execute_pre_tool_call(self, tool_name: str, arguments: Dict[str, Any]) -> Optional[Dict[str, Any]]:
41
+ """Executes all pre-tool call hooks."""
42
+ current_args = arguments
43
+ for hook in self.pre_tool_call_hooks:
44
+ try:
45
+ modified_args = await hook(tool_name, current_args)
46
+ if modified_args is not None:
47
+ current_args = modified_args
48
+ except Exception as e:
49
+ logger.error(f"[HookManager] Error in pre_tool_call hook {hook.__name__}: {e}")
50
+ return current_args
51
+
52
+ async def execute_post_tool_call(self, tool_name: str, arguments: Dict[str, Any], output: str) -> str:
53
+ """Executes all post-tool call hooks."""
54
+ current_output = output
55
+ for hook in self.post_tool_call_hooks:
56
+ try:
57
+ modified_output = await hook(tool_name, arguments, current_output)
58
+ if modified_output is not None:
59
+ current_output = modified_output
60
+ except Exception as e:
61
+ logger.error(f"[HookManager] Error in post_tool_call hook {hook.__name__}: {e}")
62
+ return current_output
63
+
64
+ async def execute_pre_model_invoke(self, params: Dict[str, Any]) -> Dict[str, Any]:
65
+ """Executes all pre-model invoke hooks."""
66
+ current_params = params
67
+ for hook in self.pre_model_invoke_hooks:
68
+ try:
69
+ modified_params = await hook(current_params)
70
+ if modified_params is not None:
71
+ current_params = modified_params
72
+ except Exception as e:
73
+ logger.error(f"[HookManager] Error in pre_model_invoke hook {hook.__name__}: {e}")
74
+ return current_params
75
+
76
+ def get_hook_manager() -> HookManager:
77
+ return HookManager.get_instance()
@@ -0,0 +1,19 @@
1
+ """
2
+ Tool output truncator hook.
3
+ Limits the size of tool outputs to prevent context bloat.
4
+ """
5
+
6
+ from typing import Any, Dict, Optional
7
+
8
+ async def output_truncator_hook(tool_name: str, arguments: Dict[str, Any], output: str) -> Optional[str]:
9
+ """
10
+ Truncates tool output if it exceeds a certain length.
11
+ """
12
+ MAX_LENGTH = 30000 # 30k characters limit
13
+
14
+ if len(output) > MAX_LENGTH:
15
+ truncated = output[:MAX_LENGTH]
16
+ summary = f"\n\n... (Result truncated from {len(output)} chars to {MAX_LENGTH} chars) ..."
17
+ return truncated + summary
18
+
19
+ return None
@@ -0,0 +1,38 @@
1
+ import os
2
+ import sys
3
+ import json
4
+ from pathlib import Path
5
+
6
+ def main():
7
+ try:
8
+ data = json.load(sys.stdin)
9
+ prompt = data.get("prompt", "")
10
+ except Exception:
11
+ return
12
+
13
+ cwd = Path(os.environ.get("CLAUDE_CWD", "."))
14
+
15
+ # Files to look for
16
+ context_files = ["AGENTS.md", "README.md", "CLAUDE.md"]
17
+ found_context = ""
18
+
19
+ for f in context_files:
20
+ path = cwd / f
21
+ if path.exists():
22
+ try:
23
+ content = path.read_text()
24
+ found_context += f"\n\n--- LOCAL CONTEXT: {f} ---\n{content}\n"
25
+ break # Only use one for brevity
26
+ except Exception:
27
+ pass
28
+
29
+ if found_context:
30
+ # Prepend context to prompt
31
+ # We wrap the user prompt to distinguish it
32
+ new_prompt = f"{found_context}\n\n[USER PROMPT]\n{prompt}"
33
+ print(new_prompt)
34
+ else:
35
+ print(prompt)
36
+
37
+ if __name__ == "__main__":
38
+ main()
@@ -0,0 +1,46 @@
1
+ import os
2
+ import sys
3
+ import json
4
+ import re
5
+
6
+ def main():
7
+ # Claude Code PostToolUse inputs via Environment Variables
8
+ tool_name = os.environ.get("CLAUDE_TOOL_NAME")
9
+
10
+ # We only care about Edit/MultiEdit
11
+ if tool_name not in ["Edit", "MultiEdit"]:
12
+ return
13
+
14
+ # Read from stdin (Claude Code passes the tool response via stdin for some hook types,
15
+ # but for PostToolUse it's often better to check the environment variable if available.
16
+ # Actually, the summary says input is a JSON payload.
17
+ try:
18
+ data = json.load(sys.stdin)
19
+ tool_response = data.get("tool_response", "")
20
+ except Exception:
21
+ # Fallback to direct string if not JSON
22
+ return
23
+
24
+ # Error patterns
25
+ error_patterns = [
26
+ r"oldString not found",
27
+ r"oldString matched multiple times",
28
+ r"line numbers out of range"
29
+ ]
30
+
31
+ recovery_needed = any(re.search(p, tool_response, re.IGNORECASE) for p in error_patterns)
32
+
33
+ if recovery_needed:
34
+ correction = (
35
+ "\n\n[SYSTEM RECOVERY] It appears the Edit tool failed to find the target string. "
36
+ "Please call 'Read' on the file again to verify the current content, "
37
+ "then ensure your 'oldString' is an EXACT match including all whitespace."
38
+ )
39
+ # For PostToolUse, stdout is captured and appended/replaces output
40
+ print(tool_response + correction)
41
+ else:
42
+ # No change
43
+ print(tool_response)
44
+
45
+ if __name__ == "__main__":
46
+ main()