gobby 0.2.8__py3-none-any.whl → 0.2.11__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +6 -0
  3. gobby/adapters/base.py +11 -2
  4. gobby/adapters/claude_code.py +5 -28
  5. gobby/adapters/codex_impl/adapter.py +38 -43
  6. gobby/adapters/copilot.py +324 -0
  7. gobby/adapters/cursor.py +373 -0
  8. gobby/adapters/gemini.py +2 -26
  9. gobby/adapters/windsurf.py +359 -0
  10. gobby/agents/definitions.py +162 -2
  11. gobby/agents/isolation.py +33 -1
  12. gobby/agents/pty_reader.py +192 -0
  13. gobby/agents/registry.py +10 -1
  14. gobby/agents/runner.py +24 -8
  15. gobby/agents/sandbox.py +8 -3
  16. gobby/agents/session.py +4 -0
  17. gobby/agents/spawn.py +9 -2
  18. gobby/agents/spawn_executor.py +49 -61
  19. gobby/agents/spawners/command_builder.py +4 -4
  20. gobby/app_context.py +64 -0
  21. gobby/cli/__init__.py +4 -0
  22. gobby/cli/install.py +259 -4
  23. gobby/cli/installers/__init__.py +12 -0
  24. gobby/cli/installers/copilot.py +242 -0
  25. gobby/cli/installers/cursor.py +244 -0
  26. gobby/cli/installers/shared.py +3 -0
  27. gobby/cli/installers/windsurf.py +242 -0
  28. gobby/cli/pipelines.py +639 -0
  29. gobby/cli/sessions.py +3 -1
  30. gobby/cli/skills.py +209 -0
  31. gobby/cli/tasks/crud.py +6 -5
  32. gobby/cli/tasks/search.py +1 -1
  33. gobby/cli/ui.py +116 -0
  34. gobby/cli/utils.py +5 -17
  35. gobby/cli/workflows.py +38 -17
  36. gobby/config/app.py +5 -0
  37. gobby/config/features.py +0 -20
  38. gobby/config/skills.py +23 -2
  39. gobby/config/tasks.py +4 -0
  40. gobby/hooks/broadcaster.py +9 -0
  41. gobby/hooks/event_handlers/__init__.py +155 -0
  42. gobby/hooks/event_handlers/_agent.py +175 -0
  43. gobby/hooks/event_handlers/_base.py +92 -0
  44. gobby/hooks/event_handlers/_misc.py +66 -0
  45. gobby/hooks/event_handlers/_session.py +487 -0
  46. gobby/hooks/event_handlers/_tool.py +196 -0
  47. gobby/hooks/events.py +48 -0
  48. gobby/hooks/hook_manager.py +27 -3
  49. gobby/install/copilot/hooks/hook_dispatcher.py +203 -0
  50. gobby/install/cursor/hooks/hook_dispatcher.py +203 -0
  51. gobby/install/gemini/hooks/hook_dispatcher.py +8 -0
  52. gobby/install/windsurf/hooks/hook_dispatcher.py +205 -0
  53. gobby/llm/__init__.py +14 -1
  54. gobby/llm/claude.py +594 -43
  55. gobby/llm/service.py +149 -0
  56. gobby/mcp_proxy/importer.py +4 -41
  57. gobby/mcp_proxy/instructions.py +9 -27
  58. gobby/mcp_proxy/manager.py +13 -3
  59. gobby/mcp_proxy/models.py +1 -0
  60. gobby/mcp_proxy/registries.py +66 -5
  61. gobby/mcp_proxy/server.py +6 -2
  62. gobby/mcp_proxy/services/recommendation.py +2 -28
  63. gobby/mcp_proxy/services/tool_filter.py +7 -0
  64. gobby/mcp_proxy/services/tool_proxy.py +19 -1
  65. gobby/mcp_proxy/stdio.py +37 -21
  66. gobby/mcp_proxy/tools/agents.py +7 -0
  67. gobby/mcp_proxy/tools/artifacts.py +3 -3
  68. gobby/mcp_proxy/tools/hub.py +30 -1
  69. gobby/mcp_proxy/tools/orchestration/cleanup.py +5 -5
  70. gobby/mcp_proxy/tools/orchestration/monitor.py +1 -1
  71. gobby/mcp_proxy/tools/orchestration/orchestrate.py +8 -3
  72. gobby/mcp_proxy/tools/orchestration/review.py +17 -4
  73. gobby/mcp_proxy/tools/orchestration/wait.py +7 -7
  74. gobby/mcp_proxy/tools/pipelines/__init__.py +254 -0
  75. gobby/mcp_proxy/tools/pipelines/_discovery.py +67 -0
  76. gobby/mcp_proxy/tools/pipelines/_execution.py +281 -0
  77. gobby/mcp_proxy/tools/sessions/_crud.py +4 -4
  78. gobby/mcp_proxy/tools/sessions/_handoff.py +1 -1
  79. gobby/mcp_proxy/tools/skills/__init__.py +184 -30
  80. gobby/mcp_proxy/tools/spawn_agent.py +229 -14
  81. gobby/mcp_proxy/tools/task_readiness.py +27 -4
  82. gobby/mcp_proxy/tools/tasks/_context.py +8 -0
  83. gobby/mcp_proxy/tools/tasks/_crud.py +27 -1
  84. gobby/mcp_proxy/tools/tasks/_helpers.py +1 -1
  85. gobby/mcp_proxy/tools/tasks/_lifecycle.py +125 -8
  86. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +2 -1
  87. gobby/mcp_proxy/tools/tasks/_search.py +1 -1
  88. gobby/mcp_proxy/tools/workflows/__init__.py +273 -0
  89. gobby/mcp_proxy/tools/workflows/_artifacts.py +225 -0
  90. gobby/mcp_proxy/tools/workflows/_import.py +112 -0
  91. gobby/mcp_proxy/tools/workflows/_lifecycle.py +332 -0
  92. gobby/mcp_proxy/tools/workflows/_query.py +226 -0
  93. gobby/mcp_proxy/tools/workflows/_resolution.py +78 -0
  94. gobby/mcp_proxy/tools/workflows/_terminal.py +175 -0
  95. gobby/mcp_proxy/tools/worktrees.py +54 -15
  96. gobby/memory/components/__init__.py +0 -0
  97. gobby/memory/components/ingestion.py +98 -0
  98. gobby/memory/components/search.py +108 -0
  99. gobby/memory/context.py +5 -5
  100. gobby/memory/manager.py +16 -25
  101. gobby/paths.py +51 -0
  102. gobby/prompts/loader.py +1 -35
  103. gobby/runner.py +131 -16
  104. gobby/servers/http.py +193 -150
  105. gobby/servers/routes/__init__.py +2 -0
  106. gobby/servers/routes/admin.py +56 -0
  107. gobby/servers/routes/mcp/endpoints/execution.py +33 -32
  108. gobby/servers/routes/mcp/endpoints/registry.py +8 -8
  109. gobby/servers/routes/mcp/hooks.py +10 -1
  110. gobby/servers/routes/pipelines.py +227 -0
  111. gobby/servers/websocket.py +314 -1
  112. gobby/sessions/analyzer.py +89 -3
  113. gobby/sessions/manager.py +5 -5
  114. gobby/sessions/transcripts/__init__.py +3 -0
  115. gobby/sessions/transcripts/claude.py +5 -0
  116. gobby/sessions/transcripts/codex.py +5 -0
  117. gobby/sessions/transcripts/gemini.py +5 -0
  118. gobby/skills/hubs/__init__.py +25 -0
  119. gobby/skills/hubs/base.py +234 -0
  120. gobby/skills/hubs/claude_plugins.py +328 -0
  121. gobby/skills/hubs/clawdhub.py +289 -0
  122. gobby/skills/hubs/github_collection.py +465 -0
  123. gobby/skills/hubs/manager.py +263 -0
  124. gobby/skills/hubs/skillhub.py +342 -0
  125. gobby/skills/parser.py +23 -0
  126. gobby/skills/sync.py +5 -4
  127. gobby/storage/artifacts.py +19 -0
  128. gobby/storage/memories.py +4 -4
  129. gobby/storage/migrations.py +118 -3
  130. gobby/storage/pipelines.py +367 -0
  131. gobby/storage/sessions.py +23 -4
  132. gobby/storage/skills.py +48 -8
  133. gobby/storage/tasks/_aggregates.py +2 -2
  134. gobby/storage/tasks/_lifecycle.py +4 -4
  135. gobby/storage/tasks/_models.py +7 -1
  136. gobby/storage/tasks/_queries.py +3 -3
  137. gobby/sync/memories.py +4 -3
  138. gobby/tasks/commits.py +48 -17
  139. gobby/tasks/external_validator.py +4 -17
  140. gobby/tasks/validation.py +13 -87
  141. gobby/tools/summarizer.py +18 -51
  142. gobby/utils/status.py +13 -0
  143. gobby/workflows/actions.py +80 -0
  144. gobby/workflows/context_actions.py +265 -27
  145. gobby/workflows/definitions.py +119 -1
  146. gobby/workflows/detection_helpers.py +23 -11
  147. gobby/workflows/enforcement/__init__.py +11 -1
  148. gobby/workflows/enforcement/blocking.py +96 -0
  149. gobby/workflows/enforcement/handlers.py +35 -1
  150. gobby/workflows/enforcement/task_policy.py +18 -0
  151. gobby/workflows/engine.py +26 -4
  152. gobby/workflows/evaluator.py +8 -5
  153. gobby/workflows/lifecycle_evaluator.py +59 -27
  154. gobby/workflows/loader.py +567 -30
  155. gobby/workflows/lobster_compat.py +147 -0
  156. gobby/workflows/pipeline_executor.py +801 -0
  157. gobby/workflows/pipeline_state.py +172 -0
  158. gobby/workflows/pipeline_webhooks.py +206 -0
  159. gobby/workflows/premature_stop.py +5 -0
  160. gobby/worktrees/git.py +135 -20
  161. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/METADATA +56 -22
  162. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/RECORD +166 -122
  163. gobby/hooks/event_handlers.py +0 -1008
  164. gobby/mcp_proxy/tools/workflows.py +0 -1023
  165. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/WHEEL +0 -0
  166. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/entry_points.txt +0 -0
  167. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/licenses/LICENSE.md +0 -0
  168. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,225 @@
