gobby 0.2.9__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 (134) 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 +2 -2
  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 +5 -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/workflows.py +38 -17
  35. gobby/config/app.py +5 -0
  36. gobby/config/skills.py +23 -2
  37. gobby/hooks/broadcaster.py +9 -0
  38. gobby/hooks/event_handlers/_base.py +6 -1
  39. gobby/hooks/event_handlers/_session.py +44 -130
  40. gobby/hooks/events.py +48 -0
  41. gobby/hooks/hook_manager.py +25 -3
  42. gobby/install/copilot/hooks/hook_dispatcher.py +203 -0
  43. gobby/install/cursor/hooks/hook_dispatcher.py +203 -0
  44. gobby/install/gemini/hooks/hook_dispatcher.py +8 -0
  45. gobby/install/windsurf/hooks/hook_dispatcher.py +205 -0
  46. gobby/llm/__init__.py +14 -1
  47. gobby/llm/claude.py +217 -1
  48. gobby/llm/service.py +149 -0
  49. gobby/mcp_proxy/instructions.py +9 -27
  50. gobby/mcp_proxy/models.py +1 -0
  51. gobby/mcp_proxy/registries.py +56 -9
  52. gobby/mcp_proxy/server.py +6 -2
  53. gobby/mcp_proxy/services/tool_filter.py +7 -0
  54. gobby/mcp_proxy/services/tool_proxy.py +19 -1
  55. gobby/mcp_proxy/stdio.py +37 -21
  56. gobby/mcp_proxy/tools/agents.py +7 -0
  57. gobby/mcp_proxy/tools/hub.py +30 -1
  58. gobby/mcp_proxy/tools/orchestration/cleanup.py +5 -5
  59. gobby/mcp_proxy/tools/orchestration/monitor.py +1 -1
  60. gobby/mcp_proxy/tools/orchestration/orchestrate.py +8 -3
  61. gobby/mcp_proxy/tools/orchestration/review.py +17 -4
  62. gobby/mcp_proxy/tools/orchestration/wait.py +7 -7
  63. gobby/mcp_proxy/tools/pipelines/__init__.py +254 -0
  64. gobby/mcp_proxy/tools/pipelines/_discovery.py +67 -0
  65. gobby/mcp_proxy/tools/pipelines/_execution.py +281 -0
  66. gobby/mcp_proxy/tools/sessions/_crud.py +4 -4
  67. gobby/mcp_proxy/tools/sessions/_handoff.py +1 -1
  68. gobby/mcp_proxy/tools/skills/__init__.py +184 -30
  69. gobby/mcp_proxy/tools/spawn_agent.py +229 -14
  70. gobby/mcp_proxy/tools/tasks/_context.py +8 -0
  71. gobby/mcp_proxy/tools/tasks/_crud.py +27 -1
  72. gobby/mcp_proxy/tools/tasks/_helpers.py +1 -1
  73. gobby/mcp_proxy/tools/tasks/_lifecycle.py +125 -8
  74. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +2 -1
  75. gobby/mcp_proxy/tools/tasks/_search.py +1 -1
  76. gobby/mcp_proxy/tools/workflows/__init__.py +9 -2
  77. gobby/mcp_proxy/tools/workflows/_lifecycle.py +12 -1
  78. gobby/mcp_proxy/tools/workflows/_query.py +45 -26
  79. gobby/mcp_proxy/tools/workflows/_terminal.py +39 -3
  80. gobby/mcp_proxy/tools/worktrees.py +54 -15
  81. gobby/memory/context.py +5 -5
  82. gobby/runner.py +108 -6
  83. gobby/servers/http.py +7 -1
  84. gobby/servers/routes/__init__.py +2 -0
  85. gobby/servers/routes/admin.py +44 -0
  86. gobby/servers/routes/mcp/endpoints/execution.py +18 -25
  87. gobby/servers/routes/mcp/hooks.py +10 -1
  88. gobby/servers/routes/pipelines.py +227 -0
  89. gobby/servers/websocket.py +314 -1
  90. gobby/sessions/analyzer.py +87 -1
  91. gobby/sessions/manager.py +5 -5
  92. gobby/sessions/transcripts/__init__.py +3 -0
  93. gobby/sessions/transcripts/claude.py +5 -0
  94. gobby/sessions/transcripts/codex.py +5 -0
  95. gobby/sessions/transcripts/gemini.py +5 -0
  96. gobby/skills/hubs/__init__.py +25 -0
  97. gobby/skills/hubs/base.py +234 -0
  98. gobby/skills/hubs/claude_plugins.py +328 -0
  99. gobby/skills/hubs/clawdhub.py +289 -0
  100. gobby/skills/hubs/github_collection.py +465 -0
  101. gobby/skills/hubs/manager.py +263 -0
  102. gobby/skills/hubs/skillhub.py +342 -0
  103. gobby/storage/memories.py +4 -4
  104. gobby/storage/migrations.py +95 -3
  105. gobby/storage/pipelines.py +367 -0
  106. gobby/storage/sessions.py +23 -4
  107. gobby/storage/skills.py +1 -1
  108. gobby/storage/tasks/_aggregates.py +2 -2
  109. gobby/storage/tasks/_lifecycle.py +4 -4
  110. gobby/storage/tasks/_models.py +7 -1
  111. gobby/storage/tasks/_queries.py +3 -3
  112. gobby/sync/memories.py +4 -3
  113. gobby/tasks/commits.py +48 -17
  114. gobby/workflows/actions.py +75 -0
  115. gobby/workflows/context_actions.py +246 -5
  116. gobby/workflows/definitions.py +119 -1
  117. gobby/workflows/detection_helpers.py +23 -11
  118. gobby/workflows/enforcement/task_policy.py +18 -0
  119. gobby/workflows/engine.py +20 -1
  120. gobby/workflows/evaluator.py +8 -5
  121. gobby/workflows/lifecycle_evaluator.py +57 -26
  122. gobby/workflows/loader.py +567 -30
  123. gobby/workflows/lobster_compat.py +147 -0
  124. gobby/workflows/pipeline_executor.py +801 -0
  125. gobby/workflows/pipeline_state.py +172 -0
  126. gobby/workflows/pipeline_webhooks.py +206 -0
  127. gobby/workflows/premature_stop.py +5 -0
  128. gobby/worktrees/git.py +135 -20
  129. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/METADATA +56 -22
  130. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/RECORD +134 -106
  131. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/WHEEL +0 -0
  132. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/entry_points.txt +0 -0
  133. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/licenses/LICENSE.md +0 -0
  134. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/top_level.txt +0 -0
