gobby 0.2.5__py3-none-any.whl → 0.2.7__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 (244) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/claude_code.py +13 -4
  4. gobby/adapters/codex_impl/__init__.py +28 -0
  5. gobby/adapters/codex_impl/adapter.py +722 -0
  6. gobby/adapters/codex_impl/client.py +679 -0
  7. gobby/adapters/codex_impl/protocol.py +20 -0
  8. gobby/adapters/codex_impl/types.py +68 -0
  9. gobby/agents/definitions.py +11 -1
  10. gobby/agents/isolation.py +395 -0
  11. gobby/agents/runner.py +8 -0
  12. gobby/agents/sandbox.py +261 -0
  13. gobby/agents/spawn.py +42 -287
  14. gobby/agents/spawn_executor.py +385 -0
  15. gobby/agents/spawners/__init__.py +24 -0
  16. gobby/agents/spawners/command_builder.py +189 -0
  17. gobby/agents/spawners/embedded.py +21 -2
  18. gobby/agents/spawners/headless.py +21 -2
  19. gobby/agents/spawners/prompt_manager.py +125 -0
  20. gobby/cli/__init__.py +6 -0
  21. gobby/cli/clones.py +419 -0
  22. gobby/cli/conductor.py +266 -0
  23. gobby/cli/install.py +4 -4
  24. gobby/cli/installers/antigravity.py +3 -9
  25. gobby/cli/installers/claude.py +15 -9
  26. gobby/cli/installers/codex.py +2 -8
  27. gobby/cli/installers/gemini.py +8 -8
  28. gobby/cli/installers/shared.py +175 -13
  29. gobby/cli/sessions.py +1 -1
  30. gobby/cli/skills.py +858 -0
  31. gobby/cli/tasks/ai.py +0 -440
  32. gobby/cli/tasks/crud.py +44 -6
  33. gobby/cli/tasks/main.py +0 -4
  34. gobby/cli/tui.py +2 -2
  35. gobby/cli/utils.py +12 -5
  36. gobby/clones/__init__.py +13 -0
  37. gobby/clones/git.py +547 -0
  38. gobby/conductor/__init__.py +16 -0
  39. gobby/conductor/alerts.py +135 -0
  40. gobby/conductor/loop.py +164 -0
  41. gobby/conductor/monitors/__init__.py +11 -0
  42. gobby/conductor/monitors/agents.py +116 -0
  43. gobby/conductor/monitors/tasks.py +155 -0
  44. gobby/conductor/pricing.py +234 -0
  45. gobby/conductor/token_tracker.py +160 -0
  46. gobby/config/__init__.py +12 -97
  47. gobby/config/app.py +69 -91
  48. gobby/config/extensions.py +2 -2
  49. gobby/config/features.py +7 -130
  50. gobby/config/search.py +110 -0
  51. gobby/config/servers.py +1 -1
  52. gobby/config/skills.py +43 -0
  53. gobby/config/tasks.py +9 -41
  54. gobby/hooks/__init__.py +0 -13
  55. gobby/hooks/event_handlers.py +188 -2
  56. gobby/hooks/hook_manager.py +50 -4
  57. gobby/hooks/plugins.py +1 -1
  58. gobby/hooks/skill_manager.py +130 -0
  59. gobby/hooks/webhooks.py +1 -1
  60. gobby/install/claude/hooks/hook_dispatcher.py +4 -4
  61. gobby/install/codex/hooks/hook_dispatcher.py +1 -1
  62. gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
  63. gobby/llm/claude.py +22 -34
  64. gobby/llm/claude_executor.py +46 -256
  65. gobby/llm/codex_executor.py +59 -291
  66. gobby/llm/executor.py +21 -0
  67. gobby/llm/gemini.py +134 -110
  68. gobby/llm/litellm_executor.py +143 -6
  69. gobby/llm/resolver.py +98 -35
  70. gobby/mcp_proxy/importer.py +62 -4
  71. gobby/mcp_proxy/instructions.py +56 -0
  72. gobby/mcp_proxy/models.py +15 -0
  73. gobby/mcp_proxy/registries.py +68 -8
  74. gobby/mcp_proxy/server.py +33 -3
  75. gobby/mcp_proxy/services/recommendation.py +43 -11
  76. gobby/mcp_proxy/services/tool_proxy.py +81 -1
  77. gobby/mcp_proxy/stdio.py +2 -1
  78. gobby/mcp_proxy/tools/__init__.py +0 -2
  79. gobby/mcp_proxy/tools/agent_messaging.py +317 -0
  80. gobby/mcp_proxy/tools/agents.py +31 -731
  81. gobby/mcp_proxy/tools/clones.py +518 -0
  82. gobby/mcp_proxy/tools/memory.py +3 -26
  83. gobby/mcp_proxy/tools/metrics.py +65 -1
  84. gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
  85. gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
  86. gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
  87. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  88. gobby/mcp_proxy/tools/sessions/_commits.py +232 -0
  89. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  90. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  91. gobby/mcp_proxy/tools/sessions/_handoff.py +499 -0
  92. gobby/mcp_proxy/tools/sessions/_messages.py +138 -0
  93. gobby/mcp_proxy/tools/skills/__init__.py +616 -0
  94. gobby/mcp_proxy/tools/spawn_agent.py +417 -0
  95. gobby/mcp_proxy/tools/task_orchestration.py +7 -0
  96. gobby/mcp_proxy/tools/task_readiness.py +14 -0
  97. gobby/mcp_proxy/tools/task_sync.py +1 -1
  98. gobby/mcp_proxy/tools/tasks/_context.py +0 -20
  99. gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
  100. gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
  101. gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
  102. gobby/mcp_proxy/tools/tasks/_lifecycle.py +110 -45
  103. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
  104. gobby/mcp_proxy/tools/workflows.py +1 -1
  105. gobby/mcp_proxy/tools/worktrees.py +0 -338
  106. gobby/memory/backends/__init__.py +6 -1
  107. gobby/memory/backends/mem0.py +6 -1
  108. gobby/memory/extractor.py +477 -0
  109. gobby/memory/ingestion/__init__.py +5 -0
  110. gobby/memory/ingestion/multimodal.py +221 -0
  111. gobby/memory/manager.py +73 -285
  112. gobby/memory/search/__init__.py +10 -0
  113. gobby/memory/search/coordinator.py +248 -0
  114. gobby/memory/services/__init__.py +5 -0
  115. gobby/memory/services/crossref.py +142 -0
  116. gobby/prompts/loader.py +5 -2
  117. gobby/runner.py +37 -16
  118. gobby/search/__init__.py +48 -6
  119. gobby/search/backends/__init__.py +159 -0
  120. gobby/search/backends/embedding.py +225 -0
  121. gobby/search/embeddings.py +238 -0
  122. gobby/search/models.py +148 -0
  123. gobby/search/unified.py +496 -0
  124. gobby/servers/http.py +24 -12
  125. gobby/servers/routes/admin.py +294 -0
  126. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  127. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  128. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  129. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  130. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  131. gobby/servers/routes/mcp/hooks.py +1 -1
  132. gobby/servers/routes/mcp/tools.py +48 -1317
  133. gobby/servers/websocket.py +2 -2
  134. gobby/sessions/analyzer.py +2 -0
  135. gobby/sessions/lifecycle.py +1 -1
  136. gobby/sessions/processor.py +10 -0
  137. gobby/sessions/transcripts/base.py +2 -0
  138. gobby/sessions/transcripts/claude.py +79 -10
  139. gobby/skills/__init__.py +91 -0
  140. gobby/skills/loader.py +685 -0
  141. gobby/skills/manager.py +384 -0
  142. gobby/skills/parser.py +286 -0
  143. gobby/skills/search.py +463 -0
  144. gobby/skills/sync.py +119 -0
  145. gobby/skills/updater.py +385 -0
  146. gobby/skills/validator.py +368 -0
  147. gobby/storage/clones.py +378 -0
  148. gobby/storage/database.py +1 -1
  149. gobby/storage/memories.py +43 -13
  150. gobby/storage/migrations.py +162 -201
  151. gobby/storage/sessions.py +116 -7
  152. gobby/storage/skills.py +782 -0
  153. gobby/storage/tasks/_crud.py +4 -4
  154. gobby/storage/tasks/_lifecycle.py +57 -7
  155. gobby/storage/tasks/_manager.py +14 -5
  156. gobby/storage/tasks/_models.py +8 -3
  157. gobby/sync/memories.py +40 -5
  158. gobby/sync/tasks.py +83 -6
  159. gobby/tasks/__init__.py +1 -2
  160. gobby/tasks/external_validator.py +1 -1
  161. gobby/tasks/validation.py +46 -35
  162. gobby/tools/summarizer.py +91 -10
  163. gobby/tui/api_client.py +4 -7
  164. gobby/tui/app.py +5 -3
  165. gobby/tui/screens/orchestrator.py +1 -2
  166. gobby/tui/screens/tasks.py +2 -4
  167. gobby/tui/ws_client.py +1 -1
  168. gobby/utils/daemon_client.py +2 -2
  169. gobby/utils/project_context.py +2 -3
  170. gobby/utils/status.py +13 -0
  171. gobby/workflows/actions.py +221 -1135
  172. gobby/workflows/artifact_actions.py +31 -0
  173. gobby/workflows/autonomous_actions.py +11 -0
  174. gobby/workflows/context_actions.py +93 -1
  175. gobby/workflows/detection_helpers.py +115 -31
  176. gobby/workflows/enforcement/__init__.py +47 -0
  177. gobby/workflows/enforcement/blocking.py +269 -0
  178. gobby/workflows/enforcement/commit_policy.py +283 -0
  179. gobby/workflows/enforcement/handlers.py +269 -0
  180. gobby/workflows/{task_enforcement_actions.py → enforcement/task_policy.py} +29 -388
  181. gobby/workflows/engine.py +13 -2
  182. gobby/workflows/git_utils.py +106 -0
  183. gobby/workflows/lifecycle_evaluator.py +29 -1
  184. gobby/workflows/llm_actions.py +30 -0
  185. gobby/workflows/loader.py +19 -6
  186. gobby/workflows/mcp_actions.py +20 -1
  187. gobby/workflows/memory_actions.py +154 -0
  188. gobby/workflows/safe_evaluator.py +183 -0
  189. gobby/workflows/session_actions.py +44 -0
  190. gobby/workflows/state_actions.py +60 -1
  191. gobby/workflows/stop_signal_actions.py +55 -0
  192. gobby/workflows/summary_actions.py +111 -1
  193. gobby/workflows/task_sync_actions.py +347 -0
  194. gobby/workflows/todo_actions.py +34 -1
  195. gobby/workflows/webhook_actions.py +185 -0
  196. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/METADATA +87 -21
  197. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/RECORD +201 -172
  198. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
  199. gobby/adapters/codex.py +0 -1292
  200. gobby/install/claude/commands/gobby/bug.md +0 -51
  201. gobby/install/claude/commands/gobby/chore.md +0 -51
  202. gobby/install/claude/commands/gobby/epic.md +0 -52
  203. gobby/install/claude/commands/gobby/eval.md +0 -235
  204. gobby/install/claude/commands/gobby/feat.md +0 -49
  205. gobby/install/claude/commands/gobby/nit.md +0 -52
  206. gobby/install/claude/commands/gobby/ref.md +0 -52
  207. gobby/install/codex/prompts/forget.md +0 -7
  208. gobby/install/codex/prompts/memories.md +0 -7
  209. gobby/install/codex/prompts/recall.md +0 -7
  210. gobby/install/codex/prompts/remember.md +0 -13
  211. gobby/llm/gemini_executor.py +0 -339
  212. gobby/mcp_proxy/tools/session_messages.py +0 -1056
  213. gobby/mcp_proxy/tools/task_expansion.py +0 -591
  214. gobby/prompts/defaults/expansion/system.md +0 -119
  215. gobby/prompts/defaults/expansion/user.md +0 -48
  216. gobby/prompts/defaults/external_validation/agent.md +0 -72
  217. gobby/prompts/defaults/external_validation/external.md +0 -63
  218. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  219. gobby/prompts/defaults/external_validation/system.md +0 -6
  220. gobby/prompts/defaults/features/import_mcp.md +0 -22
  221. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  222. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  223. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  224. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  225. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  226. gobby/prompts/defaults/features/server_description.md +0 -20
  227. gobby/prompts/defaults/features/server_description_system.md +0 -6
  228. gobby/prompts/defaults/features/task_description.md +0 -31
  229. gobby/prompts/defaults/features/task_description_system.md +0 -6
  230. gobby/prompts/defaults/features/tool_summary.md +0 -17
  231. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  232. gobby/prompts/defaults/research/step.md +0 -58
  233. gobby/prompts/defaults/validation/criteria.md +0 -47
  234. gobby/prompts/defaults/validation/validate.md +0 -38
  235. gobby/storage/migrations_legacy.py +0 -1359
  236. gobby/tasks/context.py +0 -747
  237. gobby/tasks/criteria.py +0 -342
  238. gobby/tasks/expansion.py +0 -626
  239. gobby/tasks/prompts/expand.py +0 -327
  240. gobby/tasks/research.py +0 -421
  241. gobby/tasks/tdd.py +0 -352
  242. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
  243. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
  244. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/top_level.txt +0 -0
