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
@@ -103,30 +103,42 @@ def detect_task_claim(
103
103
  if isinstance(result, dict) and result.get("error"):
104
104
  return
105
105
 
106
- # Extract task_id based on tool type
106
+ # Extract task_id based on tool type - MUST resolve to UUID
107
+ # Refs like '#123' will fail comparison with task.id (UUID) in close_task logic
107
108
  arguments = tool_input.get("arguments", {}) or {}
109
+ task_id: str | None = None
110
+
108
111
  if inner_tool_name in ("update_task", "claim_task"):
109
- task_id = arguments.get("task_id")
110
- # Resolve to UUID for consistent comparison with close_task
111
- if task_id and task_manager:
112
+ raw_task_id = arguments.get("task_id")
113
+ # MUST resolve to UUID - refs like '#123' break comparisons in close_task
114
+ if raw_task_id and task_manager:
112
115
  try:
113
- task = task_manager.get_task(task_id)
116
+ task = task_manager.get_task(raw_task_id)
114
117
  if task:
115
118
  task_id = task.id # Use UUID
116
- except Exception: # nosec B110 - best effort resolution, keep original if fails
117
- pass
119
+ else:
120
+ logger.warning(
121
+ f"Cannot resolve task ref '{raw_task_id}' to UUID - task not found"
122
+ )
123
+ except Exception as e:
124
+ logger.warning(f"Cannot resolve task ref '{raw_task_id}' to UUID: {e}")
125
+ elif raw_task_id and not task_manager:
126
+ logger.warning(f"Cannot resolve task ref '{raw_task_id}' to UUID - no task_manager")
118
127
  elif inner_tool_name == "create_task":
119
- # For create_task, the id is in the result
128
+ # For create_task, the id is in the result (already a UUID)
120
129
  result = tool_output.get("result", {}) if isinstance(tool_output, dict) else {}
121
130
  task_id = result.get("id") if isinstance(result, dict) else None
122
131
  # Skip if we can't get the task ID (e.g., Claude Code doesn't include tool results)
123
132
  # The MCP tool itself handles state updates in this case via _crud.py
124
133
  if not task_id:
125
134
  return
126
- else:
127
- task_id = None
128
135
 
129
- # All conditions met - set task_claimed and claimed_task_id
136
+ # Only set claimed_task_id if we have a valid UUID
137
+ if not task_id:
138
+ logger.debug(f"Skipping task claim state update - no valid UUID for {inner_tool_name}")
139
+ return
140
+
141
+ # All conditions met - set task_claimed and claimed_task_id (UUID)
130
142
  state.variables["task_claimed"] = True
131
143
  state.variables["claimed_task_id"] = task_id
132
144
  logger.info(
@@ -4,7 +4,12 @@ This package provides actions that enforce task tracking before allowing
4
4
  certain tools, and enforce task completion before allowing agent to stop.
5
5
  """
6
6
 
7
- from gobby.workflows.enforcement.blocking import block_tools
7
+ from gobby.workflows.enforcement.blocking import (
8
+ block_tools,
9
+ is_discovery_tool,
10
+ is_tool_unlocked,
11
+ track_schema_lookup,
12
+ )
8
13
  from gobby.workflows.enforcement.commit_policy import (
9
14
  capture_baseline_dirty_files,
10
15
  require_commit_before_stop,
@@ -17,6 +22,7 @@ from gobby.workflows.enforcement.handlers import (
17
22
  handle_require_commit_before_stop,
18
23
  handle_require_task_complete,
19
24
  handle_require_task_review_or_close_before_stop,
25
+ handle_track_schema_lookup,
20
26
  handle_validate_session_task_scope,
21
27
  )
22
28
  from gobby.workflows.enforcement.task_policy import (
@@ -28,6 +34,9 @@ from gobby.workflows.enforcement.task_policy import (
28
34
  __all__ = [
29
35
  # Blocking
30
36
  "block_tools",
37
+ "is_discovery_tool",
38
+ "is_tool_unlocked",
39
+ "track_schema_lookup",
31
40
  # Commit policy
32
41
  "capture_baseline_dirty_files",
33
42
  "require_commit_before_stop",
@@ -43,5 +52,6 @@ __all__ = [
43
52
  "handle_require_commit_before_stop",
44
53
  "handle_require_task_complete",
45
54
  "handle_require_task_review_or_close_before_stop",
55
+ "handle_track_schema_lookup",
46
56
  "handle_validate_session_task_scope",
47
57
  ]
@@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, Any
12
12
 
13
13
  from gobby.workflows.git_utils import get_dirty_files
14
14
  from gobby.workflows.safe_evaluator import LazyBool, SafeExpressionEvaluator
15
+ from gobby.workflows.templates import TemplateEngine
15
16
 
16
17
  if TYPE_CHECKING:
17
18
  from gobby.storage.tasks import LocalTaskManager
@@ -19,6 +20,89 @@ if TYPE_CHECKING:
19
20
 
20
21
  logger = logging.getLogger(__name__)
21
22
 
23
+ # MCP discovery tools that don't require prior schema lookup
24
+ DISCOVERY_TOOLS = {
25
+ "list_mcp_servers",
26
+ "list_tools",
27
+ "get_tool_schema",
28
+ "search_tools",
29
+ "recommend_tools",
30
+ "list_skills",
31
+ "get_skill",
32
+ "search_skills",
33
+ }
34
+
35
+
36
+ def is_discovery_tool(tool_name: str | None) -> bool:
37
+ """Check if the tool is a discovery/introspection tool.
38
+
39
+ These tools are allowed without prior schema lookup since they ARE
40
+ the discovery mechanism.
41
+
42
+ Args:
43
+ tool_name: The MCP tool name (from tool_input.tool_name)
44
+
45
+ Returns:
46
+ True if this is a discovery tool that doesn't need schema unlock
47
+ """
48
+ return tool_name in DISCOVERY_TOOLS if tool_name else False
49
+
50
+
51
+ def is_tool_unlocked(
52
+ tool_input: dict[str, Any],
53
+ variables: dict[str, Any],
54
+ ) -> bool:
55
+ """Check if a tool has been unlocked via prior get_tool_schema call.
56
+
57
+ Args:
58
+ tool_input: The tool input containing server_name and tool_name
59
+ variables: Workflow state variables containing unlocked_tools list
60
+
61
+ Returns:
62
+ True if the server:tool combo was previously unlocked via get_tool_schema
63
+ """
64
+ server = tool_input.get("server_name", "")
65
+ tool = tool_input.get("tool_name", "")
66
+ if not server or not tool:
67
+ return False
68
+ key = f"{server}:{tool}"
69
+ unlocked = variables.get("unlocked_tools", [])
70
+ return key in unlocked
71
+
72
+
73
+ def track_schema_lookup(
74
+ tool_input: dict[str, Any],
75
+ workflow_state: WorkflowState | None,
76
+ ) -> dict[str, Any] | None:
77
+ """Track a successful get_tool_schema call by adding to unlocked_tools.
78
+
79
+ Called from on_after_tool when tool_name is get_tool_schema and succeeded.
80
+
81
+ Args:
82
+ tool_input: The tool input containing server_name and tool_name
83
+ workflow_state: Workflow state to update
84
+
85
+ Returns:
86
+ Dict with tracking result or None
87
+ """
88
+ if not workflow_state:
89
+ return None
90
+
91
+ server = tool_input.get("server_name", "")
92
+ tool = tool_input.get("tool_name", "")
93
+ if not server or not tool:
94
+ return None
95
+
96
+ key = f"{server}:{tool}"
97
+ unlocked = workflow_state.variables.setdefault("unlocked_tools", [])
98
+
99
+ if key not in unlocked:
100
+ unlocked.append(key)
101
+ logger.debug(f"Unlocked tool schema: {key}")
102
+ return {"unlocked": key, "total_unlocked": len(unlocked)}
103
+
104
+ return {"already_unlocked": key}
105
+
22
106
 
23
107
  def _is_plan_file(file_path: str, source: str | None = None) -> bool:
24
108
  """Check if file path is a Claude Code plan file (platform-agnostic).
@@ -99,6 +183,8 @@ def _evaluate_block_condition(
99
183
  # Allowed functions for safe evaluation
100
184
  allowed_funcs: dict[str, Callable[..., Any]] = {
101
185
  "is_plan_file": _is_plan_file,
186
+ "is_discovery_tool": is_discovery_tool,
187
+ "is_tool_unlocked": lambda ti: is_tool_unlocked(ti, variables),
102
188
  "bool": bool,
103
189
  "str": str,
104
190
  "int": int,
@@ -275,6 +361,16 @@ async def block_tools(
275
361
  continue
276
362
 
277
363
  reason = rule.get("reason", f"Tool '{tool_name}' is blocked.")
364
+
365
+ # Render Jinja2 template variables in reason message
366
+ if "{{" in reason:
367
+ try:
368
+ engine = TemplateEngine()
369
+ reason = engine.render(reason, {"tool_input": tool_input})
370
+ except Exception as e:
371
+ logger.warning(f"Failed to render reason template: {e}")
372
+ # Keep original reason on failure
373
+
278
374
  logger.info(f"block_tools: Blocking '{tool_name}' - {reason[:100]}")
279
375
  return {"decision": "block", "reason": reason}
280
376
 
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
  import logging
10
10
  from typing import TYPE_CHECKING, Any
11
11
 
12
- from gobby.workflows.enforcement.blocking import block_tools
12
+ from gobby.workflows.enforcement.blocking import block_tools, track_schema_lookup
13
13
  from gobby.workflows.enforcement.commit_policy import (
14
14
  capture_baseline_dirty_files,
15
15
  require_commit_before_stop,
@@ -33,6 +33,7 @@ __all__ = [
33
33
  "handle_require_commit_before_stop",
34
34
  "handle_require_task_complete",
35
35
  "handle_require_task_review_or_close_before_stop",
36
+ "handle_track_schema_lookup",
36
37
  "handle_validate_session_task_scope",
37
38
  ]
38
39
 
@@ -267,3 +268,36 @@ async def handle_require_task_complete(
267
268
  project_id=project_id,
268
269
  workflow_state=context.state,
269
270
  )
271
+
272
+
273
+ async def handle_track_schema_lookup(
274
+ context: Any,
275
+ task_manager: LocalTaskManager | None = None,
276
+ **kwargs: Any,
277
+ ) -> dict[str, Any] | None:
278
+ """ActionHandler wrapper for track_schema_lookup.
279
+
280
+ Tracks successful get_tool_schema calls to unlock tools for call_tool.
281
+ Should be triggered on on_after_tool when the tool is get_tool_schema.
282
+ """
283
+ if not context.event_data:
284
+ return None
285
+
286
+ tool_name = context.event_data.get("tool_name", "")
287
+ is_failure = context.event_data.get("is_failure", False)
288
+
289
+ # Only track successful get_tool_schema calls
290
+ # Handle both native MCP format and Gobby proxy format
291
+ if tool_name not in ("get_tool_schema", "mcp__gobby__get_tool_schema"):
292
+ return None
293
+
294
+ if is_failure:
295
+ return None
296
+
297
+ # Extract tool_input - for MCP proxy, it's in tool_input directly
298
+ tool_input = context.event_data.get("tool_input", {}) or {}
299
+
300
+ return track_schema_lookup(
301
+ tool_input=tool_input,
302
+ workflow_state=context.state,
303
+ )
@@ -6,6 +6,7 @@ Provides actions that enforce task tracking and scoping requirements.
6
6
  from __future__ import annotations
7
7
 
8
8
  import logging
9
+ import uuid
9
10
  from typing import TYPE_CHECKING, Any
10
11
 
11
12
  from gobby.mcp_proxy.tools.task_readiness import is_descendant_of
@@ -21,6 +22,15 @@ if TYPE_CHECKING:
21
22
  logger = logging.getLogger(__name__)
22
23
 
23
24
 
25
+ def _is_uuid(value: str) -> bool:
26
+ """Check if a string is a valid UUID (not a ref like #123)."""
27
+ try:
28
+ uuid.UUID(value)
29
+ return True
30
+ except (ValueError, TypeError):
31
+ return False
32
+
33
+
24
34
  async def require_task_complete(
25
35
  task_manager: LocalTaskManager | None,
26
36
  session_id: str,
@@ -84,6 +94,14 @@ async def require_task_complete(
84
94
  if workflow_state:
85
95
  has_claimed_task = workflow_state.variables.get("task_claimed", False)
86
96
  claimed_task_id = workflow_state.variables.get("claimed_task_id")
97
+ # Resolve claimed_task_id to UUID if it's a ref (backward compat)
98
+ if claimed_task_id and not _is_uuid(claimed_task_id):
99
+ try:
100
+ claimed_task = task_manager.get_task(claimed_task_id)
101
+ if claimed_task:
102
+ claimed_task_id = claimed_task.id
103
+ except Exception: # nosec B110 - keep original ID if resolution fails
104
+ claimed_task_id = claimed_task_id # explicit no-op
87
105
 
88
106
  try:
89
107
  # Collect incomplete tasks across all specified task IDs
gobby/workflows/engine.py CHANGED
@@ -116,7 +116,11 @@ class WorkflowEngine:
116
116
  if state.step != "reflect":
117
117
  project_path = Path(event.cwd) if event.cwd else None
118
118
  workflow = self.loader.load_workflow(state.workflow_name, project_path)
119
- if workflow and workflow.get_step("reflect"):
119
+ if (
120
+ workflow
121
+ and isinstance(workflow, WorkflowDefinition)
122
+ and workflow.get_step("reflect")
123
+ ):
120
124
  await self.transition_to(state, "reflect", workflow)
121
125
  return HookResponse(
122
126
  decision="modify",
@@ -145,6 +149,11 @@ class WorkflowEngine:
145
149
  )
146
150
  return HookResponse(decision="allow")
147
151
 
152
+ # Step handling only applies to WorkflowDefinition, not PipelineDefinition
153
+ if not isinstance(workflow, WorkflowDefinition):
154
+ logger.debug(f"Workflow '{workflow.name}' is a pipeline, skipping step handling")
155
+ return HookResponse(decision="allow")
156
+
148
157
  # 4. Process event
149
158
  # Logic matches WORKFLOWS.md "Evaluation Flow"
150
159
 
@@ -344,6 +353,8 @@ class WorkflowEngine:
344
353
  memory_sync_manager=self.action_executor.memory_sync_manager,
345
354
  task_sync_manager=self.action_executor.task_sync_manager,
346
355
  session_task_manager=self.action_executor.session_task_manager,
356
+ pipeline_executor=self.action_executor.pipeline_executor,
357
+ workflow_loader=self.action_executor.workflow_loader,
347
358
  )
348
359
 
349
360
  for action_def in actions:
@@ -530,6 +541,14 @@ class WorkflowEngine:
530
541
  "error": f"Workflow '{workflow_name}' is lifecycle type (auto-runs on events)",
531
542
  }
532
543
 
544
+ # Only WorkflowDefinition can be activated as step workflows
545
+ if not isinstance(definition, WorkflowDefinition):
546
+ logger.debug(f"Workflow '{workflow_name}' is a pipeline, not a step workflow")
547
+ return {
548
+ "success": False,
549
+ "error": f"'{workflow_name}' is a pipeline. Use pipeline execution instead.",
550
+ }
551
+
533
552
  # Check for existing step workflow
534
553
  existing = self.state_manager.get_state(session_id)
535
554
  if existing and existing.workflow_name != "__lifecycle__":
@@ -553,10 +572,13 @@ class WorkflowEngine:
553
572
  }
554
573
  step = definition.steps[0].name
555
574
 
556
- # Merge workflow default variables with passed-in variables
557
- merged_variables = dict(definition.variables)
575
+ # Merge variables: preserve existing lifecycle variables, then apply workflow declarations
576
+ # Priority: existing state < workflow defaults < passed-in variables
577
+ # This preserves lifecycle variables (like unlocked_tools) that the step workflow doesn't declare
578
+ merged_variables = dict(existing.variables) if existing else {}
579
+ merged_variables.update(definition.variables) # Override with workflow-declared defaults
558
580
  if variables:
559
- merged_variables.update(variables)
581
+ merged_variables.update(variables) # Override with passed-in values
560
582
 
561
583
  # Create state
562
584
  state = WorkflowState(
@@ -23,7 +23,7 @@ def is_task_complete(task: Any) -> bool:
23
23
 
24
24
  A task is complete if:
25
25
  - status is 'closed', OR
26
- - status is 'review' AND requires_user_review is False
26
+ - status is 'needs_review' AND requires_user_review is False
27
27
  (agent marked for visibility but doesn't need user sign-off)
28
28
 
29
29
  Tasks in 'review' with requires_user_review=True are NOT complete
@@ -38,7 +38,7 @@ def is_task_complete(task: Any) -> bool:
38
38
  if task.status == "closed":
39
39
  return True
40
40
  requires_user_review = getattr(task, "requires_user_review", False)
41
- if task.status == "review" and not requires_user_review:
41
+ if task.status == "needs_review" and not requires_user_review:
42
42
  return True
43
43
  return False
44
44
 
@@ -55,7 +55,7 @@ def task_needs_user_review(task_manager: Any, task_id: str | None) -> bool:
55
55
  task_id: Task ID to check
56
56
 
57
57
  Returns:
58
- True if task is in 'review' status AND has requires_user_review=True.
58
+ True if task is in 'needs_review' status AND has requires_user_review=True.
59
59
  Returns False if task_id is None or task not found.
60
60
  """
61
61
  if not task_id or not task_manager:
@@ -65,7 +65,7 @@ def task_needs_user_review(task_manager: Any, task_id: str | None) -> bool:
65
65
  if not task:
66
66
  return False
67
67
 
68
- return bool(task.status == "review" and getattr(task, "requires_user_review", False))
68
+ return bool(task.status == "needs_review" and getattr(task, "requires_user_review", False))
69
69
 
70
70
 
71
71
  def task_tree_complete(task_manager: Any, task_id: str | list[str] | None) -> bool:
@@ -74,7 +74,7 @@ def task_tree_complete(task_manager: Any, task_id: str | list[str] | None) -> bo
74
74
 
75
75
  A task is complete if:
76
76
  - status is 'closed', OR
77
- - status is 'review' AND requires_user_review is False
77
+ - status is 'needs_review' AND requires_user_review is False
78
78
 
79
79
  Used in workflow transition conditions like:
80
80
  when: "task_tree_complete(variables.session_task)"
@@ -283,6 +283,9 @@ class ConditionEvaluator:
283
283
  "None": None,
284
284
  "True": True,
285
285
  "False": False,
286
+ # YAML/JSON use lowercase booleans
287
+ "true": True,
288
+ "false": False,
286
289
  }
287
290
 
288
291
  # Add plugin conditions as callable functions
@@ -10,10 +10,10 @@ from datetime import UTC, datetime
10
10
  from typing import TYPE_CHECKING, Any, Literal
11
11
 
12
12
  from gobby.hooks.events import HookEvent, HookEventType, HookResponse
13
+ from gobby.workflows.definitions import WorkflowDefinition, WorkflowState
13
14
 
14
15
  if TYPE_CHECKING:
15
16
  from .actions import ActionExecutor
16
- from .definitions import WorkflowDefinition, WorkflowState
17
17
  from .evaluator import ConditionEvaluator
18
18
  from .loader import WorkflowLoader
19
19
  from .state_manager import WorkflowStateManager
@@ -123,28 +123,30 @@ async def evaluate_workflow_triggers(
123
123
  session_id = event.metadata.get("_platform_session_id") or "global"
124
124
 
125
125
  # Try to load existing state, or create new one
126
- state = state_manager.get_state(session_id)
127
- if state is None:
128
- state = WorkflowState(
129
- session_id=session_id,
130
- workflow_name=workflow.name,
131
- step="global",
132
- step_entered_at=datetime.now(UTC),
133
- step_action_count=0,
134
- total_action_count=0,
135
- artifacts=event.data.get("artifacts", {}) if event.data else {},
136
- observations=[],
137
- reflection_pending=False,
138
- context_injected=False,
139
- variables={},
140
- task_list=None,
141
- current_task_index=0,
142
- files_modified_this_task=0,
143
- )
144
-
145
- # Merge context_data into state variables (context_data has session vars from earlier load)
126
+ # Track whether we created a new state to determine save behavior later
127
+ existing_state = state_manager.get_state(session_id)
128
+ state_was_created = existing_state is None
129
+ state: WorkflowState = existing_state or WorkflowState(
130
+ session_id=session_id,
131
+ workflow_name=workflow.name,
132
+ step="global",
133
+ step_entered_at=datetime.now(UTC),
134
+ step_action_count=0,
135
+ total_action_count=0,
136
+ artifacts=event.data.get("artifacts", {}) if event.data else {},
137
+ observations=[],
138
+ reflection_pending=False,
139
+ context_injected=False,
140
+ variables={},
141
+ task_list=None,
142
+ current_task_index=0,
143
+ files_modified_this_task=0,
144
+ )
145
+
146
+ # Merge context_data (workflow defaults) into state variables
147
+ # Persisted state values take precedence over workflow defaults
146
148
  if context_data:
147
- state.variables.update(context_data)
149
+ state.variables = {**context_data, **state.variables}
148
150
 
149
151
  action_ctx = ActionContext(
150
152
  session_id=session_id,
@@ -236,11 +238,28 @@ async def evaluate_workflow_triggers(
236
238
  exc_info=True,
237
239
  )
238
240
 
239
- # Persist state changes (e.g., _injected_memory_ids from memory_recall_relevant)
241
+ # Persist state changes (e.g., _injected_memory_ids from memory_recall_relevant,
242
+ # unlocked_tools from track_schema_lookup)
240
243
  # Only save if we have a real session ID (not "global" fallback)
241
244
  # The workflow_states table has a FK to sessions, so we can't save for non-existent sessions
242
245
  if session_id != "global":
243
- state_manager.save_state(state)
246
+ if state_was_created:
247
+ # We created a new lifecycle state - check for existing step workflow
248
+ # to avoid overwriting it with our new lifecycle state.
249
+ # Step workflows (activated via activate_workflow) have their own workflow_name.
250
+ current_state = state_manager.get_state(session_id)
251
+ is_step_workflow = (
252
+ current_state is not None
253
+ and current_state.workflow_name != "__lifecycle__"
254
+ and current_state.workflow_name != workflow.name
255
+ )
256
+ if not is_step_workflow:
257
+ state_manager.save_state(state)
258
+ else:
259
+ # We fetched an existing state (possibly a step workflow) and updated
260
+ # its variables. Safe to save since we're just persisting variable
261
+ # changes (like unlocked_tools), not changing workflow_name or step.
262
+ state_manager.save_state(state)
244
263
 
245
264
  final_context = "\n\n".join(injected_context) if injected_context else None
246
265
  logger.debug(
@@ -290,6 +309,11 @@ async def evaluate_lifecycle_triggers(
290
309
  logger.warning(f"Workflow '{workflow_name}' not found in project_path={project_path}")
291
310
  return HookResponse(decision="allow")
292
311
 
312
+ # Lifecycle triggers only apply to WorkflowDefinition, not PipelineDefinition
313
+ if not isinstance(workflow, WorkflowDefinition):
314
+ logger.debug(f"Workflow '{workflow_name}' is not a WorkflowDefinition, skipping triggers")
315
+ return HookResponse(decision="allow")
316
+
293
317
  logger.debug(
294
318
  f"Workflow '{workflow_name}' loaded, triggers={list(workflow.triggers.keys()) if workflow.triggers else []}"
295
319
  )
@@ -533,6 +557,10 @@ async def evaluate_all_lifecycle_workflows(
533
557
  for discovered in workflows:
534
558
  workflow = discovered.definition
535
559
 
560
+ # Skip PipelineDefinition - lifecycle triggers only for WorkflowDefinition
561
+ if not isinstance(workflow, WorkflowDefinition):
562
+ continue
563
+
536
564
  # Skip if this workflow+trigger has already been processed
537
565
  key = (workflow.name, trigger_name)
538
566
  if key in processed_triggers:
@@ -540,10 +568,11 @@ async def evaluate_all_lifecycle_workflows(
540
568
 
541
569
  # Merge workflow definition's default variables (lower priority than session state)
542
570
  # Precedence: session state > workflow YAML defaults
543
- workflow_context = {**workflow.variables, **context_data}
571
+ # Update context_data directly so workflow variables propagate to response metadata
572
+ context_data = {**workflow.variables, **context_data}
544
573
 
545
574
  response = await evaluate_workflow_triggers(
546
- workflow, event, workflow_context, state_manager, action_executor, evaluator
575
+ workflow, event, context_data, state_manager, action_executor, evaluator
547
576
  )
548
577
 
549
578
  # Accumulate context
@@ -594,6 +623,7 @@ async def evaluate_all_lifecycle_workflows(
594
623
  )
595
624
  detect_task_claim_fn(event, state)
596
625
  detect_plan_mode_fn(event, state)
626
+ # Safe to save - we're updating variables on existing state, not changing workflow_name
597
627
  state_manager.save_state(state)
598
628
 
599
629
  # Detect plan mode from system reminders for BEFORE_AGENT events
@@ -609,6 +639,7 @@ async def evaluate_all_lifecycle_workflows(
609
639
  step="",
610
640
  )
611
641
  detect_plan_mode_from_context_fn(event, state)
642
+ # Safe to save - we're updating variables on existing state, not changing workflow_name
612
643
  state_manager.save_state(state)
613
644
 
614
645
  # Check for premature stop in active step workflows on STOP events
@@ -636,6 +667,7 @@ async def evaluate_all_lifecycle_workflows(
636
667
  "path": str(w.path),
637
668
  }
638
669
  for w in workflows
639
- ]
670
+ ],
671
+ "workflow_variables": context_data,
640
672
  },
641
673
  )