gobby/workflows/loader.py CHANGED
@@ -1,11 +1,14 @@
1
1
  import logging
2
2
  from dataclasses import dataclass
3
3
  from pathlib import Path
4
- from typing import Any
4
+ from typing import TYPE_CHECKING, Any
5
5
 
6
6
  import yaml
7
7
 
8
- from .definitions import WorkflowDefinition
8
+ from .definitions import PipelineDefinition, WorkflowDefinition
9
+
10
+ if TYPE_CHECKING:
11
+ from gobby.agents.definitions import WorkflowSpec
9
12
 
10
13
  logger = logging.getLogger(__name__)
11
14
 
@@ -15,7 +18,7 @@ class DiscoveredWorkflow:
15
18
  """A discovered workflow with metadata for ordering."""
16
19
 
17
20
  name: str
18
- definition: WorkflowDefinition
21
+ definition: WorkflowDefinition | PipelineDefinition
19
22
  priority: int # Lower = higher priority (runs first)
20
23
  is_project: bool # True if from project, False if global
21
24
  path: Path
@@ -25,7 +28,7 @@ class WorkflowLoader:
25
28
  def __init__(self, workflow_dirs: list[Path] | None = None):
26
29
  # Default global workflow directory
27
30
  self.global_dirs = workflow_dirs or [Path.home() / ".gobby" / "workflows"]
28
- self._cache: dict[str, WorkflowDefinition] = {}
31
+ self._cache: dict[str, WorkflowDefinition | PipelineDefinition] = {}
29
32
  # Cache for discovered workflows per project path
30
33
  self._discovery_cache: dict[str, list[DiscoveredWorkflow]] = {}
31
34
 
@@ -34,19 +37,23 @@ class WorkflowLoader:
34
37
  name: str,
35
38
  project_path: Path | str | None = None,
36
39
  _inheritance_chain: list[str] | None = None,
