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,208 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Detection helper functions for workflow engine.
|
|
3
|
+
|
|
4
|
+
Extracted from engine.py to reduce complexity.
|
|
5
|
+
These functions detect specific events (task claims, plan mode, MCP calls)
|
|
6
|
+
and update workflow state variables accordingly.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from gobby.hooks.events import HookEvent
|
|
14
|
+
from gobby.tasks.session_tasks import SessionTaskManager
|
|
15
|
+
|
|
16
|
+
from .definitions import WorkflowState
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def detect_task_claim(
|
|
22
|
+
event: "HookEvent",
|
|
23
|
+
state: "WorkflowState",
|
|
24
|
+
session_task_manager: "SessionTaskManager | None" = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Detect gobby-tasks calls that claim or release a task for this session.
|
|
27
|
+
|
|
28
|
+
Sets `task_claimed: true` in workflow state variables when the agent
|
|
29
|
+
successfully creates a task or updates a task to in_progress status.
|
|
30
|
+
|
|
31
|
+
Clears `task_claimed: false` when the agent closes a task, requiring
|
|
32
|
+
them to claim another task before making further file modifications.
|
|
33
|
+
|
|
34
|
+
This enables session-scoped task enforcement where each session must
|
|
35
|
+
explicitly claim a task rather than free-riding on project-wide checks.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
event: The AFTER_TOOL hook event
|
|
39
|
+
state: Current workflow state (modified in place)
|
|
40
|
+
session_task_manager: Optional manager for auto-linking tasks to sessions
|
|
41
|
+
"""
|
|
42
|
+
if not event.data:
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
tool_name = event.data.get("tool_name", "")
|
|
46
|
+
tool_input = event.data.get("tool_input", {}) or {}
|
|
47
|
+
tool_output = event.data.get("tool_output", {}) or {}
|
|
48
|
+
|
|
49
|
+
# Check if this is a gobby-tasks call via MCP proxy
|
|
50
|
+
# Tool name could be "call_tool" (from legacy) or "mcp__gobby__call_tool" (direct)
|
|
51
|
+
if tool_name not in ("call_tool", "mcp__gobby__call_tool"):
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
# Check server is gobby-tasks
|
|
55
|
+
server_name = tool_input.get("server_name", "")
|
|
56
|
+
if server_name != "gobby-tasks":
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
# Check inner tool name
|
|
60
|
+
inner_tool_name = tool_input.get("tool_name", "")
|
|
61
|
+
if inner_tool_name not in ("create_task", "update_task", "close_task"):
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
# For update_task, only count if status is being set to in_progress
|
|
65
|
+
if inner_tool_name == "update_task":
|
|
66
|
+
arguments = tool_input.get("arguments", {}) or {}
|
|
67
|
+
if arguments.get("status") != "in_progress":
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
# For close_task, we'll clear task_claimed after success check
|
|
71
|
+
is_close_task = inner_tool_name == "close_task"
|
|
72
|
+
|
|
73
|
+
# Check if the call succeeded (not an error)
|
|
74
|
+
# tool_output structure varies, but errors typically have "error" key
|
|
75
|
+
# or the MCP response has "status": "error"
|
|
76
|
+
if isinstance(tool_output, dict):
|
|
77
|
+
if tool_output.get("error") or tool_output.get("status") == "error":
|
|
78
|
+
return
|
|
79
|
+
# Also check nested result for MCP proxy responses
|
|
80
|
+
result = tool_output.get("result", {})
|
|
81
|
+
if isinstance(result, dict) and result.get("error"):
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
# Handle close_task - clear the claim only if closing the claimed task
|
|
85
|
+
if is_close_task:
|
|
86
|
+
arguments = tool_input.get("arguments", {}) or {}
|
|
87
|
+
closed_task_id = arguments.get("task_id")
|
|
88
|
+
claimed_task_id = state.variables.get("claimed_task_id")
|
|
89
|
+
|
|
90
|
+
# Only clear task_claimed if we're closing the task that was claimed
|
|
91
|
+
if closed_task_id and claimed_task_id and closed_task_id == claimed_task_id:
|
|
92
|
+
state.variables["task_claimed"] = False
|
|
93
|
+
state.variables["claimed_task_id"] = None
|
|
94
|
+
logger.info(
|
|
95
|
+
f"Session {state.session_id}: task_claimed=False "
|
|
96
|
+
f"(claimed task {closed_task_id} closed via close_task)"
|
|
97
|
+
)
|
|
98
|
+
else:
|
|
99
|
+
logger.debug(
|
|
100
|
+
f"Session {state.session_id}: close_task for {closed_task_id} "
|
|
101
|
+
f"(claimed: {claimed_task_id}) - not clearing task_claimed"
|
|
102
|
+
)
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
# Extract task_id based on tool type
|
|
106
|
+
arguments = tool_input.get("arguments", {}) or {}
|
|
107
|
+
if inner_tool_name == "update_task":
|
|
108
|
+
task_id = arguments.get("task_id")
|
|
109
|
+
elif inner_tool_name == "create_task":
|
|
110
|
+
# For create_task, the id is in the result
|
|
111
|
+
result = tool_output.get("result", {}) if isinstance(tool_output, dict) else {}
|
|
112
|
+
task_id = result.get("id") if isinstance(result, dict) else None
|
|
113
|
+
else:
|
|
114
|
+
task_id = None
|
|
115
|
+
|
|
116
|
+
# All conditions met - set task_claimed and claimed_task_id
|
|
117
|
+
state.variables["task_claimed"] = True
|
|
118
|
+
state.variables["claimed_task_id"] = task_id
|
|
119
|
+
logger.info(
|
|
120
|
+
f"Session {state.session_id}: task_claimed=True, claimed_task_id={task_id} "
|
|
121
|
+
f"(via {inner_tool_name})"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Auto-link task to session when status is set to in_progress
|
|
125
|
+
if inner_tool_name == "update_task":
|
|
126
|
+
arguments = tool_input.get("arguments", {}) or {}
|
|
127
|
+
task_id = arguments.get("task_id")
|
|
128
|
+
if task_id and session_task_manager:
|
|
129
|
+
try:
|
|
130
|
+
session_task_manager.link_task(state.session_id, task_id, "worked_on")
|
|
131
|
+
logger.info(f"Auto-linked task {task_id} to session {state.session_id}")
|
|
132
|
+
except Exception as e:
|
|
133
|
+
logger.warning(f"Failed to auto-link task {task_id}: {e}")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def detect_plan_mode(event: "HookEvent", state: "WorkflowState") -> None:
|
|
137
|
+
"""Detect Claude Code plan mode entry/exit and set workflow variable.
|
|
138
|
+
|
|
139
|
+
Sets `plan_mode: true` when EnterPlanMode tool is called, allowing
|
|
140
|
+
file modifications without an active task (planning writes to plan files).
|
|
141
|
+
|
|
142
|
+
Clears `plan_mode: false` when ExitPlanMode tool is called, re-enabling
|
|
143
|
+
task enforcement for actual implementation work.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
event: The AFTER_TOOL hook event
|
|
147
|
+
state: Current workflow state (modified in place)
|
|
148
|
+
"""
|
|
149
|
+
if not event.data:
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
tool_name = event.data.get("tool_name", "")
|
|
153
|
+
|
|
154
|
+
if tool_name == "EnterPlanMode":
|
|
155
|
+
state.variables["plan_mode"] = True
|
|
156
|
+
logger.info(f"Session {state.session_id}: plan_mode=True (entered plan mode)")
|
|
157
|
+
elif tool_name == "ExitPlanMode":
|
|
158
|
+
state.variables["plan_mode"] = False
|
|
159
|
+
logger.info(f"Session {state.session_id}: plan_mode=False (exited plan mode)")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def detect_mcp_call(event: "HookEvent", state: "WorkflowState") -> None:
|
|
163
|
+
"""Track MCP tool calls by server/tool for workflow conditions.
|
|
164
|
+
|
|
165
|
+
Sets state.variables["mcp_calls"] = {
|
|
166
|
+
"gobby-memory": ["recall", "remember"],
|
|
167
|
+
"context7": ["get-library-docs"],
|
|
168
|
+
...
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
This enables workflow conditions like:
|
|
172
|
+
when: "mcp_called('gobby-memory', 'recall')"
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
event: The AFTER_TOOL hook event
|
|
176
|
+
state: Current workflow state (modified in place)
|
|
177
|
+
"""
|
|
178
|
+
if not event.data:
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
tool_name = event.data.get("tool_name", "")
|
|
182
|
+
tool_input = event.data.get("tool_input", {}) or {}
|
|
183
|
+
tool_output = event.data.get("tool_output", {}) or {}
|
|
184
|
+
|
|
185
|
+
# Check for MCP proxy call
|
|
186
|
+
if tool_name not in ("call_tool", "mcp__gobby__call_tool"):
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
server_name = tool_input.get("server_name", "")
|
|
190
|
+
inner_tool = tool_input.get("tool_name", "")
|
|
191
|
+
|
|
192
|
+
if not server_name or not inner_tool:
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
# Check if call succeeded (skip tracking failed calls)
|
|
196
|
+
if isinstance(tool_output, dict):
|
|
197
|
+
if tool_output.get("error") or tool_output.get("status") == "error":
|
|
198
|
+
return
|
|
199
|
+
result = tool_output.get("result", {})
|
|
200
|
+
if isinstance(result, dict) and result.get("error"):
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
# Track the call
|
|
204
|
+
mcp_calls = state.variables.setdefault("mcp_calls", {})
|
|
205
|
+
server_calls = mcp_calls.setdefault(server_name, [])
|
|
206
|
+
if inner_tool not in server_calls:
|
|
207
|
+
server_calls.append(inner_tool)
|
|
208
|
+
logger.debug(f"Session {state.session_id}: MCP call tracked {server_name}/{inner_tool}")
|
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from datetime import UTC, datetime
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from types import SimpleNamespace
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from gobby.hooks.events import HookEvent, HookEventType, HookResponse
|
|
8
|
+
from gobby.storage.workflow_audit import WorkflowAuditManager
|
|
9
|
+
|
|
10
|
+
from .approval_flow import handle_approval_response
|
|
11
|
+
from .audit_helpers import (
|
|
12
|
+
log_approval,
|
|
13
|
+
log_rule_eval,
|
|
14
|
+
log_tool_call,
|
|
15
|
+
log_transition,
|
|
16
|
+
)
|
|
17
|
+
from .definitions import WorkflowDefinition, WorkflowState
|
|
18
|
+
from .detection_helpers import detect_mcp_call, detect_plan_mode, detect_task_claim
|
|
19
|
+
from .evaluator import ConditionEvaluator
|
|
20
|
+
from .lifecycle_evaluator import (
|
|
21
|
+
evaluate_all_lifecycle_workflows as _evaluate_all_lifecycle_workflows,
|
|
22
|
+
)
|
|
23
|
+
from .lifecycle_evaluator import (
|
|
24
|
+
evaluate_lifecycle_triggers as _evaluate_lifecycle_triggers,
|
|
25
|
+
)
|
|
26
|
+
from .lifecycle_evaluator import (
|
|
27
|
+
evaluate_workflow_triggers as _evaluate_workflow_triggers,
|
|
28
|
+
)
|
|
29
|
+
from .lifecycle_evaluator import (
|
|
30
|
+
process_action_result,
|
|
31
|
+
)
|
|
32
|
+
from .loader import WorkflowLoader
|
|
33
|
+
from .premature_stop import check_premature_stop
|
|
34
|
+
from .state_manager import WorkflowStateManager
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from .actions import ActionExecutor
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class WorkflowEngine:
|
|
43
|
+
"""
|
|
44
|
+
Core engine for executing step-based workflows.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
loader: WorkflowLoader,
|
|
50
|
+
state_manager: WorkflowStateManager,
|
|
51
|
+
action_executor: "ActionExecutor",
|
|
52
|
+
evaluator: ConditionEvaluator | None = None,
|
|
53
|
+
audit_manager: WorkflowAuditManager | None = None,
|
|
54
|
+
):
|
|
55
|
+
self.loader = loader
|
|
56
|
+
self.state_manager = state_manager
|
|
57
|
+
self.action_executor = action_executor
|
|
58
|
+
self.evaluator = evaluator or ConditionEvaluator()
|
|
59
|
+
self.audit_manager = audit_manager
|
|
60
|
+
|
|
61
|
+
# Maps canonical trigger names to their legacy aliases for backward compatibility.
|
|
62
|
+
TRIGGER_ALIASES: dict[str, list[str]] = {
|
|
63
|
+
"on_before_agent": ["on_prompt_submit"],
|
|
64
|
+
"on_before_tool": ["on_tool_call"],
|
|
65
|
+
"on_after_tool": ["on_tool_result"],
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# Variables to inherit from parent session
|
|
69
|
+
VARS_TO_INHERIT = ["plan_mode"]
|
|
70
|
+
|
|
71
|
+
async def handle_event(self, event: HookEvent) -> HookResponse:
|
|
72
|
+
"""
|
|
73
|
+
Main entry point for hook events.
|
|
74
|
+
"""
|
|
75
|
+
session_id = event.metadata.get("_platform_session_id")
|
|
76
|
+
if not session_id:
|
|
77
|
+
return HookResponse(decision="allow") # No session, no workflow
|
|
78
|
+
|
|
79
|
+
# 1. Load state
|
|
80
|
+
state = self.state_manager.get_state(session_id)
|
|
81
|
+
|
|
82
|
+
# 2. If no state, check triggers to start one (e.g. on_session_start)
|
|
83
|
+
# Note: This logic might need to move to a specialized trigger handler
|
|
84
|
+
# For now, simplistic check
|
|
85
|
+
|
|
86
|
+
if not state:
|
|
87
|
+
# TODO: Logic to load workflow?
|
|
88
|
+
# For now, return allow
|
|
89
|
+
return HookResponse(decision="allow")
|
|
90
|
+
|
|
91
|
+
# Check if workflow is temporarily disabled (escape hatch)
|
|
92
|
+
if state.disabled:
|
|
93
|
+
logger.debug(
|
|
94
|
+
f"Workflow '{state.workflow_name}' is disabled for session {session_id}. "
|
|
95
|
+
f"Reason: {state.disabled_reason or 'No reason specified'}"
|
|
96
|
+
)
|
|
97
|
+
return HookResponse(decision="allow")
|
|
98
|
+
|
|
99
|
+
# Stuck prevention: Check if step duration exceeding limit
|
|
100
|
+
# This is a basic implementation of "Stuck Detection"
|
|
101
|
+
if state.step_entered_at:
|
|
102
|
+
logger.debug(f"step_entered_at type: {type(state.step_entered_at)}")
|
|
103
|
+
logger.debug(f"step_entered_at value: {state.step_entered_at}")
|
|
104
|
+
diff = datetime.now(UTC) - state.step_entered_at
|
|
105
|
+
logger.debug(f"diff type: {type(diff)}, value: {diff}")
|
|
106
|
+
duration = diff.total_seconds()
|
|
107
|
+
logger.debug(f"duration type: {type(duration)}, value: {duration}")
|
|
108
|
+
# Hardcoded limit for MVP: 30 minutes
|
|
109
|
+
if duration > 1800:
|
|
110
|
+
# Force transition to reflect if not already there
|
|
111
|
+
if state.step != "reflect":
|
|
112
|
+
project_path = Path(event.cwd) if event.cwd else None
|
|
113
|
+
workflow = self.loader.load_workflow(state.workflow_name, project_path)
|
|
114
|
+
if workflow and workflow.get_step("reflect"):
|
|
115
|
+
await self.transition_to(state, "reflect", workflow)
|
|
116
|
+
return HookResponse(
|
|
117
|
+
decision="modify",
|
|
118
|
+
context="[System Alert] Step duration limit exceeded. Transitioning to 'reflect' step.",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# 3. Load definition
|
|
122
|
+
# Skip if this is a lifecycle-only state (used for task_claimed tracking)
|
|
123
|
+
if state.workflow_name == "__lifecycle__":
|
|
124
|
+
logger.debug(
|
|
125
|
+
f"Skipping step workflow handling for lifecycle state in session {session_id}"
|
|
126
|
+
)
|
|
127
|
+
return HookResponse(decision="allow")
|
|
128
|
+
|
|
129
|
+
project_path = Path(event.cwd) if event.cwd else None
|
|
130
|
+
workflow = self.loader.load_workflow(state.workflow_name, project_path)
|
|
131
|
+
if not workflow:
|
|
132
|
+
logger.error(f"Workflow '{state.workflow_name}' not found for session {session_id}")
|
|
133
|
+
return HookResponse(decision="allow")
|
|
134
|
+
|
|
135
|
+
# Skip step handling for lifecycle workflows - they only use triggers
|
|
136
|
+
if workflow.type == "lifecycle":
|
|
137
|
+
logger.debug(
|
|
138
|
+
f"Skipping step workflow handling for lifecycle workflow '{workflow.name}' "
|
|
139
|
+
f"in session {session_id}"
|
|
140
|
+
)
|
|
141
|
+
return HookResponse(decision="allow")
|
|
142
|
+
|
|
143
|
+
# 4. Process event
|
|
144
|
+
# Logic matches WORKFLOWS.md "Evaluation Flow"
|
|
145
|
+
|
|
146
|
+
# Determine context for evaluation
|
|
147
|
+
# Use SimpleNamespace for variables so dot notation works (variables.session_task)
|
|
148
|
+
# Look up session info for condition evaluation
|
|
149
|
+
session_info = {}
|
|
150
|
+
if (
|
|
151
|
+
self.action_executor
|
|
152
|
+
and self.action_executor.session_manager
|
|
153
|
+
and event.machine_id
|
|
154
|
+
and event.project_id
|
|
155
|
+
):
|
|
156
|
+
session = self.action_executor.session_manager.find_by_external_id(
|
|
157
|
+
external_id=event.session_id,
|
|
158
|
+
machine_id=event.machine_id,
|
|
159
|
+
project_id=event.project_id,
|
|
160
|
+
source=event.source.value,
|
|
161
|
+
)
|
|
162
|
+
if session:
|
|
163
|
+
session_info = {
|
|
164
|
+
"id": session.id,
|
|
165
|
+
"external_id": session.external_id,
|
|
166
|
+
"project_id": session.project_id,
|
|
167
|
+
"status": session.status,
|
|
168
|
+
"git_branch": session.git_branch,
|
|
169
|
+
"source": session.source,
|
|
170
|
+
}
|
|
171
|
+
eval_context = {
|
|
172
|
+
"event": event,
|
|
173
|
+
"workflow_state": state,
|
|
174
|
+
"variables": SimpleNamespace(**state.variables),
|
|
175
|
+
"session": SimpleNamespace(**session_info),
|
|
176
|
+
"tool_name": event.data.get("tool_name"),
|
|
177
|
+
"tool_args": event.data.get("tool_args", {}),
|
|
178
|
+
# State attributes for transition conditions
|
|
179
|
+
"step_action_count": state.step_action_count,
|
|
180
|
+
"total_action_count": state.total_action_count,
|
|
181
|
+
"step": state.step,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
current_step = workflow.get_step(state.step)
|
|
185
|
+
if not current_step:
|
|
186
|
+
logger.error(f"Step '{state.step}' not found in workflow '{workflow.name}'")
|
|
187
|
+
return HookResponse(decision="allow")
|
|
188
|
+
|
|
189
|
+
# Handle approval flow on user prompt submit
|
|
190
|
+
if event.event_type == HookEventType.BEFORE_AGENT:
|
|
191
|
+
approval_response = self._handle_approval_response(event, state, current_step)
|
|
192
|
+
if approval_response:
|
|
193
|
+
return approval_response
|
|
194
|
+
|
|
195
|
+
# Reset premature stop counter on user prompt
|
|
196
|
+
# This allows the failsafe to distinguish agent-stuck-in-loop from user-initiated-stops
|
|
197
|
+
if state.variables.get("_premature_stop_count", 0) > 0:
|
|
198
|
+
state.variables["_premature_stop_count"] = 0
|
|
199
|
+
self.state_manager.save_state(state)
|
|
200
|
+
logger.debug(f"Reset premature_stop_count for session {session_id}")
|
|
201
|
+
|
|
202
|
+
# Check blocked tools
|
|
203
|
+
if event.event_type == HookEventType.BEFORE_TOOL:
|
|
204
|
+
# Block tool calls while waiting for approval
|
|
205
|
+
if state.approval_pending:
|
|
206
|
+
reason = "Waiting for user approval. Please respond with 'yes' or 'no'."
|
|
207
|
+
self._log_tool_call(session_id, state.step, "unknown", "block", reason)
|
|
208
|
+
return HookResponse(decision="block", reason=reason)
|
|
209
|
+
|
|
210
|
+
# Reset premature stop counter on tool calls
|
|
211
|
+
# This ensures the failsafe only triggers for repeated stops without work in between
|
|
212
|
+
if state.variables.get("_premature_stop_count", 0) > 0:
|
|
213
|
+
state.variables["_premature_stop_count"] = 0
|
|
214
|
+
self.state_manager.save_state(state)
|
|
215
|
+
logger.debug(f"Reset premature_stop_count on tool call for session {session_id}")
|
|
216
|
+
|
|
217
|
+
raw_tool_name = eval_context.get("tool_name")
|
|
218
|
+
tool_name = str(raw_tool_name) if raw_tool_name is not None else ""
|
|
219
|
+
|
|
220
|
+
# Check blocked list
|
|
221
|
+
if tool_name in current_step.blocked_tools:
|
|
222
|
+
reason = f"Tool '{tool_name}' is blocked in step '{state.step}'."
|
|
223
|
+
self._log_tool_call(session_id, state.step, tool_name, "block", reason)
|
|
224
|
+
return HookResponse(decision="block", reason=reason)
|
|
225
|
+
|
|
226
|
+
# Check allowed list (if not "all")
|
|
227
|
+
if current_step.allowed_tools != "all":
|
|
228
|
+
if tool_name not in current_step.allowed_tools:
|
|
229
|
+
reason = f"Tool '{tool_name}' is not in allowed list for step '{state.step}'."
|
|
230
|
+
self._log_tool_call(session_id, state.step, tool_name, "block", reason)
|
|
231
|
+
return HookResponse(decision="block", reason=reason)
|
|
232
|
+
|
|
233
|
+
# Check rules
|
|
234
|
+
for rule in current_step.rules:
|
|
235
|
+
if self.evaluator.evaluate(rule.when, eval_context):
|
|
236
|
+
if rule.action == "block":
|
|
237
|
+
reason = rule.message or "Blocked by workflow rule."
|
|
238
|
+
self._log_rule_eval(
|
|
239
|
+
session_id,
|
|
240
|
+
state.step,
|
|
241
|
+
rule.name or "unnamed",
|
|
242
|
+
rule.when,
|
|
243
|
+
"block",
|
|
244
|
+
reason,
|
|
245
|
+
)
|
|
246
|
+
return HookResponse(decision="block", reason=reason)
|
|
247
|
+
# Handle other actions like warn, require_approval
|
|
248
|
+
|
|
249
|
+
# Log successful tool allow
|
|
250
|
+
self._log_tool_call(session_id, state.step, tool_name, "allow")
|
|
251
|
+
|
|
252
|
+
# Check transitions
|
|
253
|
+
logger.debug("Checking transitions")
|
|
254
|
+
for transition in current_step.transitions:
|
|
255
|
+
if self.evaluator.evaluate(transition.when, eval_context):
|
|
256
|
+
# Transition!
|
|
257
|
+
await self.transition_to(state, transition.to, workflow)
|
|
258
|
+
return HookResponse(
|
|
259
|
+
decision="modify", context=f"Transitioning to step: {transition.to}"
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Check exit conditions
|
|
263
|
+
logger.debug("Checking exit conditions")
|
|
264
|
+
if self.evaluator.check_exit_conditions(current_step.exit_conditions, state):
|
|
265
|
+
# TODO: Determine next step or completion logic
|
|
266
|
+
# For now, simplistic 'next step' if linear, or rely on transitions
|
|
267
|
+
pass
|
|
268
|
+
|
|
269
|
+
# Update stats (generic)
|
|
270
|
+
if event.event_type == HookEventType.AFTER_TOOL:
|
|
271
|
+
state.step_action_count += 1
|
|
272
|
+
state.total_action_count += 1
|
|
273
|
+
|
|
274
|
+
# Detect gobby-tasks calls for session-scoped task claiming
|
|
275
|
+
self._detect_task_claim(event, state)
|
|
276
|
+
|
|
277
|
+
# Detect Claude Code plan mode entry/exit
|
|
278
|
+
self._detect_plan_mode(event, state)
|
|
279
|
+
|
|
280
|
+
# Track all MCP proxy calls for workflow conditions
|
|
281
|
+
self._detect_mcp_call(event, state)
|
|
282
|
+
|
|
283
|
+
self.state_manager.save_state(state) # Persist updates
|
|
284
|
+
|
|
285
|
+
return HookResponse(decision="allow")
|
|
286
|
+
|
|
287
|
+
async def transition_to(
|
|
288
|
+
self, state: WorkflowState, new_step_name: str, workflow: WorkflowDefinition
|
|
289
|
+
) -> None:
|
|
290
|
+
"""
|
|
291
|
+
Execute transition logic.
|
|
292
|
+
"""
|
|
293
|
+
old_step = workflow.get_step(state.step)
|
|
294
|
+
new_step = workflow.get_step(new_step_name)
|
|
295
|
+
|
|
296
|
+
if not new_step:
|
|
297
|
+
logger.error(f"Cannot transition to unknown step '{new_step_name}'")
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
logger.info(
|
|
301
|
+
f"Transitioning session {state.session_id} from '{state.step}' to '{new_step_name}'"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# Log the transition
|
|
305
|
+
self._log_transition(state.session_id, state.step, new_step_name)
|
|
306
|
+
|
|
307
|
+
# Execute on_exit of old step
|
|
308
|
+
if old_step:
|
|
309
|
+
await self._execute_actions(old_step.on_exit, state)
|
|
310
|
+
|
|
311
|
+
# Update state
|
|
312
|
+
state.step = new_step_name
|
|
313
|
+
state.step_entered_at = datetime.now(UTC)
|
|
314
|
+
state.step_action_count = 0
|
|
315
|
+
state.context_injected = False # Reset for new step context
|
|
316
|
+
|
|
317
|
+
self.state_manager.save_state(state)
|
|
318
|
+
|
|
319
|
+
# Execute on_enter of new step
|
|
320
|
+
await self._execute_actions(new_step.on_enter, state)
|
|
321
|
+
|
|
322
|
+
async def _execute_actions(self, actions: list[dict[str, Any]], state: WorkflowState) -> None:
|
|
323
|
+
"""
|
|
324
|
+
Execute a list of actions.
|
|
325
|
+
"""
|
|
326
|
+
from .actions import ActionContext
|
|
327
|
+
|
|
328
|
+
context = ActionContext(
|
|
329
|
+
session_id=state.session_id,
|
|
330
|
+
state=state,
|
|
331
|
+
db=self.action_executor.db,
|
|
332
|
+
session_manager=self.action_executor.session_manager,
|
|
333
|
+
template_engine=self.action_executor.template_engine,
|
|
334
|
+
llm_service=self.action_executor.llm_service,
|
|
335
|
+
transcript_processor=self.action_executor.transcript_processor,
|
|
336
|
+
config=self.action_executor.config,
|
|
337
|
+
mcp_manager=self.action_executor.mcp_manager,
|
|
338
|
+
memory_manager=self.action_executor.memory_manager,
|
|
339
|
+
memory_sync_manager=self.action_executor.memory_sync_manager,
|
|
340
|
+
task_sync_manager=self.action_executor.task_sync_manager,
|
|
341
|
+
session_task_manager=self.action_executor.session_task_manager,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
for action_def in actions:
|
|
345
|
+
action_type = action_def.get("action")
|
|
346
|
+
if not action_type:
|
|
347
|
+
continue
|
|
348
|
+
|
|
349
|
+
result = await self.action_executor.execute(action_type, context, **action_def)
|
|
350
|
+
|
|
351
|
+
if result and "inject_context" in result:
|
|
352
|
+
# Log context injection for now
|
|
353
|
+
logger.info(f"Context injected: {result['inject_context'][:50]}...")
|
|
354
|
+
|
|
355
|
+
def _handle_approval_response(
|
|
356
|
+
self,
|
|
357
|
+
event: HookEvent,
|
|
358
|
+
state: WorkflowState,
|
|
359
|
+
current_step: Any,
|
|
360
|
+
) -> HookResponse | None:
|
|
361
|
+
"""Handle user response to approval request."""
|
|
362
|
+
return handle_approval_response(
|
|
363
|
+
event, state, current_step, self.evaluator, self.state_manager
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
async def evaluate_all_lifecycle_workflows(
|
|
367
|
+
self, event: HookEvent, context_data: dict[str, Any] | None = None
|
|
368
|
+
) -> HookResponse:
|
|
369
|
+
"""Discover and evaluate all lifecycle workflows for the given event."""
|
|
370
|
+
return await _evaluate_all_lifecycle_workflows(
|
|
371
|
+
event=event,
|
|
372
|
+
loader=self.loader,
|
|
373
|
+
state_manager=self.state_manager,
|
|
374
|
+
action_executor=self.action_executor,
|
|
375
|
+
evaluator=self.evaluator,
|
|
376
|
+
detect_task_claim_fn=self._detect_task_claim,
|
|
377
|
+
detect_plan_mode_fn=self._detect_plan_mode,
|
|
378
|
+
check_premature_stop_fn=self._check_premature_stop,
|
|
379
|
+
context_data=context_data,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
def _process_action_result(
|
|
383
|
+
self,
|
|
384
|
+
result: dict[str, Any],
|
|
385
|
+
context_data: dict[str, Any],
|
|
386
|
+
state: "WorkflowState",
|
|
387
|
+
injected_context: list[str],
|
|
388
|
+
) -> str | None:
|
|
389
|
+
"""Process action execution result."""
|
|
390
|
+
return process_action_result(result, context_data, state, injected_context)
|
|
391
|
+
|
|
392
|
+
async def _evaluate_workflow_triggers(
|
|
393
|
+
self,
|
|
394
|
+
workflow: "WorkflowDefinition",
|
|
395
|
+
event: HookEvent,
|
|
396
|
+
context_data: dict[str, Any],
|
|
397
|
+
) -> HookResponse:
|
|
398
|
+
"""Evaluate triggers for a single workflow definition."""
|
|
399
|
+
return await _evaluate_workflow_triggers(
|
|
400
|
+
workflow, event, context_data, self.state_manager, self.action_executor, self.evaluator
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
async def evaluate_lifecycle_triggers(
|
|
404
|
+
self, workflow_name: str, event: HookEvent, context_data: dict[str, Any] | None = None
|
|
405
|
+
) -> HookResponse:
|
|
406
|
+
"""Evaluate triggers for a specific lifecycle workflow (e.g. session-handoff)."""
|
|
407
|
+
return await _evaluate_lifecycle_triggers(
|
|
408
|
+
workflow_name, event, self.loader, self.action_executor, self.evaluator, context_data
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
# --- Premature Stop Handling ---
|
|
412
|
+
|
|
413
|
+
async def _check_premature_stop(
|
|
414
|
+
self, event: HookEvent, context_data: dict[str, Any]
|
|
415
|
+
) -> HookResponse | None:
|
|
416
|
+
"""Check if an active step workflow should handle a premature stop."""
|
|
417
|
+
template_engine = self.action_executor.template_engine if self.action_executor else None
|
|
418
|
+
return await check_premature_stop(
|
|
419
|
+
event, context_data, self.state_manager, self.loader, self.evaluator, template_engine
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
# --- Audit Logging Helpers ---
|
|
423
|
+
|
|
424
|
+
def _log_tool_call(
|
|
425
|
+
self,
|
|
426
|
+
session_id: str,
|
|
427
|
+
step: str,
|
|
428
|
+
tool_name: str,
|
|
429
|
+
result: str,
|
|
430
|
+
reason: str | None = None,
|
|
431
|
+
context: dict[str, Any] | None = None,
|
|
432
|
+
) -> None:
|
|
433
|
+
"""Log a tool call permission check to the audit log."""
|
|
434
|
+
log_tool_call(self.audit_manager, session_id, step, tool_name, result, reason, context)
|
|
435
|
+
|
|
436
|
+
def _log_rule_eval(
|
|
437
|
+
self,
|
|
438
|
+
session_id: str,
|
|
439
|
+
step: str,
|
|
440
|
+
rule_id: str,
|
|
441
|
+
condition: str,
|
|
442
|
+
result: str,
|
|
443
|
+
reason: str | None = None,
|
|
444
|
+
context: dict[str, Any] | None = None,
|
|
445
|
+
) -> None:
|
|
446
|
+
"""Log a rule evaluation to the audit log."""
|
|
447
|
+
log_rule_eval(
|
|
448
|
+
self.audit_manager, session_id, step, rule_id, condition, result, reason, context
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
def _log_transition(
|
|
452
|
+
self,
|
|
453
|
+
session_id: str,
|
|
454
|
+
from_step: str,
|
|
455
|
+
to_step: str,
|
|
456
|
+
reason: str | None = None,
|
|
457
|
+
context: dict[str, Any] | None = None,
|
|
458
|
+
) -> None:
|
|
459
|
+
"""Log a step transition to the audit log."""
|
|
460
|
+
log_transition(self.audit_manager, session_id, from_step, to_step, reason, context)
|
|
461
|
+
|
|
462
|
+
def _log_approval(
|
|
463
|
+
self,
|
|
464
|
+
session_id: str,
|
|
465
|
+
step: str,
|
|
466
|
+
result: str,
|
|
467
|
+
condition_id: str | None = None,
|
|
468
|
+
prompt: str | None = None,
|
|
469
|
+
context: dict[str, Any] | None = None,
|
|
470
|
+
) -> None:
|
|
471
|
+
"""Log an approval gate event to the audit log."""
|
|
472
|
+
log_approval(self.audit_manager, session_id, step, result, condition_id, prompt, context)
|
|
473
|
+
|
|
474
|
+
def _detect_task_claim(self, event: HookEvent, state: WorkflowState) -> None:
|
|
475
|
+
"""Detect gobby-tasks calls that claim or release a task for this session."""
|
|
476
|
+
session_task_manager = getattr(self.action_executor, "session_task_manager", None)
|
|
477
|
+
detect_task_claim(event, state, session_task_manager)
|
|
478
|
+
|
|
479
|
+
def _detect_plan_mode(self, event: HookEvent, state: WorkflowState) -> None:
|
|
480
|
+
"""Detect Claude Code plan mode entry/exit and set workflow variable."""
|
|
481
|
+
detect_plan_mode(event, state)
|
|
482
|
+
|
|
483
|
+
def _detect_mcp_call(self, event: HookEvent, state: WorkflowState) -> None:
|
|
484
|
+
"""Track MCP tool calls by server/tool for workflow conditions."""
|
|
485
|
+
detect_mcp_call(event, state)
|