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
@@ -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(
@@ -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__":
@@ -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