37
- ) -> WorkflowDefinition | None:
40
+ ) -> WorkflowDefinition | PipelineDefinition | None:
38
41
  """
39
42
  Load a workflow by name (without extension).
40
43
  Supports inheritance via 'extends' field with cycle detection.
44
+ Auto-detects pipeline type and returns PipelineDefinition for type='pipeline'.
45
+
46
+ Qualified names (agent:workflow) are resolved by loading the inline workflow
47
+ from the agent definition.
41
48
 
42
49
  Args:
43
- name: Workflow name (without .yaml extension)
50
+ name: Workflow name (without .yaml extension), or qualified name (agent:workflow)
44
51
  project_path: Optional project directory for project-specific workflows.
45
52
  Searches: 1) {project_path}/.gobby/workflows/ 2) ~/.gobby/workflows/
46
53
  _inheritance_chain: Internal parameter for cycle detection. Do not pass directly.
47
54
 
48
55
  Raises:
49
- ValueError: If circular inheritance is detected.
56
+ ValueError: If circular inheritance is detected or pipeline references are invalid.
50
57
  """
51
58
  # Initialize or check inheritance chain for cycle detection
52
59
  if _inheritance_chain is None:
@@ -56,10 +63,23 @@ class WorkflowLoader:
56
63
  cycle_path = " -> ".join(_inheritance_chain + [name])
57
64
  logger.error(f"Circular workflow inheritance detected: {cycle_path}")
58
65
  raise ValueError(f"Circular workflow inheritance detected: {cycle_path}")
66
+
59
67
  # Build cache key including project path for project-specific caching
60
68
  cache_key = f"{project_path or 'global'}:{name}"
61
69
  if cache_key in self._cache:
62
- return self._cache[cache_key]
70
+ cached = self._cache[cache_key]
71
+ if isinstance(cached, (WorkflowDefinition, PipelineDefinition)):
72
+ return cached
73
+ return None
74
+
75
+ # Check for qualified name (agent:workflow) - try to load from agent definition first
76
+ if ":" in name:
77
+ agent_workflow = self._load_from_agent_definition(name, project_path)
78
+ if agent_workflow:
79
+ self._cache[cache_key] = agent_workflow
80
+ return agent_workflow
81
+ # Fall through to file-based lookup (for backwards compatibility with
82
+ # persisted inline workflows like meeseeks-worker.yaml)
63
83
 
64
84
  # Build search directories: project-specific first, then global
65
85
  search_dirs = list(self.global_dirs)
@@ -92,33 +112,341 @@ class WorkflowLoader:
92
112
  else:
93
113
  logger.error(f"Parent workflow '{parent_name}' not found for '{name}'")
94
114
 
95
- # 4. Validate and create model
96
- definition = WorkflowDefinition(**data)
115
+ # 4. Auto-detect pipeline type
116
+ if data.get("type") == "pipeline":
117
+ # Validate step references for pipelines
118
+ self._validate_pipeline_references(data)
119
+ definition: WorkflowDefinition | PipelineDefinition = PipelineDefinition(**data)
120
+ else:
121
+ definition = WorkflowDefinition(**data)
122
+
97
123
  self._cache[cache_key] = definition
98
124
  return definition
99
125
 
100
126
  except ValueError:
101
- # Re-raise ValueError (used for cycle detection)
127
+ # Re-raise ValueError (used for cycle detection and reference validation)
102
128
  raise
103
129
  except Exception as e:
104
130
  logger.error(f"Failed to load workflow '{name}' from {path}: {e}", exc_info=True)
105
131
  return None
106
132
 