@@ -3,16 +3,20 @@
3
3
  Provides core task operations: create, get, update, list, and tree building.
4
4
  """
5
5
 
6
+ import logging
6
7
  from typing import Any
7
8
 
8
9
  from gobby.mcp_proxy.tools.internal import InternalToolRegistry
9
10
  from gobby.mcp_proxy.tools.tasks._context import RegistryContext
10
11
  from gobby.mcp_proxy.tools.tasks._helpers import _infer_category
11
12
  from gobby.mcp_proxy.tools.tasks._resolution import resolve_task_id_for_mcp
13
+ from gobby.storage.task_dependencies import DependencyCycleError
12
14
  from gobby.storage.tasks import TaskNotFoundError
13
15
  from gobby.utils.project_context import get_project_context
14
16
  from gobby.utils.project_init import initialize_project
15
17
 
18
+ logger = logging.getLogger(__name__)
19
+
16
20
 
17
21
  def create_crud_registry(ctx: RegistryContext) -> InternalToolRegistry:
18
22
  """Create a registry with task CRUD tools.
@@ -36,9 +40,11 @@ def create_crud_registry(ctx: RegistryContext) -> InternalToolRegistry:
36
40
  task_type: str = "task",
37
41
  parent_task_id: str | None = None,
38
42
  blocks: list[str] | None = None,
