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,973 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Internal MCP tools for Gobby Workflow System.
|
|
3
|
+
|
|
4
|
+
Exposes functionality for:
|
|
5
|
+
- get_workflow: Get details about a specific workflow definition
|
|
6
|
+
- list_workflows: Discover available workflow definitions
|
|
7
|
+
- activate_workflow: Start a step-based workflow (supports initial variables)
|
|
8
|
+
- end_workflow: Complete/terminate active workflow
|
|
9
|
+
- get_workflow_status: Get current workflow state
|
|
10
|
+
- request_step_transition: Request transition to a different step
|
|
11
|
+
- mark_artifact_complete: Register an artifact as complete
|
|
12
|
+
- set_variable: Set a workflow variable for the session
|
|
13
|
+
- get_variable: Get workflow variable(s) for the session
|
|
14
|
+
- get_workflow_status: Get current workflow state
|
|
15
|
+
- request_step_transition: Request transition to a different step
|
|
16
|
+
- mark_artifact_complete: Register an artifact as complete
|
|
17
|
+
- set_variable: Set a workflow variable for the session
|
|
18
|
+
- get_variable: Get workflow variable(s) for the session
|
|
19
|
+
- import_workflow: Import a workflow from a file path
|
|
20
|
+
- reload_cache: Clear the workflow loader cache to pick up file changes
|
|
21
|
+
|
|
22
|
+
These tools are registered with the InternalToolRegistry and accessed
|
|
23
|
+
via the downstream proxy pattern (call_tool, list_tools, get_tool_schema).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
from datetime import UTC, datetime
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from gobby.mcp_proxy.tools.internal import InternalToolRegistry
|
|
32
|
+
from gobby.storage.database import LocalDatabase
|
|
33
|
+
from gobby.storage.sessions import LocalSessionManager
|
|
34
|
+
from gobby.storage.tasks._id import resolve_task_reference
|
|
35
|
+
from gobby.storage.tasks._models import TaskNotFoundError
|
|
36
|
+
from gobby.utils.project_context import get_workflow_project_path
|
|
37
|
+
from gobby.workflows.definitions import WorkflowState
|
|
38
|
+
from gobby.workflows.loader import WorkflowLoader
|
|
39
|
+
from gobby.workflows.state_manager import WorkflowStateManager
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _resolve_session_task_value(
|
|
45
|
+
value: str,
|
|
46
|
+
session_id: str | None,
|
|
47
|
+
session_manager: LocalSessionManager,
|
|
48
|
+
db: LocalDatabase,
|
|
49
|
+
) -> str:
|
|
50
|
+
"""
|
|
51
|
+
Resolve a session_task value from seq_num reference (#N or N) to UUID.
|
|
52
|
+
|
|
53
|
+
This prevents repeated resolution failures in condition evaluation when
|
|
54
|
+
task_tree_complete() is called with a seq_num that requires project_id.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
value: The value to potentially resolve (e.g., "#4424", "47", or a UUID)
|
|
58
|
+
session_id: Session ID to look up project_id
|
|
59
|
+
session_manager: Session manager for lookups
|
|
60
|
+
db: Database for task resolution
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Resolved UUID if value was a seq_num reference, otherwise original value
|
|
64
|
+
"""
|
|
65
|
+
# Only process string values that look like seq_num references
|
|
66
|
+
if not isinstance(value, str):
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
# Check if it's a seq_num reference (#N or plain N)
|
|
70
|
+
is_seq_ref = value.startswith("#") or value.isdigit()
|
|
71
|
+
if not is_seq_ref:
|
|
72
|
+
return value
|
|
73
|
+
|
|
74
|
+
# Need session to get project_id
|
|
75
|
+
if not session_id:
|
|
76
|
+
logger.warning(f"Cannot resolve task reference '{value}': no session_id provided")
|
|
77
|
+
return value
|
|
78
|
+
|
|
79
|
+
# Get project_id from session
|
|
80
|
+
session = session_manager.get(session_id)
|
|
81
|
+
if not session or not session.project_id:
|
|
82
|
+
logger.warning(f"Cannot resolve task reference '{value}': session has no project_id")
|
|
83
|
+
return value
|
|
84
|
+
|
|
85
|
+
# Resolve the reference
|
|
86
|
+
try:
|
|
87
|
+
resolved = resolve_task_reference(db, value, session.project_id)
|
|
88
|
+
logger.debug(f"Resolved session_task '{value}' to UUID '{resolved}'")
|
|
89
|
+
return resolved
|
|
90
|
+
except TaskNotFoundError as e:
|
|
91
|
+
logger.warning(f"Could not resolve task reference '{value}': {e}")
|
|
92
|
+
return value
|
|
93
|
+
except Exception as e:
|
|
94
|
+
logger.warning(f"Unexpected error resolving task reference '{value}': {e}")
|
|
95
|
+
return value
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def create_workflows_registry(
|
|
99
|
+
loader: WorkflowLoader | None = None,
|
|
100
|
+
state_manager: WorkflowStateManager | None = None,
|
|
101
|
+
session_manager: LocalSessionManager | None = None,
|
|
102
|
+
db: LocalDatabase | None = None,
|
|
103
|
+
) -> InternalToolRegistry:
|
|
104
|
+
"""
|
|
105
|
+
Create a workflow tool registry with all workflow-related tools.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
loader: WorkflowLoader instance
|
|
109
|
+
state_manager: WorkflowStateManager instance
|
|
110
|
+
session_manager: LocalSessionManager instance
|
|
111
|
+
db: LocalDatabase instance
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
InternalToolRegistry with workflow tools registered
|
|
115
|
+
"""
|
|
116
|
+
# Create defaults if not provided
|
|
117
|
+
_db = db or LocalDatabase()
|
|
118
|
+
_loader = loader or WorkflowLoader()
|
|
119
|
+
_state_manager = state_manager or WorkflowStateManager(_db)
|
|
120
|
+
_session_manager = session_manager or LocalSessionManager(_db)
|
|
121
|
+
|
|
122
|
+
registry = InternalToolRegistry(
|
|
123
|
+
name="gobby-workflows",
|
|
124
|
+
description="Workflow management - list, activate, status, transition, end",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
@registry.tool(
|
|
128
|
+
name="get_workflow",
|
|
129
|
+
description="Get details about a specific workflow definition.",
|
|
130
|
+
)
|
|
131
|
+
def get_workflow(
|
|
132
|
+
name: str,
|
|
133
|
+
project_path: str | None = None,
|
|
134
|
+
) -> dict[str, Any]:
|
|
135
|
+
"""
|
|
136
|
+
Get workflow details including steps, triggers, and settings.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
name: Workflow name (without .yaml extension)
|
|
140
|
+
project_path: Project directory path. Auto-discovered from cwd if not provided.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Workflow definition details
|
|
144
|
+
"""
|
|
145
|
+
# Auto-discover project path if not provided
|
|
146
|
+
if not project_path:
|
|
147
|
+
discovered = get_workflow_project_path()
|
|
148
|
+
if discovered:
|
|
149
|
+
project_path = str(discovered)
|
|
150
|
+
|
|
151
|
+
proj = Path(project_path) if project_path else None
|
|
152
|
+
definition = _loader.load_workflow(name, proj)
|
|
153
|
+
|
|
154
|
+
if not definition:
|
|
155
|
+
return {"success": False, "error": f"Workflow '{name}' not found"}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
"success": True,
|
|
159
|
+
"name": definition.name,
|
|
160
|
+
"type": definition.type,
|
|
161
|
+
"description": definition.description,
|
|
162
|
+
"version": definition.version,
|
|
163
|
+
"steps": (
|
|
164
|
+
[
|
|
165
|
+
{
|
|
166
|
+
"name": s.name,
|
|
167
|
+
"description": s.description,
|
|
168
|
+
"allowed_tools": s.allowed_tools,
|
|
169
|
+
"blocked_tools": s.blocked_tools,
|
|
170
|
+
}
|
|
171
|
+
for s in definition.steps
|
|
172
|
+
]
|
|
173
|
+
if definition.steps
|
|
174
|
+
else []
|
|
175
|
+
),
|
|
176
|
+
"triggers": (
|
|
177
|
+
{name: len(actions) for name, actions in definition.triggers.items()}
|
|
178
|
+
if definition.triggers
|
|
179
|
+
else {}
|
|
180
|
+
),
|
|
181
|
+
"settings": definition.settings,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
@registry.tool(
|
|
185
|
+
name="list_workflows",
|
|
186
|
+
description="List available workflow definitions from project and global directories.",
|
|
187
|
+
)
|
|
188
|
+
def list_workflows(
|
|
189
|
+
project_path: str | None = None,
|
|
190
|
+
workflow_type: str | None = None,
|
|
191
|
+
global_only: bool = False,
|
|
192
|
+
) -> dict[str, Any]:
|
|
193
|
+
"""
|
|
194
|
+
List available workflows.
|
|
195
|
+
|
|
196
|
+
Lists workflows from both project (.gobby/workflows) and global (~/.gobby/workflows)
|
|
197
|
+
directories. Project workflows shadow global ones with the same name.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
project_path: Project directory path. Auto-discovered from cwd if not provided.
|
|
201
|
+
workflow_type: Filter by type ("step" or "lifecycle")
|
|
202
|
+
global_only: If True, only show global workflows (ignore project)
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
List of workflows with name, type, description, and source
|
|
206
|
+
"""
|
|
207
|
+
import yaml
|
|
208
|
+
|
|
209
|
+
# Auto-discover project path if not provided
|
|
210
|
+
if not project_path:
|
|
211
|
+
discovered = get_workflow_project_path()
|
|
212
|
+
if discovered:
|
|
213
|
+
project_path = str(discovered)
|
|
214
|
+
|
|
215
|
+
search_dirs = list(_loader.global_dirs)
|
|
216
|
+
proj = Path(project_path) if project_path else None
|
|
217
|
+
|
|
218
|
+
# Include project workflows unless global_only (project searched first to shadow global)
|
|
219
|
+
if not global_only and proj:
|
|
220
|
+
project_dir = proj / ".gobby" / "workflows"
|
|
221
|
+
if project_dir.exists():
|
|
222
|
+
search_dirs.insert(0, project_dir)
|
|
223
|
+
|
|
224
|
+
workflows = []
|
|
225
|
+
seen_names = set()
|
|
226
|
+
|
|
227
|
+
for search_dir in search_dirs:
|
|
228
|
+
if not search_dir.exists():
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
is_project = proj and search_dir == (proj / ".gobby" / "workflows")
|
|
232
|
+
|
|
233
|
+
for yaml_path in search_dir.glob("*.yaml"):
|
|
234
|
+
name = yaml_path.stem
|
|
235
|
+
if name in seen_names:
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
with open(yaml_path) as f:
|
|
240
|
+
data = yaml.safe_load(f)
|
|
241
|
+
|
|
242
|
+
if not data:
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
wf_type = data.get("type", "step")
|
|
246
|
+
|
|
247
|
+
if workflow_type and wf_type != workflow_type:
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
workflows.append(
|
|
251
|
+
{
|
|
252
|
+
"name": name,
|
|
253
|
+
"type": wf_type,
|
|
254
|
+
"description": data.get("description", ""),
|
|
255
|
+
"source": "project" if is_project else "global",
|
|
256
|
+
}
|
|
257
|
+
)
|
|
258
|
+
seen_names.add(name)
|
|
259
|
+
|
|
260
|
+
except Exception:
|
|
261
|
+
pass # nosec B110 - skip invalid workflow files
|
|
262
|
+
|
|
263
|
+
return {"workflows": workflows, "count": len(workflows)}
|
|
264
|
+
|
|
265
|
+
@registry.tool(
|
|
266
|
+
name="activate_workflow",
|
|
267
|
+
description="Activate a step-based workflow for the current session.",
|
|
268
|
+
)
|
|
269
|
+
def activate_workflow(
|
|
270
|
+
name: str,
|
|
271
|
+
session_id: str | None = None,
|
|
272
|
+
initial_step: str | None = None,
|
|
273
|
+
variables: dict[str, Any] | None = None,
|
|
274
|
+
project_path: str | None = None,
|
|
275
|
+
) -> dict[str, Any]:
|
|
276
|
+
"""
|
|
277
|
+
Activate a step-based workflow for the current session.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
name: Workflow name (e.g., "plan-act-reflect", "auto-task")
|
|
281
|
+
session_id: Required session ID (must be provided to prevent cross-session bleed)
|
|
282
|
+
initial_step: Optional starting step (defaults to first step)
|
|
283
|
+
variables: Optional initial variables to set (merged with workflow defaults)
|
|
284
|
+
project_path: Project directory path. Auto-discovered from cwd if not provided.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
Success status, workflow info, and current step.
|
|
288
|
+
|
|
289
|
+
Example:
|
|
290
|
+
activate_workflow(
|
|
291
|
+
name="auto-task",
|
|
292
|
+
variables={"session_task": "#47"},
|
|
293
|
+
session_id="..."
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
Errors if:
|
|
297
|
+
- session_id not provided
|
|
298
|
+
- Another step-based workflow is currently active
|
|
299
|
+
- Workflow not found
|
|
300
|
+
- Workflow is lifecycle type (those auto-run, not manually activated)
|
|
301
|
+
"""
|
|
302
|
+
# Auto-discover project path if not provided
|
|
303
|
+
if not project_path:
|
|
304
|
+
discovered = get_workflow_project_path()
|
|
305
|
+
if discovered:
|
|
306
|
+
project_path = str(discovered)
|
|
307
|
+
|
|
308
|
+
proj = Path(project_path) if project_path else None
|
|
309
|
+
|
|
310
|
+
# Load workflow
|
|
311
|
+
definition = _loader.load_workflow(name, proj)
|
|
312
|
+
if not definition:
|
|
313
|
+
return {"success": False, "error": f"Workflow '{name}' not found"}
|
|
314
|
+
|
|
315
|
+
if definition.type == "lifecycle":
|
|
316
|
+
return {
|
|
317
|
+
"success": False,
|
|
318
|
+
"error": f"Workflow '{name}' is lifecycle type (auto-runs on events, not manually activated)",
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
# Require explicit session_id to prevent cross-session bleed
|
|
322
|
+
if not session_id:
|
|
323
|
+
return {
|
|
324
|
+
"success": False,
|
|
325
|
+
"error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
# Check for existing workflow
|
|
329
|
+
# Allow if:
|
|
330
|
+
# - No existing state
|
|
331
|
+
# - Existing is __lifecycle__ placeholder
|
|
332
|
+
# - Existing is a lifecycle-type workflow (they run concurrently with step workflows)
|
|
333
|
+
existing = _state_manager.get_state(session_id)
|
|
334
|
+
if existing and existing.workflow_name != "__lifecycle__":
|
|
335
|
+
# Check if existing workflow is a lifecycle type
|
|
336
|
+
existing_def = _loader.load_workflow(existing.workflow_name, proj)
|
|
337
|
+
# Only allow if we can confirm it's a lifecycle workflow
|
|
338
|
+
# If definition not found or it's a step workflow, block activation
|
|
339
|
+
if not existing_def or existing_def.type != "lifecycle":
|
|
340
|
+
# It's a step workflow (or unknown) - can only have one active
|
|
341
|
+
return {
|
|
342
|
+
"success": False,
|
|
343
|
+
"error": f"Session already has step workflow '{existing.workflow_name}' active. Use end_workflow first.",
|
|
344
|
+
}
|
|
345
|
+
# Existing is a lifecycle workflow - allow step workflow to activate alongside it
|
|
346
|
+
|
|
347
|
+
# Determine initial step
|
|
348
|
+
if initial_step:
|
|
349
|
+
if not any(s.name == initial_step for s in definition.steps):
|
|
350
|
+
return {
|
|
351
|
+
"success": False,
|
|
352
|
+
"error": f"Step '{initial_step}' not found. Available: {[s.name for s in definition.steps]}",
|
|
353
|
+
}
|
|
354
|
+
step = initial_step
|
|
355
|
+
else:
|
|
356
|
+
step = definition.steps[0].name if definition.steps else "default"
|
|
357
|
+
|
|
358
|
+
# Merge workflow default variables with passed-in variables
|
|
359
|
+
merged_variables = dict(definition.variables) # Start with workflow defaults
|
|
360
|
+
if variables:
|
|
361
|
+
merged_variables.update(variables) # Override with passed-in values
|
|
362
|
+
|
|
363
|
+
# Resolve session_task references (#N or N) to UUIDs upfront
|
|
364
|
+
# This prevents repeated resolution failures in condition evaluation
|
|
365
|
+
if "session_task" in merged_variables:
|
|
366
|
+
session_task_val = merged_variables["session_task"]
|
|
367
|
+
if isinstance(session_task_val, str):
|
|
368
|
+
merged_variables["session_task"] = _resolve_session_task_value(
|
|
369
|
+
session_task_val, session_id, _session_manager, _db
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
# Create state
|
|
373
|
+
state = WorkflowState(
|
|
374
|
+
session_id=session_id,
|
|
375
|
+
workflow_name=name,
|
|
376
|
+
step=step,
|
|
377
|
+
step_entered_at=datetime.now(UTC),
|
|
378
|
+
step_action_count=0,
|
|
379
|
+
total_action_count=0,
|
|
380
|
+
artifacts={},
|
|
381
|
+
observations=[],
|
|
382
|
+
reflection_pending=False,
|
|
383
|
+
context_injected=False,
|
|
384
|
+
variables=merged_variables,
|
|
385
|
+
task_list=None,
|
|
386
|
+
current_task_index=0,
|
|
387
|
+
files_modified_this_task=0,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
_state_manager.save_state(state)
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
"success": True,
|
|
394
|
+
"session_id": session_id,
|
|
395
|
+
"workflow": name,
|
|
396
|
+
"step": step,
|
|
397
|
+
"steps": [s.name for s in definition.steps],
|
|
398
|
+
"variables": merged_variables,
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
@registry.tool(
|
|
402
|
+
name="end_workflow",
|
|
403
|
+
description="End the currently active step-based workflow.",
|
|
404
|
+
)
|
|
405
|
+
def end_workflow(
|
|
406
|
+
session_id: str | None = None,
|
|
407
|
+
reason: str | None = None,
|
|
408
|
+
) -> dict[str, Any]:
|
|
409
|
+
"""
|
|
410
|
+
End the currently active step-based workflow.
|
|
411
|
+
|
|
412
|
+
Allows starting a different workflow afterward.
|
|
413
|
+
Does not affect lifecycle workflows (they continue running).
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
session_id: Required session ID (must be provided to prevent cross-session bleed)
|
|
417
|
+
reason: Optional reason for ending
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
Success status
|
|
421
|
+
"""
|
|
422
|
+
# Require explicit session_id to prevent cross-session bleed
|
|
423
|
+
if not session_id:
|
|
424
|
+
return {
|
|
425
|
+
"success": False,
|
|
426
|
+
"error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
state = _state_manager.get_state(session_id)
|
|
430
|
+
if not state:
|
|
431
|
+
return {"error": "No workflow active for session"}
|
|
432
|
+
|
|
433
|
+
_state_manager.delete_state(session_id)
|
|
434
|
+
|
|
435
|
+
return {}
|
|
436
|
+
|
|
437
|
+
@registry.tool(
|
|
438
|
+
name="get_workflow_status",
|
|
439
|
+
description="Get current workflow step and state.",
|
|
440
|
+
)
|
|
441
|
+
def get_workflow_status(session_id: str | None = None) -> dict[str, Any]:
|
|
442
|
+
"""
|
|
443
|
+
Get current workflow step and state.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
session_id: Required session ID (must be provided to prevent cross-session bleed)
|
|
447
|
+
|
|
448
|
+
Returns:
|
|
449
|
+
Workflow state including step, action counts, artifacts
|
|
450
|
+
"""
|
|
451
|
+
# Require explicit session_id to prevent cross-session bleed
|
|
452
|
+
if not session_id:
|
|
453
|
+
return {
|
|
454
|
+
"has_workflow": False,
|
|
455
|
+
"error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
state = _state_manager.get_state(session_id)
|
|
459
|
+
if not state:
|
|
460
|
+
return {"has_workflow": False, "session_id": session_id}
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
"has_workflow": True,
|
|
464
|
+
"session_id": session_id,
|
|
465
|
+
"workflow_name": state.workflow_name,
|
|
466
|
+
"step": state.step,
|
|
467
|
+
"step_action_count": state.step_action_count,
|
|
468
|
+
"total_action_count": state.total_action_count,
|
|
469
|
+
"reflection_pending": state.reflection_pending,
|
|
470
|
+
"artifacts": list(state.artifacts.keys()) if state.artifacts else [],
|
|
471
|
+
"variables": state.variables,
|
|
472
|
+
"task_progress": (
|
|
473
|
+
f"{state.current_task_index + 1}/{len(state.task_list)}"
|
|
474
|
+
if state.task_list
|
|
475
|
+
else None
|
|
476
|
+
),
|
|
477
|
+
"updated_at": state.updated_at.isoformat() if state.updated_at else None,
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
@registry.tool(
|
|
481
|
+
name="request_step_transition",
|
|
482
|
+
description="Request transition to a different step.",
|
|
483
|
+
)
|
|
484
|
+
def request_step_transition(
|
|
485
|
+
to_step: str,
|
|
486
|
+
reason: str | None = None,
|
|
487
|
+
session_id: str | None = None,
|
|
488
|
+
force: bool = False,
|
|
489
|
+
project_path: str | None = None,
|
|
490
|
+
) -> dict[str, Any]:
|
|
491
|
+
"""
|
|
492
|
+
Request transition to a different step. May require approval.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
to_step: Target step name
|
|
496
|
+
reason: Reason for transition
|
|
497
|
+
session_id: Required session ID (must be provided to prevent cross-session bleed)
|
|
498
|
+
force: Skip exit condition checks
|
|
499
|
+
project_path: Project directory path. Auto-discovered from cwd if not provided.
|
|
500
|
+
|
|
501
|
+
Returns:
|
|
502
|
+
Success status and new step info
|
|
503
|
+
"""
|
|
504
|
+
# Auto-discover project path if not provided
|
|
505
|
+
if not project_path:
|
|
506
|
+
discovered = get_workflow_project_path()
|
|
507
|
+
if discovered:
|
|
508
|
+
project_path = str(discovered)
|
|
509
|
+
|
|
510
|
+
proj = Path(project_path) if project_path else None
|
|
511
|
+
|
|
512
|
+
# Require explicit session_id to prevent cross-session bleed
|
|
513
|
+
if not session_id:
|
|
514
|
+
return {
|
|
515
|
+
"success": False,
|
|
516
|
+
"error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
state = _state_manager.get_state(session_id)
|
|
520
|
+
if not state:
|
|
521
|
+
return {"success": False, "error": "No workflow active for session"}
|
|
522
|
+
|
|
523
|
+
# Load workflow to validate step
|
|
524
|
+
definition = _loader.load_workflow(state.workflow_name, proj)
|
|
525
|
+
if not definition:
|
|
526
|
+
return {"success": False, "error": f"Workflow '{state.workflow_name}' not found"}
|
|
527
|
+
|
|
528
|
+
if not any(s.name == to_step for s in definition.steps):
|
|
529
|
+
return {
|
|
530
|
+
"success": False,
|
|
531
|
+
"error": f"Step '{to_step}' not found. Available: {[s.name for s in definition.steps]}",
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
# Block manual transitions to steps that have conditional auto-transitions
|
|
535
|
+
# These steps should only be reached when their conditions are met
|
|
536
|
+
current_step_def = next((s for s in definition.steps if s.name == state.step), None)
|
|
537
|
+
if current_step_def and current_step_def.transitions:
|
|
538
|
+
for transition in current_step_def.transitions:
|
|
539
|
+
if transition.to == to_step and transition.when:
|
|
540
|
+
# This step has a conditional transition - block manual transition
|
|
541
|
+
return {
|
|
542
|
+
"success": False,
|
|
543
|
+
"error": (
|
|
544
|
+
f"Step '{to_step}' has a conditional auto-transition "
|
|
545
|
+
f"(when: {transition.when}). Manual transitions to this step "
|
|
546
|
+
f"are blocked to prevent workflow circumvention. "
|
|
547
|
+
f"The transition will occur automatically when the condition is met."
|
|
548
|
+
),
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
old_step = state.step
|
|
552
|
+
state.step = to_step
|
|
553
|
+
state.step_entered_at = datetime.now(UTC)
|
|
554
|
+
state.step_action_count = 0
|
|
555
|
+
|
|
556
|
+
_state_manager.save_state(state)
|
|
557
|
+
|
|
558
|
+
return {
|
|
559
|
+
"success": True,
|
|
560
|
+
"from_step": old_step,
|
|
561
|
+
"to_step": to_step,
|
|
562
|
+
"reason": reason,
|
|
563
|
+
"forced": force,
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
@registry.tool(
|
|
567
|
+
name="mark_artifact_complete",
|
|
568
|
+
description="Register an artifact as complete (plan, spec, etc.).",
|
|
569
|
+
)
|
|
570
|
+
def mark_artifact_complete(
|
|
571
|
+
artifact_type: str,
|
|
572
|
+
file_path: str,
|
|
573
|
+
session_id: str | None = None,
|
|
574
|
+
) -> dict[str, Any]:
|
|
575
|
+
"""
|
|
576
|
+
Register an artifact as complete.
|
|
577
|
+
|
|
578
|
+
Args:
|
|
579
|
+
artifact_type: Type of artifact (e.g., "plan", "spec", "test")
|
|
580
|
+
file_path: Path to the artifact file
|
|
581
|
+
session_id: Required session ID (must be provided to prevent cross-session bleed)
|
|
582
|
+
|
|
583
|
+
Returns:
|
|
584
|
+
Success status
|
|
585
|
+
"""
|
|
586
|
+
# Require explicit session_id to prevent cross-session bleed
|
|
587
|
+
if not session_id:
|
|
588
|
+
return {
|
|
589
|
+
"success": False,
|
|
590
|
+
"error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
state = _state_manager.get_state(session_id)
|
|
594
|
+
if not state:
|
|
595
|
+
return {"error": "No workflow active for session"}
|
|
596
|
+
|
|
597
|
+
# Update artifacts
|
|
598
|
+
state.artifacts[artifact_type] = file_path
|
|
599
|
+
_state_manager.save_state(state)
|
|
600
|
+
|
|
601
|
+
return {}
|
|
602
|
+
|
|
603
|
+
@registry.tool(
|
|
604
|
+
name="set_variable",
|
|
605
|
+
description="Set a workflow variable for the current session (session-scoped, not persisted to YAML).",
|
|
606
|
+
)
|
|
607
|
+
def set_variable(
|
|
608
|
+
name: str,
|
|
609
|
+
value: str | int | float | bool | None,
|
|
610
|
+
session_id: str | None = None,
|
|
611
|
+
) -> dict[str, Any]:
|
|
612
|
+
"""
|
|
613
|
+
Set a workflow variable for the current session.
|
|
614
|
+
|
|
615
|
+
Variables set this way are session-scoped - they persist in the database
|
|
616
|
+
for the duration of the session but do not modify the workflow YAML file.
|
|
617
|
+
|
|
618
|
+
This is useful for:
|
|
619
|
+
- Setting session_epic to enforce epic completion before stopping
|
|
620
|
+
- Setting is_worktree to mark a session as a worktree agent
|
|
621
|
+
- Dynamic configuration without modifying workflow definitions
|
|
622
|
+
|
|
623
|
+
Args:
|
|
624
|
+
name: Variable name (e.g., "session_epic", "is_worktree")
|
|
625
|
+
value: Variable value (string, number, boolean, or null)
|
|
626
|
+
session_id: Required session ID (must be provided to prevent cross-session bleed)
|
|
627
|
+
|
|
628
|
+
Returns:
|
|
629
|
+
Success status and updated variables
|
|
630
|
+
"""
|
|
631
|
+
# Require explicit session_id to prevent cross-session bleed
|
|
632
|
+
if not session_id:
|
|
633
|
+
return {
|
|
634
|
+
"success": False,
|
|
635
|
+
"error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
# Get or create state
|
|
639
|
+
state = _state_manager.get_state(session_id)
|
|
640
|
+
if not state:
|
|
641
|
+
# Create a minimal lifecycle state for variable storage
|
|
642
|
+
state = WorkflowState(
|
|
643
|
+
session_id=session_id,
|
|
644
|
+
workflow_name="__lifecycle__",
|
|
645
|
+
step="",
|
|
646
|
+
step_entered_at=datetime.now(UTC),
|
|
647
|
+
variables={},
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
# Block modification of session_task when a real workflow is active
|
|
651
|
+
# This prevents circumventing workflows by changing the tracked task
|
|
652
|
+
if name == "session_task" and state.workflow_name != "__lifecycle__":
|
|
653
|
+
current_value = state.variables.get("session_task")
|
|
654
|
+
if current_value is not None and value != current_value:
|
|
655
|
+
return {
|
|
656
|
+
"success": False,
|
|
657
|
+
"error": (
|
|
658
|
+
f"Cannot modify session_task while workflow '{state.workflow_name}' is active. "
|
|
659
|
+
f"Current value: {current_value}. "
|
|
660
|
+
f"Use end_workflow() first if you need to change the tracked task."
|
|
661
|
+
),
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
# Resolve session_task references (#N or N) to UUIDs upfront
|
|
665
|
+
# This prevents repeated resolution failures in condition evaluation
|
|
666
|
+
if name == "session_task" and isinstance(value, str):
|
|
667
|
+
value = _resolve_session_task_value(value, session_id, _session_manager, _db)
|
|
668
|
+
|
|
669
|
+
# Set the variable
|
|
670
|
+
state.variables[name] = value
|
|
671
|
+
_state_manager.save_state(state)
|
|
672
|
+
|
|
673
|
+
# Add deprecation warning for session_task variable (when no workflow active)
|
|
674
|
+
if name == "session_task" and value and state.workflow_name == "__lifecycle__":
|
|
675
|
+
return {
|
|
676
|
+
"warning": (
|
|
677
|
+
"DEPRECATED: Setting session_task directly is deprecated. "
|
|
678
|
+
"Use activate_workflow(name='auto-task', variables={'session_task': ...}) instead "
|
|
679
|
+
"for proper state machine semantics and on_premature_stop handling."
|
|
680
|
+
),
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
return {}
|
|
684
|
+
|
|
685
|
+
@registry.tool(
|
|
686
|
+
name="get_variable",
|
|
687
|
+
description="Get workflow variable(s) for the current session.",
|
|
688
|
+
)
|
|
689
|
+
def get_variable(
|
|
690
|
+
name: str | None = None,
|
|
691
|
+
session_id: str | None = None,
|
|
692
|
+
) -> dict[str, Any]:
|
|
693
|
+
"""
|
|
694
|
+
Get workflow variable(s) for the current session.
|
|
695
|
+
|
|
696
|
+
Args:
|
|
697
|
+
name: Variable name to get (if None, returns all variables)
|
|
698
|
+
session_id: Required session ID (must be provided to prevent cross-session bleed)
|
|
699
|
+
|
|
700
|
+
Returns:
|
|
701
|
+
Variable value(s) and session info
|
|
702
|
+
"""
|
|
703
|
+
# Require explicit session_id to prevent cross-session bleed
|
|
704
|
+
if not session_id:
|
|
705
|
+
return {
|
|
706
|
+
"success": False,
|
|
707
|
+
"error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
state = _state_manager.get_state(session_id)
|
|
711
|
+
if not state:
|
|
712
|
+
if name:
|
|
713
|
+
return {
|
|
714
|
+
"success": True,
|
|
715
|
+
"session_id": session_id,
|
|
716
|
+
"variable": name,
|
|
717
|
+
"value": None,
|
|
718
|
+
"exists": False,
|
|
719
|
+
}
|
|
720
|
+
return {
|
|
721
|
+
"success": True,
|
|
722
|
+
"session_id": session_id,
|
|
723
|
+
"variables": {},
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if name:
|
|
727
|
+
value = state.variables.get(name)
|
|
728
|
+
return {
|
|
729
|
+
"success": True,
|
|
730
|
+
"session_id": session_id,
|
|
731
|
+
"variable": name,
|
|
732
|
+
"value": value,
|
|
733
|
+
"exists": name in state.variables,
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
return {
|
|
737
|
+
"success": True,
|
|
738
|
+
"session_id": session_id,
|
|
739
|
+
"variables": state.variables,
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
@registry.tool(
|
|
743
|
+
name="import_workflow",
|
|
744
|
+
description="Import a workflow from a file path into the project or global directory.",
|
|
745
|
+
)
|
|
746
|
+
def import_workflow(
|
|
747
|
+
source_path: str,
|
|
748
|
+
workflow_name: str | None = None,
|
|
749
|
+
is_global: bool = False,
|
|
750
|
+
project_path: str | None = None,
|
|
751
|
+
) -> dict[str, Any]:
|
|
752
|
+
"""
|
|
753
|
+
Import a workflow from a file.
|
|
754
|
+
|
|
755
|
+
Args:
|
|
756
|
+
source_path: Path to the workflow YAML file
|
|
757
|
+
workflow_name: Override the workflow name (defaults to name in file)
|
|
758
|
+
is_global: Install to global ~/.gobby/workflows instead of project
|
|
759
|
+
project_path: Project directory path. Auto-discovered from cwd if not provided.
|
|
760
|
+
|
|
761
|
+
Returns:
|
|
762
|
+
Success status and destination path
|
|
763
|
+
"""
|
|
764
|
+
import shutil
|
|
765
|
+
|
|
766
|
+
import yaml
|
|
767
|
+
|
|
768
|
+
source = Path(source_path)
|
|
769
|
+
if not source.exists():
|
|
770
|
+
return {"success": False, "error": f"File not found: {source_path}"}
|
|
771
|
+
|
|
772
|
+
if source.suffix != ".yaml":
|
|
773
|
+
return {"success": False, "error": "Workflow file must have .yaml extension"}
|
|
774
|
+
|
|
775
|
+
try:
|
|
776
|
+
with open(source) as f:
|
|
777
|
+
data = yaml.safe_load(f)
|
|
778
|
+
|
|
779
|
+
if not data or "name" not in data:
|
|
780
|
+
return {"success": False, "error": "Invalid workflow: missing 'name' field"}
|
|
781
|
+
|
|
782
|
+
except yaml.YAMLError as e:
|
|
783
|
+
return {"success": False, "error": f"Invalid YAML: {e}"}
|
|
784
|
+
|
|
785
|
+
name = workflow_name or data.get("name", source.stem)
|
|
786
|
+
filename = f"{name}.yaml"
|
|
787
|
+
|
|
788
|
+
if is_global:
|
|
789
|
+
dest_dir = Path.home() / ".gobby" / "workflows"
|
|
790
|
+
else:
|
|
791
|
+
# Auto-discover project path if not provided
|
|
792
|
+
if not project_path:
|
|
793
|
+
discovered = get_workflow_project_path()
|
|
794
|
+
if discovered:
|
|
795
|
+
project_path = str(discovered)
|
|
796
|
+
|
|
797
|
+
proj = Path(project_path) if project_path else None
|
|
798
|
+
if not proj:
|
|
799
|
+
return {
|
|
800
|
+
"success": False,
|
|
801
|
+
"error": "project_path required when not using is_global (could not auto-discover)",
|
|
802
|
+
}
|
|
803
|
+
dest_dir = proj / ".gobby" / "workflows"
|
|
804
|
+
|
|
805
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
806
|
+
dest_path = dest_dir / filename
|
|
807
|
+
|
|
808
|
+
shutil.copy(source, dest_path)
|
|
809
|
+
|
|
810
|
+
# Clear loader cache so new workflow is discoverable
|
|
811
|
+
_loader.clear_discovery_cache()
|
|
812
|
+
|
|
813
|
+
return {
|
|
814
|
+
"success": True,
|
|
815
|
+
"workflow_name": name,
|
|
816
|
+
"destination": str(dest_path),
|
|
817
|
+
"is_global": is_global,
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
@registry.tool(
|
|
821
|
+
name="reload_cache",
|
|
822
|
+
description="Clear the workflow cache. Use this after modifying workflow YAML files.",
|
|
823
|
+
)
|
|
824
|
+
def reload_cache() -> dict[str, Any]:
|
|
825
|
+
"""
|
|
826
|
+
Clear the workflow loader cache.
|
|
827
|
+
|
|
828
|
+
This forces the daemon to re-read workflow YAML files from disk
|
|
829
|
+
on the next access. Use this when you've modified workflow files
|
|
830
|
+
and want the changes to take effect immediately without restarting
|
|
831
|
+
the daemon.
|
|
832
|
+
|
|
833
|
+
Returns:
|
|
834
|
+
Success status
|
|
835
|
+
"""
|
|
836
|
+
_loader.clear_cache()
|
|
837
|
+
logger.info("Workflow cache cleared via reload_cache tool")
|
|
838
|
+
return {"message": "Workflow cache cleared"}
|
|
839
|
+
|
|
840
|
+
@registry.tool(
|
|
841
|
+
name="close_terminal",
|
|
842
|
+
description=(
|
|
843
|
+
"Close the current terminal window/pane (agent self-termination). "
|
|
844
|
+
"Launches ~/.gobby/scripts/agent_shutdown.sh which handles "
|
|
845
|
+
"terminal-specific shutdown (tmux, iTerm, etc.). Rebuilds script if missing."
|
|
846
|
+
),
|
|
847
|
+
)
|
|
848
|
+
async def close_terminal(
|
|
849
|
+
signal: str = "TERM",
|
|
850
|
+
delay_ms: int = 0,
|
|
851
|
+
) -> dict[str, Any]:
|
|
852
|
+
"""
|
|
853
|
+
Close the current terminal by running the agent shutdown script.
|
|
854
|
+
|
|
855
|
+
This is for agent self-termination (meeseeks-style). The agent calls
|
|
856
|
+
this to close its own terminal window when done with its workflow.
|
|
857
|
+
|
|
858
|
+
The script is located at ~/.gobby/scripts/agent_shutdown.sh and is
|
|
859
|
+
automatically rebuilt if missing. It handles different terminal types
|
|
860
|
+
(tmux, iTerm, Terminal.app, Ghostty, Kitty, WezTerm, etc.).
|
|
861
|
+
|
|
862
|
+
Args:
|
|
863
|
+
signal: Signal to use for shutdown (TERM, KILL, INT). Default: TERM.
|
|
864
|
+
delay_ms: Optional delay in milliseconds before shutdown. Default: 0.
|
|
865
|
+
|
|
866
|
+
Returns:
|
|
867
|
+
Dict with success status and message.
|
|
868
|
+
"""
|
|
869
|
+
import asyncio
|
|
870
|
+
import os
|
|
871
|
+
import stat
|
|
872
|
+
import subprocess # nosec B404 - subprocess needed for agent shutdown script
|
|
873
|
+
|
|
874
|
+
# Script location
|
|
875
|
+
gobby_dir = Path.home() / ".gobby"
|
|
876
|
+
scripts_dir = gobby_dir / "scripts"
|
|
877
|
+
script_path = scripts_dir / "agent_shutdown.sh"
|
|
878
|
+
|
|
879
|
+
# Source script from the install directory (single source of truth)
|
|
880
|
+
source_script_path = (
|
|
881
|
+
Path(__file__).parent.parent.parent
|
|
882
|
+
/ "install"
|
|
883
|
+
/ "shared"
|
|
884
|
+
/ "scripts"
|
|
885
|
+
/ "agent_shutdown.sh"
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
def get_script_version(script_content: str) -> str | None:
|
|
889
|
+
"""Extract VERSION marker from script content."""
|
|
890
|
+
import re
|
|
891
|
+
|
|
892
|
+
match = re.search(r"^# VERSION:\s*(.+)$", script_content, re.MULTILINE)
|
|
893
|
+
return match.group(1).strip() if match else None
|
|
894
|
+
|
|
895
|
+
# Ensure directories exist and script is present/up-to-date
|
|
896
|
+
script_rebuilt = False
|
|
897
|
+
try:
|
|
898
|
+
scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
899
|
+
|
|
900
|
+
# Read source script content
|
|
901
|
+
if source_script_path.exists():
|
|
902
|
+
source_content = source_script_path.read_text()
|
|
903
|
+
source_version = get_script_version(source_content)
|
|
904
|
+
else:
|
|
905
|
+
logger.warning(f"Source shutdown script not found at {source_script_path}")
|
|
906
|
+
source_content = None
|
|
907
|
+
source_version = None
|
|
908
|
+
|
|
909
|
+
# Check if installed script exists and compare versions
|
|
910
|
+
needs_rebuild = False
|
|
911
|
+
if not script_path.exists():
|
|
912
|
+
needs_rebuild = True
|
|
913
|
+
elif source_content:
|
|
914
|
+
installed_content = script_path.read_text()
|
|
915
|
+
installed_version = get_script_version(installed_content)
|
|
916
|
+
# Rebuild if versions differ or installed has no version marker
|
|
917
|
+
if installed_version != source_version:
|
|
918
|
+
needs_rebuild = True
|
|
919
|
+
logger.info(
|
|
920
|
+
f"Shutdown script version mismatch: installed={installed_version}, source={source_version}"
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
if needs_rebuild and source_content:
|
|
924
|
+
script_path.write_text(source_content)
|
|
925
|
+
# Make executable
|
|
926
|
+
script_path.chmod(script_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP)
|
|
927
|
+
script_rebuilt = True
|
|
928
|
+
logger.info(f"Created/updated agent shutdown script at {script_path}")
|
|
929
|
+
except OSError as e:
|
|
930
|
+
return {
|
|
931
|
+
"success": False,
|
|
932
|
+
"error": f"Failed to create shutdown script: {e}",
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
# Validate signal
|
|
936
|
+
valid_signals = {"TERM", "KILL", "INT", "HUP", "QUIT"}
|
|
937
|
+
if signal.upper() not in valid_signals:
|
|
938
|
+
return {
|
|
939
|
+
"success": False,
|
|
940
|
+
"error": f"Invalid signal '{signal}'. Valid: {valid_signals}",
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
# Apply delay before launching script (non-blocking)
|
|
944
|
+
if delay_ms > 0:
|
|
945
|
+
await asyncio.sleep(delay_ms / 1000.0)
|
|
946
|
+
|
|
947
|
+
# Launch the script
|
|
948
|
+
try:
|
|
949
|
+
# Run in background - we don't wait for it since it kills our process
|
|
950
|
+
env = os.environ.copy()
|
|
951
|
+
|
|
952
|
+
subprocess.Popen( # nosec B603 - script path is from gobby scripts directory
|
|
953
|
+
[str(script_path), signal.upper(), "0"], # Delay already applied
|
|
954
|
+
env=env,
|
|
955
|
+
start_new_session=True, # Detach from parent
|
|
956
|
+
stdout=subprocess.DEVNULL,
|
|
957
|
+
stderr=subprocess.DEVNULL,
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
return {
|
|
961
|
+
"success": True,
|
|
962
|
+
"message": "Shutdown script launched",
|
|
963
|
+
"script_path": str(script_path),
|
|
964
|
+
"script_rebuilt": script_rebuilt,
|
|
965
|
+
"signal": signal.upper(),
|
|
966
|
+
}
|
|
967
|
+
except Exception as e:
|
|
968
|
+
return {
|
|
969
|
+
"success": False,
|
|
970
|
+
"error": f"Failed to launch shutdown script: {e}",
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
return registry
|