133
+ def load_pipeline(
134
+ self,
135
+ name: str,
136
+ project_path: Path | str | None = None,
137
+ _inheritance_chain: list[str] | None = None,
138
+ ) -> PipelineDefinition | None:
139
+ """
140
+ Load a pipeline workflow by name (without extension).
141
+ Only returns workflows with type='pipeline'.
142
+
143
+ Args:
144
+ name: Pipeline name (without .yaml extension)
145
+ project_path: Optional project directory for project-specific pipelines.
146
+ Searches: 1) {project_path}/.gobby/workflows/ 2) ~/.gobby/workflows/
147
+ _inheritance_chain: Internal parameter for cycle detection. Do not pass directly.
148
+
149
+ Returns:
150
+ PipelineDefinition if found and type is 'pipeline', None otherwise.
151
+ """
152
+ # Initialize or check inheritance chain for cycle detection
153
+ if _inheritance_chain is None:
154
+ _inheritance_chain = []
155
+
156
+ if name in _inheritance_chain:
157
+ cycle_path = " -> ".join(_inheritance_chain + [name])
158
+ logger.error(f"Circular pipeline inheritance detected: {cycle_path}")
159
+ raise ValueError(f"Circular pipeline inheritance detected: {cycle_path}")
160
+
161
+ # Build cache key including project path for project-specific caching
162
+ cache_key = f"pipeline:{project_path or 'global'}:{name}"
163
+ if cache_key in self._cache:
164
+ cached = self._cache[cache_key]
165
+ if isinstance(cached, PipelineDefinition):
166
+ return cached
167
+ return None
168
+
169
+ # Build search directories: project-specific first, then global
170
+ search_dirs = list(self.global_dirs)
171
+ if project_path:
172
+ project_dir = Path(project_path) / ".gobby" / "workflows"
173
+ search_dirs.insert(0, project_dir)
174
+
175
+ # 1. Find file
176
+ path = self._find_workflow_file(name, search_dirs)
177
+ if not path:
178
+ logger.debug(f"Pipeline '{name}' not found in {search_dirs}")
179
+ return None
180
+
181
+ try:
182
+ # 2. Parse YAML
183
+ with open(path) as f:
184
+ data = yaml.safe_load(f)
185
+
186
+ # 3. Check if this is a pipeline type
187
+ if data.get("type") != "pipeline":
188
+ logger.debug(f"'{name}' is not a pipeline (type={data.get('type')})")
189
+ return None
190
+
191
+ # 4. Handle inheritance with cycle detection
192
+ if "extends" in data:
193
+ parent_name = data["extends"]
194
+ # Add current pipeline to chain before loading parent
195
+ parent = self.load_pipeline(
196
+ parent_name,
197
+ project_path=project_path,
198
+ _inheritance_chain=_inheritance_chain + [name],
199
+ )
200
+ if parent:
201
+ data = self._merge_workflows(parent.model_dump(), data)
202
+ else:
203
+ logger.error(f"Parent pipeline '{parent_name}' not found for '{name}'")
204
+
205
+ # 5. Validate step references
206
+ self._validate_pipeline_references(data)
207
+
208
+ # 6. Validate and create model
209
+ definition = PipelineDefinition(**data)
210
+ self._cache[cache_key] = definition
211
+ return definition
212
+
213
+ except ValueError:
214
+ # Re-raise ValueError (used for cycle detection)
215
+ raise
216
+ except Exception as e:
217
+ logger.error(f"Failed to load pipeline '{name}' from {path}: {e}", exc_info=True)
218
+ return None
219
+
107
220
  def _find_workflow_file(self, name: str, search_dirs: list[Path]) -> Path | None:
108
- filename = f"{name}.yaml"
221
+ # Try both the original name and converted name (for inline workflows)
222
+ # "meeseeks:worker" -> also try "meeseeks-worker"
223
+ filenames = [f"{name}.yaml"]
224
+ if ":" in name:
225
+ filenames.append(f"{name.replace(':', '-')}.yaml")
226
+
109
227
  for d in search_dirs:
110
- # Check root directory
111
- candidate = d / filename
112
- if candidate.exists():
113
- return candidate
114
- # Check subdirectories (lifecycle/, etc.)
115
- for subdir in d.iterdir() if d.exists() else []:
116
- if subdir.is_dir():
117
- candidate = subdir / filename
118
- if candidate.exists():
119
- return candidate
228
+ for filename in filenames:
229
+ # Check root directory
230
+ candidate = d / filename
231
+ if candidate.exists():
232
+ return candidate
233
+ # Check subdirectories (lifecycle/, etc.)
234
+ for subdir in d.iterdir() if d.exists() else []:
235
+ if subdir.is_dir():
236
+ candidate = subdir / filename
237
+ if candidate.exists():
238
+ return candidate
239
+ return None
240
+
241
+ def _load_from_agent_definition(
242
+ self,
243
+ qualified_name: str,
244
+ project_path: Path | str | None = None,
245
+ ) -> WorkflowDefinition | PipelineDefinition | None:
246
+ """
247
+ Load an inline workflow from an agent definition.
248
+
249
+ Qualified names like "meeseeks:worker" are parsed to extract the agent name
250
+ and workflow name, then the workflow is loaded from the agent's workflows map.
251
+
252
+ Args:
253
+ qualified_name: Qualified workflow name (e.g., "meeseeks:worker")
254
+ project_path: Project path for agent definition lookup
255
+
256
+ Returns:
257
+ WorkflowDefinition or PipelineDefinition if found, None otherwise
258
+ """
259
+ if ":" not in qualified_name:
260
+ return None
261
+
262
+ agent_name, workflow_name = qualified_name.split(":", 1)
263
+
264
+ # Import here to avoid circular imports
265
+ from gobby.agents.definitions import AgentDefinitionLoader
266
+
267
+ agent_loader = AgentDefinitionLoader()
268
+ agent_def = agent_loader.load(agent_name)
269
+
270
+ if not agent_def:
271
+ logger.debug(
272
+ f"Agent definition '{agent_name}' not found for workflow '{qualified_name}'"
273
+ )
274
+ return None
275
+
276
+ if not agent_def.workflows:
277
+ logger.debug(f"Agent '{agent_name}' has no workflows defined")
278
+ return None
279
+
280
+ spec = agent_def.workflows.get(workflow_name)
281
+ if not spec:
282
+ logger.debug(f"Workflow '{workflow_name}' not found in agent '{agent_name}'")
283
+ return None
284
+
285
+ # If it's a file reference, load from the file
286
+ if spec.is_file_reference():
287
+ file_name = spec.file or ""
288
+ # Remove .yaml extension if present for load_workflow call
289
+ workflow_file = file_name.removesuffix(".yaml")
290
+ logger.debug(
291
+ f"Loading file-referenced workflow '{workflow_file}' for '{qualified_name}'"
292
+ )
293
+ return self.load_workflow(workflow_file, project_path)
294
+
295
+ # It's an inline workflow - build definition from spec
296
+ if spec.is_inline():
297
+ return self._build_definition_from_spec(spec, qualified_name)
298
+
299
+ logger.debug(f"WorkflowSpec for '{qualified_name}' is neither file reference nor inline")
120
300
  return None