1
+ """
2
+ Artifact and variable tools for workflows.
3
+ """
4
+
5
+ import logging
6
+ from datetime import UTC, datetime
7
+ from typing import Any
8
+
9
+ from gobby.mcp_proxy.tools.workflows._resolution import (
10
+ resolve_session_id,
11
+ resolve_session_task_value,
12
+ )
13
+ from gobby.storage.database import DatabaseProtocol
14
+ from gobby.storage.sessions import LocalSessionManager
15
+ from gobby.workflows.definitions import WorkflowState
16
+ from gobby.workflows.state_manager import WorkflowStateManager
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def mark_artifact_complete(
22
+ state_manager: WorkflowStateManager,
23
+ session_manager: LocalSessionManager,
24
+ artifact_type: str,
25
+ file_path: str,
26
+ session_id: str | None = None,
27
+ ) -> dict[str, Any]:
28
+ """
29
+ Register an artifact as complete.
30
+
31
+ Args:
32
+ state_manager: WorkflowStateManager instance
33
+ session_manager: LocalSessionManager instance
34
+ artifact_type: Type of artifact (e.g., "plan", "spec", "test")
35
+ file_path: Path to the artifact file
36
+ session_id: Session reference (accepts #N, N, UUID, or prefix) - required to prevent cross-session bleed
37
+
38
+ Returns:
39
+ Success status
40
+ """
41
+ # Require explicit session_id to prevent cross-session bleed
42
+ if not session_id:
43
+ return {
44
+ "success": False,
45
+ "error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
46
+ }
47
+
48
+ # Resolve session_id to UUID (accepts #N, N, UUID, or prefix)
49
+ try:
50
+ resolved_session_id = resolve_session_id(session_manager, session_id)
51
+ except ValueError as e:
52
+ return {"success": False, "error": str(e)}
53
+
54
+ state = state_manager.get_state(resolved_session_id)
55
+ if not state:
56
+ return {"success": False, "error": "No workflow active for session"}
57
+
58
+ # Update artifacts
59
+ state.artifacts[artifact_type] = file_path
60
+ state_manager.save_state(state)
61
+
62
+ return {"success": True}
63
+
64
+
65
+ def set_variable(
66
+ state_manager: WorkflowStateManager,
67
+ session_manager: LocalSessionManager,
68
+ db: DatabaseProtocol,
69
+ name: str,
70
+ value: str | int | float | bool | None,
71
+ session_id: str | None = None,
72
+ ) -> dict[str, Any]:
73
+ """
74
+ Set a workflow variable for the current session.
75
+
76
+ Variables set this way are session-scoped - they persist in the database
77
+ for the duration of the session but do not modify the workflow YAML file.
78
+
79
+ This is useful for:
80
+ - Setting session_epic to enforce epic completion before stopping
81
+ - Setting is_worktree to mark a session as a worktree agent
82
+ - Dynamic configuration without modifying workflow definitions
83
+
84
+ Args:
85
+ state_manager: WorkflowStateManager instance
86
+ session_manager: LocalSessionManager instance
87
+ db: LocalDatabase instance
88
+ name: Variable name (e.g., "session_epic", "is_worktree")
89
+ value: Variable value (string, number, boolean, or null)
90
+ session_id: Session reference (accepts #N, N, UUID, or prefix) - required to prevent cross-session bleed
91
+
92
+ Returns:
93
+ Success status and updated variables
94
+ """
95
+ # Require explicit session_id to prevent cross-session bleed
96
+ if not session_id:
97
+ return {
98
+ "success": False,
99
+ "error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
100
+ }
101
+
102
+ # Resolve session_id to UUID (accepts #N, N, UUID, or prefix)
103
+ try:
104
+ resolved_session_id = resolve_session_id(session_manager, session_id)
105
+ except ValueError as e:
106
+ return {"success": False, "error": str(e)}
107
+
108
+ # Get or create state
109
+ state = state_manager.get_state(resolved_session_id)
110
+ if not state:
111
+ # Create a minimal lifecycle state for variable storage
112
+ state = WorkflowState(
113
+ session_id=resolved_session_id,
114
+ workflow_name="__lifecycle__",
115
+ step="",
116
+ step_entered_at=datetime.now(UTC),
117
+ variables={},
118
+ )
119
+
120
+ # Block modification of session_task when a real workflow is active
121
+ # This prevents circumventing workflows by changing the tracked task
122
+ if name == "session_task" and state.workflow_name != "__lifecycle__":
123
+ current_value = state.variables.get("session_task")
124
+ if current_value is not None and value != current_value:
125
+ return {
126
+ "success": False,
127
+ "error": (
128
+ f"Cannot modify session_task while workflow '{state.workflow_name}' is active. "
129
+ f"Current value: {current_value}. "
130
+ f"Use end_workflow() first if you need to change the tracked task."
131
+ ),
132
+ }
133
+
134
+ # Resolve session_task references (#N or N) to UUIDs upfront
135
+ # This prevents repeated resolution failures in condition evaluation
136
+ if name == "session_task" and isinstance(value, str):
137
+ try:
138
+ value = resolve_session_task_value(value, resolved_session_id, session_manager, db)
139
+ except (ValueError, KeyError) as e:
140
+ logger.warning(
141
+ f"Failed to resolve session_task value '{value}' for session {resolved_session_id}: {e}"
142
+ )
143
+ return {
144
+ "success": False,
145
+ "error": f"Failed to resolve session_task value '{value}': {e}",
146
+ }
147
+
148
+ # Set the variable
149
+ state.variables[name] = value
150
+ state_manager.save_state(state)
151
+
152
+ # Add deprecation warning for session_task on __lifecycle__ workflow
153
+ if name == "session_task" and state.workflow_name == "__lifecycle__":
154
+ return {
155
+ "warning": (
156
+ "DEPRECATED: Setting session_task via set_variable on __lifecycle__ workflow. "
157
+ "Prefer using activate_workflow(variables={session_task: ...}) instead."
158
+ )
159
+ }
160
+
161
+ return {}
162
+
163
+
164
+ def get_variable(
165
+ state_manager: WorkflowStateManager,
166
+ session_manager: LocalSessionManager,
167
+ name: str | None = None,
168
+ session_id: str | None = None,
169
+ ) -> dict[str, Any]:
170
+ """
171
+ Get workflow variable(s) for the current session.
172
+
173
+ Args:
174
+ state_manager: WorkflowStateManager instance
175
+ session_manager: LocalSessionManager instance
176
+ name: Variable name to get (if None, returns all variables)
177
+ session_id: Session reference (accepts #N, N, UUID, or prefix) - required to prevent cross-session bleed
178
+
179
+ Returns:
180
+ Variable value(s) and session info
181
+ """
182
+ # Require explicit session_id to prevent cross-session bleed
183
+ if not session_id:
184
+ return {
185
+ "success": False,
186
+ "error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
187
+ }
188
+
189
+ # Resolve session_id to UUID (accepts #N, N, UUID, or prefix)
190
+ try:
191
+ resolved_session_id = resolve_session_id(session_manager, session_id)
192
+ except ValueError as e:
193
+ return {"success": False, "error": str(e)}
194
+
195
+ state = state_manager.get_state(resolved_session_id)
196
+ if not state:
197
+ if name:
198
+ return {
199
+ "success": True,
200
+ "session_id": resolved_session_id,
201
+ "variable": name,
202
+ "value": None,
203
+ "exists": False,
204
+ }
205
+ return {
206
+ "success": True,
207
+ "session_id": resolved_session_id,
208
+ "variables": {},
209
+ }
210
+
211
+ if name:
212
+ value = state.variables.get(name)
213
+ return {
214
+ "success": True,
215
+ "session_id": resolved_session_id,
216
+ "variable": name,
217
+ "value": value,
218
+ "exists": name in state.variables,
219
+ }
220
+
221
+ return {
222
+ "success": True,
223
+ "session_id": resolved_session_id,
224
+ "variables": state.variables,
225
+ }
@@ -0,0 +1,112 @@
1
+ """
2
+ Import and cache tools for workflows.
3
+ """
4
+
5
+ import logging
6
+ import re
7
+ import shutil
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import yaml
12
+
13
+ from gobby.utils.project_context import get_workflow_project_path
14
+ from gobby.workflows.loader import WorkflowLoader
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def import_workflow(
20
+ loader: WorkflowLoader,
21
+ source_path: str,
22
+ workflow_name: str | None = None,
23
+ is_global: bool = False,
24
+ project_path: str | None = None,
25
+ ) -> dict[str, Any]:
26
+ """
27
+ Import a workflow from a file.
28
+
29
+ Args:
30
+ loader: WorkflowLoader instance
31
+ source_path: Path to the workflow YAML file
32
+ workflow_name: Override the workflow name (defaults to name in file)
33
+ is_global: Install to global ~/.gobby/workflows instead of project
34
+ project_path: Project directory path. Auto-discovered from cwd if not provided.
35
+
36
+ Returns:
37
+ Success status and destination path
38
+ """
39
+ source = Path(source_path)
40
+ if not source.exists():
41
+ return {"success": False, "error": f"File not found: {source_path}"}
42
+
43
+ if source.suffix != ".yaml":
44
+ return {"success": False, "error": "Workflow file must have .yaml extension"}
45
+
46
+ try:
47
+ with open(source, encoding="utf-8") as f:
48
+ data = yaml.safe_load(f)
49
+
50
+ if not data or "name" not in data:
51
+ return {"success": False, "error": "Invalid workflow: missing 'name' field"}
52
+
53
+ except yaml.YAMLError as e:
54
+ return {"success": False, "error": f"Invalid YAML: {e}"}
55
+
56
+ raw_name = workflow_name or data.get("name", source.stem)
57
+ # Sanitize name to prevent path traversal: strip path components, allow only safe chars
58
+ safe_name = Path(raw_name).name # Strip any path components
59
+ safe_name = re.sub(r"[^a-zA-Z0-9_\-.]", "_", safe_name) # Replace unsafe chars
60
+ safe_name = safe_name.strip("._") # Remove leading/trailing dots and underscores
61
+ if not safe_name:
62
+ safe_name = source.stem # Fallback to source filename
63
+ filename = f"{safe_name}.yaml"
64
+
65
+ if is_global:
66
+ dest_dir = Path.home() / ".gobby" / "workflows"
67
+ else:
68
+ # Auto-discover project path if not provided
69
+ if not project_path:
70
+ discovered = get_workflow_project_path()
71
+ if discovered:
72
+ project_path = str(discovered)
73
+
74
+ proj = Path(project_path) if project_path else None
75
+ if not proj:
76
+ return {
77
+ "success": False,
78
+ "error": "project_path required when not using is_global (could not auto-discover)",
79
+ }
80
+ dest_dir = proj / ".gobby" / "workflows"
81
+
82
+ dest_dir.mkdir(parents=True, exist_ok=True)
83
+ dest_path = dest_dir / filename
84
+
85
+ shutil.copy(source, dest_path)
86
+
87
+ # Clear loader cache so new workflow is discoverable
88
+ loader.clear_cache()
89
+
90
+ return {
91
+ "success": True,
92
+ "workflow_name": safe_name,
93
+ "destination": str(dest_path),
94
+ "is_global": is_global,
95
+ }
96
+
97
+
98
+ def reload_cache(loader: WorkflowLoader) -> dict[str, Any]:
99
+ """
100
+ Clear the workflow loader cache.
101
+
102
+ This forces the daemon to re-read workflow YAML files from disk
103
+ on the next access. Use this when you've modified workflow files
104
+ and want the changes to take effect immediately without restarting
105
+ the daemon.
106
+
107
+ Returns:
108
+ Success status
109
+ """
110
+ loader.clear_cache()
111
+ logger.info("Workflow cache cleared via reload_cache tool")
112
+ return {"message": "Workflow cache cleared"}
@@ -0,0 +1,332 @@
1
+ """
2
+ Lifecycle tools for workflows (activate, end, transition).
3
+ """
4
+
5
+ from datetime import UTC, datetime
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from gobby.mcp_proxy.tools.workflows._resolution import (
10
+ resolve_session_id,
11
+ resolve_session_task_value,
12
+ )
13
+ from gobby.storage.database import DatabaseProtocol
14
+ from gobby.storage.sessions import LocalSessionManager
15
+ from gobby.utils.project_context import get_workflow_project_path
16
+ from gobby.workflows.definitions import WorkflowDefinition, WorkflowState
17
+ from gobby.workflows.loader import WorkflowLoader
18
+ from gobby.workflows.state_manager import WorkflowStateManager
19
+
20
+
21
+ def activate_workflow(
22
+ loader: WorkflowLoader,
23
+ state_manager: WorkflowStateManager,
24
+ session_manager: LocalSessionManager,
25
+ db: DatabaseProtocol,
26
+ name: str,
27
+ session_id: str | None = None,
28
+ initial_step: str | None = None,
29
+ variables: dict[str, Any] | None = None,
30
+ project_path: str | None = None,
31
+ ) -> dict[str, Any]:
32
+ """
33
+ Activate a step-based workflow for the current session.
34
+
35
+ Args:
36
+ loader: WorkflowLoader instance
37
+ state_manager: WorkflowStateManager instance
38
+ session_manager: LocalSessionManager instance
39
+ db: LocalDatabase instance
40
+ name: Workflow name (e.g., "plan-act-reflect", "auto-task")
41
+ session_id: Session reference (accepts #N, N, UUID, or prefix) - required to prevent cross-session bleed
42
+ initial_step: Optional starting step (defaults to first step)
43
+ variables: Optional initial variables to set (merged with workflow defaults)
44
+ project_path: Project directory path. Auto-discovered from cwd if not provided.
45
+
46
+ Returns:
47
+ Success status, workflow info, and current step.
48
+ """
49
+ # Auto-discover project path if not provided
50
+ if not project_path:
51
+ discovered = get_workflow_project_path()
52
+ if discovered:
53
+ project_path = str(discovered)
54
+
55
+ proj = Path(project_path) if project_path else None
56
+
57
+ # Load workflow
58
+ definition = loader.load_workflow(name, proj)
59
+ if not definition:
60
+ return {"success": False, "error": f"Workflow '{name}' not found"}
61
+
62
+ if definition.type == "lifecycle":
63
+ return {
64
+ "success": False,
65
+ "error": f"Workflow '{name}' is lifecycle type (auto-runs on events, not manually activated)",
66
+ }
67
+
68
+ # This function only supports step-based workflows (WorkflowDefinition)
69
+ if not isinstance(definition, WorkflowDefinition):
70
+ return {
71
+ "success": False,
72
+ "error": f"'{name}' is a pipeline. Use pipeline execution tools instead.",
73
+ }
74
+
75
+ # Require explicit session_id to prevent cross-session bleed
76
+ if not session_id:
77
+ return {
78
+ "success": False,
79
+ "error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
80
+ }
81
+
82
+ # Resolve session_id to UUID (accepts #N, N, UUID, or prefix)
83
+ try:
84
+ resolved_session_id = resolve_session_id(session_manager, session_id)
85
+ except ValueError as e:
86
+ return {"success": False, "error": str(e)}
87
+
88
+ # Check for existing workflow
89
+ # Allow if:
90
+ # - No existing state
91
+ # - Existing is __lifecycle__ placeholder
92
+ # - Existing is a lifecycle-type workflow (they run concurrently with step workflows)
93
+ existing = state_manager.get_state(resolved_session_id)
94
+ if existing and existing.workflow_name != "__lifecycle__":
95
+ # Check if existing workflow is a lifecycle type
96
+ existing_def = loader.load_workflow(existing.workflow_name, proj)
97
+ # Only allow if we can confirm it's a lifecycle workflow
98
+ # If definition not found or it's a step workflow, block activation
99
+ if not existing_def or existing_def.type != "lifecycle":
100
+ # It's a step workflow (or unknown) - can only have one active
101
+ return {
102
+ "success": False,
103
+ "error": f"Session already has step workflow '{existing.workflow_name}' active. Use end_workflow first.",
104
+ }
105
+ # Existing is a lifecycle workflow - allow step workflow to activate alongside it
106
+
107
+ # Determine initial step
108
+ if initial_step:
109
+ if not any(s.name == initial_step for s in definition.steps):
110
+ return {
111
+ "success": False,
112
+ "error": f"Step '{initial_step}' not found. Available: {[s.name for s in definition.steps]}",
113
+ }
114
+ step = initial_step
115
+ else:
116
+ if not definition.steps:
117
+ return {
118
+ "success": False,
119
+ "error": f"Workflow '{name}' has no steps defined. Cannot activate a workflow without steps.",
120
+ }
121
+ step = definition.steps[0].name
122
+
123
+ # Merge variables: preserve existing lifecycle variables, then apply workflow declarations
124
+ # Priority: existing state < workflow defaults < passed-in variables
125
+ # This preserves lifecycle variables (like unlocked_tools) that the step workflow doesn't declare
126
+ merged_variables = dict(existing.variables) if existing else {}
127
+ merged_variables.update(definition.variables) # Override with workflow-declared defaults
128
+ if variables:
129
+ merged_variables.update(variables) # Override with passed-in values
130
+
131
+ # Resolve session_task references (#N or N) to UUIDs upfront
132
+ # This prevents repeated resolution failures in condition evaluation
133
+ if "session_task" in merged_variables:
134
+ session_task_val = merged_variables["session_task"]
135
+ if isinstance(session_task_val, str):
136
+ merged_variables["session_task"] = resolve_session_task_value(
137
+ session_task_val, resolved_session_id, session_manager, db
138
+ )
139
+
140
+ # Create state
141
+ state = WorkflowState(
142
+ session_id=resolved_session_id,
143
+ workflow_name=name,
144
+ step=step,
145
+ step_entered_at=datetime.now(UTC),
146
+ step_action_count=0,
147
+ total_action_count=0,
148
+ artifacts={},
149
+ observations=[],
150
+ reflection_pending=False,
151
+ context_injected=False,
152
+ variables=merged_variables,
153
+ task_list=None,
154
+ current_task_index=0,
155
+ files_modified_this_task=0,
156
+ )
157
+
158
+ state_manager.save_state(state)
159
+
160
+ return {
161
+ "success": True,
162
+ "session_id": resolved_session_id,
163
+ "workflow": name,
164
+ "step": step,
165
+ "steps": [s.name for s in definition.steps],
166
+ "variables": merged_variables,
167
+ }
168
+
169
+
170
+ def end_workflow(
171
+ loader: WorkflowLoader,
172
+ state_manager: WorkflowStateManager,
173
+ session_manager: LocalSessionManager,
174
+ session_id: str | None = None,
175
+ reason: str | None = None,
176
+ project_path: str | None = None,
177
+ ) -> dict[str, Any]:
178
+ """
179
+ End the currently active step-based workflow.
180
+
181
+ Allows starting a different workflow afterward.
182
+ Does not affect lifecycle workflows (they continue running).
183
+
184
+ Args:
185
+ loader: WorkflowLoader instance
186
+ state_manager: WorkflowStateManager instance
187
+ session_manager: LocalSessionManager instance
188
+ session_id: Session reference (accepts #N, N, UUID, or prefix) - required to prevent cross-session bleed
189
+ reason: Optional reason for ending
190
+ project_path: Project directory path. Auto-discovered from cwd if not provided.
191
+
192
+ Returns:
193
+ Success status
194
+ """
195
+ # Require explicit session_id to prevent cross-session bleed
196
+ if not session_id:
197
+ return {
198
+ "success": False,
199
+ "error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
200
+ }
201
+
202
+ # Resolve session_id to UUID (accepts #N, N, UUID, or prefix)
203
+ try:
204
+ resolved_session_id = resolve_session_id(session_manager, session_id)
205
+ except ValueError as e:
206
+ return {"success": False, "error": str(e)}
207
+
208
+ state = state_manager.get_state(resolved_session_id)
209
+ if not state:
210
+ return {"success": False, "error": "No workflow active for session"}
211
+
212
+ # Check if this is a lifecycle workflow - those cannot be ended manually
213
+ # Auto-discover project path if not provided
214
+ if not project_path:
215
+ discovered = get_workflow_project_path()
216
+ if discovered:
217
+ project_path = str(discovered)
218
+
219
+ proj = Path(project_path) if project_path else None
220
+ definition = loader.load_workflow(state.workflow_name, proj)
221
+
222
+ # If definition exists and is lifecycle type, block manual ending
223
+ if definition and definition.type == "lifecycle":
224
+ return {
225
+ "success": False,
226
+ "error": f"Workflow '{state.workflow_name}' is lifecycle type (auto-runs on events, cannot be manually ended).",
227
+ }
228
+
229
+ state_manager.delete_state(resolved_session_id)
230
+
231
+ return {"success": True, "workflow": state.workflow_name, "reason": reason}
232
+
233
+
234
+ def request_step_transition(
235
+ loader: WorkflowLoader,
236
+ state_manager: WorkflowStateManager,
237
+ session_manager: LocalSessionManager,
238
+ to_step: str,
239
+ reason: str | None = None,
240
+ session_id: str | None = None,
241
+ force: bool = False,
242
+ project_path: str | None = None,
243
+ ) -> dict[str, Any]:
244
+ """
245
+ Request transition to a different step. May require approval.
246
+
247
+ Args:
248
+ loader: WorkflowLoader instance
249
+ state_manager: WorkflowStateManager instance
250
+ session_manager: LocalSessionManager instance
251
+ to_step: Target step name
252
+ reason: Reason for transition
253
+ session_id: Session reference (accepts #N, N, UUID, or prefix) - required to prevent cross-session bleed
254
+ force: Skip exit condition checks
255
+ project_path: Project directory path. Auto-discovered from cwd if not provided.
256
+
257
+ Returns:
258
+ Success status and new step info
259
+ """
260
+ # Auto-discover project path if not provided
261
+ if not project_path:
262
+ discovered = get_workflow_project_path()
263
+ if discovered:
264
+ project_path = str(discovered)
265
+
266
+ proj = Path(project_path) if project_path else None
267
+
268
+ # Require explicit session_id to prevent cross-session bleed
269
+ if not session_id:
270
+ return {
271
+ "success": False,
272
+ "error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
273
+ }
274
+
275
+ # Resolve session_id to UUID (accepts #N, N, UUID, or prefix)
276
+ try:
277
+ resolved_session_id = resolve_session_id(session_manager, session_id)
278
+ except ValueError as e:
279
+ return {"success": False, "error": str(e)}
280
+
281
+ state = state_manager.get_state(resolved_session_id)
282
+ if not state:
283
+ return {"success": False, "error": "No workflow active for session"}
284
+
285
+ # Load workflow to validate step
286
+ definition = loader.load_workflow(state.workflow_name, proj)
287
+ if not definition:
288
+ return {"success": False, "error": f"Workflow '{state.workflow_name}' not found"}
289
+
290
+ # Transitions only apply to step-based workflows
291
+ if not isinstance(definition, WorkflowDefinition):
292
+ return {"success": False, "error": "Transitions are not supported for pipelines"}
293
+
294
+ if not any(s.name == to_step for s in definition.steps):
295
+ return {
296
+ "success": False,
297
+ "error": f"Step '{to_step}' not found. Available: {[s.name for s in definition.steps]}",
298
+ }
299
+
300
+ # Block manual transitions to steps that have conditional auto-transitions
301
+ # These steps should only be reached when their conditions are met
302
+ # Skip this check when force=True to allow bypassing workflow guards
303
+ if not force:
304
+ current_step_def = next((s for s in definition.steps if s.name == state.step), None)
305
+ if current_step_def and current_step_def.transitions:
306
+ for transition in current_step_def.transitions:
307
+ if transition.to == to_step and transition.when:
308
+ # This step has a conditional transition - block manual transition
309
+ return {
310
+ "success": False,
311
+ "error": (
312
+ f"Step '{to_step}' has a conditional auto-transition "
313
+ f"(when: {transition.when}). Manual transitions to this step "
314
+ f"are blocked to prevent workflow circumvention. "
315
+ f"The transition will occur automatically when the condition is met."
316
+ ),
317
+ }
318
+
319
+ old_step = state.step
320
+ state.step = to_step
321
+ state.step_entered_at = datetime.now(UTC)
322
+ state.step_action_count = 0
323
+
324
+ state_manager.save_state(state)
325
+
326
+ return {
327
+ "success": True,
328
+ "from_step": old_step,
329
+ "to_step": to_step,
330
+ "reason": reason,
331
+ "forced": force,
332
+ }