gobby 0.2.7__py3-none-any.whl → 0.2.9__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 (125) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/claude_code.py +99 -61
  3. gobby/adapters/gemini.py +140 -38
  4. gobby/agents/isolation.py +130 -0
  5. gobby/agents/registry.py +11 -0
  6. gobby/agents/session.py +1 -0
  7. gobby/agents/spawn_executor.py +43 -13
  8. gobby/agents/spawners/macos.py +26 -1
  9. gobby/app_context.py +59 -0
  10. gobby/cli/__init__.py +0 -2
  11. gobby/cli/memory.py +185 -0
  12. gobby/cli/utils.py +5 -17
  13. gobby/clones/git.py +177 -0
  14. gobby/config/features.py +0 -20
  15. gobby/config/skills.py +31 -0
  16. gobby/config/tasks.py +4 -0
  17. gobby/hooks/event_handlers/__init__.py +155 -0
  18. gobby/hooks/event_handlers/_agent.py +175 -0
  19. gobby/hooks/event_handlers/_base.py +87 -0
  20. gobby/hooks/event_handlers/_misc.py +66 -0
  21. gobby/hooks/event_handlers/_session.py +573 -0
  22. gobby/hooks/event_handlers/_tool.py +196 -0
  23. gobby/hooks/hook_manager.py +21 -1
  24. gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
  25. gobby/llm/claude.py +377 -42
  26. gobby/mcp_proxy/importer.py +4 -41
  27. gobby/mcp_proxy/instructions.py +2 -2
  28. gobby/mcp_proxy/manager.py +13 -3
  29. gobby/mcp_proxy/registries.py +35 -4
  30. gobby/mcp_proxy/services/recommendation.py +2 -28
  31. gobby/mcp_proxy/tools/agent_messaging.py +93 -44
  32. gobby/mcp_proxy/tools/agents.py +45 -9
  33. gobby/mcp_proxy/tools/artifacts.py +46 -12
  34. gobby/mcp_proxy/tools/sessions/_commits.py +31 -24
  35. gobby/mcp_proxy/tools/sessions/_crud.py +5 -5
  36. gobby/mcp_proxy/tools/sessions/_handoff.py +45 -41
  37. gobby/mcp_proxy/tools/sessions/_messages.py +35 -7
  38. gobby/mcp_proxy/tools/spawn_agent.py +44 -6
  39. gobby/mcp_proxy/tools/task_readiness.py +27 -4
  40. gobby/mcp_proxy/tools/tasks/_context.py +18 -0
  41. gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
  42. gobby/mcp_proxy/tools/tasks/_lifecycle.py +29 -14
  43. gobby/mcp_proxy/tools/tasks/_session.py +22 -7
  44. gobby/mcp_proxy/tools/workflows/__init__.py +266 -0
  45. gobby/mcp_proxy/tools/workflows/_artifacts.py +225 -0
  46. gobby/mcp_proxy/tools/workflows/_import.py +112 -0
  47. gobby/mcp_proxy/tools/workflows/_lifecycle.py +321 -0
  48. gobby/mcp_proxy/tools/workflows/_query.py +207 -0
  49. gobby/mcp_proxy/tools/workflows/_resolution.py +78 -0
  50. gobby/mcp_proxy/tools/workflows/_terminal.py +139 -0
  51. gobby/mcp_proxy/tools/worktrees.py +32 -7
  52. gobby/memory/components/__init__.py +0 -0
  53. gobby/memory/components/ingestion.py +98 -0
  54. gobby/memory/components/search.py +108 -0
  55. gobby/memory/extractor.py +15 -1
  56. gobby/memory/manager.py +16 -25
  57. gobby/paths.py +51 -0
  58. gobby/prompts/loader.py +1 -35
  59. gobby/runner.py +36 -10
  60. gobby/servers/http.py +186 -149
  61. gobby/servers/routes/admin.py +12 -0
  62. gobby/servers/routes/mcp/endpoints/execution.py +15 -7
  63. gobby/servers/routes/mcp/endpoints/registry.py +8 -8
  64. gobby/servers/routes/mcp/hooks.py +50 -3
  65. gobby/servers/websocket.py +57 -1
  66. gobby/sessions/analyzer.py +4 -4
  67. gobby/sessions/manager.py +9 -0
  68. gobby/sessions/transcripts/gemini.py +100 -34
  69. gobby/skills/parser.py +23 -0
  70. gobby/skills/sync.py +5 -4
  71. gobby/storage/artifacts.py +19 -0
  72. gobby/storage/database.py +9 -2
  73. gobby/storage/memories.py +32 -21
  74. gobby/storage/migrations.py +46 -4
  75. gobby/storage/sessions.py +4 -2
  76. gobby/storage/skills.py +87 -7
  77. gobby/tasks/external_validator.py +4 -17
  78. gobby/tasks/validation.py +13 -87
  79. gobby/tools/summarizer.py +18 -51
  80. gobby/utils/status.py +13 -0
  81. gobby/workflows/actions.py +5 -0
  82. gobby/workflows/context_actions.py +21 -24
  83. gobby/workflows/detection_helpers.py +38 -24
  84. gobby/workflows/enforcement/__init__.py +11 -1
  85. gobby/workflows/enforcement/blocking.py +109 -1
  86. gobby/workflows/enforcement/handlers.py +35 -1
  87. gobby/workflows/engine.py +96 -0
  88. gobby/workflows/evaluator.py +110 -0
  89. gobby/workflows/hooks.py +41 -0
  90. gobby/workflows/lifecycle_evaluator.py +2 -1
  91. gobby/workflows/memory_actions.py +11 -0
  92. gobby/workflows/safe_evaluator.py +8 -0
  93. gobby/workflows/summary_actions.py +123 -50
  94. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/METADATA +1 -1
  95. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/RECORD +99 -107
  96. gobby/cli/tui.py +0 -34
  97. gobby/hooks/event_handlers.py +0 -909
  98. gobby/mcp_proxy/tools/workflows.py +0 -973
  99. gobby/tui/__init__.py +0 -5
  100. gobby/tui/api_client.py +0 -278
  101. gobby/tui/app.py +0 -329
  102. gobby/tui/screens/__init__.py +0 -25
  103. gobby/tui/screens/agents.py +0 -333
  104. gobby/tui/screens/chat.py +0 -450
  105. gobby/tui/screens/dashboard.py +0 -377
  106. gobby/tui/screens/memory.py +0 -305
  107. gobby/tui/screens/metrics.py +0 -231
  108. gobby/tui/screens/orchestrator.py +0 -903
  109. gobby/tui/screens/sessions.py +0 -412
  110. gobby/tui/screens/tasks.py +0 -440
  111. gobby/tui/screens/workflows.py +0 -289
  112. gobby/tui/screens/worktrees.py +0 -174
  113. gobby/tui/widgets/__init__.py +0 -21
  114. gobby/tui/widgets/chat.py +0 -210
  115. gobby/tui/widgets/conductor.py +0 -104
  116. gobby/tui/widgets/menu.py +0 -132
  117. gobby/tui/widgets/message_panel.py +0 -160
  118. gobby/tui/widgets/review_gate.py +0 -224
  119. gobby/tui/widgets/task_tree.py +0 -99
  120. gobby/tui/widgets/token_budget.py +0 -166
  121. gobby/tui/ws_client.py +0 -258
  122. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/WHEEL +0 -0
  123. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/entry_points.txt +0 -0
  124. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/licenses/LICENSE.md +0 -0
  125. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,321 @@
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 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
+ # Require explicit session_id to prevent cross-session bleed
69
+ if not session_id:
70
+ return {
71
+ "success": False,
72
+ "error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
73
+ }
74
+
75
+ # Resolve session_id to UUID (accepts #N, N, UUID, or prefix)
76
+ try:
77
+ resolved_session_id = resolve_session_id(session_manager, session_id)
78
+ except ValueError as e:
79
+ return {"success": False, "error": str(e)}
80
+
81
+ # Check for existing workflow
82
+ # Allow if:
83
+ # - No existing state
84
+ # - Existing is __lifecycle__ placeholder
85
+ # - Existing is a lifecycle-type workflow (they run concurrently with step workflows)
86
+ existing = state_manager.get_state(resolved_session_id)
87
+ if existing and existing.workflow_name != "__lifecycle__":
88
+ # Check if existing workflow is a lifecycle type
89
+ existing_def = loader.load_workflow(existing.workflow_name, proj)
90
+ # Only allow if we can confirm it's a lifecycle workflow
91
+ # If definition not found or it's a step workflow, block activation
92
+ if not existing_def or existing_def.type != "lifecycle":
93
+ # It's a step workflow (or unknown) - can only have one active
94
+ return {
95
+ "success": False,
96
+ "error": f"Session already has step workflow '{existing.workflow_name}' active. Use end_workflow first.",
97
+ }
98
+ # Existing is a lifecycle workflow - allow step workflow to activate alongside it
99
+
100
+ # Determine initial step
101
+ if initial_step:
102
+ if not any(s.name == initial_step for s in definition.steps):
103
+ return {
104
+ "success": False,
105
+ "error": f"Step '{initial_step}' not found. Available: {[s.name for s in definition.steps]}",
106
+ }
107
+ step = initial_step
108
+ else:
109
+ if not definition.steps:
110
+ return {
111
+ "success": False,
112
+ "error": f"Workflow '{name}' has no steps defined. Cannot activate a workflow without steps.",
113
+ }
114
+ step = definition.steps[0].name
115
+
116
+ # Merge variables: preserve existing lifecycle variables, then apply workflow declarations
117
+ # Priority: existing state < workflow defaults < passed-in variables
118
+ # This preserves lifecycle variables (like unlocked_tools) that the step workflow doesn't declare
119
+ merged_variables = dict(existing.variables) if existing else {}
120
+ merged_variables.update(definition.variables) # Override with workflow-declared defaults
121
+ if variables:
122
+ merged_variables.update(variables) # Override with passed-in values
123
+
124
+ # Resolve session_task references (#N or N) to UUIDs upfront
125
+ # This prevents repeated resolution failures in condition evaluation
126
+ if "session_task" in merged_variables:
127
+ session_task_val = merged_variables["session_task"]
128
+ if isinstance(session_task_val, str):
129
+ merged_variables["session_task"] = resolve_session_task_value(
130
+ session_task_val, resolved_session_id, session_manager, db
131
+ )
132
+
133
+ # Create state
134
+ state = WorkflowState(
135
+ session_id=resolved_session_id,
136
+ workflow_name=name,
137
+ step=step,
138
+ step_entered_at=datetime.now(UTC),
139
+ step_action_count=0,
140
+ total_action_count=0,
141
+ artifacts={},
142
+ observations=[],
143
+ reflection_pending=False,
144
+ context_injected=False,
145
+ variables=merged_variables,
146
+ task_list=None,
147
+ current_task_index=0,
148
+ files_modified_this_task=0,
149
+ )
150
+
151
+ state_manager.save_state(state)
152
+
153
+ return {
154
+ "success": True,
155
+ "session_id": resolved_session_id,
156
+ "workflow": name,
157
+ "step": step,
158
+ "steps": [s.name for s in definition.steps],
159
+ "variables": merged_variables,
160
+ }
161
+
162
+
163
+ def end_workflow(
164
+ loader: WorkflowLoader,
165
+ state_manager: WorkflowStateManager,
166
+ session_manager: LocalSessionManager,
167
+ session_id: str | None = None,
168
+ reason: str | None = None,
169
+ project_path: str | None = None,
170
+ ) -> dict[str, Any]:
171
+ """
172
+ End the currently active step-based workflow.
173
+
174
+ Allows starting a different workflow afterward.
175
+ Does not affect lifecycle workflows (they continue running).
176
+
177
+ Args:
178
+ loader: WorkflowLoader instance
179
+ state_manager: WorkflowStateManager instance
180
+ session_manager: LocalSessionManager instance
181
+ session_id: Session reference (accepts #N, N, UUID, or prefix) - required to prevent cross-session bleed
182
+ reason: Optional reason for ending
183
+ project_path: Project directory path. Auto-discovered from cwd if not provided.
184
+
185
+ Returns:
186
+ Success status
187
+ """
188
+ # Require explicit session_id to prevent cross-session bleed
189
+ if not session_id:
190
+ return {
191
+ "success": False,
192
+ "error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
193
+ }
194
+
195
+ # Resolve session_id to UUID (accepts #N, N, UUID, or prefix)
196
+ try:
197
+ resolved_session_id = resolve_session_id(session_manager, session_id)
198
+ except ValueError as e:
199
+ return {"success": False, "error": str(e)}
200
+
201
+ state = state_manager.get_state(resolved_session_id)
202
+ if not state:
203
+ return {"success": False, "error": "No workflow active for session"}
204
+
205
+ # Check if this is a lifecycle workflow - those cannot be ended manually
206
+ # Auto-discover project path if not provided
207
+ if not project_path:
208
+ discovered = get_workflow_project_path()
209
+ if discovered:
210
+ project_path = str(discovered)
211
+
212
+ proj = Path(project_path) if project_path else None
213
+ definition = loader.load_workflow(state.workflow_name, proj)
214
+
215
+ # If definition exists and is lifecycle type, block manual ending
216
+ if definition and definition.type == "lifecycle":
217
+ return {
218
+ "success": False,
219
+ "error": f"Workflow '{state.workflow_name}' is lifecycle type (auto-runs on events, cannot be manually ended).",
220
+ }
221
+
222
+ state_manager.delete_state(resolved_session_id)
223
+
224
+ return {"success": True, "workflow": state.workflow_name, "reason": reason}
225
+
226
+
227
+ def request_step_transition(
228
+ loader: WorkflowLoader,
229
+ state_manager: WorkflowStateManager,
230
+ session_manager: LocalSessionManager,
231
+ to_step: str,
232
+ reason: str | None = None,
233
+ session_id: str | None = None,
234
+ force: bool = False,
235
+ project_path: str | None = None,
236
+ ) -> dict[str, Any]:
237
+ """
238
+ Request transition to a different step. May require approval.
239
+
240
+ Args:
241
+ loader: WorkflowLoader instance
242
+ state_manager: WorkflowStateManager instance
243
+ session_manager: LocalSessionManager instance
244
+ to_step: Target step name
245
+ reason: Reason for transition
246
+ session_id: Session reference (accepts #N, N, UUID, or prefix) - required to prevent cross-session bleed
247
+ force: Skip exit condition checks
248
+ project_path: Project directory path. Auto-discovered from cwd if not provided.
249
+
250
+ Returns:
251
+ Success status and new step info
252
+ """
253
+ # Auto-discover project path if not provided
254
+ if not project_path:
255
+ discovered = get_workflow_project_path()
256
+ if discovered:
257
+ project_path = str(discovered)
258
+
259
+ proj = Path(project_path) if project_path else None
260
+
261
+ # Require explicit session_id to prevent cross-session bleed
262
+ if not session_id:
263
+ return {
264
+ "success": False,
265
+ "error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
266
+ }
267
+
268
+ # Resolve session_id to UUID (accepts #N, N, UUID, or prefix)
269
+ try:
270
+ resolved_session_id = resolve_session_id(session_manager, session_id)
271
+ except ValueError as e:
272
+ return {"success": False, "error": str(e)}
273
+
274
+ state = state_manager.get_state(resolved_session_id)
275
+ if not state:
276
+ return {"success": False, "error": "No workflow active for session"}
277
+
278
+ # Load workflow to validate step
279
+ definition = loader.load_workflow(state.workflow_name, proj)
280
+ if not definition:
281
+ return {"success": False, "error": f"Workflow '{state.workflow_name}' not found"}
282
+
283
+ if not any(s.name == to_step for s in definition.steps):
284
+ return {
285
+ "success": False,
286
+ "error": f"Step '{to_step}' not found. Available: {[s.name for s in definition.steps]}",
287
+ }
288
+
289
+ # Block manual transitions to steps that have conditional auto-transitions
290
+ # These steps should only be reached when their conditions are met
291
+ # Skip this check when force=True to allow bypassing workflow guards
292
+ if not force:
293
+ current_step_def = next((s for s in definition.steps if s.name == state.step), None)
294
+ if current_step_def and current_step_def.transitions:
295
+ for transition in current_step_def.transitions:
296
+ if transition.to == to_step and transition.when:
297
+ # This step has a conditional transition - block manual transition
298
+ return {
299
+ "success": False,
300
+ "error": (
301
+ f"Step '{to_step}' has a conditional auto-transition "
302
+ f"(when: {transition.when}). Manual transitions to this step "
303
+ f"are blocked to prevent workflow circumvention. "
304
+ f"The transition will occur automatically when the condition is met."
305
+ ),
306
+ }
307
+
308
+ old_step = state.step
309
+ state.step = to_step
310
+ state.step_entered_at = datetime.now(UTC)
311
+ state.step_action_count = 0
312
+
313
+ state_manager.save_state(state)
314
+
315
+ return {
316
+ "success": True,
317
+ "from_step": old_step,
318
+ "to_step": to_step,
319
+ "reason": reason,
320
+ "forced": force,
321
+ }
@@ -0,0 +1,207 @@
1
+ """
2
+ Query tools for workflows.
3
+ """
4
+
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+ from gobby.mcp_proxy.tools.workflows._resolution import resolve_session_id
12
+ from gobby.storage.sessions import LocalSessionManager
13
+ from gobby.utils.project_context import get_workflow_project_path
14
+ from gobby.workflows.loader import WorkflowLoader
15
+ from gobby.workflows.state_manager import WorkflowStateManager
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def get_workflow(
21
+ loader: WorkflowLoader,
22
+ name: str,
23
+ project_path: str | None = None,
24
+ ) -> dict[str, Any]:
25
+ """
26
+ Get workflow details including steps, triggers, and settings.
27
+
28
+ Args:
29
+ loader: WorkflowLoader instance
30
+ name: Workflow name (without .yaml extension)
31
+ project_path: Project directory path. Auto-discovered from cwd if not provided.
32
+
33
+ Returns:
34
+ Workflow definition details
35
+ """
36
+ # Auto-discover project path if not provided
37
+ if not project_path:
38
+ discovered = get_workflow_project_path()
39
+ if discovered:
40
+ project_path = str(discovered)
41
+
42
+ proj = Path(project_path) if project_path else None
43
+ definition = loader.load_workflow(name, proj)
44
+
45
+ if not definition:
46
+ return {"success": False, "error": f"Workflow '{name}' not found"}
47
+
48
+ return {
49
+ "success": True,
50
+ "name": definition.name,
51
+ "type": definition.type,
52
+ "description": definition.description,
53
+ "version": definition.version,
54
+ "steps": (
55
+ [
56
+ {
57
+ "name": s.name,
58
+ "description": s.description,
59
+ "allowed_tools": s.allowed_tools,
60
+ "blocked_tools": s.blocked_tools,
61
+ }
62
+ for s in definition.steps
63
+ ]
64
+ if definition.steps
65
+ else []
66
+ ),
67
+ "triggers": (
68
+ {name: len(actions) for name, actions in definition.triggers.items()}
69
+ if definition.triggers
70
+ else {}
71
+ ),
72
+ "settings": definition.settings,
73
+ }
74
+
75
+
76
+ def list_workflows(
77
+ loader: WorkflowLoader,
78
+ project_path: str | None = None,
79
+ workflow_type: str | None = None,
80
+ global_only: bool = False,
81
+ ) -> dict[str, Any]:
82
+ """
83
+ List available workflows.
84
+
85
+ Lists workflows from both project (.gobby/workflows) and global (~/.gobby/workflows)
86
+ directories. Project workflows shadow global ones with the same name.
87
+
88
+ Args:
89
+ loader: WorkflowLoader instance
90
+ project_path: Project directory path. Auto-discovered from cwd if not provided.
91
+ workflow_type: Filter by type ("step" or "lifecycle")
92
+ global_only: If True, only show global workflows (ignore project)
93
+
94
+ Returns:
95
+ List of workflows with name, type, description, and source
96
+ """
97
+
98
+ # Auto-discover project path if not provided
99
+ if not project_path:
100
+ discovered = get_workflow_project_path()
101
+ if discovered:
102
+ project_path = str(discovered)
103
+
104
+ search_dirs = list(loader.global_dirs)
105
+ proj = Path(project_path) if project_path else None
106
+
107
+ # Include project workflows unless global_only (project searched first to shadow global)
108
+ if not global_only and proj:
109
+ project_dir = proj / ".gobby" / "workflows"
110
+ if project_dir.exists():
111
+ search_dirs.insert(0, project_dir)
112
+
113
+ workflows = []
114
+ seen_names = set()
115
+
116
+ for search_dir in search_dirs:
117
+ if not search_dir.exists():
118
+ continue
119
+
120
+ is_project = proj and search_dir == (proj / ".gobby" / "workflows")
121
+
122
+ for yaml_path in search_dir.glob("*.yaml"):
123
+ name = yaml_path.stem
124
+ if name in seen_names:
125
+ continue
126
+
127
+ try:
128
+ with open(yaml_path, encoding="utf-8") as f:
129
+ data = yaml.safe_load(f)
130
+
131
+ if not data:
132
+ continue
133
+
134
+ wf_type = data.get("type", "step")
135
+
136
+ if workflow_type and wf_type != workflow_type:
137
+ continue
138
+
139
+ workflows.append(
140
+ {
141
+ "name": name,
142
+ "type": wf_type,
143
+ "description": data.get("description", ""),
144
+ "source": "project" if is_project else "global",
145
+ }
146
+ )
147
+ seen_names.add(name)
148
+
149
+ except Exception as e:
150
+ logger.debug(
151
+ "Skipping invalid workflow file %s: %s",
152
+ yaml_path,
153
+ e,
154
+ exc_info=True,
155
+ ) # nosec B110
156
+
157
+ return {"workflows": workflows, "count": len(workflows)}
158
+
159
+
160
+ def get_workflow_status(
161
+ state_manager: WorkflowStateManager,
162
+ session_manager: LocalSessionManager,
163
+ session_id: str | None = None,
164
+ ) -> dict[str, Any]:
165
+ """
166
+ Get current workflow step and state.
167
+
168
+ Args:
169
+ state_manager: WorkflowStateManager instance
170
+ session_manager: LocalSessionManager instance
171
+ session_id: Session reference (accepts #N, N, UUID, or prefix) - required to prevent cross-session bleed
172
+
173
+ Returns:
174
+ Workflow state including step, action counts, artifacts
175
+ """
176
+ # Require explicit session_id to prevent cross-session bleed
177
+ if not session_id:
178
+ return {
179
+ "has_workflow": False,
180
+ "error": "session_id is required. Pass the session ID explicitly to prevent cross-session variable bleed.",
181
+ }
182
+
183
+ # Resolve session_id to UUID (accepts #N, N, UUID, or prefix)
184
+ try:
185
+ resolved_session_id = resolve_session_id(session_manager, session_id)
186
+ except ValueError as e:
187
+ return {"has_workflow": False, "error": str(e)}
188
+
189
+ state = state_manager.get_state(resolved_session_id)
190
+ if not state:
191
+ return {"has_workflow": False, "session_id": resolved_session_id}
192
+
193
+ return {
194
+ "has_workflow": True,
195
+ "session_id": resolved_session_id,
196
+ "workflow_name": state.workflow_name,
197
+ "step": state.step,
198
+ "step_action_count": state.step_action_count,
199
+ "total_action_count": state.total_action_count,
200
+ "reflection_pending": state.reflection_pending,
201
+ "artifacts": list(state.artifacts.keys()) if state.artifacts else [],
202
+ "variables": state.variables,
203
+ "task_progress": (
204
+ f"{state.current_task_index + 1}/{len(state.task_list)}" if state.task_list else None
205
+ ),
206
+ "updated_at": state.updated_at.isoformat() if state.updated_at else None,
207
+ }
@@ -0,0 +1,78 @@
1
+ """
2
+ Resolution utilities for workflow tools.
3
+
4
+ Provides functions to resolve session and task references from various
5
+ formats (#N, N, UUID, prefix) to canonical UUIDs.
6
+ """
7
+
8
+ import logging
9
+ from typing import Any
10
+
11
+ from gobby.storage.database import DatabaseProtocol
12
+ from gobby.storage.sessions import LocalSessionManager
13
+ from gobby.storage.tasks._id import resolve_task_reference
14
+ from gobby.storage.tasks._models import TaskNotFoundError
15
+ from gobby.utils.project_context import get_project_context
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def resolve_session_id(session_manager: LocalSessionManager, ref: str) -> str:
21
+ """Resolve session reference (#N, N, UUID, or prefix) to UUID."""
22
+ project_ctx = get_project_context()
23
+ project_id = project_ctx.get("id") if project_ctx else None
24
+ return session_manager.resolve_session_reference(ref, project_id)
25
+
26
+
27
+ def resolve_session_task_value(
28
+ value: Any,
29
+ session_id: str | None,
30
+ session_manager: LocalSessionManager,
31
+ db: DatabaseProtocol,
32
+ ) -> Any:
33
+ """
34
+ Resolve a session_task value from seq_num reference (#N or N) to UUID.
35
+
36
+ This prevents repeated resolution failures in condition evaluation when
37
+ task_tree_complete() is called with a seq_num that requires project_id.
38
+
39
+ Args:
40
+ value: The value to potentially resolve (e.g., "#4424", "47", or a UUID)
41
+ session_id: Session ID to look up project_id
42
+ session_manager: Session manager for lookups
43
+ db: Database for task resolution
44
+
45
+ Returns:
46
+ Resolved UUID if value was a seq_num reference, otherwise original value
47
+ """
48
+ # Only process string values that look like seq_num references
49
+ if not isinstance(value, str):
50
+ return value
51
+
52
+ # Check if it's a seq_num reference (#N or plain N)
53
+ is_seq_ref = value.startswith("#") or value.isdigit()
54
+ if not is_seq_ref:
55
+ return value
56
+
57
+ # Need session to get project_id
58
+ if not session_id:
59
+ logger.warning(f"Cannot resolve task reference '{value}': no session_id provided")
60
+ return value
61
+
62
+ # Get project_id from session
63
+ session = session_manager.get(session_id)
64
+ if not session or not session.project_id:
65
+ logger.warning(f"Cannot resolve task reference '{value}': session has no project_id")
66
+ return value
67
+
68
+ # Resolve the reference
69
+ try:
70
+ resolved = resolve_task_reference(db, value, session.project_id)
71
+ logger.debug(f"Resolved session_task '{value}' to UUID '{resolved}'")
72
+ return resolved
73
+ except TaskNotFoundError as e:
74
+ logger.warning(f"Could not resolve task reference '{value}': {e}")
75
+ return value
76
+ except Exception as e:
77
+ logger.warning(f"Unexpected error resolving task reference '{value}': {e}")
78
+ return value