121
301
 
302
+ def _build_definition_from_spec(
303
+ self,
304
+ spec: "WorkflowSpec",
305
+ name: str,
306
+ ) -> WorkflowDefinition | PipelineDefinition:
307
+ """
308
+ Build a WorkflowDefinition or PipelineDefinition from a WorkflowSpec.
309
+
310
+ Args:
311
+ spec: The WorkflowSpec from an agent definition
312
+ name: The qualified workflow name (e.g., "meeseeks:worker")
313
+
314
+ Returns:
315
+ WorkflowDefinition or PipelineDefinition
316
+ """
317
+ # Convert spec to dict for definition creation
318
+ data = spec.model_dump(exclude_none=True, exclude_unset=True)
319
+
320
+ # Ensure name is set
321
+ if "name" not in data or data.get("name") is None:
322
+ data["name"] = name
323
+
324
+ # Remove 'file' field if present (it's not part of WorkflowDefinition)
325
+ data.pop("file", None)
326
+
327
+ # Default to step workflow if type not specified
328
+ if "type" not in data:
329
+ data["type"] = "step"
330
+
331
+ if data.get("type") == "pipeline":
332
+ self._validate_pipeline_references(data)
333
+ return PipelineDefinition(**data)
334
+ else:
335
+ return WorkflowDefinition(**data)
336
+
337
+ def _validate_pipeline_references(self, data: dict[str, Any]) -> None:
338
+ """
339
+ Validate that all $step_id.output references in a pipeline refer to earlier steps.
340
+
341
+ Args:
342
+ data: Pipeline data dictionary
343
+
344
+ Raises:
345
+ ValueError: If a reference points to a non-existent or later step
346
+ """
347
+ steps = data.get("steps", [])
348
+ step_ids = [s.get("id") for s in steps if s.get("id")]
349
+
350
+ # Build set of valid step IDs that can be referenced at each position
351
+ valid_at_position: dict[int, set[str]] = {}
352
+ for i in range(len(step_ids)):
353
+ # Steps at position i can only reference steps 0..i-1
354
+ valid_at_position[i] = set(step_ids[:i])
355
+
356
+ # Validate references in each step
357
+ for i, step in enumerate(steps):
358
+ step_id = step.get("id", f"step_{i}")
359
+ valid_refs = valid_at_position.get(i, set())
360
+
361
+ # Check prompt field
362
+ if "prompt" in step and step["prompt"]:
363
+ refs = self._extract_step_refs(step["prompt"])
364
+ self._check_refs(refs, valid_refs, step_ids, step_id, "prompt")
365
+
366
+ # Check condition field
367
+ if "condition" in step and step["condition"]:
368
+ refs = self._extract_step_refs(step["condition"])
369
+ self._check_refs(refs, valid_refs, step_ids, step_id, "condition")
370
+
371
+ # Check input field
372
+ if "input" in step and step["input"]:
373
+ refs = self._extract_step_refs(step["input"])
374
+ self._check_refs(refs, valid_refs, step_ids, step_id, "input")
375
+
376
+ # Check exec field (might have embedded references)
377
+ if "exec" in step and step["exec"]:
378
+ refs = self._extract_step_refs(step["exec"])
379
+ self._check_refs(refs, valid_refs, step_ids, step_id, "exec")
380
+
381
+ # Validate references in pipeline outputs (can reference any step)
382
+ all_step_ids = set(step_ids)
383
+ outputs = data.get("outputs", {})
384
+ for output_name, output_value in outputs.items():
385
+ if isinstance(output_value, str):
386
+ refs = self._extract_step_refs(output_value)
387
+ for ref in refs:
388
+ if ref not in all_step_ids:
389
+ raise ValueError(
390
+ f"Pipeline output '{output_name}' references unknown step '{ref}'. "
391
+ f"Valid steps: {sorted(all_step_ids)}"
392
+ )
393
+
394
+ def _extract_step_refs(self, text: str) -> set[str]:
395
+ """
396
+ Extract step IDs from $step_id.output patterns in text.
397
+
398
+ Args:
399
+ text: Text to search for references
400
+
401
+ Returns:
402
+ Set of step IDs referenced
403
+ """
404
+ import re
405
+
406
+ # Match $step_id.output or $step_id.output.field patterns
407
+ # Exclude $inputs.* which are input references, not step references
408
+ pattern = r"\$([a-zA-Z_][a-zA-Z0-9_]*)\.(output|approved)"
409
+ matches = re.findall(pattern, text)
410
+ # Filter out 'inputs' which is a special reference
411
+ return {m[0] for m in matches if m[0] != "inputs"}
412
+
413
+ def _check_refs(
414
+ self,
415
+ refs: set[str],
416
+ valid_refs: set[str],
417
+ all_step_ids: list[str],
418
+ current_step: str,
419
+ field_name: str,
420
+ ) -> None:
421
+ """
422
+ Check that all references are valid.
423
+
424
+ Args:
425
+ refs: Set of referenced step IDs
426
+ valid_refs: Set of step IDs that can be referenced (earlier steps)
427
+ all_step_ids: List of all step IDs in the pipeline
428
+ current_step: Current step ID (for error messages)
429
+ field_name: Field name being checked (for error messages)
430
+
431
+ Raises:
432
+ ValueError: If any reference is invalid
433
+ """
434
+ for ref in refs:
435
+ if ref not in valid_refs:
436
+ if ref in all_step_ids:
437
+ # It's a forward reference
438
+ raise ValueError(
439
+ f"Step '{current_step}' {field_name} references step '{ref}' "
440
+ f"which appears later in the pipeline. Steps can only reference "
441
+ f"earlier steps. Valid references: {sorted(valid_refs) if valid_refs else '(none)'}"
442
+ )
443
+ else:
444
+ # It's a non-existent step
445
+ raise ValueError(
446
+ f"Step '{current_step}' {field_name} references unknown step '{ref}'. "
447
+ f"Valid steps: {sorted(all_step_ids)}"
448
+ )
449
+
122
450
  def _merge_workflows(self, parent: dict[str, Any], child: dict[str, Any]) -> dict[str, Any]:
123
451
  """
124
452
  Deep merge parent and child workflow dicts.
@@ -142,22 +470,28 @@ class WorkflowLoader:
142
470
 
143
471
  def _merge_steps(self, parent_steps: list[Any], child_steps: list[Any]) -> list[Any]:
144
472
  """
145
- Merge step lists by step name.
473
+ Merge step lists by step name or id.
474
+ Supports both workflow steps (name key) and pipeline steps (id key).
146
475
  """
147
- # Convert parent list to dict by name, creating copies to avoid mutating originals
476
+ # Determine which key to use: 'id' for pipelines, 'name' for workflows
477
+ key_field = "id" if (parent_steps and "id" in parent_steps[0]) else "name"
478
+ if not parent_steps and child_steps:
479
+ key_field = "id" if "id" in child_steps[0] else "name"
480
+
481
+ # Convert parent list to dict by key, creating copies to avoid mutating originals
148
482
  parent_map: dict[str, dict[str, Any]] = {}
149
483
  for s in parent_steps:
150
- if "name" not in s:
151
- logger.warning("Skipping parent step without 'name' key")
484
+ if key_field not in s:
485
+ logger.warning(f"Skipping parent step without '{key_field}' key")
152
486
  continue
153
487
  # Create a shallow copy to avoid mutating the original
154
- parent_map[s["name"]] = dict(s)
488
+ parent_map[s[key_field]] = dict(s)
155
489
 
156
490
  for child_step in child_steps:
157
- if "name" not in child_step:
158
- logger.warning("Skipping child step without 'name' key")
491
+ if key_field not in child_step:
492
+ logger.warning(f"Skipping child step without '{key_field}' key")
159
493
  continue
160
- name = child_step["name"]
494
+ name = child_step[key_field]
161
495
  if name in parent_map:
162
496
  # Merge existing step by updating the copy with child values
163
497
  parent_map[name].update(child_step)
@@ -228,6 +562,150 @@ class WorkflowLoader:
228
562
  self._discovery_cache[cache_key] = sorted_workflows
229
563
  return sorted_workflows
230
564
 
565
+ def discover_pipeline_workflows(
566
+ self, project_path: Path | str | None = None
567
+ ) -> list[DiscoveredWorkflow]:
568
+ """
569
+ Discover all pipeline workflows from project and global directories.
570
+
571
+ Returns workflows sorted by:
572
+ 1. Project workflows first (is_project=True), then global
573
+ 2. Within each group: by priority (ascending), then alphabetically by name
574
+
575
+ Project workflows shadow global workflows with the same name.
576
+
577
+ Note: Unlike lifecycle workflows which are in lifecycle/ subdirs,
578
+ pipelines are in the root workflows/ directory.
579
+
580
+ Args:
581
+ project_path: Optional project directory. If provided, searches
582
+ {project_path}/.gobby/workflows/ first.
583
+
584
+ Returns:
585
+ List of DiscoveredWorkflow objects with type='pipeline', sorted and deduplicated.
586
+ """
587
+ cache_key = f"pipelines:{project_path}" if project_path else "pipelines:global"
588
+
589
+ # Check cache
590
+ if cache_key in self._discovery_cache:
591
+ return self._discovery_cache[cache_key]
592
+
593
+ discovered: dict[str, DiscoveredWorkflow] = {} # name -> workflow (for shadowing)
594
+ failed: dict[str, str] = {} # name -> error message for failed workflows
595
+
596
+ # 1. Scan global workflows directory first (will be shadowed by project)
597
+ for global_dir in self.global_dirs:
598
+ self._scan_pipeline_directory(global_dir, is_project=False, discovered=discovered)
599
+
600
+ # 2. Scan project workflows directory (shadows global)
601
+ if project_path:
602
+ project_dir = Path(project_path) / ".gobby" / "workflows"
603
+ self._scan_pipeline_directory(
604
+ project_dir, is_project=True, discovered=discovered, failed=failed
605
+ )
606
+
607
+ # Log errors when project pipeline fails but global exists (failed shadowing)
608
+ for name, error in failed.items():
609
+ if name in discovered and not discovered[name].is_project:
610
+ logger.error(
611
+ f"Project pipeline '{name}' failed to load, using global instead: {error}"
612
+ )
613
+
614
+ # 3. Sort: project first, then by priority (asc), then by name (alpha)
615
+ sorted_pipelines = sorted(
616
+ discovered.values(),
617
+ key=lambda w: (
618
+ 0 if w.is_project else 1, # Project first
619
+ w.priority, # Lower priority = runs first
620
+ w.name, # Alphabetical
621
+ ),
622
+ )
623
+
624
+ # Cache and return
625
+ self._discovery_cache[cache_key] = sorted_pipelines
626
+ return sorted_pipelines
627
+
628
+ def _scan_pipeline_directory(
629
+ self,
630
+ directory: Path,
631
+ is_project: bool,
632
+ discovered: dict[str, DiscoveredWorkflow],
633
+ failed: dict[str, str] | None = None,
634
+ ) -> None:
635
+ """
636
+ Scan a directory for pipeline YAML files and add to discovered dict.
637
+
638
+ Only includes workflows with type='pipeline'.
639
+
640
+ Args:
641
+ directory: Directory to scan
642
+ is_project: Whether this is a project directory (for shadowing)
643
+ discovered: Dict to update (name -> DiscoveredWorkflow)
644
+ failed: Optional dict to track failed pipelines (name -> error message)
645
+ """
646
+ if not directory.exists():
647
+ return
648
+
649
+ for yaml_path in directory.glob("*.yaml"):
650
+ name = yaml_path.stem
651
+ try:
652
+ with open(yaml_path) as f:
653
+ data = yaml.safe_load(f)
654
+
655
+ if not data:
656
+ continue
657
+
658
+ # Only process pipeline type workflows
659
+ if data.get("type") != "pipeline":
660
+ continue
661
+
662
+ # Handle inheritance with cycle detection
663
+ if "extends" in data:
664
+ parent_name = data["extends"]
665
+ try:
666
+ parent = self.load_pipeline(
667
+ parent_name,
668
+ _inheritance_chain=[name],
669
+ )
670
+ if parent:
671
+ data = self._merge_workflows(parent.model_dump(), data)
672
+ except ValueError as e:
673
+ logger.warning(f"Skipping pipeline {name}: {e}")
674
+ if failed is not None:
675
+ failed[name] = str(e)
676
+ continue
677
+
678
+ # Validate references before creating definition
679
+ self._validate_pipeline_references(data)
680
+
681
+ definition = PipelineDefinition(**data)
682
+
683
+ # Get priority from data settings or default to 100
684
+ # (PipelineDefinition doesn't have settings field, use raw data)
685
+ priority = 100
686
+ settings = data.get("settings", {})
687
+ if settings and "priority" in settings:
688
+ priority = settings["priority"]
689
+
690
+ # Log successful shadowing when project pipeline overrides global
691
+ if name in discovered and is_project and not discovered[name].is_project:
692
+ logger.info(f"Project pipeline '{name}' shadows global pipeline")
693
+
694
+ # Project pipelines shadow global (overwrite in dict)
695
+ # Global is scanned first, so project overwrites
696
+ discovered[name] = DiscoveredWorkflow(
697
+ name=name,
698
+ definition=definition,
699
+ priority=priority,
700
+ is_project=is_project,
701
+ path=yaml_path,
702
+ )
703
+
704
+ except Exception as e:
705
+ logger.warning(f"Failed to load pipeline from {yaml_path}: {e}")
706
+ if failed is not None:
707
+ failed[name] = str(e)
708
+
231
709
  def _scan_directory(
232
710
  self,
233
711
  directory: Path,
@@ -306,6 +784,65 @@ class WorkflowLoader:
306
784
  self._cache.clear()
307
785
  self._discovery_cache.clear()
308
786
 
787
+ def register_inline_workflow(
788
+ self,
789
+ name: str,
790
+ data: dict[str, Any],
791
+ project_path: Path | str | None = None,
792
+ ) -> WorkflowDefinition | PipelineDefinition:
793
+ """
794
+ Register an inline workflow definition in the cache.
795
+
796
+ Inline workflows are embedded in agent definitions and registered
797
+ at spawn time with qualified names like "agent:workflow".
798
+
799
+ Note: Inline workflows are NOT written to disk. Child agents can load
800
+ them directly from agent definitions via load_workflow() which handles
801
+ qualified names (agent:workflow) by parsing the agent YAML.
802
+
803
+ Args:
804
+ name: Qualified workflow name (e.g., "meeseeks:worker")
805
+ data: Workflow definition data dict
806
+ project_path: Project path for cache key scoping
807
+
808
+ Returns:
809
+ The created WorkflowDefinition or PipelineDefinition
810
+
811
+ Raises:
812
+ ValueError: If the workflow definition is invalid
813
+ """
814
+ cache_key = f"{project_path or 'global'}:{name}"
815
+
816
+ # Already registered?
817
+ if cache_key in self._cache:
818
+ cached = self._cache[cache_key]
819
+ if isinstance(cached, (WorkflowDefinition, PipelineDefinition)):
820
+ return cached
821
+
822
+ # Ensure name is set in data (handle both missing and None)
823
+ if "name" not in data or data.get("name") is None:
824
+ data["name"] = name
825
+
826
+ # Create definition based on type
827
+ try:
828
+ if data.get("type") == "pipeline":
829
+ self._validate_pipeline_references(data)
830
+ definition: WorkflowDefinition | PipelineDefinition = PipelineDefinition(**data)
831
+ else:
832
+ # Default to step workflow
833
+ if "type" not in data:
834
+ data["type"] = "step"
835
+ definition = WorkflowDefinition(**data)
836
+
837
+ self._cache[cache_key] = definition
838
+
839
+ logger.debug(f"Registered inline workflow '{name}' (type={definition.type})")
840
+ return definition
841
+
842
+ except Exception as e:
843
+ logger.error(f"Failed to register inline workflow '{name}': {e}")
844
+ raise ValueError(f"Invalid inline workflow '{name}': {e}") from e
845
+
309
846
  def validate_workflow_for_agent(
310
847
  self,
311
848
  workflow_name: str,