43
+ depends_on: list[str] | None = None,
39
44
  labels: list[str] | None = None,
40
45
  category: str | None = None,
41
46
  validation_criteria: str | None = None,
47
+ claim: bool = False,
42
48
  ) -> dict[str, Any]:
43
49
  """Create a single task in the current project.
44
50
 
@@ -47,15 +53,17 @@ def create_crud_registry(ctx: RegistryContext) -> InternalToolRegistry:
47
53
 
48
54
  Args:
49
55
  title: Task title
50
- session_id: Your session ID for tracking (REQUIRED)
56
+ session_id: Your session ID for tracking (REQUIRED).
51
57
  description: Detailed description
52
58
  priority: Priority level (1=High, 2=Medium, 3=Low)
53
59
  task_type: Task type (task, bug, feature, epic)
54
60
  parent_task_id: Optional parent task ID
55
61
  blocks: List of task IDs that this new task blocks
62
+ depends_on: List of task IDs that this new task depends on (must complete first)
56
63
  labels: List of labels
57
64
  category: Task domain category (test, code, document, research, config, manual)
58
65
  validation_criteria: Acceptance criteria for validating completion.
66
+ claim: If True, auto-claim the task (set assignee and status to in_progress).
59
67
 
60
68
  Returns:
61
69
  Created task dict with id (minimal) or full task details based on config.
@@ -98,10 +106,72 @@ def create_crud_registry(ctx: RegistryContext) -> InternalToolRegistry:
98
106
 
99
107
  task = ctx.task_manager.get_task(create_result["task"]["id"])
100
108
 
109
+ # Link task to session (best-effort) - tracks which session created the task
110
+ try:
111
+ ctx.session_task_manager.link_task(session_id, task.id, "created")
112
+ except Exception:
113
+ pass # nosec B110 - best-effort linking
114
+
115
+ # Auto-claim if requested: set assignee and status to in_progress
116
+ if claim:
117
+ updated_task = ctx.task_manager.update_task(
118
+ task.id,
119
+ assignee=session_id,
120
+ status="in_progress",
121
+ )
122
+ if updated_task is None:
123
+ logger.warning(f"Failed to auto-claim task {task.id}: update_task returned None")
124
+ else:
125
+ task = updated_task
126
+ # Link task to session with "claimed" action (best-effort)
127
+ try:
128
+ ctx.session_task_manager.link_task(session_id, task.id, "claimed")
129
+ except Exception:
130
+ pass # nosec B110 - best-effort linking
131
+
132
+ # Set workflow state for Claude Code (CC doesn't include tool results in PostToolUse)
133
+ # This mirrors close_task behavior in _lifecycle.py:196-207
134
+ try:
135
+ state = ctx.workflow_state_manager.get_state(session_id)
136
+ if state:
137
+ state.variables["task_claimed"] = True
138
+ state.variables["claimed_task_id"] = task.id # Always use UUID
139
+ ctx.workflow_state_manager.save_state(state)
140
+ except Exception:
141
+ pass # nosec B110 - best-effort state update
142
+
101
143
  # Handle 'blocks' argument if provided (syntactic sugar)
144
+ # Collect errors consistently with depends_on handling below
145
+ dependency_errors: list[str] = []
102
146
  if blocks:
103
147
  for blocked_id in blocks:
104
- ctx.dep_manager.add_dependency(task.id, blocked_id, "blocks")
148
+ try:
149
+ resolved_blocked = resolve_task_id_for_mcp(
150
+ ctx.task_manager, blocked_id, project_id
151
+ )
152
+ ctx.dep_manager.add_dependency(task.id, resolved_blocked, "blocks")
153
+ except TaskNotFoundError:
154
+ dependency_errors.append(f"Task '{blocked_id}' not found (blocks)")
155
+ except ValueError as e:
156
+ dependency_errors.append(f"Invalid ref '{blocked_id}' (blocks): {e}")
157
+ except DependencyCycleError:
158
+ dependency_errors.append(f"Cycle detected for '{blocked_id}' (blocks)")
159
+
160
+ # Handle 'depends_on' argument if provided
161
+ # The new task depends on resolved_blocker, meaning resolved_blocker blocks the new task
162
+ if depends_on:
163
+ for blocker_ref in depends_on:
164
+ try:
165
+ resolved_blocker = resolve_task_id_for_mcp(
166
+ ctx.task_manager, blocker_ref, project_id
167
+ )
168
+ ctx.dep_manager.add_dependency(resolved_blocker, task.id, "blocks")
169
+ except TaskNotFoundError:
170
+ dependency_errors.append(f"Task '{blocker_ref}' not found")
171
+ except ValueError as e:
172
+ dependency_errors.append(f"Invalid ref '{blocker_ref}': {e}")
173
+ except DependencyCycleError:
174
+ dependency_errors.append(f"Cycle detected for '{blocker_ref}'")
105
175
 
106
176
  # Return minimal or full result based on config
107
177
  if ctx.show_result_on_create:
@@ -113,6 +183,11 @@ def create_crud_registry(ctx: RegistryContext) -> InternalToolRegistry:
113
183
  "ref": f"#{task.seq_num}",
114
184
  }
115
185
 
186
+ # Include dependency errors if any
187
+ if dependency_errors:
188
+ result["dependency_errors"] = dependency_errors
189
+ result["warning"] = f"Task created but {len(dependency_errors)} dependency(s) failed"
190
+
116
191
  return result
117
192
 
118
193
  registry.register(
@@ -148,6 +223,12 @@ def create_crud_registry(ctx: RegistryContext) -> InternalToolRegistry:
148
223
  "description": "List of task IDs that this new task blocks (optional)",
149
224
  "default": None,
150
225
  },
226
+ "depends_on": {
227
+ "type": "array",
228
+ "items": {"type": "string"},
229
+ "description": "Tasks this new task depends on (must complete first): #N, N, path, or UUID",
230
+ "default": None,
231
+ },
151
232
  "labels": {
152
233
  "type": "array",
153
234
  "items": {"type": "string"},
@@ -169,6 +250,11 @@ def create_crud_registry(ctx: RegistryContext) -> InternalToolRegistry:
169
250
  "type": "string",
170
251
  "description": "Your session ID (from system context). Required to track which session created the task.",
171
252
  },
253
+ "claim": {
254
+ "type": "boolean",
255
+ "description": "If true, auto-claim the task (set assignee to session_id and status to in_progress). Default: false - task is created with status 'open' and no assignee.",
256
+ "default": False,
257
+ },
172
258
  },
173
259
  "required": ["title", "session_id"],
174
260
  },
@@ -265,8 +351,9 @@ def create_crud_registry(ctx: RegistryContext) -> InternalToolRegistry:
265
351
  try:
266
352
  resolved_parent = resolve_task_id_for_mcp(ctx.task_manager, parent_task_id)
267
353
  kwargs["parent_task_id"] = resolved_parent
268
- except (TaskNotFoundError, ValueError):
269
- kwargs["parent_task_id"] = parent_task_id # Fall back to original
354
+ except (TaskNotFoundError, ValueError) as e:
355
+ logger.warning(f"Invalid parent_task_id '{parent_task_id}': {e}")
356
+ return {"error": f"Invalid parent_task_id '{parent_task_id}': {e}"}
270
357
  else:
271
358
  kwargs["parent_task_id"] = None
272
359
  if category is not None:
@@ -0,0 +1,348 @@
1
+ """Expansion tools for skill-based task decomposition.
2
+
3
+ Provides tools for the /gobby-expand skill workflow:
4
+ 1. save_expansion_spec - Save expansion spec to task for later execution
5
+ 2. execute_expansion - Create subtasks atomically from saved spec
6
+ 3. get_expansion_spec - Check for pending expansion (for resume)
7
+ """
8
+
9
+ import json
10
+ import logging
11
+ from typing import Any
12
+
13
+ from gobby.mcp_proxy.tools.internal import InternalToolRegistry
14
+ from gobby.mcp_proxy.tools.tasks._context import RegistryContext
15
+ from gobby.mcp_proxy.tools.tasks._resolution import resolve_task_id_for_mcp
16
+ from gobby.storage.tasks import TaskNotFoundError
17
+ from gobby.utils.project_context import get_project_context
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def create_expansion_registry(ctx: RegistryContext) -> InternalToolRegistry:
23
+ """Create a registry with task expansion tools.
24
+
25
+ Args:
26
+ ctx: Shared registry context
27
+
28
+ Returns:
29
+ InternalToolRegistry with expansion tools registered
30
+ """
31
+ registry = InternalToolRegistry(
32
+ name="gobby-tasks-expansion",
33
+ description="Task expansion for skill-based decomposition",
34
+ )
35
+
36
+ async def save_expansion_spec(
37
+ task_id: str,
38
+ spec: dict[str, Any],
39
+ ) -> dict[str, Any]:
40
+ """Save expansion spec to task.expansion_context for later execution.
41
+
42
+ Used by the /gobby-expand skill to persist the expansion plan before
43
+ creating subtasks. This ensures the spec survives session compaction.
44
+
45
+ Args:
46
+ task_id: Task ID to expand (can be #N, path, or UUID)
47
+ spec: Expansion specification containing:
48
+ - subtasks: List of subtask definitions, each with:
49
+ - title: Subtask title (required)
50
+ - category: code, config, docs, research, planning, manual
51
+ - depends_on: List of indices of subtasks this depends on
52
+ - validation: Validation criteria string
53
+ - description: Optional description
54
+ - priority: Optional priority (default: 2)
55
+
56
+ Returns:
57
+ {"saved": True, "task_id": str, "subtask_count": int}
58
+ """
59
+ # Get project context
60
+ project_ctx = get_project_context()
61
+ project_id = project_ctx.get("id") if project_ctx else None
62
+
63
+ # Resolve task ID
64
+ try:
65
+ resolved_id = resolve_task_id_for_mcp(ctx.task_manager, task_id, project_id)
66
+ except (TaskNotFoundError, ValueError) as e:
67
+ return {"error": f"Task not found: {e}"}
68
+
69
+ # Validate spec structure
70
+ if "subtasks" not in spec or not isinstance(spec["subtasks"], list):
71
+ return {"error": "Spec must contain 'subtasks' array"}
72
+
73
+ if len(spec["subtasks"]) == 0:
74
+ return {"error": "Spec must contain at least one subtask"}
75
+
76
+ # Validate subtask structure
77
+ for i, subtask in enumerate(spec["subtasks"]):
78
+ if "title" not in subtask:
79
+ return {"error": f"Subtask {i} missing required 'title' field"}
80
+
81
+ # Save spec to task
82
+ ctx.task_manager.update_task(
83
+ resolved_id,
84
+ expansion_context=json.dumps(spec),
85
+ expansion_status="pending",
86
+ )
87
+
88
+ logger.info(
89
+ f"Saved expansion spec for task {task_id} with {len(spec['subtasks'])} subtasks"
90
+ )
91
+
92
+ return {
93
+ "saved": True,
94
+ "task_id": resolved_id,
95
+ "subtask_count": len(spec["subtasks"]),
96
+ }
97
+
98
+ async def execute_expansion(
99
+ parent_task_id: str,
100
+ session_id: str,
101
+ ) -> dict[str, Any]:
102
+ """Execute a saved expansion spec atomically.
103
+
104
+ Creates all subtasks from the saved spec and wires dependencies.
105
+ Call save_expansion_spec first to persist the spec.
106
+
107
+ Args:
108
+ parent_task_id: Task ID with saved expansion spec
109
+ session_id: Session ID for tracking created tasks
110
+
111
+ Returns:
112
+ {"created": ["#N", ...], "count": int} or {"error": str}
113
+ """
114
+ # Get project context
115
+ project_ctx = get_project_context()
116
+ project_id = project_ctx.get("id") if project_ctx else None
117
+
118
+ # Resolve task ID
119
+ try:
120
+ resolved_id = resolve_task_id_for_mcp(ctx.task_manager, parent_task_id, project_id)
121
+ except (TaskNotFoundError, ValueError) as e:
122
+ return {"error": f"Task not found: {e}"}
123
+
124
+ # Get task and check for pending spec
125
+ task = ctx.task_manager.get_task(resolved_id)
126
+ if not task:
127
+ return {"error": f"Task {parent_task_id} not found"}
128
+
129
+ if task.expansion_status != "pending":
130
+ return {
131
+ "error": f"Task has no pending expansion spec (status: {task.expansion_status})"
132
+ }
133
+
134
+ if not task.expansion_context:
135
+ return {"error": "Task has no expansion_context"}
136
+
137
+ # Parse spec
138
+ try:
139
+ spec = json.loads(task.expansion_context)
140
+ except json.JSONDecodeError as e:
141
+ return {"error": f"Invalid expansion_context JSON: {e}"}
142
+
143
+ subtasks = spec.get("subtasks", [])
144
+ if not subtasks:
145
+ return {"error": "No subtasks in spec"}
146
+
147
+ # Create subtasks atomically - clean up on failure
148
+ created_tasks = []
149
+ created_refs = []
150
+
151
+ try:
152
+ for subtask in subtasks:
153
+ result = ctx.task_manager.create_task_with_decomposition(
154
+ project_id=task.project_id,
155
+ title=subtask["title"],
156
+ description=subtask.get("description"),
157
+ priority=subtask.get("priority", 2),
158
+ task_type=subtask.get("task_type", "task"),
159
+ parent_task_id=resolved_id,
160
+ category=subtask.get("category"),
161
+ validation_criteria=subtask.get("validation"),
162
+ created_in_session_id=session_id,
163
+ )
164
+
165
+ # Get the task (create_task_with_decomposition returns dict with task dict)
166
+ subtask_id = result["task"]["id"]
167
+ created_task = ctx.task_manager.get_task(subtask_id)
168
+ created_tasks.append(created_task)
169
+
170
+ # Build ref
171
+ ref = f"#{created_task.seq_num}" if created_task.seq_num else created_task.id[:8]
172
+ created_refs.append(ref)
173
+ except Exception as e:
174
+ # Clean up any tasks created before failure
175
+ logger.error(f"Expansion failed after creating {len(created_tasks)} tasks: {e}")
176
+ for task_to_delete in created_tasks:
177
+ try:
178
+ ctx.task_manager.delete_task(task_to_delete.id)
179
+ except Exception as delete_err:
180
+ logger.warning(f"Failed to clean up task {task_to_delete.id}: {delete_err}")
181
+ return {"error": f"Expansion failed: {e}", "cleaned_up": len(created_tasks)}
182
+
183
+ # Wire dependencies
184
+ for i, subtask in enumerate(subtasks):
185
+ depends_on = subtask.get("depends_on", [])
186
+ for dep_idx in depends_on:
187
+ if 0 <= dep_idx < len(created_tasks):
188
+ try:
189
+ ctx.dep_manager.add_dependency(
190
+ task_id=created_tasks[i].id,
191
+ depends_on=created_tasks[dep_idx].id,
192
+ dep_type="blocks",
193
+ )
194
+ except ValueError:
195
+ pass # Dependency already exists or invalid
196
+
197
+ # Wire parent blocked by all children
198
+ for created_task in created_tasks:
199
+ try:
200
+ ctx.dep_manager.add_dependency(
201
+ task_id=resolved_id,
202
+ depends_on=created_task.id,
203
+ dep_type="blocks",
204
+ )
205
+ except ValueError:
206
+ pass # Already exists
207
+
208
+ # Update parent task status
209
+ ctx.task_manager.update_task(
210
+ resolved_id,
211
+ is_expanded=True,
212
+ expansion_status="completed",
213
+ validation_criteria="All subtasks must be completed (status: closed).",
214
+ )
215
+
216
+ logger.info(
217
+ f"Executed expansion for task {parent_task_id}: created {len(created_tasks)} subtasks"
218
+ )
219
+
220
+ return {
221
+ "created": created_refs,
222
+ "count": len(created_refs),
223
+ }
224
+
225
+ async def get_expansion_spec(
226
+ task_id: str,
227
+ ) -> dict[str, Any]:
228
+ """Check for pending expansion spec (for resume after compaction).
229
+
230
+ Used by /gobby-expand skill to check if there's a pending expansion
231
+ that was interrupted and can be resumed.
232
+
233
+ Args:
234
+ task_id: Task ID to check
235
+
236
+ Returns:
237
+ {"pending": True, "spec": {...}} if pending expansion exists
238
+ {"pending": False} otherwise
239
+ """
240
+ # Get project context
241
+ project_ctx = get_project_context()
242
+ project_id = project_ctx.get("id") if project_ctx else None
243
+
244
+ # Resolve task ID
245
+ try:
246
+ resolved_id = resolve_task_id_for_mcp(ctx.task_manager, task_id, project_id)
247
+ except (TaskNotFoundError, ValueError) as e:
248
+ return {"error": f"Task not found: {e}"}
249
+
250
+ # Get task
251
+ task = ctx.task_manager.get_task(resolved_id)
252
+ if not task:
253
+ return {"error": f"Task {task_id} not found"}
254
+
255
+ # Check for pending expansion
256
+ if task.expansion_status == "pending" and task.expansion_context:
257
+ try:
258
+ spec = json.loads(task.expansion_context)
259
+ return {
260
+ "pending": True,
261
+ "spec": spec,
262
+ "subtask_count": len(spec.get("subtasks", [])),
263
+ }
264
+ except json.JSONDecodeError:
265
+ return {"pending": False, "error": "Invalid expansion_context JSON"}
266
+
267
+ return {"pending": False}
268
+
269
+ # Register tools
270
+ registry.register(
271
+ name="save_expansion_spec",
272
+ description="Save expansion spec to task for later execution. Used by /gobby-expand skill.",
273
+ input_schema={
274
+ "type": "object",
275
+ "properties": {
276
+ "task_id": {
277
+ "type": "string",
278
+ "description": "Task ID to expand (can be #N, path, or UUID)",
279
+ },
280
+ "spec": {
281
+ "type": "object",
282
+ "description": "Expansion specification containing subtasks array",
283
+ "properties": {
284
+ "subtasks": {
285
+ "type": "array",
286
+ "description": "List of subtask definitions",
287
+ "items": {
288
+ "type": "object",
289
+ "properties": {
290
+ "title": {"type": "string"},
291
+ "category": {"type": "string"},
292
+ "depends_on": {
293
+ "type": "array",
294
+ "items": {"type": "integer"},
295
+ },
296
+ "validation": {"type": "string"},
297
+ "description": {"type": "string"},
298
+ "priority": {"type": "integer"},
299
+ },
300
+ "required": ["title"],
301
+ },
302
+ },
303
+ },
304
+ "required": ["subtasks"],
305
+ },
306
+ },
307
+ "required": ["task_id", "spec"],
308
+ },
309
+ func=save_expansion_spec,
310
+ )
311
+
312
+ registry.register(
313
+ name="execute_expansion",
314
+ description="Execute a saved expansion spec atomically. Creates subtasks with dependencies.",
315
+ input_schema={
316
+ "type": "object",
317
+ "properties": {
318
+ "parent_task_id": {
319
+ "type": "string",
320
+ "description": "Task ID with saved expansion spec",
321
+ },
322
+ "session_id": {
323
+ "type": "string",
324
+ "description": "Session ID for tracking created tasks",
325
+ },
326
+ },
327
+ "required": ["parent_task_id", "session_id"],
328
+ },
329
+ func=execute_expansion,
330
+ )
331
+
332
+ registry.register(
333
+ name="get_expansion_spec",
334
+ description="Check for pending expansion spec (for resume after session compaction).",
335
+ input_schema={
336
+ "type": "object",
337
+ "properties": {
338
+ "task_id": {
339
+ "type": "string",
340
+ "description": "Task ID to check",
341
+ },
342
+ },
343
+ "required": ["task_id"],
344
+ },
345
+ func=get_expansion_spec,
346
+ )
347
+
348
+ return registry
@@ -8,7 +8,6 @@ from typing import TYPE_CHECKING
8
8
 
9
9
  from gobby.mcp_proxy.tools.internal import InternalToolRegistry
10
10
  from gobby.mcp_proxy.tools.task_dependencies import create_dependency_registry
11
- from gobby.mcp_proxy.tools.task_expansion import create_expansion_registry
12
11
  from gobby.mcp_proxy.tools.task_github import create_github_sync_registry
13
12
  from gobby.mcp_proxy.tools.task_orchestration import create_orchestration_registry
14
13
  from gobby.mcp_proxy.tools.task_readiness import create_readiness_registry
@@ -16,13 +15,13 @@ from gobby.mcp_proxy.tools.task_sync import create_sync_registry
16
15
  from gobby.mcp_proxy.tools.task_validation import create_validation_registry
17
16
  from gobby.mcp_proxy.tools.tasks._context import RegistryContext
18
17
  from gobby.mcp_proxy.tools.tasks._crud import create_crud_registry
18
+ from gobby.mcp_proxy.tools.tasks._expansion import create_expansion_registry
19
19
  from gobby.mcp_proxy.tools.tasks._lifecycle import create_lifecycle_registry
20
20
  from gobby.mcp_proxy.tools.tasks._search import create_search_registry
21
21
  from gobby.mcp_proxy.tools.tasks._session import create_session_registry
22
22
  from gobby.storage.tasks import LocalTaskManager
23
23
  from gobby.storage.worktrees import LocalWorktreeManager
24
24
  from gobby.sync.tasks import TaskSyncManager
25
- from gobby.tasks.expansion import TaskExpander
26
25
  from gobby.tasks.validation import TaskValidator
27
26
 
28
27
  if TYPE_CHECKING:
@@ -35,7 +34,6 @@ if TYPE_CHECKING:
35
34
  def create_task_registry(
36
35
  task_manager: LocalTaskManager,
37
36
  sync_manager: TaskSyncManager,
38
- task_expander: TaskExpander | None = None,
39
37
  task_validator: TaskValidator | None = None,
40
38
  config: "DaemonConfig | None" = None,
41
39
  agent_runner: "AgentRunner | None" = None,
@@ -50,7 +48,6 @@ def create_task_registry(
50
48
  Args:
51
49
  task_manager: LocalTaskManager instance
52
50
  sync_manager: TaskSyncManager instance
53
- task_expander: TaskExpander instance (optional)
54
51
  task_validator: TaskValidator instance (optional)
55
52
  config: DaemonConfig instance (optional)
56
53
  agent_runner: AgentRunner instance for external validator agent mode (optional)
@@ -66,7 +63,6 @@ def create_task_registry(
66
63
  ctx = RegistryContext(
67
64
  task_manager=task_manager,
68
65
  sync_manager=sync_manager,
69
- task_expander=task_expander,
70
66
  task_validator=task_validator,
71
67
  agent_runner=agent_runner,
72
68
  config=config,
@@ -98,6 +94,11 @@ def create_task_registry(
98
94
  for tool_name, tool in search_registry._tools.items():
99
95
  registry._tools[tool_name] = tool
100
96
 
97
+ # Merge expansion tools (skill-based task decomposition)
98
+ expansion_registry = create_expansion_registry(ctx)
99
+ for tool_name, tool in expansion_registry._tools.items():
100
+ registry._tools[tool_name] = tool
101
+
101
102
  # Merge validation tools from extracted module (Strangler Fig pattern)
102
103
  validation_registry = create_validation_registry(
103
104
  task_manager=task_manager,
@@ -108,17 +109,6 @@ def create_task_registry(
108
109
  for tool_name, tool in validation_registry._tools.items():
109
110
  registry._tools[tool_name] = tool
110
111
 
111
- # Merge expansion tools from extracted module (Strangler Fig pattern)
112
- expansion_registry = create_expansion_registry(
113
- task_manager=task_manager,
114
- task_expander=task_expander,
115
- task_validator=task_validator,
116
- auto_generate_on_expand=ctx.auto_generate_on_expand,
117
- resolve_tdd_mode=ctx.resolve_tdd_mode,
118
- )
119
- for tool_name, tool in expansion_registry._tools.items():
120
- registry._tools[tool_name] = tool
121
-
122
112
  # Merge dependency tools from extracted module (Strangler Fig pattern)
123
113
  dependency_registry = create_dependency_registry(
124
114
  task_manager=task_manager,