gobby 0.2.5__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.
Files changed (383) hide show
  1. gobby/__init__.py +3 -0
  2. gobby/adapters/__init__.py +30 -0
  3. gobby/adapters/base.py +93 -0
  4. gobby/adapters/claude_code.py +276 -0
  5. gobby/adapters/codex.py +1292 -0
  6. gobby/adapters/gemini.py +343 -0
  7. gobby/agents/__init__.py +37 -0
  8. gobby/agents/codex_session.py +120 -0
  9. gobby/agents/constants.py +112 -0
  10. gobby/agents/context.py +362 -0
  11. gobby/agents/definitions.py +133 -0
  12. gobby/agents/gemini_session.py +111 -0
  13. gobby/agents/registry.py +618 -0
  14. gobby/agents/runner.py +968 -0
  15. gobby/agents/session.py +259 -0
  16. gobby/agents/spawn.py +916 -0
  17. gobby/agents/spawners/__init__.py +77 -0
  18. gobby/agents/spawners/base.py +142 -0
  19. gobby/agents/spawners/cross_platform.py +266 -0
  20. gobby/agents/spawners/embedded.py +225 -0
  21. gobby/agents/spawners/headless.py +226 -0
  22. gobby/agents/spawners/linux.py +125 -0
  23. gobby/agents/spawners/macos.py +277 -0
  24. gobby/agents/spawners/windows.py +308 -0
  25. gobby/agents/tty_config.py +319 -0
  26. gobby/autonomous/__init__.py +32 -0
  27. gobby/autonomous/progress_tracker.py +447 -0
  28. gobby/autonomous/stop_registry.py +269 -0
  29. gobby/autonomous/stuck_detector.py +383 -0
  30. gobby/cli/__init__.py +67 -0
  31. gobby/cli/__main__.py +8 -0
  32. gobby/cli/agents.py +529 -0
  33. gobby/cli/artifacts.py +266 -0
  34. gobby/cli/daemon.py +329 -0
  35. gobby/cli/extensions.py +526 -0
  36. gobby/cli/github.py +263 -0
  37. gobby/cli/init.py +53 -0
  38. gobby/cli/install.py +614 -0
  39. gobby/cli/installers/__init__.py +37 -0
  40. gobby/cli/installers/antigravity.py +65 -0
  41. gobby/cli/installers/claude.py +363 -0
  42. gobby/cli/installers/codex.py +192 -0
  43. gobby/cli/installers/gemini.py +294 -0
  44. gobby/cli/installers/git_hooks.py +377 -0
  45. gobby/cli/installers/shared.py +737 -0
  46. gobby/cli/linear.py +250 -0
  47. gobby/cli/mcp.py +30 -0
  48. gobby/cli/mcp_proxy.py +698 -0
  49. gobby/cli/memory.py +304 -0
  50. gobby/cli/merge.py +384 -0
  51. gobby/cli/projects.py +79 -0
  52. gobby/cli/sessions.py +622 -0
  53. gobby/cli/tasks/__init__.py +30 -0
  54. gobby/cli/tasks/_utils.py +658 -0
  55. gobby/cli/tasks/ai.py +1025 -0
  56. gobby/cli/tasks/commits.py +169 -0
  57. gobby/cli/tasks/crud.py +685 -0
  58. gobby/cli/tasks/deps.py +135 -0
  59. gobby/cli/tasks/labels.py +63 -0
  60. gobby/cli/tasks/main.py +273 -0
  61. gobby/cli/tasks/search.py +178 -0
  62. gobby/cli/tui.py +34 -0
  63. gobby/cli/utils.py +513 -0
  64. gobby/cli/workflows.py +927 -0
  65. gobby/cli/worktrees.py +481 -0
  66. gobby/config/__init__.py +129 -0
  67. gobby/config/app.py +551 -0
  68. gobby/config/extensions.py +167 -0
  69. gobby/config/features.py +472 -0
  70. gobby/config/llm_providers.py +98 -0
  71. gobby/config/logging.py +66 -0
  72. gobby/config/mcp.py +346 -0
  73. gobby/config/persistence.py +247 -0
  74. gobby/config/servers.py +141 -0
  75. gobby/config/sessions.py +250 -0
  76. gobby/config/tasks.py +784 -0
  77. gobby/hooks/__init__.py +104 -0
  78. gobby/hooks/artifact_capture.py +213 -0
  79. gobby/hooks/broadcaster.py +243 -0
  80. gobby/hooks/event_handlers.py +723 -0
  81. gobby/hooks/events.py +218 -0
  82. gobby/hooks/git.py +169 -0
  83. gobby/hooks/health_monitor.py +171 -0
  84. gobby/hooks/hook_manager.py +856 -0
  85. gobby/hooks/hook_types.py +575 -0
  86. gobby/hooks/plugins.py +813 -0
  87. gobby/hooks/session_coordinator.py +396 -0
  88. gobby/hooks/verification_runner.py +268 -0
  89. gobby/hooks/webhooks.py +339 -0
  90. gobby/install/claude/commands/gobby/bug.md +51 -0
  91. gobby/install/claude/commands/gobby/chore.md +51 -0
  92. gobby/install/claude/commands/gobby/epic.md +52 -0
  93. gobby/install/claude/commands/gobby/eval.md +235 -0
  94. gobby/install/claude/commands/gobby/feat.md +49 -0
  95. gobby/install/claude/commands/gobby/nit.md +52 -0
  96. gobby/install/claude/commands/gobby/ref.md +52 -0
  97. gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
  98. gobby/install/claude/hooks/hook_dispatcher.py +364 -0
  99. gobby/install/claude/hooks/validate_settings.py +102 -0
  100. gobby/install/claude/hooks-template.json +118 -0
  101. gobby/install/codex/hooks/hook_dispatcher.py +153 -0
  102. gobby/install/codex/prompts/forget.md +7 -0
  103. gobby/install/codex/prompts/memories.md +7 -0
  104. gobby/install/codex/prompts/recall.md +7 -0
  105. gobby/install/codex/prompts/remember.md +13 -0
  106. gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
  107. gobby/install/gemini/hooks-template.json +138 -0
  108. gobby/install/shared/plugins/code_guardian.py +456 -0
  109. gobby/install/shared/plugins/example_notify.py +331 -0
  110. gobby/integrations/__init__.py +10 -0
  111. gobby/integrations/github.py +145 -0
  112. gobby/integrations/linear.py +145 -0
  113. gobby/llm/__init__.py +40 -0
  114. gobby/llm/base.py +120 -0
  115. gobby/llm/claude.py +578 -0
  116. gobby/llm/claude_executor.py +503 -0
  117. gobby/llm/codex.py +322 -0
  118. gobby/llm/codex_executor.py +513 -0
  119. gobby/llm/executor.py +316 -0
  120. gobby/llm/factory.py +34 -0
  121. gobby/llm/gemini.py +258 -0
  122. gobby/llm/gemini_executor.py +339 -0
  123. gobby/llm/litellm.py +287 -0
  124. gobby/llm/litellm_executor.py +303 -0
  125. gobby/llm/resolver.py +499 -0
  126. gobby/llm/service.py +236 -0
  127. gobby/mcp_proxy/__init__.py +29 -0
  128. gobby/mcp_proxy/actions.py +175 -0
  129. gobby/mcp_proxy/daemon_control.py +198 -0
  130. gobby/mcp_proxy/importer.py +436 -0
  131. gobby/mcp_proxy/lazy.py +325 -0
  132. gobby/mcp_proxy/manager.py +798 -0
  133. gobby/mcp_proxy/metrics.py +609 -0
  134. gobby/mcp_proxy/models.py +139 -0
  135. gobby/mcp_proxy/registries.py +215 -0
  136. gobby/mcp_proxy/schema_hash.py +381 -0
  137. gobby/mcp_proxy/semantic_search.py +706 -0
  138. gobby/mcp_proxy/server.py +549 -0
  139. gobby/mcp_proxy/services/__init__.py +0 -0
  140. gobby/mcp_proxy/services/fallback.py +306 -0
  141. gobby/mcp_proxy/services/recommendation.py +224 -0
  142. gobby/mcp_proxy/services/server_mgmt.py +214 -0
  143. gobby/mcp_proxy/services/system.py +72 -0
  144. gobby/mcp_proxy/services/tool_filter.py +231 -0
  145. gobby/mcp_proxy/services/tool_proxy.py +309 -0
  146. gobby/mcp_proxy/stdio.py +565 -0
  147. gobby/mcp_proxy/tools/__init__.py +27 -0
  148. gobby/mcp_proxy/tools/agents.py +1103 -0
  149. gobby/mcp_proxy/tools/artifacts.py +207 -0
  150. gobby/mcp_proxy/tools/hub.py +335 -0
  151. gobby/mcp_proxy/tools/internal.py +337 -0
  152. gobby/mcp_proxy/tools/memory.py +543 -0
  153. gobby/mcp_proxy/tools/merge.py +422 -0
  154. gobby/mcp_proxy/tools/metrics.py +283 -0
  155. gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
  156. gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
  157. gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
  158. gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
  159. gobby/mcp_proxy/tools/orchestration/review.py +736 -0
  160. gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
  161. gobby/mcp_proxy/tools/session_messages.py +1056 -0
  162. gobby/mcp_proxy/tools/task_dependencies.py +219 -0
  163. gobby/mcp_proxy/tools/task_expansion.py +591 -0
  164. gobby/mcp_proxy/tools/task_github.py +393 -0
  165. gobby/mcp_proxy/tools/task_linear.py +379 -0
  166. gobby/mcp_proxy/tools/task_orchestration.py +77 -0
  167. gobby/mcp_proxy/tools/task_readiness.py +522 -0
  168. gobby/mcp_proxy/tools/task_sync.py +351 -0
  169. gobby/mcp_proxy/tools/task_validation.py +843 -0
  170. gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
  171. gobby/mcp_proxy/tools/tasks/_context.py +112 -0
  172. gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
  173. gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
  174. gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
  175. gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
  176. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
  177. gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
  178. gobby/mcp_proxy/tools/tasks/_search.py +215 -0
  179. gobby/mcp_proxy/tools/tasks/_session.py +125 -0
  180. gobby/mcp_proxy/tools/workflows.py +973 -0
  181. gobby/mcp_proxy/tools/worktrees.py +1264 -0
  182. gobby/mcp_proxy/transports/__init__.py +0 -0
  183. gobby/mcp_proxy/transports/base.py +95 -0
  184. gobby/mcp_proxy/transports/factory.py +44 -0
  185. gobby/mcp_proxy/transports/http.py +139 -0
  186. gobby/mcp_proxy/transports/stdio.py +213 -0
  187. gobby/mcp_proxy/transports/websocket.py +136 -0
  188. gobby/memory/backends/__init__.py +116 -0
  189. gobby/memory/backends/mem0.py +408 -0
  190. gobby/memory/backends/memu.py +485 -0
  191. gobby/memory/backends/null.py +111 -0
  192. gobby/memory/backends/openmemory.py +537 -0
  193. gobby/memory/backends/sqlite.py +304 -0
  194. gobby/memory/context.py +87 -0
  195. gobby/memory/manager.py +1001 -0
  196. gobby/memory/protocol.py +451 -0
  197. gobby/memory/search/__init__.py +66 -0
  198. gobby/memory/search/text.py +127 -0
  199. gobby/memory/viz.py +258 -0
  200. gobby/prompts/__init__.py +13 -0
  201. gobby/prompts/defaults/expansion/system.md +119 -0
  202. gobby/prompts/defaults/expansion/user.md +48 -0
  203. gobby/prompts/defaults/external_validation/agent.md +72 -0
  204. gobby/prompts/defaults/external_validation/external.md +63 -0
  205. gobby/prompts/defaults/external_validation/spawn.md +83 -0
  206. gobby/prompts/defaults/external_validation/system.md +6 -0
  207. gobby/prompts/defaults/features/import_mcp.md +22 -0
  208. gobby/prompts/defaults/features/import_mcp_github.md +17 -0
  209. gobby/prompts/defaults/features/import_mcp_search.md +16 -0
  210. gobby/prompts/defaults/features/recommend_tools.md +32 -0
  211. gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
  212. gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
  213. gobby/prompts/defaults/features/server_description.md +20 -0
  214. gobby/prompts/defaults/features/server_description_system.md +6 -0
  215. gobby/prompts/defaults/features/task_description.md +31 -0
  216. gobby/prompts/defaults/features/task_description_system.md +6 -0
  217. gobby/prompts/defaults/features/tool_summary.md +17 -0
  218. gobby/prompts/defaults/features/tool_summary_system.md +6 -0
  219. gobby/prompts/defaults/research/step.md +58 -0
  220. gobby/prompts/defaults/validation/criteria.md +47 -0
  221. gobby/prompts/defaults/validation/validate.md +38 -0
  222. gobby/prompts/loader.py +346 -0
  223. gobby/prompts/models.py +113 -0
  224. gobby/py.typed +0 -0
  225. gobby/runner.py +488 -0
  226. gobby/search/__init__.py +23 -0
  227. gobby/search/protocol.py +104 -0
  228. gobby/search/tfidf.py +232 -0
  229. gobby/servers/__init__.py +7 -0
  230. gobby/servers/http.py +636 -0
  231. gobby/servers/models.py +31 -0
  232. gobby/servers/routes/__init__.py +23 -0
  233. gobby/servers/routes/admin.py +416 -0
  234. gobby/servers/routes/dependencies.py +118 -0
  235. gobby/servers/routes/mcp/__init__.py +24 -0
  236. gobby/servers/routes/mcp/hooks.py +135 -0
  237. gobby/servers/routes/mcp/plugins.py +121 -0
  238. gobby/servers/routes/mcp/tools.py +1337 -0
  239. gobby/servers/routes/mcp/webhooks.py +159 -0
  240. gobby/servers/routes/sessions.py +582 -0
  241. gobby/servers/websocket.py +766 -0
  242. gobby/sessions/__init__.py +13 -0
  243. gobby/sessions/analyzer.py +322 -0
  244. gobby/sessions/lifecycle.py +240 -0
  245. gobby/sessions/manager.py +563 -0
  246. gobby/sessions/processor.py +225 -0
  247. gobby/sessions/summary.py +532 -0
  248. gobby/sessions/transcripts/__init__.py +41 -0
  249. gobby/sessions/transcripts/base.py +125 -0
  250. gobby/sessions/transcripts/claude.py +386 -0
  251. gobby/sessions/transcripts/codex.py +143 -0
  252. gobby/sessions/transcripts/gemini.py +195 -0
  253. gobby/storage/__init__.py +21 -0
  254. gobby/storage/agents.py +409 -0
  255. gobby/storage/artifact_classifier.py +341 -0
  256. gobby/storage/artifacts.py +285 -0
  257. gobby/storage/compaction.py +67 -0
  258. gobby/storage/database.py +357 -0
  259. gobby/storage/inter_session_messages.py +194 -0
  260. gobby/storage/mcp.py +680 -0
  261. gobby/storage/memories.py +562 -0
  262. gobby/storage/merge_resolutions.py +550 -0
  263. gobby/storage/migrations.py +860 -0
  264. gobby/storage/migrations_legacy.py +1359 -0
  265. gobby/storage/projects.py +166 -0
  266. gobby/storage/session_messages.py +251 -0
  267. gobby/storage/session_tasks.py +97 -0
  268. gobby/storage/sessions.py +817 -0
  269. gobby/storage/task_dependencies.py +223 -0
  270. gobby/storage/tasks/__init__.py +42 -0
  271. gobby/storage/tasks/_aggregates.py +180 -0
  272. gobby/storage/tasks/_crud.py +449 -0
  273. gobby/storage/tasks/_id.py +104 -0
  274. gobby/storage/tasks/_lifecycle.py +311 -0
  275. gobby/storage/tasks/_manager.py +889 -0
  276. gobby/storage/tasks/_models.py +300 -0
  277. gobby/storage/tasks/_ordering.py +119 -0
  278. gobby/storage/tasks/_path_cache.py +110 -0
  279. gobby/storage/tasks/_queries.py +343 -0
  280. gobby/storage/tasks/_search.py +143 -0
  281. gobby/storage/workflow_audit.py +393 -0
  282. gobby/storage/worktrees.py +547 -0
  283. gobby/sync/__init__.py +29 -0
  284. gobby/sync/github.py +333 -0
  285. gobby/sync/linear.py +304 -0
  286. gobby/sync/memories.py +284 -0
  287. gobby/sync/tasks.py +641 -0
  288. gobby/tasks/__init__.py +8 -0
  289. gobby/tasks/build_verification.py +193 -0
  290. gobby/tasks/commits.py +633 -0
  291. gobby/tasks/context.py +747 -0
  292. gobby/tasks/criteria.py +342 -0
  293. gobby/tasks/enhanced_validator.py +226 -0
  294. gobby/tasks/escalation.py +263 -0
  295. gobby/tasks/expansion.py +626 -0
  296. gobby/tasks/external_validator.py +764 -0
  297. gobby/tasks/issue_extraction.py +171 -0
  298. gobby/tasks/prompts/expand.py +327 -0
  299. gobby/tasks/research.py +421 -0
  300. gobby/tasks/tdd.py +352 -0
  301. gobby/tasks/tree_builder.py +263 -0
  302. gobby/tasks/validation.py +712 -0
  303. gobby/tasks/validation_history.py +357 -0
  304. gobby/tasks/validation_models.py +89 -0
  305. gobby/tools/__init__.py +0 -0
  306. gobby/tools/summarizer.py +170 -0
  307. gobby/tui/__init__.py +5 -0
  308. gobby/tui/api_client.py +281 -0
  309. gobby/tui/app.py +327 -0
  310. gobby/tui/screens/__init__.py +25 -0
  311. gobby/tui/screens/agents.py +333 -0
  312. gobby/tui/screens/chat.py +450 -0
  313. gobby/tui/screens/dashboard.py +377 -0
  314. gobby/tui/screens/memory.py +305 -0
  315. gobby/tui/screens/metrics.py +231 -0
  316. gobby/tui/screens/orchestrator.py +904 -0
  317. gobby/tui/screens/sessions.py +412 -0
  318. gobby/tui/screens/tasks.py +442 -0
  319. gobby/tui/screens/workflows.py +289 -0
  320. gobby/tui/screens/worktrees.py +174 -0
  321. gobby/tui/widgets/__init__.py +21 -0
  322. gobby/tui/widgets/chat.py +210 -0
  323. gobby/tui/widgets/conductor.py +104 -0
  324. gobby/tui/widgets/menu.py +132 -0
  325. gobby/tui/widgets/message_panel.py +160 -0
  326. gobby/tui/widgets/review_gate.py +224 -0
  327. gobby/tui/widgets/task_tree.py +99 -0
  328. gobby/tui/widgets/token_budget.py +166 -0
  329. gobby/tui/ws_client.py +258 -0
  330. gobby/utils/__init__.py +3 -0
  331. gobby/utils/daemon_client.py +235 -0
  332. gobby/utils/git.py +222 -0
  333. gobby/utils/id.py +38 -0
  334. gobby/utils/json_helpers.py +161 -0
  335. gobby/utils/logging.py +376 -0
  336. gobby/utils/machine_id.py +135 -0
  337. gobby/utils/metrics.py +589 -0
  338. gobby/utils/project_context.py +182 -0
  339. gobby/utils/project_init.py +263 -0
  340. gobby/utils/status.py +256 -0
  341. gobby/utils/validation.py +80 -0
  342. gobby/utils/version.py +23 -0
  343. gobby/workflows/__init__.py +4 -0
  344. gobby/workflows/actions.py +1310 -0
  345. gobby/workflows/approval_flow.py +138 -0
  346. gobby/workflows/artifact_actions.py +103 -0
  347. gobby/workflows/audit_helpers.py +110 -0
  348. gobby/workflows/autonomous_actions.py +286 -0
  349. gobby/workflows/context_actions.py +394 -0
  350. gobby/workflows/definitions.py +130 -0
  351. gobby/workflows/detection_helpers.py +208 -0
  352. gobby/workflows/engine.py +485 -0
  353. gobby/workflows/evaluator.py +669 -0
  354. gobby/workflows/git_utils.py +96 -0
  355. gobby/workflows/hooks.py +169 -0
  356. gobby/workflows/lifecycle_evaluator.py +613 -0
  357. gobby/workflows/llm_actions.py +70 -0
  358. gobby/workflows/loader.py +333 -0
  359. gobby/workflows/mcp_actions.py +60 -0
  360. gobby/workflows/memory_actions.py +272 -0
  361. gobby/workflows/premature_stop.py +164 -0
  362. gobby/workflows/session_actions.py +139 -0
  363. gobby/workflows/state_actions.py +123 -0
  364. gobby/workflows/state_manager.py +104 -0
  365. gobby/workflows/stop_signal_actions.py +163 -0
  366. gobby/workflows/summary_actions.py +344 -0
  367. gobby/workflows/task_actions.py +249 -0
  368. gobby/workflows/task_enforcement_actions.py +901 -0
  369. gobby/workflows/templates.py +52 -0
  370. gobby/workflows/todo_actions.py +84 -0
  371. gobby/workflows/webhook.py +223 -0
  372. gobby/workflows/webhook_executor.py +399 -0
  373. gobby/worktrees/__init__.py +5 -0
  374. gobby/worktrees/git.py +690 -0
  375. gobby/worktrees/merge/__init__.py +20 -0
  376. gobby/worktrees/merge/conflict_parser.py +177 -0
  377. gobby/worktrees/merge/resolver.py +485 -0
  378. gobby-0.2.5.dist-info/METADATA +351 -0
  379. gobby-0.2.5.dist-info/RECORD +383 -0
  380. gobby-0.2.5.dist-info/WHEEL +5 -0
  381. gobby-0.2.5.dist-info/entry_points.txt +2 -0
  382. gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
  383. gobby-0.2.5.dist-info/top_level.txt +1 -0
