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.
- gobby/__init__.py +3 -0
- gobby/adapters/__init__.py +30 -0
- gobby/adapters/base.py +93 -0
- gobby/adapters/claude_code.py +276 -0
- gobby/adapters/codex.py +1292 -0
- gobby/adapters/gemini.py +343 -0
- gobby/agents/__init__.py +37 -0
- gobby/agents/codex_session.py +120 -0
- gobby/agents/constants.py +112 -0
- gobby/agents/context.py +362 -0
- gobby/agents/definitions.py +133 -0
- gobby/agents/gemini_session.py +111 -0
- gobby/agents/registry.py +618 -0
- gobby/agents/runner.py +968 -0
- gobby/agents/session.py +259 -0
- gobby/agents/spawn.py +916 -0
- gobby/agents/spawners/__init__.py +77 -0
- gobby/agents/spawners/base.py +142 -0
- gobby/agents/spawners/cross_platform.py +266 -0
- gobby/agents/spawners/embedded.py +225 -0
- gobby/agents/spawners/headless.py +226 -0
- gobby/agents/spawners/linux.py +125 -0
- gobby/agents/spawners/macos.py +277 -0
- gobby/agents/spawners/windows.py +308 -0
- gobby/agents/tty_config.py +319 -0
- gobby/autonomous/__init__.py +32 -0
- gobby/autonomous/progress_tracker.py +447 -0
- gobby/autonomous/stop_registry.py +269 -0
- gobby/autonomous/stuck_detector.py +383 -0
- gobby/cli/__init__.py +67 -0
- gobby/cli/__main__.py +8 -0
- gobby/cli/agents.py +529 -0
- gobby/cli/artifacts.py +266 -0
- gobby/cli/daemon.py +329 -0
- gobby/cli/extensions.py +526 -0
- gobby/cli/github.py +263 -0
- gobby/cli/init.py +53 -0
- gobby/cli/install.py +614 -0
- gobby/cli/installers/__init__.py +37 -0
- gobby/cli/installers/antigravity.py +65 -0
- gobby/cli/installers/claude.py +363 -0
- gobby/cli/installers/codex.py +192 -0
- gobby/cli/installers/gemini.py +294 -0
- gobby/cli/installers/git_hooks.py +377 -0
- gobby/cli/installers/shared.py +737 -0
- gobby/cli/linear.py +250 -0
- gobby/cli/mcp.py +30 -0
- gobby/cli/mcp_proxy.py +698 -0
- gobby/cli/memory.py +304 -0
- gobby/cli/merge.py +384 -0
- gobby/cli/projects.py +79 -0
- gobby/cli/sessions.py +622 -0
- gobby/cli/tasks/__init__.py +30 -0
- gobby/cli/tasks/_utils.py +658 -0
- gobby/cli/tasks/ai.py +1025 -0
- gobby/cli/tasks/commits.py +169 -0
- gobby/cli/tasks/crud.py +685 -0
- gobby/cli/tasks/deps.py +135 -0
- gobby/cli/tasks/labels.py +63 -0
- gobby/cli/tasks/main.py +273 -0
- gobby/cli/tasks/search.py +178 -0
- gobby/cli/tui.py +34 -0
- gobby/cli/utils.py +513 -0
- gobby/cli/workflows.py +927 -0
- gobby/cli/worktrees.py +481 -0
- gobby/config/__init__.py +129 -0
- gobby/config/app.py +551 -0
- gobby/config/extensions.py +167 -0
- gobby/config/features.py +472 -0
- gobby/config/llm_providers.py +98 -0
- gobby/config/logging.py +66 -0
- gobby/config/mcp.py +346 -0
- gobby/config/persistence.py +247 -0
- gobby/config/servers.py +141 -0
- gobby/config/sessions.py +250 -0
- gobby/config/tasks.py +784 -0
- gobby/hooks/__init__.py +104 -0
- gobby/hooks/artifact_capture.py +213 -0
- gobby/hooks/broadcaster.py +243 -0
- gobby/hooks/event_handlers.py +723 -0
- gobby/hooks/events.py +218 -0
- gobby/hooks/git.py +169 -0
- gobby/hooks/health_monitor.py +171 -0
- gobby/hooks/hook_manager.py +856 -0
- gobby/hooks/hook_types.py +575 -0
- gobby/hooks/plugins.py +813 -0
- gobby/hooks/session_coordinator.py +396 -0
- gobby/hooks/verification_runner.py +268 -0
- gobby/hooks/webhooks.py +339 -0
- gobby/install/claude/commands/gobby/bug.md +51 -0
- gobby/install/claude/commands/gobby/chore.md +51 -0
- gobby/install/claude/commands/gobby/epic.md +52 -0
- gobby/install/claude/commands/gobby/eval.md +235 -0
- gobby/install/claude/commands/gobby/feat.md +49 -0
- gobby/install/claude/commands/gobby/nit.md +52 -0
- gobby/install/claude/commands/gobby/ref.md +52 -0
- gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
- gobby/install/claude/hooks/hook_dispatcher.py +364 -0
- gobby/install/claude/hooks/validate_settings.py +102 -0
- gobby/install/claude/hooks-template.json +118 -0
- gobby/install/codex/hooks/hook_dispatcher.py +153 -0
- gobby/install/codex/prompts/forget.md +7 -0
- gobby/install/codex/prompts/memories.md +7 -0
- gobby/install/codex/prompts/recall.md +7 -0
- gobby/install/codex/prompts/remember.md +13 -0
- gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
- gobby/install/gemini/hooks-template.json +138 -0
- gobby/install/shared/plugins/code_guardian.py +456 -0
- gobby/install/shared/plugins/example_notify.py +331 -0
- gobby/integrations/__init__.py +10 -0
- gobby/integrations/github.py +145 -0
- gobby/integrations/linear.py +145 -0
- gobby/llm/__init__.py +40 -0
- gobby/llm/base.py +120 -0
- gobby/llm/claude.py +578 -0
- gobby/llm/claude_executor.py +503 -0
- gobby/llm/codex.py +322 -0
- gobby/llm/codex_executor.py +513 -0
- gobby/llm/executor.py +316 -0
- gobby/llm/factory.py +34 -0
- gobby/llm/gemini.py +258 -0
- gobby/llm/gemini_executor.py +339 -0
- gobby/llm/litellm.py +287 -0
- gobby/llm/litellm_executor.py +303 -0
- gobby/llm/resolver.py +499 -0
- gobby/llm/service.py +236 -0
- gobby/mcp_proxy/__init__.py +29 -0
- gobby/mcp_proxy/actions.py +175 -0
- gobby/mcp_proxy/daemon_control.py +198 -0
- gobby/mcp_proxy/importer.py +436 -0
- gobby/mcp_proxy/lazy.py +325 -0
- gobby/mcp_proxy/manager.py +798 -0
- gobby/mcp_proxy/metrics.py +609 -0
- gobby/mcp_proxy/models.py +139 -0
- gobby/mcp_proxy/registries.py +215 -0
- gobby/mcp_proxy/schema_hash.py +381 -0
- gobby/mcp_proxy/semantic_search.py +706 -0
- gobby/mcp_proxy/server.py +549 -0
- gobby/mcp_proxy/services/__init__.py +0 -0
- gobby/mcp_proxy/services/fallback.py +306 -0
- gobby/mcp_proxy/services/recommendation.py +224 -0
- gobby/mcp_proxy/services/server_mgmt.py +214 -0
- gobby/mcp_proxy/services/system.py +72 -0
- gobby/mcp_proxy/services/tool_filter.py +231 -0
- gobby/mcp_proxy/services/tool_proxy.py +309 -0
- gobby/mcp_proxy/stdio.py +565 -0
- gobby/mcp_proxy/tools/__init__.py +27 -0
- gobby/mcp_proxy/tools/agents.py +1103 -0
- gobby/mcp_proxy/tools/artifacts.py +207 -0
- gobby/mcp_proxy/tools/hub.py +335 -0
- gobby/mcp_proxy/tools/internal.py +337 -0
- gobby/mcp_proxy/tools/memory.py +543 -0
- gobby/mcp_proxy/tools/merge.py +422 -0
- gobby/mcp_proxy/tools/metrics.py +283 -0
- gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
- gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
- gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
- gobby/mcp_proxy/tools/orchestration/review.py +736 -0
- gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
- gobby/mcp_proxy/tools/session_messages.py +1056 -0
- gobby/mcp_proxy/tools/task_dependencies.py +219 -0
- gobby/mcp_proxy/tools/task_expansion.py +591 -0
- gobby/mcp_proxy/tools/task_github.py +393 -0
- gobby/mcp_proxy/tools/task_linear.py +379 -0
- gobby/mcp_proxy/tools/task_orchestration.py +77 -0
- gobby/mcp_proxy/tools/task_readiness.py +522 -0
- gobby/mcp_proxy/tools/task_sync.py +351 -0
- gobby/mcp_proxy/tools/task_validation.py +843 -0
- gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
- gobby/mcp_proxy/tools/tasks/_context.py +112 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
- gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
- gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
- gobby/mcp_proxy/tools/tasks/_search.py +215 -0
- gobby/mcp_proxy/tools/tasks/_session.py +125 -0
- gobby/mcp_proxy/tools/workflows.py +973 -0
- gobby/mcp_proxy/tools/worktrees.py +1264 -0
- gobby/mcp_proxy/transports/__init__.py +0 -0
- gobby/mcp_proxy/transports/base.py +95 -0
- gobby/mcp_proxy/transports/factory.py +44 -0
- gobby/mcp_proxy/transports/http.py +139 -0
- gobby/mcp_proxy/transports/stdio.py +213 -0
- gobby/mcp_proxy/transports/websocket.py +136 -0
- gobby/memory/backends/__init__.py +116 -0
- gobby/memory/backends/mem0.py +408 -0
- gobby/memory/backends/memu.py +485 -0
- gobby/memory/backends/null.py +111 -0
- gobby/memory/backends/openmemory.py +537 -0
- gobby/memory/backends/sqlite.py +304 -0
- gobby/memory/context.py +87 -0
- gobby/memory/manager.py +1001 -0
- gobby/memory/protocol.py +451 -0
- gobby/memory/search/__init__.py +66 -0
- gobby/memory/search/text.py +127 -0
- gobby/memory/viz.py +258 -0
- gobby/prompts/__init__.py +13 -0
- gobby/prompts/defaults/expansion/system.md +119 -0
- gobby/prompts/defaults/expansion/user.md +48 -0
- gobby/prompts/defaults/external_validation/agent.md +72 -0
- gobby/prompts/defaults/external_validation/external.md +63 -0
- gobby/prompts/defaults/external_validation/spawn.md +83 -0
- gobby/prompts/defaults/external_validation/system.md +6 -0
- gobby/prompts/defaults/features/import_mcp.md +22 -0
- gobby/prompts/defaults/features/import_mcp_github.md +17 -0
- gobby/prompts/defaults/features/import_mcp_search.md +16 -0
- gobby/prompts/defaults/features/recommend_tools.md +32 -0
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
- gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
- gobby/prompts/defaults/features/server_description.md +20 -0
- gobby/prompts/defaults/features/server_description_system.md +6 -0
- gobby/prompts/defaults/features/task_description.md +31 -0
- gobby/prompts/defaults/features/task_description_system.md +6 -0
- gobby/prompts/defaults/features/tool_summary.md +17 -0
- gobby/prompts/defaults/features/tool_summary_system.md +6 -0
- gobby/prompts/defaults/research/step.md +58 -0
- gobby/prompts/defaults/validation/criteria.md +47 -0
- gobby/prompts/defaults/validation/validate.md +38 -0
- gobby/prompts/loader.py +346 -0
- gobby/prompts/models.py +113 -0
- gobby/py.typed +0 -0
- gobby/runner.py +488 -0
- gobby/search/__init__.py +23 -0
- gobby/search/protocol.py +104 -0
- gobby/search/tfidf.py +232 -0
- gobby/servers/__init__.py +7 -0
- gobby/servers/http.py +636 -0
- gobby/servers/models.py +31 -0
- gobby/servers/routes/__init__.py +23 -0
- gobby/servers/routes/admin.py +416 -0
- gobby/servers/routes/dependencies.py +118 -0
- gobby/servers/routes/mcp/__init__.py +24 -0
- gobby/servers/routes/mcp/hooks.py +135 -0
- gobby/servers/routes/mcp/plugins.py +121 -0
- gobby/servers/routes/mcp/tools.py +1337 -0
- gobby/servers/routes/mcp/webhooks.py +159 -0
- gobby/servers/routes/sessions.py +582 -0
- gobby/servers/websocket.py +766 -0
- gobby/sessions/__init__.py +13 -0
- gobby/sessions/analyzer.py +322 -0
- gobby/sessions/lifecycle.py +240 -0
- gobby/sessions/manager.py +563 -0
- gobby/sessions/processor.py +225 -0
- gobby/sessions/summary.py +532 -0
- gobby/sessions/transcripts/__init__.py +41 -0
- gobby/sessions/transcripts/base.py +125 -0
- gobby/sessions/transcripts/claude.py +386 -0
- gobby/sessions/transcripts/codex.py +143 -0
- gobby/sessions/transcripts/gemini.py +195 -0
- gobby/storage/__init__.py +21 -0
- gobby/storage/agents.py +409 -0
- gobby/storage/artifact_classifier.py +341 -0
- gobby/storage/artifacts.py +285 -0
- gobby/storage/compaction.py +67 -0
- gobby/storage/database.py +357 -0
- gobby/storage/inter_session_messages.py +194 -0
- gobby/storage/mcp.py +680 -0
- gobby/storage/memories.py +562 -0
- gobby/storage/merge_resolutions.py +550 -0
- gobby/storage/migrations.py +860 -0
- gobby/storage/migrations_legacy.py +1359 -0
- gobby/storage/projects.py +166 -0
- gobby/storage/session_messages.py +251 -0
- gobby/storage/session_tasks.py +97 -0
- gobby/storage/sessions.py +817 -0
- gobby/storage/task_dependencies.py +223 -0
- gobby/storage/tasks/__init__.py +42 -0
- gobby/storage/tasks/_aggregates.py +180 -0
- gobby/storage/tasks/_crud.py +449 -0
- gobby/storage/tasks/_id.py +104 -0
- gobby/storage/tasks/_lifecycle.py +311 -0
- gobby/storage/tasks/_manager.py +889 -0
- gobby/storage/tasks/_models.py +300 -0
- gobby/storage/tasks/_ordering.py +119 -0
- gobby/storage/tasks/_path_cache.py +110 -0
- gobby/storage/tasks/_queries.py +343 -0
- gobby/storage/tasks/_search.py +143 -0
- gobby/storage/workflow_audit.py +393 -0
- gobby/storage/worktrees.py +547 -0
- gobby/sync/__init__.py +29 -0
- gobby/sync/github.py +333 -0
- gobby/sync/linear.py +304 -0
- gobby/sync/memories.py +284 -0
- gobby/sync/tasks.py +641 -0
- gobby/tasks/__init__.py +8 -0
- gobby/tasks/build_verification.py +193 -0
- gobby/tasks/commits.py +633 -0
- gobby/tasks/context.py +747 -0
- gobby/tasks/criteria.py +342 -0
- gobby/tasks/enhanced_validator.py +226 -0
- gobby/tasks/escalation.py +263 -0
- gobby/tasks/expansion.py +626 -0
- gobby/tasks/external_validator.py +764 -0
- gobby/tasks/issue_extraction.py +171 -0
- gobby/tasks/prompts/expand.py +327 -0
- gobby/tasks/research.py +421 -0
- gobby/tasks/tdd.py +352 -0
- gobby/tasks/tree_builder.py +263 -0
- gobby/tasks/validation.py +712 -0
- gobby/tasks/validation_history.py +357 -0
- gobby/tasks/validation_models.py +89 -0
- gobby/tools/__init__.py +0 -0
- gobby/tools/summarizer.py +170 -0
- gobby/tui/__init__.py +5 -0
- gobby/tui/api_client.py +281 -0
- gobby/tui/app.py +327 -0
- gobby/tui/screens/__init__.py +25 -0
- gobby/tui/screens/agents.py +333 -0
- gobby/tui/screens/chat.py +450 -0
- gobby/tui/screens/dashboard.py +377 -0
- gobby/tui/screens/memory.py +305 -0
- gobby/tui/screens/metrics.py +231 -0
- gobby/tui/screens/orchestrator.py +904 -0
- gobby/tui/screens/sessions.py +412 -0
- gobby/tui/screens/tasks.py +442 -0
- gobby/tui/screens/workflows.py +289 -0
- gobby/tui/screens/worktrees.py +174 -0
- gobby/tui/widgets/__init__.py +21 -0
- gobby/tui/widgets/chat.py +210 -0
- gobby/tui/widgets/conductor.py +104 -0
- gobby/tui/widgets/menu.py +132 -0
- gobby/tui/widgets/message_panel.py +160 -0
- gobby/tui/widgets/review_gate.py +224 -0
- gobby/tui/widgets/task_tree.py +99 -0
- gobby/tui/widgets/token_budget.py +166 -0
- gobby/tui/ws_client.py +258 -0
- gobby/utils/__init__.py +3 -0
- gobby/utils/daemon_client.py +235 -0
- gobby/utils/git.py +222 -0
- gobby/utils/id.py +38 -0
- gobby/utils/json_helpers.py +161 -0
- gobby/utils/logging.py +376 -0
- gobby/utils/machine_id.py +135 -0
- gobby/utils/metrics.py +589 -0
- gobby/utils/project_context.py +182 -0
- gobby/utils/project_init.py +263 -0
- gobby/utils/status.py +256 -0
- gobby/utils/validation.py +80 -0
- gobby/utils/version.py +23 -0
- gobby/workflows/__init__.py +4 -0
- gobby/workflows/actions.py +1310 -0
- gobby/workflows/approval_flow.py +138 -0
- gobby/workflows/artifact_actions.py +103 -0
- gobby/workflows/audit_helpers.py +110 -0
- gobby/workflows/autonomous_actions.py +286 -0
- gobby/workflows/context_actions.py +394 -0
- gobby/workflows/definitions.py +130 -0
- gobby/workflows/detection_helpers.py +208 -0
- gobby/workflows/engine.py +485 -0
- gobby/workflows/evaluator.py +669 -0
- gobby/workflows/git_utils.py +96 -0
- gobby/workflows/hooks.py +169 -0
- gobby/workflows/lifecycle_evaluator.py +613 -0
- gobby/workflows/llm_actions.py +70 -0
- gobby/workflows/loader.py +333 -0
- gobby/workflows/mcp_actions.py +60 -0
- gobby/workflows/memory_actions.py +272 -0
- gobby/workflows/premature_stop.py +164 -0
- gobby/workflows/session_actions.py +139 -0
- gobby/workflows/state_actions.py +123 -0
- gobby/workflows/state_manager.py +104 -0
- gobby/workflows/stop_signal_actions.py +163 -0
- gobby/workflows/summary_actions.py +344 -0
- gobby/workflows/task_actions.py +249 -0
- gobby/workflows/task_enforcement_actions.py +901 -0
- gobby/workflows/templates.py +52 -0
- gobby/workflows/todo_actions.py +84 -0
- gobby/workflows/webhook.py +223 -0
- gobby/workflows/webhook_executor.py +399 -0
- gobby/worktrees/__init__.py +5 -0
- gobby/worktrees/git.py +690 -0
- gobby/worktrees/merge/__init__.py +20 -0
- gobby/worktrees/merge/conflict_parser.py +177 -0
- gobby/worktrees/merge/resolver.py +485 -0
- gobby-0.2.5.dist-info/METADATA +351 -0
- gobby-0.2.5.dist-info/RECORD +383 -0
- gobby-0.2.5.dist-info/WHEEL +5 -0
- gobby-0.2.5.dist-info/entry_points.txt +2 -0
- gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
- gobby-0.2.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hook Manager - Clean Coordinator for Claude Code Hooks.
|
|
3
|
+
|
|
4
|
+
This is the refactored HookManager that serves purely as a coordinator,
|
|
5
|
+
delegating all work to focused subsystems. It replaces the 5,774-line
|
|
6
|
+
God Object with a ~300-line routing layer.
|
|
7
|
+
|
|
8
|
+
Architecture:
|
|
9
|
+
HookManager creates and coordinates subsystems:
|
|
10
|
+
- Session-agnostic: DaemonClient, TranscriptProcessor
|
|
11
|
+
- Session-scoped: SessionManager
|
|
12
|
+
- Workflow-driven: WorkflowEngine handles session handoff via generate_handoff action
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
```python
|
|
16
|
+
from gobby.hooks.hook_manager import HookManager
|
|
17
|
+
|
|
18
|
+
manager = HookManager(
|
|
19
|
+
daemon_host="localhost",
|
|
20
|
+
daemon_port=8765
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
result = manager.execute(
|
|
24
|
+
hook_type="session-start",
|
|
25
|
+
input_data={"external_id": "abc123", ...}
|
|
26
|
+
)
|
|
27
|
+
```
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import asyncio
|
|
31
|
+
import logging
|
|
32
|
+
import time
|
|
33
|
+
from logging.handlers import RotatingFileHandler
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
36
|
+
|
|
37
|
+
from gobby.autonomous.progress_tracker import ProgressTracker
|
|
38
|
+
from gobby.autonomous.stop_registry import StopRegistry
|
|
39
|
+
from gobby.autonomous.stuck_detector import StuckDetector
|
|
40
|
+
from gobby.hooks.event_handlers import EventHandlers
|
|
41
|
+
from gobby.hooks.events import HookEvent, HookEventType, HookResponse
|
|
42
|
+
from gobby.hooks.health_monitor import HealthMonitor
|
|
43
|
+
from gobby.hooks.plugins import PluginLoader, run_plugin_handlers
|
|
44
|
+
from gobby.hooks.session_coordinator import SessionCoordinator
|
|
45
|
+
from gobby.hooks.webhooks import WebhookDispatcher
|
|
46
|
+
from gobby.memory.manager import MemoryManager
|
|
47
|
+
from gobby.sessions.manager import SessionManager
|
|
48
|
+
from gobby.sessions.summary import SummaryFileGenerator
|
|
49
|
+
from gobby.sessions.transcripts.claude import ClaudeTranscriptParser
|
|
50
|
+
from gobby.storage.agents import LocalAgentRunManager
|
|
51
|
+
from gobby.storage.database import LocalDatabase
|
|
52
|
+
from gobby.storage.memories import LocalMemoryManager
|
|
53
|
+
from gobby.storage.session_messages import LocalSessionMessageManager
|
|
54
|
+
from gobby.storage.session_tasks import SessionTaskManager
|
|
55
|
+
from gobby.storage.sessions import LocalSessionManager
|
|
56
|
+
from gobby.storage.tasks import LocalTaskManager
|
|
57
|
+
from gobby.storage.worktrees import LocalWorktreeManager
|
|
58
|
+
from gobby.utils.daemon_client import DaemonClient
|
|
59
|
+
from gobby.workflows.hooks import WorkflowHookHandler
|
|
60
|
+
from gobby.workflows.loader import WorkflowLoader
|
|
61
|
+
from gobby.workflows.state_manager import WorkflowStateManager
|
|
62
|
+
|
|
63
|
+
if TYPE_CHECKING:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
# Backward-compatible alias
|
|
67
|
+
TranscriptProcessor = ClaudeTranscriptParser
|
|
68
|
+
|
|
69
|
+
if TYPE_CHECKING:
|
|
70
|
+
from gobby.llm.service import LLMService
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class HookManager:
|
|
74
|
+
"""
|
|
75
|
+
Session-scoped coordinator for Claude Code hooks.
|
|
76
|
+
|
|
77
|
+
Delegates all work to subsystems:
|
|
78
|
+
- DaemonClient: HTTP communication with Gobby daemon
|
|
79
|
+
- TranscriptProcessor: JSONL parsing and analysis
|
|
80
|
+
- WorkflowEngine: Handles session handoff and LLM-powered summaries
|
|
81
|
+
|
|
82
|
+
Attributes:
|
|
83
|
+
daemon_host: Host for daemon communication
|
|
84
|
+
daemon_port: Port for daemon communication
|
|
85
|
+
log_file: Full path to log file
|
|
86
|
+
logger: Configured logger instance
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
daemon_host: str = "localhost",
|
|
92
|
+
daemon_port: int = 8765,
|
|
93
|
+
llm_service: "LLMService | None" = None,
|
|
94
|
+
config: Any | None = None,
|
|
95
|
+
log_file: str | None = None,
|
|
96
|
+
log_max_bytes: int = 10 * 1024 * 1024, # 10MB
|
|
97
|
+
log_backup_count: int = 5,
|
|
98
|
+
broadcaster: Any | None = None,
|
|
99
|
+
mcp_manager: Any | None = None,
|
|
100
|
+
message_processor: Any | None = None,
|
|
101
|
+
memory_sync_manager: Any | None = None,
|
|
102
|
+
task_sync_manager: Any | None = None,
|
|
103
|
+
):
|
|
104
|
+
"""
|
|
105
|
+
Initialize HookManager with subsystems.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
daemon_host: Daemon host for communication
|
|
109
|
+
daemon_port: Daemon port for communication
|
|
110
|
+
llm_service: Optional LLMService for multi-provider support
|
|
111
|
+
config: Optional DaemonConfig instance for feature configuration
|
|
112
|
+
log_file: Full path to log file (default: ~/.gobby/logs/hook-manager.log)
|
|
113
|
+
log_max_bytes: Max log file size before rotation
|
|
114
|
+
log_backup_count: Number of backup log files
|
|
115
|
+
broadcaster: Optional HookEventBroadcaster instance
|
|
116
|
+
mcp_manager: Optional MCPClientManager instance
|
|
117
|
+
message_processor: SessionMessageProcessor instance
|
|
118
|
+
memory_sync_manager: Optional MemorySyncManager instance
|
|
119
|
+
task_sync_manager: Optional TaskSyncManager instance
|
|
120
|
+
"""
|
|
121
|
+
self.daemon_host = daemon_host
|
|
122
|
+
self.daemon_port = daemon_port
|
|
123
|
+
self.daemon_url = f"http://{daemon_host}:{daemon_port}"
|
|
124
|
+
self.log_file = log_file or str(Path.home() / ".gobby" / "logs" / "hook-manager.log")
|
|
125
|
+
self.log_max_bytes = log_max_bytes
|
|
126
|
+
self.log_backup_count = log_backup_count
|
|
127
|
+
self.broadcaster = broadcaster
|
|
128
|
+
self.mcp_manager = mcp_manager
|
|
129
|
+
self._message_processor = message_processor
|
|
130
|
+
self.memory_sync_manager = memory_sync_manager
|
|
131
|
+
self.task_sync_manager = task_sync_manager
|
|
132
|
+
|
|
133
|
+
# Capture event loop for thread-safe broadcasting (if running in async context)
|
|
134
|
+
self._loop: asyncio.AbstractEventLoop | None
|
|
135
|
+
try:
|
|
136
|
+
self._loop = asyncio.get_running_loop()
|
|
137
|
+
except RuntimeError:
|
|
138
|
+
self._loop = None
|
|
139
|
+
|
|
140
|
+
# Setup logging first
|
|
141
|
+
self.logger = self._setup_logging()
|
|
142
|
+
|
|
143
|
+
# Store LLM service
|
|
144
|
+
self._llm_service = llm_service
|
|
145
|
+
|
|
146
|
+
# Load configuration - prefer passed config over loading new one
|
|
147
|
+
self._config = config
|
|
148
|
+
if not self._config:
|
|
149
|
+
try:
|
|
150
|
+
from gobby.config.app import load_config
|
|
151
|
+
|
|
152
|
+
self._config = load_config()
|
|
153
|
+
except Exception as e:
|
|
154
|
+
self.logger.error(
|
|
155
|
+
f"Failed to load config in HookManager, using defaults: {e}",
|
|
156
|
+
exc_info=True,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Extract config values
|
|
160
|
+
if self._config:
|
|
161
|
+
health_check_interval = self._config.daemon_health_check_interval
|
|
162
|
+
|
|
163
|
+
else:
|
|
164
|
+
health_check_interval = 10.0
|
|
165
|
+
|
|
166
|
+
# Initialize Database - use config's database_path if available
|
|
167
|
+
if self._config and self._config.database_path:
|
|
168
|
+
db_path = Path(self._config.database_path).expanduser()
|
|
169
|
+
self._database = LocalDatabase(db_path)
|
|
170
|
+
else:
|
|
171
|
+
self._database = LocalDatabase()
|
|
172
|
+
|
|
173
|
+
# Create session-agnostic subsystems (shared across all sessions)
|
|
174
|
+
self._daemon_client = DaemonClient(
|
|
175
|
+
host=daemon_host,
|
|
176
|
+
port=daemon_port,
|
|
177
|
+
timeout=5.0,
|
|
178
|
+
logger=self.logger,
|
|
179
|
+
)
|
|
180
|
+
self._transcript_processor = TranscriptProcessor(logger_instance=self.logger)
|
|
181
|
+
|
|
182
|
+
# Create local storage for sessions
|
|
183
|
+
self._session_storage = LocalSessionManager(self._database)
|
|
184
|
+
self._session_task_manager = SessionTaskManager(self._database)
|
|
185
|
+
|
|
186
|
+
# Initialize Memory storage
|
|
187
|
+
self._memory_storage = LocalMemoryManager(self._database)
|
|
188
|
+
self._message_manager = LocalSessionMessageManager(self._database)
|
|
189
|
+
self._task_manager = LocalTaskManager(self._database)
|
|
190
|
+
|
|
191
|
+
# Initialize Agent Run and Worktree managers (for terminal mode result capture)
|
|
192
|
+
self._agent_run_manager = LocalAgentRunManager(self._database)
|
|
193
|
+
self._worktree_manager = LocalWorktreeManager(self._database)
|
|
194
|
+
|
|
195
|
+
# Initialize Artifact storage and capture hook
|
|
196
|
+
from gobby.hooks.artifact_capture import ArtifactCaptureHook
|
|
197
|
+
from gobby.storage.artifacts import LocalArtifactManager
|
|
198
|
+
|
|
199
|
+
self._artifact_manager = LocalArtifactManager(self._database)
|
|
200
|
+
self._artifact_capture_hook = ArtifactCaptureHook(artifact_manager=self._artifact_manager)
|
|
201
|
+
|
|
202
|
+
# Initialize autonomous execution components
|
|
203
|
+
self._stop_registry = StopRegistry(self._database)
|
|
204
|
+
self._progress_tracker = ProgressTracker(self._database)
|
|
205
|
+
self._stuck_detector = StuckDetector(
|
|
206
|
+
self._database, progress_tracker=self._progress_tracker
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Use config or defaults
|
|
210
|
+
memory_config = (
|
|
211
|
+
self._config.memory if self._config and hasattr(self._config, "memory") else None
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
if not memory_config:
|
|
215
|
+
from gobby.config.app import MemoryConfig
|
|
216
|
+
|
|
217
|
+
memory_config = MemoryConfig()
|
|
218
|
+
|
|
219
|
+
self._memory_manager = MemoryManager(self._database, memory_config)
|
|
220
|
+
|
|
221
|
+
# Initialize Workflow Engine (Phase 0-2 + 3 Integration)
|
|
222
|
+
# Import WorkflowEngine here to avoid circular import (hooks -> hook_manager -> engine -> hooks)
|
|
223
|
+
from gobby.workflows.actions import ActionExecutor
|
|
224
|
+
from gobby.workflows.engine import WorkflowEngine
|
|
225
|
+
from gobby.workflows.templates import TemplateEngine
|
|
226
|
+
|
|
227
|
+
# Workflow loader handles project-specific paths dynamically via project_path parameter
|
|
228
|
+
# Global workflows are loaded from ~/.gobby/workflows/
|
|
229
|
+
# Project-specific workflows are loaded from {project_path}/.gobby/workflows/
|
|
230
|
+
# Workflows are installed via `gobby install` from install/shared/workflows/
|
|
231
|
+
self._workflow_loader = WorkflowLoader(workflow_dirs=[Path.home() / ".gobby" / "workflows"])
|
|
232
|
+
self._workflow_state_manager = WorkflowStateManager(self._database)
|
|
233
|
+
|
|
234
|
+
# Initialize Template Engine
|
|
235
|
+
# We can pass template directory from package templates or user templates
|
|
236
|
+
# For now, let's include the built-in templates dir if we can find it
|
|
237
|
+
# Assuming templates are in package 'gobby.templates.workflows'?
|
|
238
|
+
# Or just use the one we are about to create in project root?
|
|
239
|
+
# Ideally, we should look for templates in typical locations.
|
|
240
|
+
# But 'TemplateEngine' constructor takes optional dirs.
|
|
241
|
+
self._template_engine = TemplateEngine()
|
|
242
|
+
|
|
243
|
+
# Get websocket_server from broadcaster if available
|
|
244
|
+
websocket_server = None
|
|
245
|
+
if self.broadcaster and hasattr(self.broadcaster, "websocket_server"):
|
|
246
|
+
websocket_server = self.broadcaster.websocket_server
|
|
247
|
+
|
|
248
|
+
self._action_executor = ActionExecutor(
|
|
249
|
+
db=self._database,
|
|
250
|
+
session_manager=self._session_storage,
|
|
251
|
+
template_engine=self._template_engine,
|
|
252
|
+
llm_service=self._llm_service,
|
|
253
|
+
transcript_processor=self._transcript_processor,
|
|
254
|
+
config=self._config,
|
|
255
|
+
mcp_manager=self.mcp_manager,
|
|
256
|
+
memory_manager=self._memory_manager,
|
|
257
|
+
memory_sync_manager=self.memory_sync_manager,
|
|
258
|
+
task_manager=self._task_manager,
|
|
259
|
+
task_sync_manager=self.task_sync_manager,
|
|
260
|
+
session_task_manager=self._session_task_manager,
|
|
261
|
+
stop_registry=self._stop_registry,
|
|
262
|
+
progress_tracker=self._progress_tracker,
|
|
263
|
+
stuck_detector=self._stuck_detector,
|
|
264
|
+
websocket_server=websocket_server,
|
|
265
|
+
)
|
|
266
|
+
self._workflow_engine = WorkflowEngine(
|
|
267
|
+
loader=self._workflow_loader,
|
|
268
|
+
state_manager=self._workflow_state_manager,
|
|
269
|
+
action_executor=self._action_executor,
|
|
270
|
+
)
|
|
271
|
+
# Register task_manager with evaluator for task_tree_complete() condition helper
|
|
272
|
+
if self._task_manager and self._workflow_engine.evaluator:
|
|
273
|
+
self._workflow_engine.evaluator.register_task_manager(self._task_manager)
|
|
274
|
+
# Register stop_registry with evaluator for has_stop_signal() condition helper
|
|
275
|
+
if self._stop_registry and self._workflow_engine.evaluator:
|
|
276
|
+
self._workflow_engine.evaluator.register_stop_registry(self._stop_registry)
|
|
277
|
+
workflow_timeout: float = 0.0 # 0 = no timeout
|
|
278
|
+
workflow_enabled = True
|
|
279
|
+
if self._config:
|
|
280
|
+
workflow_timeout = self._config.workflow.timeout
|
|
281
|
+
workflow_enabled = self._config.workflow.enabled
|
|
282
|
+
|
|
283
|
+
self._workflow_handler = WorkflowHookHandler(
|
|
284
|
+
engine=self._workflow_engine,
|
|
285
|
+
loop=self._loop,
|
|
286
|
+
timeout=workflow_timeout,
|
|
287
|
+
enabled=workflow_enabled,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# Initialize Failover Summary Generator
|
|
291
|
+
self._summary_file_generator = SummaryFileGenerator(
|
|
292
|
+
transcript_processor=self._transcript_processor,
|
|
293
|
+
logger_instance=self.logger,
|
|
294
|
+
llm_service=self._llm_service,
|
|
295
|
+
config=self._config,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
# Initialize Webhook Dispatcher (Sprint 8: Webhooks)
|
|
299
|
+
webhooks_config = None
|
|
300
|
+
if self._config and hasattr(self._config, "hook_extensions"):
|
|
301
|
+
webhooks_config = self._config.hook_extensions.webhooks
|
|
302
|
+
if not webhooks_config:
|
|
303
|
+
from gobby.config.app import WebhooksConfig
|
|
304
|
+
|
|
305
|
+
webhooks_config = WebhooksConfig()
|
|
306
|
+
self._webhook_dispatcher = WebhookDispatcher(webhooks_config)
|
|
307
|
+
|
|
308
|
+
# Initialize Plugin Loader (Sprint 9: Python Plugins)
|
|
309
|
+
self._plugin_loader: PluginLoader | None = None
|
|
310
|
+
plugins_config = None
|
|
311
|
+
if self._config and hasattr(self._config, "hook_extensions"):
|
|
312
|
+
plugins_config = self._config.hook_extensions.plugins
|
|
313
|
+
if plugins_config is not None and plugins_config.enabled:
|
|
314
|
+
self._plugin_loader = PluginLoader(plugins_config)
|
|
315
|
+
try:
|
|
316
|
+
loaded = self._plugin_loader.load_all()
|
|
317
|
+
if loaded:
|
|
318
|
+
self.logger.info(
|
|
319
|
+
f"Loaded {len(loaded)} plugin(s): {', '.join(p.name for p in loaded)}"
|
|
320
|
+
)
|
|
321
|
+
# Register plugin actions and conditions with workflow system
|
|
322
|
+
self._action_executor.register_plugin_actions(self._plugin_loader.registry)
|
|
323
|
+
self._workflow_engine.evaluator.register_plugin_conditions(
|
|
324
|
+
self._plugin_loader.registry
|
|
325
|
+
)
|
|
326
|
+
except Exception as e:
|
|
327
|
+
self.logger.error(f"Failed to load plugins: {e}", exc_info=True)
|
|
328
|
+
|
|
329
|
+
# Session manager handles registration, lookup, and status updates
|
|
330
|
+
# Note: source is passed explicitly per call (Phase 2C+), not stored in manager
|
|
331
|
+
self._session_manager = SessionManager(
|
|
332
|
+
session_storage=self._session_storage,
|
|
333
|
+
logger_instance=self.logger,
|
|
334
|
+
config=self._config,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# Session coordination (delegated to SessionCoordinator)
|
|
338
|
+
self._session_coordinator = SessionCoordinator(
|
|
339
|
+
session_storage=self._session_storage,
|
|
340
|
+
message_processor=self._message_processor,
|
|
341
|
+
agent_run_manager=self._agent_run_manager,
|
|
342
|
+
worktree_manager=self._worktree_manager,
|
|
343
|
+
logger=self.logger,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# Daemon health check monitoring (delegated to HealthMonitor)
|
|
347
|
+
self._health_monitor = HealthMonitor(
|
|
348
|
+
daemon_client=self._daemon_client,
|
|
349
|
+
health_check_interval=health_check_interval,
|
|
350
|
+
logger=self.logger,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
# Event handlers (delegated to EventHandlers module)
|
|
354
|
+
self._event_handlers = EventHandlers(
|
|
355
|
+
session_manager=self._session_manager,
|
|
356
|
+
workflow_handler=self._workflow_handler,
|
|
357
|
+
session_storage=self._session_storage,
|
|
358
|
+
session_task_manager=self._session_task_manager,
|
|
359
|
+
message_processor=self._message_processor,
|
|
360
|
+
summary_file_generator=self._summary_file_generator,
|
|
361
|
+
task_manager=self._task_manager,
|
|
362
|
+
session_coordinator=self._session_coordinator,
|
|
363
|
+
message_manager=self._message_manager,
|
|
364
|
+
get_machine_id=self.get_machine_id,
|
|
365
|
+
resolve_project_id=self._resolve_project_id,
|
|
366
|
+
logger=self.logger,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
# Start background health check monitoring
|
|
370
|
+
self._start_health_check_monitoring()
|
|
371
|
+
|
|
372
|
+
# Re-register active sessions with message processor (after daemon restart)
|
|
373
|
+
self._reregister_active_sessions()
|
|
374
|
+
|
|
375
|
+
self.logger.debug("HookManager initialized")
|
|
376
|
+
|
|
377
|
+
def _setup_logging(self) -> logging.Logger:
|
|
378
|
+
"""
|
|
379
|
+
Setup structured logging with rotation.
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
Configured logger instance
|
|
383
|
+
"""
|
|
384
|
+
# Create logger
|
|
385
|
+
logger = logging.getLogger("gobby.hooks")
|
|
386
|
+
logger.setLevel(logging.DEBUG)
|
|
387
|
+
|
|
388
|
+
# Avoid duplicate handlers if logger already configured
|
|
389
|
+
if logger.handlers:
|
|
390
|
+
return logger
|
|
391
|
+
|
|
392
|
+
# File handler with rotation - use full path from config
|
|
393
|
+
# Expand ~ to home directory before creating directories
|
|
394
|
+
log_file_path = Path(self.log_file).expanduser()
|
|
395
|
+
log_file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
396
|
+
file_handler = RotatingFileHandler(
|
|
397
|
+
log_file_path,
|
|
398
|
+
maxBytes=self.log_max_bytes,
|
|
399
|
+
backupCount=self.log_backup_count,
|
|
400
|
+
)
|
|
401
|
+
file_handler.setLevel(logging.DEBUG)
|
|
402
|
+
|
|
403
|
+
# Formatter with context
|
|
404
|
+
formatter = logging.Formatter(
|
|
405
|
+
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
|
406
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
407
|
+
)
|
|
408
|
+
file_handler.setFormatter(formatter)
|
|
409
|
+
|
|
410
|
+
logger.addHandler(file_handler)
|
|
411
|
+
|
|
412
|
+
return logger
|
|
413
|
+
|
|
414
|
+
def _reregister_active_sessions(self) -> None:
|
|
415
|
+
"""
|
|
416
|
+
Re-register active sessions with the message processor.
|
|
417
|
+
|
|
418
|
+
Called during HookManager initialization to restore message processing
|
|
419
|
+
for sessions that were active before a daemon restart.
|
|
420
|
+
"""
|
|
421
|
+
self._session_coordinator.reregister_active_sessions()
|
|
422
|
+
|
|
423
|
+
def _start_health_check_monitoring(self) -> None:
|
|
424
|
+
"""Start background daemon health check monitoring."""
|
|
425
|
+
self._health_monitor.start()
|
|
426
|
+
|
|
427
|
+
def _get_cached_daemon_status(self) -> tuple[bool, str | None, str, str | None]:
|
|
428
|
+
"""
|
|
429
|
+
Get cached daemon status without making HTTP call.
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
Tuple of (is_ready, message, status, error)
|
|
433
|
+
"""
|
|
434
|
+
return self._health_monitor.get_cached_status()
|
|
435
|
+
|
|
436
|
+
def handle(self, event: HookEvent) -> HookResponse:
|
|
437
|
+
"""
|
|
438
|
+
Handle unified HookEvent from any CLI source.
|
|
439
|
+
|
|
440
|
+
This is the main entry point for hook handling. Adapters translate
|
|
441
|
+
CLI-specific payloads to HookEvent and call this method.
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
event: Unified HookEvent with event_type, session_id, source, and data.
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
HookResponse with decision, context, and reason fields.
|
|
448
|
+
|
|
449
|
+
Raises:
|
|
450
|
+
ValueError: If event_type has no registered handler.
|
|
451
|
+
"""
|
|
452
|
+
# Check daemon status (cached)
|
|
453
|
+
is_ready, _, daemon_status, error_reason = self._get_cached_daemon_status()
|
|
454
|
+
|
|
455
|
+
# Critical hooks that should retry before giving up
|
|
456
|
+
# These hooks are essential for session context preservation
|
|
457
|
+
critical_hooks = {
|
|
458
|
+
HookEventType.SESSION_START,
|
|
459
|
+
HookEventType.SESSION_END,
|
|
460
|
+
HookEventType.PRE_COMPACT,
|
|
461
|
+
}
|
|
462
|
+
retry_delays = [0.5, 1.0, 2.0] # Exponential backoff
|
|
463
|
+
|
|
464
|
+
# Retry with fresh health checks for critical hooks
|
|
465
|
+
if not is_ready and event.event_type in critical_hooks:
|
|
466
|
+
for attempt, delay in enumerate(retry_delays, 1):
|
|
467
|
+
time.sleep(delay)
|
|
468
|
+
is_ready = self._health_monitor.check_now()
|
|
469
|
+
if is_ready:
|
|
470
|
+
self.logger.info(
|
|
471
|
+
f"Daemon recovered after {attempt} retry(ies) for {event.event_type}"
|
|
472
|
+
)
|
|
473
|
+
break
|
|
474
|
+
self.logger.debug(
|
|
475
|
+
f"Daemon still unavailable, retry {attempt}/{len(retry_delays)} "
|
|
476
|
+
f"for {event.event_type}"
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
if not is_ready:
|
|
480
|
+
self.logger.warning(
|
|
481
|
+
f"Daemon not available after retries, skipping hook execution: {event.event_type}. "
|
|
482
|
+
f"Status: {daemon_status}, Error: {error_reason}"
|
|
483
|
+
)
|
|
484
|
+
return HookResponse(
|
|
485
|
+
decision="allow", # Fail-open
|
|
486
|
+
reason=f"Daemon {daemon_status}: {error_reason or 'Unknown'}",
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
# Look up platform session_id from cli_key (event.session_id is the cli_key)
|
|
490
|
+
external_id = event.session_id
|
|
491
|
+
platform_session_id = None
|
|
492
|
+
|
|
493
|
+
if external_id:
|
|
494
|
+
# Check SessionManager's cache first (keyed by (external_id, source))
|
|
495
|
+
platform_session_id = self._session_manager.get_session_id(
|
|
496
|
+
external_id, event.source.value
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
# If not in mapping and not session-start, try to query database
|
|
500
|
+
if not platform_session_id and event.event_type != HookEventType.SESSION_START:
|
|
501
|
+
with self._session_coordinator.get_lookup_lock():
|
|
502
|
+
# Double check in case another thread finished lookup
|
|
503
|
+
platform_session_id = self._session_manager.get_session_id(
|
|
504
|
+
external_id, event.source.value
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
if not platform_session_id:
|
|
508
|
+
self.logger.debug(
|
|
509
|
+
f"Session not in mapping, querying database for external_id={external_id}"
|
|
510
|
+
)
|
|
511
|
+
# Resolve context for lookup
|
|
512
|
+
machine_id = event.machine_id or self.get_machine_id()
|
|
513
|
+
cwd = event.data.get("cwd")
|
|
514
|
+
project_id = self._resolve_project_id(event.data.get("project_id"), cwd)
|
|
515
|
+
|
|
516
|
+
# Lookup with full composite key
|
|
517
|
+
platform_session_id = self._session_manager.lookup_session_id(
|
|
518
|
+
external_id,
|
|
519
|
+
source=event.source.value,
|
|
520
|
+
machine_id=machine_id,
|
|
521
|
+
project_id=project_id,
|
|
522
|
+
)
|
|
523
|
+
if platform_session_id:
|
|
524
|
+
self.logger.debug(
|
|
525
|
+
f"Found session_id {platform_session_id} for external_id {external_id}"
|
|
526
|
+
)
|
|
527
|
+
else:
|
|
528
|
+
# Auto-register session if not found
|
|
529
|
+
self.logger.debug(
|
|
530
|
+
f"Session not found for external_id={external_id}, auto-registering"
|
|
531
|
+
)
|
|
532
|
+
platform_session_id = self._session_manager.register_session(
|
|
533
|
+
external_id=external_id,
|
|
534
|
+
machine_id=machine_id,
|
|
535
|
+
project_id=project_id,
|
|
536
|
+
parent_session_id=None,
|
|
537
|
+
jsonl_path=event.data.get("transcript_path"),
|
|
538
|
+
source=event.source.value,
|
|
539
|
+
project_path=cwd,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
# Resolve active task for this session if we have a platform session ID
|
|
543
|
+
if platform_session_id:
|
|
544
|
+
try:
|
|
545
|
+
# Get tasks linked with 'worked_on' action which implies active focus
|
|
546
|
+
session_tasks = self._session_task_manager.get_session_tasks(
|
|
547
|
+
platform_session_id
|
|
548
|
+
)
|
|
549
|
+
# Filter for active 'worked_on' tasks - taking the most recent one
|
|
550
|
+
active_tasks = [t for t in session_tasks if t.get("action") == "worked_on"]
|
|
551
|
+
if active_tasks:
|
|
552
|
+
# Use the most recent task - populate full task context
|
|
553
|
+
task = active_tasks[0]["task"]
|
|
554
|
+
event.task_id = task.id
|
|
555
|
+
event.metadata["_task_context"] = {
|
|
556
|
+
"id": task.id,
|
|
557
|
+
"title": task.title,
|
|
558
|
+
"status": task.status,
|
|
559
|
+
}
|
|
560
|
+
# Keep legacy field for backwards compatibility
|
|
561
|
+
event.metadata["_task_title"] = task.title
|
|
562
|
+
except Exception as e:
|
|
563
|
+
self.logger.warning(f"Failed to resolve active task: {e}")
|
|
564
|
+
|
|
565
|
+
# Store platform session_id in event metadata for handlers
|
|
566
|
+
event.metadata["_platform_session_id"] = platform_session_id
|
|
567
|
+
|
|
568
|
+
# Get handler for this event type
|
|
569
|
+
handler = self._get_event_handler(event.event_type)
|
|
570
|
+
if handler is None:
|
|
571
|
+
self.logger.warning(f"No handler for event type: {event.event_type}")
|
|
572
|
+
return HookResponse(decision="allow") # Fail-open for unknown events
|
|
573
|
+
|
|
574
|
+
# --- Workflow Engine Evaluation (Phase 3) ---
|
|
575
|
+
# Evalute workflow rules before executing specific handlers
|
|
576
|
+
workflow_context = None
|
|
577
|
+
try:
|
|
578
|
+
workflow_response = self._workflow_handler.handle(event)
|
|
579
|
+
|
|
580
|
+
# If workflow blocks or asks, return immediately
|
|
581
|
+
if workflow_response.decision != "allow":
|
|
582
|
+
self.logger.info(f"Workflow blocked/modified event: {workflow_response.decision}")
|
|
583
|
+
return workflow_response
|
|
584
|
+
|
|
585
|
+
# Capture context to merge later
|
|
586
|
+
if workflow_response.context:
|
|
587
|
+
workflow_context = workflow_response.context
|
|
588
|
+
|
|
589
|
+
except Exception as e:
|
|
590
|
+
self.logger.error(f"Workflow evaluation failed: {e}", exc_info=True)
|
|
591
|
+
# Fail-open for workflow errors
|
|
592
|
+
# --------------------------------------------
|
|
593
|
+
|
|
594
|
+
# --- Blocking Webhooks Evaluation (Sprint 8) ---
|
|
595
|
+
# Dispatch to blocking webhooks BEFORE handler execution
|
|
596
|
+
try:
|
|
597
|
+
webhook_results = self._dispatch_webhooks_sync(event, blocking_only=True)
|
|
598
|
+
decision, reason = self._webhook_dispatcher.get_blocking_decision(webhook_results)
|
|
599
|
+
if decision == "block":
|
|
600
|
+
self.logger.info(f"Webhook blocked event: {reason}")
|
|
601
|
+
return HookResponse(decision="block", reason=reason or "Blocked by webhook")
|
|
602
|
+
except Exception as e:
|
|
603
|
+
self.logger.error(f"Blocking webhook dispatch failed: {e}", exc_info=True)
|
|
604
|
+
# Fail-open for webhook errors
|
|
605
|
+
# -----------------------------------------------
|
|
606
|
+
|
|
607
|
+
# --- Plugin Pre-Handlers (Sprint 9: can block) ---
|
|
608
|
+
if self._plugin_loader:
|
|
609
|
+
try:
|
|
610
|
+
pre_response = run_plugin_handlers(self._plugin_loader.registry, event, pre=True)
|
|
611
|
+
if pre_response and pre_response.decision in ("deny", "block"):
|
|
612
|
+
self.logger.info(f"Plugin blocked event: {pre_response.reason}")
|
|
613
|
+
return pre_response
|
|
614
|
+
except Exception as e:
|
|
615
|
+
self.logger.error(f"Plugin pre-handler failed: {e}", exc_info=True)
|
|
616
|
+
# Fail-open for plugin errors
|
|
617
|
+
# -------------------------------------------------
|
|
618
|
+
|
|
619
|
+
# Execute handler
|
|
620
|
+
try:
|
|
621
|
+
response = handler(event)
|
|
622
|
+
|
|
623
|
+
# Merge workflow context if present
|
|
624
|
+
if workflow_context:
|
|
625
|
+
if response.context:
|
|
626
|
+
response.context = f"{response.context}\n\n{workflow_context}"
|
|
627
|
+
else:
|
|
628
|
+
response.context = workflow_context
|
|
629
|
+
|
|
630
|
+
# Broadcast event (fire-and-forget)
|
|
631
|
+
if self.broadcaster:
|
|
632
|
+
try:
|
|
633
|
+
# Case 1: Running in an event loop (e.g. from app-server client)
|
|
634
|
+
loop = asyncio.get_running_loop()
|
|
635
|
+
loop.create_task(self.broadcaster.broadcast_event(event, response))
|
|
636
|
+
except RuntimeError:
|
|
637
|
+
# Case 2: Running in a thread (e.g. from HTTP endpoint via to_thread)
|
|
638
|
+
if self._loop:
|
|
639
|
+
try:
|
|
640
|
+
# Use the main loop captured at init
|
|
641
|
+
asyncio.run_coroutine_threadsafe(
|
|
642
|
+
self.broadcaster.broadcast_event(event, response),
|
|
643
|
+
self._loop,
|
|
644
|
+
)
|
|
645
|
+
except Exception as e:
|
|
646
|
+
self.logger.warning(f"Failed to schedule broadcast threadsafe: {e}")
|
|
647
|
+
else:
|
|
648
|
+
self.logger.debug("No event loop available for broadcasting")
|
|
649
|
+
|
|
650
|
+
# Dispatch non-blocking webhooks (fire-and-forget)
|
|
651
|
+
try:
|
|
652
|
+
self._dispatch_webhooks_async(event)
|
|
653
|
+
except Exception as e:
|
|
654
|
+
self.logger.warning(f"Non-blocking webhook dispatch failed: {e}")
|
|
655
|
+
|
|
656
|
+
# --- Plugin Post-Handlers (Sprint 9: observe only) ---
|
|
657
|
+
if self._plugin_loader:
|
|
658
|
+
try:
|
|
659
|
+
run_plugin_handlers(
|
|
660
|
+
self._plugin_loader.registry,
|
|
661
|
+
event,
|
|
662
|
+
pre=False,
|
|
663
|
+
core_response=response,
|
|
664
|
+
)
|
|
665
|
+
except Exception as e:
|
|
666
|
+
self.logger.error(f"Plugin post-handler failed: {e}", exc_info=True)
|
|
667
|
+
# Continue - post-handlers are observe-only
|
|
668
|
+
# -----------------------------------------------------
|
|
669
|
+
|
|
670
|
+
return cast(HookResponse, response)
|
|
671
|
+
except Exception as e:
|
|
672
|
+
self.logger.error(f"Event handler {event.event_type} failed: {e}", exc_info=True)
|
|
673
|
+
# Fail-open on handler errors
|
|
674
|
+
return HookResponse(
|
|
675
|
+
decision="allow",
|
|
676
|
+
reason=f"Handler error: {e}",
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
def _get_event_handler(self, event_type: HookEventType) -> Any | None:
|
|
680
|
+
"""
|
|
681
|
+
Get the handler method for a given HookEventType.
|
|
682
|
+
|
|
683
|
+
Args:
|
|
684
|
+
event_type: The unified event type enum value.
|
|
685
|
+
|
|
686
|
+
Returns:
|
|
687
|
+
Handler method or None if not found.
|
|
688
|
+
"""
|
|
689
|
+
return self._event_handlers.get_handler(event_type)
|
|
690
|
+
|
|
691
|
+
def _dispatch_webhooks_sync(self, event: HookEvent, blocking_only: bool = False) -> list[Any]:
|
|
692
|
+
"""
|
|
693
|
+
Dispatch webhooks synchronously (for blocking webhooks).
|
|
694
|
+
|
|
695
|
+
Args:
|
|
696
|
+
event: The hook event to dispatch.
|
|
697
|
+
blocking_only: If True, only dispatch to blocking (can_block=True) endpoints.
|
|
698
|
+
|
|
699
|
+
Returns:
|
|
700
|
+
List of WebhookResult objects.
|
|
701
|
+
"""
|
|
702
|
+
from gobby.hooks.webhooks import WebhookResult
|
|
703
|
+
|
|
704
|
+
if not self._webhook_dispatcher.config.enabled:
|
|
705
|
+
return []
|
|
706
|
+
|
|
707
|
+
# Filter endpoints if blocking_only
|
|
708
|
+
matching_endpoints = [
|
|
709
|
+
ep
|
|
710
|
+
for ep in self._webhook_dispatcher.config.endpoints
|
|
711
|
+
if ep.enabled
|
|
712
|
+
and self._webhook_dispatcher._matches_event(ep, event.event_type.value)
|
|
713
|
+
and (not blocking_only or ep.can_block)
|
|
714
|
+
]
|
|
715
|
+
|
|
716
|
+
if not matching_endpoints:
|
|
717
|
+
return []
|
|
718
|
+
|
|
719
|
+
# Build payload once
|
|
720
|
+
payload = self._webhook_dispatcher._build_payload(event)
|
|
721
|
+
|
|
722
|
+
# Run async dispatch in sync context
|
|
723
|
+
async def dispatch_all() -> list[WebhookResult]:
|
|
724
|
+
results: list[WebhookResult] = []
|
|
725
|
+
for endpoint in matching_endpoints:
|
|
726
|
+
result = await self._webhook_dispatcher._dispatch_single(endpoint, payload)
|
|
727
|
+
results.append(result)
|
|
728
|
+
return results
|
|
729
|
+
|
|
730
|
+
# Execute in event loop
|
|
731
|
+
try:
|
|
732
|
+
asyncio.get_running_loop()
|
|
733
|
+
# Already in async context - this method shouldn't be called here
|
|
734
|
+
# Fall back to creating a new thread to run the coroutine synchronously
|
|
735
|
+
import concurrent.futures
|
|
736
|
+
|
|
737
|
+
with concurrent.futures.ThreadPoolExecutor() as executor:
|
|
738
|
+
future = executor.submit(asyncio.run, dispatch_all())
|
|
739
|
+
return future.result()
|
|
740
|
+
except RuntimeError:
|
|
741
|
+
# Not in async context, run synchronously
|
|
742
|
+
return asyncio.run(dispatch_all())
|
|
743
|
+
|
|
744
|
+
def _dispatch_webhooks_async(self, event: HookEvent) -> None:
|
|
745
|
+
"""
|
|
746
|
+
Dispatch non-blocking webhooks asynchronously (fire-and-forget).
|
|
747
|
+
|
|
748
|
+
Args:
|
|
749
|
+
event: The hook event to dispatch.
|
|
750
|
+
"""
|
|
751
|
+
if not self._webhook_dispatcher.config.enabled:
|
|
752
|
+
return
|
|
753
|
+
|
|
754
|
+
# Filter to non-blocking endpoints only
|
|
755
|
+
matching_endpoints = [
|
|
756
|
+
ep
|
|
757
|
+
for ep in self._webhook_dispatcher.config.endpoints
|
|
758
|
+
if ep.enabled
|
|
759
|
+
and self._webhook_dispatcher._matches_event(ep, event.event_type.value)
|
|
760
|
+
and not ep.can_block
|
|
761
|
+
]
|
|
762
|
+
|
|
763
|
+
if not matching_endpoints:
|
|
764
|
+
return
|
|
765
|
+
|
|
766
|
+
# Build payload
|
|
767
|
+
payload = self._webhook_dispatcher._build_payload(event)
|
|
768
|
+
|
|
769
|
+
async def dispatch_all() -> None:
|
|
770
|
+
tasks = [
|
|
771
|
+
self._webhook_dispatcher._dispatch_single(ep, payload) for ep in matching_endpoints
|
|
772
|
+
]
|
|
773
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
774
|
+
|
|
775
|
+
# Fire and forget
|
|
776
|
+
try:
|
|
777
|
+
loop = asyncio.get_running_loop()
|
|
778
|
+
loop.create_task(dispatch_all())
|
|
779
|
+
except RuntimeError:
|
|
780
|
+
# No event loop, try using captured loop
|
|
781
|
+
if self._loop:
|
|
782
|
+
try:
|
|
783
|
+
asyncio.run_coroutine_threadsafe(dispatch_all(), self._loop)
|
|
784
|
+
except Exception as e:
|
|
785
|
+
self.logger.warning(f"Failed to schedule async webhook: {e}")
|
|
786
|
+
|
|
787
|
+
def shutdown(self) -> None:
|
|
788
|
+
"""
|
|
789
|
+
Clean up HookManager resources on daemon shutdown.
|
|
790
|
+
|
|
791
|
+
Stops background health check monitoring and transcript watchers.
|
|
792
|
+
"""
|
|
793
|
+
self.logger.debug("HookManager shutting down")
|
|
794
|
+
|
|
795
|
+
# Stop health check monitoring (delegated to HealthMonitor)
|
|
796
|
+
self._health_monitor.stop()
|
|
797
|
+
|
|
798
|
+
# Close webhook dispatcher HTTP client
|
|
799
|
+
try:
|
|
800
|
+
if self._loop:
|
|
801
|
+
asyncio.run_coroutine_threadsafe(
|
|
802
|
+
self._webhook_dispatcher.close(), self._loop
|
|
803
|
+
).result(timeout=5.0)
|
|
804
|
+
else:
|
|
805
|
+
asyncio.run(self._webhook_dispatcher.close())
|
|
806
|
+
except Exception as e:
|
|
807
|
+
self.logger.warning(f"Failed to close webhook dispatcher: {e}")
|
|
808
|
+
|
|
809
|
+
if hasattr(self, "_database"):
|
|
810
|
+
self._database.close()
|
|
811
|
+
|
|
812
|
+
self.logger.debug("HookManager shutdown complete")
|
|
813
|
+
|
|
814
|
+
# ==================== HELPER METHODS ====================
|
|
815
|
+
|
|
816
|
+
def get_machine_id(self) -> str:
|
|
817
|
+
"""Get unique machine identifier."""
|
|
818
|
+
from gobby.utils.machine_id import get_machine_id as _get_machine_id
|
|
819
|
+
|
|
820
|
+
result = _get_machine_id()
|
|
821
|
+
return result or "unknown-machine"
|
|
822
|
+
|
|
823
|
+
def _resolve_project_id(self, project_id: str | None, cwd: str | None) -> str:
|
|
824
|
+
"""
|
|
825
|
+
Resolve project_id from cwd if not provided.
|
|
826
|
+
|
|
827
|
+
If project_id is given, returns it directly.
|
|
828
|
+
Otherwise, looks up project from .gobby/project.json in the cwd.
|
|
829
|
+
If no project.json exists, automatically initializes the project.
|
|
830
|
+
|
|
831
|
+
Args:
|
|
832
|
+
project_id: Optional explicit project ID
|
|
833
|
+
cwd: Current working directory path
|
|
834
|
+
|
|
835
|
+
Returns:
|
|
836
|
+
Project ID (existing or newly created)
|
|
837
|
+
"""
|
|
838
|
+
if project_id:
|
|
839
|
+
return project_id
|
|
840
|
+
|
|
841
|
+
# Get cwd or use current directory
|
|
842
|
+
working_dir = Path(cwd) if cwd else Path.cwd()
|
|
843
|
+
|
|
844
|
+
# Look up project from .gobby/project.json
|
|
845
|
+
from gobby.utils.project_context import get_project_context
|
|
846
|
+
|
|
847
|
+
project_context = get_project_context(working_dir)
|
|
848
|
+
if project_context and project_context.get("id"):
|
|
849
|
+
return str(project_context["id"])
|
|
850
|
+
|
|
851
|
+
# No project.json found - auto-initialize the project
|
|
852
|
+
from gobby.utils.project_init import initialize_project
|
|
853
|
+
|
|
854
|
+
result = initialize_project(cwd=working_dir)
|
|
855
|
+
self.logger.info(f"Auto-initialized project '{result.project_name}' in {working_dir}")
|
|
856
|
+
return result.project_id
|