gobby/hooks/plugins.py ADDED
@@ -0,0 +1,813 @@
1
+ """
2
+ Python plugin system for hook handlers.
3
+
4
+ This module provides infrastructure for dynamically loading Python plugins
5
+ that can intercept and modify hook behavior.
6
+
7
+ Security Note: Plugins run with full daemon privileges. Only enable plugins
8
+ you trust. The plugin system is disabled by default.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import importlib.util
14
+ import inspect
15
+ import logging
16
+ import sys
17
+ from abc import ABC
18
+ from collections.abc import Callable
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import TYPE_CHECKING, Any
22
+
23
+ from gobby.hooks.events import HookEvent, HookEventType, HookResponse
24
+
25
+ if TYPE_CHECKING:
26
+ from gobby.config.app import PluginsConfig
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ # =============================================================================
32
+ # Plugin Action Registration
33
+ # =============================================================================
34
+
35
+
36
+ @dataclass
37
+ class PluginAction:
38
+ """A registered workflow action from a plugin.
39
+
40
+ Attributes:
41
+ name: Action name (without plugin prefix).
42
+ handler: Async callable matching ActionHandler protocol.
43
+ schema: JSON Schema dict describing the action's input parameters.
44
+ plugin_name: Name of the plugin that registered this action.
45
+ """
46
+
47
+ name: str
48
+ handler: Callable[..., Any]
49
+ schema: dict[str, Any]
50
+ plugin_name: str
51
+
52
+ def validate_input(self, kwargs: dict[str, Any]) -> tuple[bool, str | None]:
53
+ """Validate input arguments against the action's schema.
54
+
55
+ Args:
56
+ kwargs: Input arguments to validate.
57
+
58
+ Returns:
59
+ Tuple of (is_valid, error_message).
60
+ If valid, error_message is None.
61
+ """
62
+ if not self.schema:
63
+ return True, None # No schema means no validation
64
+
65
+ # Basic JSON Schema validation (properties + required)
66
+ properties = self.schema.get("properties", {})
67
+ required = self.schema.get("required", [])
68
+
69
+ # Check required fields
70
+ for field_name in required:
71
+ if field_name not in kwargs:
72
+ return False, f"Missing required field: {field_name}"
73
+
74
+ # Check property types if specified
75
+ for prop_name, prop_schema in properties.items():
76
+ if prop_name not in kwargs:
77
+ continue # Optional field not provided
78
+
79
+ value = kwargs[prop_name]
80
+ prop_type = prop_schema.get("type")
81
+
82
+ if prop_type and not _check_type(value, prop_type):
83
+ return False, f"Field '{prop_name}' has invalid type: expected {prop_type}"
84
+
85
+ return True, None
86
+
87
+
88
+ def _check_type(value: Any, expected_type: str) -> bool:
89
+ """Check if a value matches a JSON Schema type."""
90
+ # Explicitly reject bool for numeric types since bool is a subclass of int
91
+ if expected_type in ("integer", "number") and isinstance(value, bool):
92
+ return False
93
+
94
+ type_map = {
95
+ "string": str,
96
+ "number": (int, float),
97
+ "integer": int,
98
+ "boolean": bool,
99
+ "array": list,
100
+ "object": dict,
101
+ "null": type(None),
102
+ }
103
+
104
+ expected = type_map.get(expected_type)
105
+ if expected is None:
106
+ return True # Unknown type, skip validation
107
+
108
+ return isinstance(value, expected) # type: ignore[arg-type]
109
+
110
+
111
+ # =============================================================================
112
+ # Decorator
113
+ # =============================================================================
114
+
115
+
116
+ def hook_handler(
117
+ event_type: HookEventType,
118
+ priority: int = 50,
119
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
120
+ """
121
+ Decorator to mark a method as a hook handler.
122
+
123
+ Args:
124
+ event_type: The HookEventType this handler responds to.
125
+ priority: Execution priority (lower = earlier).
126
+ - Priority < 50: Pre-handlers (run before core, can block)
127
+ - Priority >= 50: Post-handlers (run after core, observe only)
128
+
129
+ Handler Signatures:
130
+ Pre-handlers (priority < 50):
131
+ def handler(self, event: HookEvent) -> HookResponse | None
132
+ - Receives only the event
133
+ - Return HookResponse with decision="deny" or "block" to block
134
+ - Return None to continue to next handler
135
+
136
+ Post-handlers (priority >= 50):
137
+ def handler(self, event: HookEvent, core_response: HookResponse | None) -> None
138
+ - Receives event AND the core handler's response
139
+ - Cannot block; return value is ignored
140
+ - IMPORTANT: Must accept two arguments or a TypeError will be raised
141
+
142
+ Examples:
143
+ class MyPlugin(HookPlugin):
144
+ name = "my-plugin"
145
+
146
+ # Pre-handler: can block dangerous tools
147
+ @hook_handler(HookEventType.BEFORE_TOOL, priority=10)
148
+ def check_tool(self, event: HookEvent) -> HookResponse | None:
149
+ if "dangerous" in event.data.get("tool_name", ""):
150
+ return HookResponse(decision="deny", reason="Blocked")
151
+ return None # Continue to next handler
152
+
153
+ # Post-handler: observe and log after core processing
154
+ @hook_handler(HookEventType.AFTER_TOOL, priority=60)
155
+ def log_tool_result(
156
+ self, event: HookEvent, core_response: HookResponse | None
157
+ ) -> None:
158
+ tool = event.data.get("tool_name", "unknown")
159
+ status = core_response.decision if core_response else "no-response"
160
+ logger.info(f"Tool {tool} completed with status: {status}")
161
+ """
162
+
163
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
164
+ # Store metadata on the function
165
+ func._hook_event_type = event_type # type: ignore[attr-defined]
166
+ func._hook_priority = priority # type: ignore[attr-defined]
167
+ return func
168
+
169
+ return decorator
170
+
171
+
172
+ # =============================================================================
173
+ # Base Class
174
+ # =============================================================================
175
+
176
+
177
+ class HookPlugin(ABC):
178
+ """
179
+ Base class for hook plugins.
180
+
181
+ Subclass this to create a plugin. At minimum, set the `name` class attribute
182
+ and implement handler methods decorated with @hook_handler.
183
+
184
+ Attributes:
185
+ name: Unique plugin identifier (required).
186
+ version: Plugin version string (default: "1.0.0").
187
+ description: Human-readable description.
188
+
189
+ Example:
190
+ class MyPlugin(HookPlugin):
191
+ name = "my-plugin"
192
+ version = "1.0.0"
193
+ description = "Blocks dangerous commands"
194
+
195
+ def on_load(self, config: dict) -> None:
196
+ self.blocked_patterns = config.get("blocked", [])
197
+
198
+ @hook_handler(HookEventType.BEFORE_TOOL, priority=10)
199
+ def check_tool(self, event: HookEvent) -> HookResponse | None:
200
+ # Return HookResponse to block, None to continue
201
+ return None
202
+ """
203
+
204
+ name: str
205
+ version: str = "1.0.0"
206
+ description: str = ""
207
+
208
+ def __init__(self) -> None:
209
+ """Initialize plugin instance."""
210
+ # Containers for registered workflow extensions
211
+ self._actions: dict[str, PluginAction] = {}
212
+ self._conditions: dict[str, Callable[..., Any]] = {}
213
+ self.logger = logging.getLogger(f"gobby.plugins.{self.name}")
214
+
215
+ def on_load(self, config: dict[str, Any]) -> None: # noqa: B027
216
+ """
217
+ Called when plugin is loaded.
218
+
219
+ Override to initialize plugin state with configuration.
220
+
221
+ Args:
222
+ config: Plugin-specific configuration from PluginItemConfig.config
223
+ """
224
+ # Optional lifecycle hook - subclasses may override
225
+
226
+ def on_unload(self) -> None: # noqa: B027
227
+ """
228
+ Called when plugin is unloaded.
229
+
230
+ Override to cleanup resources.
231
+ """
232
+ # Optional lifecycle hook - subclasses may override
233
+
234
+ def register_action(self, name: str, handler: Callable[..., Any]) -> None:
235
+ """
236
+ Register a workflow action (simple form without schema).
237
+
238
+ Actions registered here can be used in workflow YAML files.
239
+ They will be available as `plugin:<plugin-name>:<action-name>`.
240
+
241
+ For actions that require input validation, use register_workflow_action().
242
+
243
+ Args:
244
+ name: Action name (without plugin prefix).
245
+ handler: Async callable matching ActionHandler protocol.
246
+ """
247
+ action = PluginAction(
248
+ name=name,
249
+ handler=handler,
250
+ schema={},
251
+ plugin_name=self.name,
252
+ )
253
+ self._actions[name] = action
254
+ self.logger.debug(f"Registered action: {name}")
255
+
256
+ def register_workflow_action(
257
+ self,
258
+ action_type: str,
259
+ schema: dict[str, Any],
260
+ executor_fn: Callable[..., Any],
261
+ ) -> None:
262
+ """
263
+ Register a workflow action with schema validation.
264
+
265
+ Actions registered here can be used in workflow YAML files.
266
+ They will be available as `plugin:<plugin-name>:<action-type>`.
267
+ Input arguments will be validated against the schema before execution.
268
+
269
+ Args:
270
+ action_type: Action name (without plugin prefix).
271
+ schema: JSON Schema dict for input validation. Should contain:
272
+ - properties: Dict of property names to their schemas
273
+ - required: List of required property names
274
+ Example:
275
+ {
276
+ "properties": {
277
+ "message": {"type": "string"},
278
+ "channel": {"type": "string"}
279
+ },
280
+ "required": ["message"]
281
+ }
282
+ executor_fn: Async callable matching ActionHandler protocol:
283
+ async def handler(context: ActionContext, **kwargs) -> dict | None
284
+
285
+ Raises:
286
+ ValueError: If action_type is already registered.
287
+ """
288
+ if action_type in self._actions:
289
+ raise ValueError(
290
+ f"Action type '{action_type}' is already registered for plugin '{self.name}'"
291
+ )
292
+
293
+ action = PluginAction(
294
+ name=action_type,
295
+ handler=executor_fn,
296
+ schema=schema,
297
+ plugin_name=self.name,
298
+ )
299
+ self._actions[action_type] = action
300
+ self.logger.debug(f"Registered workflow action: {action_type} with schema")
301
+
302
+ def get_action(self, name: str) -> PluginAction | None:
303
+ """
304
+ Get a registered action by name.
305
+
306
+ Args:
307
+ name: Action name (without plugin prefix).
308
+
309
+ Returns:
310
+ PluginAction if found, None otherwise.
311
+ """
312
+ return self._actions.get(name)
313
+
314
+ def register_condition(self, name: str, evaluator: Callable[..., Any]) -> None:
315
+ """
316
+ Register a workflow condition.
317
+
318
+ Conditions registered here can be used in workflow `when` clauses.
319
+ They will be available as `plugin:<plugin-name>:<condition-name>`.
320
+
321
+ Args:
322
+ name: Condition name (without plugin prefix).
323
+ evaluator: Callable that returns bool given context dict.
324
+ """
325
+ self._conditions[name] = evaluator
326
+ self.logger.debug(f"Registered condition: {name}")
327
+
328
+
329
+ # =============================================================================
330
+ # Handler Registration
331
+ # =============================================================================
332
+
333
+
334
+ @dataclass
335
+ class RegisteredHandler:
336
+ """A registered hook handler with metadata."""
337
+
338
+ plugin: HookPlugin
339
+ method: Callable[..., Any]
340
+ event_type: HookEventType
341
+ priority: int
342
+
343
+
344
+ @dataclass
345
+ class PluginRegistry:
346
+ """
347
+ Manages loaded plugins and their handlers.
348
+
349
+ Maintains a registry of plugins and their hook handlers, providing
350
+ priority-sorted handler retrieval.
351
+ """
352
+
353
+ _plugins: dict[str, HookPlugin] = field(default_factory=dict)
354
+ _handlers: dict[HookEventType, list[RegisteredHandler]] = field(default_factory=dict)
355
+
356
+ def register_plugin(self, plugin: HookPlugin) -> None:
357
+ """
358
+ Register a plugin and its handlers.
359
+
360
+ Scans the plugin for methods decorated with @hook_handler and
361
+ registers them in priority order.
362
+
363
+ Args:
364
+ plugin: The plugin instance to register.
365
+
366
+ Raises:
367
+ ValueError: If a plugin with the same name is already registered.
368
+ """
369
+ if plugin.name in self._plugins:
370
+ raise ValueError(f"Plugin already registered: {plugin.name}")
371
+
372
+ self._plugins[plugin.name] = plugin
373
+
374
+ # Find and register all @hook_handler decorated methods
375
+ for name, method in inspect.getmembers(plugin, predicate=inspect.ismethod):
376
+ if hasattr(method, "_hook_event_type"):
377
+ event_type = method._hook_event_type
378
+ priority = getattr(method, "_hook_priority", 50)
379
+
380
+ handler = RegisteredHandler(
381
+ plugin=plugin,
382
+ method=method,
383
+ event_type=event_type,
384
+ priority=priority,
385
+ )
386
+
387
+ if event_type not in self._handlers:
388
+ self._handlers[event_type] = []
389
+
390
+ self._handlers[event_type].append(handler)
391
+ # Keep sorted by priority
392
+ self._handlers[event_type].sort(key=lambda h: h.priority)
393
+
394
+ logger.debug(
395
+ f"Registered handler: {plugin.name}.{name} for {event_type.value} "
396
+ f"(priority={priority})"
397
+ )
398
+
399
+ def unregister_plugin(self, name: str) -> None:
400
+ """
401
+ Unregister a plugin and remove its handlers.
402
+
403
+ Args:
404
+ name: The plugin name to unregister.
405
+ """
406
+ if name not in self._plugins:
407
+ logger.warning(f"Plugin not registered: {name}")
408
+ return
409
+
410
+ plugin = self._plugins.pop(name)
411
+
412
+ # Remove handlers for this plugin
413
+ for event_type in list(self._handlers.keys()):
414
+ self._handlers[event_type] = [
415
+ h for h in self._handlers[event_type] if h.plugin is not plugin
416
+ ]
417
+ if not self._handlers[event_type]:
418
+ del self._handlers[event_type]
419
+
420
+ logger.info(f"Unregistered plugin: {name}")
421
+
422
+ def get_handlers(
423
+ self,
424
+ event_type: HookEventType,
425
+ pre_only: bool = False,
426
+ post_only: bool = False,
427
+ ) -> list[RegisteredHandler]:
428
+ """
429
+ Get handlers for an event type, optionally filtered by priority.
430
+
431
+ Args:
432
+ event_type: The event type to get handlers for.
433
+ pre_only: If True, only return handlers with priority < 50.
434
+ post_only: If True, only return handlers with priority >= 50.
435
+
436
+ Returns:
437
+ List of RegisteredHandler sorted by priority.
438
+ """
439
+ handlers = self._handlers.get(event_type, [])
440
+
441
+ if pre_only:
442
+ return [h for h in handlers if h.priority < 50]
443
+ if post_only:
444
+ return [h for h in handlers if h.priority >= 50]
445
+
446
+ return handlers
447
+
448
+ def get_plugin(self, name: str) -> HookPlugin | None:
449
+ """Get a plugin by name."""
450
+ return self._plugins.get(name)
451
+
452
+ def list_plugins(self) -> list[dict[str, Any]]:
453
+ """List all registered plugins with metadata."""
454
+ return [
455
+ {
456
+ "name": p.name,
457
+ "version": p.version,
458
+ "description": p.description,
459
+ "handlers": [
460
+ {"event": h.event_type.value, "priority": h.priority}
461
+ for handlers in self._handlers.values()
462
+ for h in handlers
463
+ if h.plugin is p
464
+ ],
465
+ "actions": [
466
+ {
467
+ "name": action.name,
468
+ "has_schema": bool(action.schema),
469
+ "schema": action.schema if action.schema else None,
470
+ }
471
+ for action in p._actions.values()
472
+ ],
473
+ "conditions": list(p._conditions.keys()),
474
+ }
475
+ for p in self._plugins.values()
476
+ ]
477
+
478
+ def get_plugin_action(self, plugin_name: str, action_name: str) -> PluginAction | None:
479
+ """Get a specific action from a plugin.
480
+
481
+ Args:
482
+ plugin_name: Name of the plugin.
483
+ action_name: Name of the action.
484
+
485
+ Returns:
486
+ PluginAction if found, None otherwise.
487
+ """
488
+ plugin = self._plugins.get(plugin_name)
489
+ if plugin is None:
490
+ return None
491
+ return plugin.get_action(action_name)
492
+
493
+
494
+ # =============================================================================
495
+ # Plugin Loader
496
+ # =============================================================================
497
+
498
+
499
+ class PluginLoader:
500
+ """
501
+ Discovers and loads plugins from configured directories.
502
+
503
+ Handles plugin discovery, import, instantiation, and lifecycle management.
504
+ """
505
+
506
+ def __init__(self, config: PluginsConfig) -> None:
507
+ """
508
+ Initialize the plugin loader.
509
+
510
+ Args:
511
+ config: Plugin system configuration.
512
+ """
513
+ self.config = config
514
+ self.registry = PluginRegistry()
515
+ self._loaded_modules: dict[str, Any] = {}
516
+ self._plugin_sources: dict[str, Path] = {} # Maps plugin name -> source file path
517
+
518
+ def discover_plugins(self, dirs: list[str] | None = None) -> list[type[HookPlugin]]:
519
+ """
520
+ Discover plugin classes from configured directories.
521
+
522
+ Args:
523
+ dirs: Optional list of directories to scan. Uses config.plugin_dirs if None.
524
+
525
+ Returns:
526
+ List of discovered HookPlugin subclasses.
527
+ """
528
+ search_dirs = dirs or self.config.plugin_dirs
529
+ discovered: list[type[HookPlugin]] = []
530
+
531
+ for dir_path in search_dirs:
532
+ # Expand ~ and resolve path
533
+ expanded = Path(dir_path).expanduser().resolve()
534
+
535
+ if not expanded.exists():
536
+ logger.debug(f"Plugin directory does not exist: {expanded}")
537
+ continue
538
+
539
+ if not expanded.is_dir():
540
+ logger.warning(f"Plugin path is not a directory: {expanded}")
541
+ continue
542
+
543
+ # Scan for Python files
544
+ for py_file in expanded.glob("*.py"):
545
+ if py_file.name.startswith("_"):
546
+ continue # Skip __init__.py, __pycache__, etc.
547
+
548
+ try:
549
+ plugin_classes = self._load_module(py_file)
550
+ discovered.extend(plugin_classes)
551
+ except Exception as e:
552
+ logger.error(f"Failed to load plugin module {py_file}: {e}")
553
+
554
+ logger.info(f"Discovered {len(discovered)} plugin class(es)")
555
+ return discovered
556
+
557
+ def _load_module(self, path: Path) -> list[type[HookPlugin]]:
558
+ """
559
+ Load a Python module and find HookPlugin subclasses.
560
+
561
+ Args:
562
+ path: Path to the Python file.
563
+
564
+ Returns:
565
+ List of HookPlugin subclasses found in the module.
566
+ """
567
+ module_name = f"gobby_plugin_{path.stem}"
568
+
569
+ # Check if already loaded
570
+ if module_name in self._loaded_modules:
571
+ module = self._loaded_modules[module_name]
572
+ else:
573
+ # Load the module
574
+ spec = importlib.util.spec_from_file_location(module_name, path)
575
+ if spec is None or spec.loader is None:
576
+ raise ImportError(f"Cannot load module spec from {path}")
577
+
578
+ module = importlib.util.module_from_spec(spec)
579
+ sys.modules[module_name] = module
580
+ spec.loader.exec_module(module)
581
+ self._loaded_modules[module_name] = module
582
+
583
+ # Find HookPlugin subclasses
584
+ plugin_classes: list[type[HookPlugin]] = []
585
+ for _name, obj in inspect.getmembers(module, inspect.isclass):
586
+ if (
587
+ issubclass(obj, HookPlugin)
588
+ and obj is not HookPlugin
589
+ and hasattr(obj, "name")
590
+ and obj.name # Must have a non-empty name
591
+ ):
592
+ # Store source path on the class for reload tracking
593
+ obj._gobby_source_path = path # type: ignore[attr-defined]
594
+ plugin_classes.append(obj)
595
+
596
+ return plugin_classes
597
+
598
+ def load_plugin(
599
+ self,
600
+ plugin_class: type[HookPlugin],
601
+ config: dict[str, Any] | None = None,
602
+ ) -> HookPlugin:
603
+ """
604
+ Instantiate and load a plugin.
605
+
606
+ Args:
607
+ plugin_class: The plugin class to instantiate.
608
+ config: Optional configuration to pass to on_load().
609
+
610
+ Returns:
611
+ The loaded plugin instance.
612
+ """
613
+ # Get per-plugin config from PluginsConfig if available
614
+ plugin_config = config or {}
615
+ if plugin_class.name in self.config.plugins:
616
+ item_config = self.config.plugins[plugin_class.name]
617
+ if not item_config.enabled:
618
+ raise ValueError(f"Plugin is disabled in config: {plugin_class.name}")
619
+ plugin_config = item_config.config
620
+
621
+ # Instantiate
622
+ plugin = plugin_class()
623
+
624
+ # Call lifecycle hook
625
+ try:
626
+ plugin.on_load(plugin_config)
627
+ except Exception as e:
628
+ logger.error(f"Plugin on_load failed for {plugin.name}: {e}")
629
+ raise
630
+
631
+ # Register in registry
632
+ self.registry.register_plugin(plugin)
633
+
634
+ # Track source path for reload support
635
+ if hasattr(plugin_class, "_gobby_source_path"):
636
+ self._plugin_sources[plugin.name] = plugin_class._gobby_source_path
637
+
638
+ logger.info(f"Loaded plugin: {plugin.name} v{plugin.version}")
639
+ return plugin
640
+
641
+ def unload_plugin(self, name: str) -> None:
642
+ """
643
+ Unload a plugin.
644
+
645
+ Args:
646
+ name: The plugin name to unload.
647
+ """
648
+ plugin = self.registry.get_plugin(name)
649
+ if plugin is None:
650
+ logger.warning(f"Plugin not found: {name}")
651
+ return
652
+
653
+ # Call lifecycle hook
654
+ try:
655
+ plugin.on_unload()
656
+ except Exception as e:
657
+ logger.error(f"Plugin on_unload failed for {name}: {e}")
658
+ # Continue with unregistration even if on_unload fails
659
+
660
+ # Unregister
661
+ self.registry.unregister_plugin(name)
662
+
663
+ logger.info(f"Unloaded plugin: {name}")
664
+
665
+ def load_all(self) -> list[HookPlugin]:
666
+ """
667
+ Discover and load all plugins.
668
+
669
+ Returns:
670
+ List of successfully loaded plugins.
671
+ """
672
+ if not self.config.enabled:
673
+ logger.debug("Plugin system is disabled")
674
+ return []
675
+
676
+ loaded: list[HookPlugin] = []
677
+
678
+ if self.config.auto_discover:
679
+ plugin_classes = self.discover_plugins()
680
+
681
+ for plugin_class in plugin_classes:
682
+ # Check if explicitly disabled
683
+ if plugin_class.name in self.config.plugins:
684
+ if not self.config.plugins[plugin_class.name].enabled:
685
+ logger.debug(f"Skipping disabled plugin: {plugin_class.name}")
686
+ continue
687
+
688
+ try:
689
+ plugin = self.load_plugin(plugin_class)
690
+ loaded.append(plugin)
691
+ except Exception as e:
692
+ logger.error(f"Failed to load plugin {plugin_class.name}: {e}")
693
+ # Continue loading other plugins
694
+
695
+ return loaded
696
+
697
+ def unload_all(self) -> None:
698
+ """Unload all plugins."""
699
+ plugin_names = list(self.registry._plugins.keys())
700
+ for name in plugin_names:
701
+ try:
702
+ self.unload_plugin(name)
703
+ except Exception as e:
704
+ logger.error(f"Failed to unload plugin {name}: {e}")
705
+
706
+ def reload_plugin(self, name: str) -> HookPlugin | None:
707
+ """
708
+ Reload a plugin (unload then load).
709
+
710
+ Note: Plugin state is lost on reload.
711
+
712
+ Args:
713
+ name: The plugin name to reload.
714
+
715
+ Returns:
716
+ The reloaded plugin instance, or None if reload failed.
717
+ """
718
+ plugin = self.registry.get_plugin(name)
719
+ if plugin is None:
720
+ logger.warning(f"Plugin not found for reload: {name}")
721
+ return None
722
+
723
+ # Get source path before unloading (prefer tracked path over name-based key)
724
+ source_path = self._plugin_sources.get(name)
725
+
726
+ # Unload
727
+ self.unload_plugin(name)
728
+
729
+ # Compute module name from source path if available, else fall back to plugin name
730
+ if source_path is not None:
731
+ module_name = f"gobby_plugin_{source_path.stem}"
732
+ else:
733
+ module_name = f"gobby_plugin_{name}"
734
+
735
+ # Clear module cache to force reimport
736
+ if module_name in self._loaded_modules:
737
+ del self._loaded_modules[module_name]
738
+ if module_name in sys.modules:
739
+ del sys.modules[module_name]
740
+
741
+ # Clear source tracking (will be re-added on load)
742
+ if name in self._plugin_sources:
743
+ del self._plugin_sources[name]
744
+
745
+ # Reload from source file if available
746
+ if source_path is not None and source_path.exists():
747
+ try:
748
+ plugin_classes = self._load_module(source_path)
749
+ # Find the plugin class with matching name
750
+ for plugin_class in plugin_classes:
751
+ if plugin_class.name == name:
752
+ return self.load_plugin(plugin_class)
753
+ logger.error(f"Plugin class '{name}' not found in reloaded module")
754
+ return None
755
+ except Exception as e:
756
+ logger.error(f"Failed to reload plugin {name}: {e}")
757
+ return None
758
+ else:
759
+ logger.error(f"Cannot reload plugin {name}: source path not available")
760
+ return None
761
+
762
+
763
+ # =============================================================================
764
+ # Handler Execution
765
+ # =============================================================================
766
+
767
+
768
+ def run_plugin_handlers(
769
+ registry: PluginRegistry,
770
+ event: HookEvent,
771
+ pre: bool = True,
772
+ core_response: HookResponse | None = None,
773
+ ) -> HookResponse | None:
774
+ """
775
+ Execute plugin handlers for an event.
776
+
777
+ Args:
778
+ registry: The plugin registry.
779
+ event: The hook event to process.
780
+ pre: If True, run pre-handlers (priority < 50). If False, run post-handlers.
781
+ core_response: For post-handlers, the response from the core handler.
782
+
783
+ Returns:
784
+ For pre-handlers: HookResponse if any handler blocks, None otherwise.
785
+ For post-handlers: Always None (observe only).
786
+ """
787
+ handlers = registry.get_handlers(event.event_type, pre_only=pre, post_only=not pre)
788
+
789
+ for handler in handlers:
790
+ try:
791
+ if pre:
792
+ # Pre-handlers can return HookResponse to block
793
+ result = handler.method(event)
794
+ if result is not None and isinstance(result, HookResponse):
795
+ if result.decision in ("deny", "block"):
796
+ logger.info(f"Plugin {handler.plugin.name} blocked event: {result.reason}")
797
+ return HookResponse(
798
+ decision=result.decision,
799
+ reason=result.reason,
800
+ metadata=result.metadata,
801
+ )
802
+ else:
803
+ # Post-handlers receive the core response but can't block
804
+ handler.method(event, core_response)
805
+
806
+ except Exception as e:
807
+ # Fail-open: log error but continue processing
808
+ logger.error(
809
+ f"Plugin handler {handler.plugin.name}.{handler.method.__name__} failed: {e}",
810
+ exc_info=True,
811
+ )
812
+
813
+ return None