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
gobby/tasks/expansion.py DELETED
@@ -1,626 +0,0 @@
1
- """
2
- Task expansion module.
3
-
4
- Handles breaking down high-level tasks into smaller, actionable subtasks
5
- using LLM providers with structured JSON output.
6
- """
7
-
8
- import asyncio
9
- import json
10
- import logging
11
- import re
12
- from dataclasses import dataclass
13
- from typing import Any
14
-
15
- from gobby.config.app import ProjectVerificationConfig, TaskExpansionConfig
16
- from gobby.llm import LLMService
17
- from gobby.storage.task_dependencies import TaskDependencyManager
18
- from gobby.storage.tasks import LocalTaskManager, Task
19
- from gobby.tasks.context import ExpansionContext, ExpansionContextGatherer
20
- from gobby.tasks.criteria import PatternCriteriaInjector
21
- from gobby.tasks.prompts.expand import ExpansionPromptBuilder
22
- from gobby.utils.json_helpers import extract_json_from_text
23
- from gobby.utils.project_context import get_verification_config
24
-
25
- logger = logging.getLogger(__name__)
26
-
27
-
28
- @dataclass
29
- class SubtaskSpec:
30
- """Parsed subtask specification from LLM output."""
31
-
32
- title: str
33
- description: str | None = None
34
- priority: int = 2
35
- task_type: str = "task"
36
- category: str | None = None
37
- validation: str | None = None # Acceptance criteria from LLM
38
- depends_on: list[int] | None = None
39
-
40
- def __post_init__(self) -> None:
41
- """Validate and normalize category after initialization."""
42
- if self.category:
43
- from gobby.storage.tasks import validate_category
44
-
45
- self.category = validate_category(self.category)
46
-
47
-
48
- class TaskExpander:
49
- """Expands tasks into subtasks using LLM and context."""
50
-
51
- def __init__(
52
- self,
53
- config: TaskExpansionConfig,
54
- llm_service: LLMService,
55
- task_manager: LocalTaskManager,
56
- mcp_manager: Any | None = None,
57
- verification_config: ProjectVerificationConfig | None = None,
58
- ):
59
- self.config = config
60
- self.llm_service = llm_service
61
- self.task_manager = task_manager
62
- self.mcp_manager = mcp_manager
63
- self.context_gatherer = ExpansionContextGatherer(
64
- task_manager=task_manager,
65
- llm_service=llm_service,
66
- config=config,
67
- mcp_manager=mcp_manager,
68
- )
69
- self.prompt_builder = ExpansionPromptBuilder(config)
70
-
71
- # Initialize pattern criteria injector
72
- # Try to get verification config from project if not provided
73
- if verification_config is None:
74
- verification_config = get_verification_config()
75
- self.criteria_injector = PatternCriteriaInjector(
76
- pattern_config=config.pattern_criteria,
77
- verification_config=verification_config,
78
- )
79
-
80
- def _resolve_tdd_mode(self, session_id: str | None, task_type: str | None = None) -> bool:
81
- """Resolve tdd_mode with cascading precedence.
82
-
83
- Order: task_type override > step workflow > lifecycle workflow > config.yaml > pydantic default
84
-
85
- Epic tasks never use TDD mode since their closing condition is
86
- 'all children are closed', not test-based verification.
87
-
88
- Args:
89
- session_id: Session ID to resolve TDD mode from workflow state
90
- task_type: Task type - epics always disable TDD mode
91
-
92
- Returns:
93
- True if TDD mode is enabled, False otherwise
94
- """
95
- # Epics never use TDD mode
96
- if task_type == "epic":
97
- return False
98
-
99
- if session_id:
100
- try:
101
- from gobby.workflows.state_manager import WorkflowStateManager
102
-
103
- state_manager = WorkflowStateManager(self.task_manager.db)
104
- state = state_manager.get_state(session_id)
105
- if state and state.variables and "tdd_mode" in state.variables:
106
- return bool(state.variables["tdd_mode"])
107
- except Exception as e:
108
- logger.debug(f"Failed to resolve tdd_mode from workflow state: {e}")
109
-
110
- # Fall back to config (includes pydantic default)
111
- return self.config.tdd_mode
112
-
113
- async def expand_task(
114
- self,
115
- task_id: str,
116
- title: str,
117
- description: str | None = None,
118
- context: str | None = None,
119
- enable_web_research: bool = False,
120
- enable_code_context: bool = True,
121
- session_id: str | None = None,
122
- ) -> dict[str, Any]:
123
- """
124
- Expand a task into subtasks using structured JSON output.
125
-
126
- The LLM returns a JSON object with subtask specifications, which are
127
- then parsed and created as tasks with proper dependency wiring.
128
-
129
- Note: This creates plain subtasks only. To apply TDD structure
130
- (test/implement/refactor triplets), use the apply_tdd command
131
- separately after expansion.
132
-
133
- Args:
134
- task_id: ID of the task to expand
135
- title: Task title
136
- description: Task description
137
- context: Additional context for expansion
138
- enable_web_research: Whether to enable web research (default: False)
139
- enable_code_context: Whether to enable code context gathering (default: True)
140
- session_id: Session ID for TDD mode resolution (optional)
141
-
142
- Returns:
143
- Dictionary with:
144
- - subtask_ids: List of created subtask IDs
145
- - subtask_count: Number of subtasks created
146
- - raw_response: The raw LLM response (for debugging)
147
- """
148
- if not self.config.enabled:
149
- logger.info("Task expansion disabled, skipping")
150
- return {
151
- "subtask_ids": [],
152
- "subtask_count": 0,
153
- "raw_response": "Expansion disabled",
154
- }
155
-
156
- logger.info(f"Expanding task {task_id}: {title}")
157
-
158
- # Apply overall timeout for entire expansion
159
- timeout_seconds = self.config.timeout
160
- try:
161
- async with asyncio.timeout(timeout_seconds):
162
- return await self._expand_task_impl(
163
- task_id=task_id,
164
- title=title,
165
- description=description,
166
- context=context,
167
- enable_web_research=enable_web_research,
168
- enable_code_context=enable_code_context,
169
- session_id=session_id,
170
- )
171
- except TimeoutError:
172
- error_msg = (
173
- f"Task expansion timed out after {timeout_seconds} seconds. "
174
- f"Consider increasing task_expansion.timeout in config or simplifying the task."
175
- )
176
- logger.error(f"Expansion timeout for {task_id}: {error_msg}")
177
- return {
178
- "error": error_msg,
179
- "subtask_ids": [],
180
- "subtask_count": 0,
181
- "timeout": True,
182
- }
183
-
184
- async def _expand_task_impl(
185
- self,
186
- task_id: str,
187
- title: str,
188
- description: str | None = None,
189
- context: str | None = None,
190
- enable_web_research: bool = False,
191
- enable_code_context: bool = True,
192
- session_id: str | None = None,
193
- ) -> dict[str, Any]:
194
- """Internal implementation of expand_task (called within timeout context)."""
195
- # Gather enhanced context
196
- task_obj = self.task_manager.get_task(task_id)
197
- if not task_obj:
198
- logger.warning(f"Task {task_id} not found for context gathering, using basic info")
199
- task_obj = Task(
200
- id=task_id,
201
- project_id="unknown",
202
- title=title,
203
- status="open",
204
- priority=2,
205
- task_type="task",
206
- created_at="",
207
- updated_at="",
208
- description=description,
209
- )
210
-
211
- expansion_ctx = await self.context_gatherer.gather_context(
212
- task_obj,
213
- enable_web_research=enable_web_research,
214
- enable_code_context=enable_code_context,
215
- )
216
-
217
- # Inject pattern-specific criteria based on task labels and description
218
- pattern_criteria = self.criteria_injector.inject(
219
- task=task_obj,
220
- context=expansion_ctx,
221
- )
222
-
223
- # Combine user context with pattern criteria if detected
224
- combined_instructions = context or ""
225
- if pattern_criteria:
226
- logger.info(f"Detected patterns for {task_id}, adding pattern-specific criteria")
227
- if combined_instructions:
228
- combined_instructions += f"\n\n{pattern_criteria}"
229
- else:
230
- combined_instructions = pattern_criteria
231
-
232
- # Build prompt using builder
233
- prompt = self.prompt_builder.build_user_prompt(
234
- task=task_obj,
235
- context=expansion_ctx,
236
- user_instructions=combined_instructions if combined_instructions else None,
237
- )
238
-
239
- try:
240
- # Get provider and generate text response
241
- provider = self.llm_service.get_provider(self.config.provider)
242
-
243
- # Resolve TDD mode from session workflow state or config
244
- # Epics never use TDD mode
245
- tdd_mode = self._resolve_tdd_mode(session_id, task_obj.task_type)
246
-
247
- # Note: TDD transformation is applied separately via apply_tdd command.
248
- # The expand_task only creates plain subtasks.
249
- response = await provider.generate_text(
250
- prompt=prompt,
251
- system_prompt=self.prompt_builder.get_system_prompt(tdd_mode=tdd_mode),
252
- model=self.config.model,
253
- )
254
-
255
- logger.debug(f"LLM response (first 500 chars): {response[:500]}")
256
-
257
- # Parse JSON from response
258
- subtask_specs = self._parse_subtasks(response)
259
- logger.debug(f"Parsed {len(subtask_specs)} subtask specs")
260
-
261
- if not subtask_specs:
262
- logger.warning(f"No subtasks parsed from response for {task_id}")
263
- return {
264
- "subtask_ids": [],
265
- "subtask_count": 0,
266
- "raw_response": response,
267
- "error": "No subtasks found in response",
268
- }
269
-
270
- # Create tasks with dependency wiring and precise criteria
271
- # Note: TDD transformation is done separately via apply_tdd command
272
- subtask_ids = await self._create_subtasks(
273
- parent_task_id=task_id,
274
- project_id=task_obj.project_id,
275
- subtask_specs=subtask_specs,
276
- expansion_context=expansion_ctx,
277
- parent_labels=task_obj.labels or [],
278
- )
279
-
280
- # Save expansion context to the parent task for audit/reuse
281
- self._save_expansion_context(task_id, expansion_ctx)
282
-
283
- logger.info(f"Expansion complete for {task_id}: created {len(subtask_ids)} subtasks")
284
-
285
- return {
286
- "subtask_ids": subtask_ids,
287
- "subtask_count": len(subtask_ids),
288
- "raw_response": response,
289
- }
290
-
291
- except Exception as e:
292
- error_msg = str(e) or f"{type(e).__name__}: (no message)"
293
- logger.error(f"Failed to expand task {task_id}: {error_msg}", exc_info=True)
294
- return {"error": error_msg, "subtask_ids": [], "subtask_count": 0}
295
-
296
- # Patterns that indicate a test task (case-insensitive)
297
- TEST_TASK_PATTERNS = (
298
- r"^write\s+tests?\s+for",
299
- r"^add\s+(?:unit\s+)?tests?\s+for",
300
- r"^create\s+(?:unit\s+)?tests?",
301
- r"^unit\s+tests?\s+for",
302
- r"^integration\s+tests?\s+for",
303
- r"^test\s+(?:the\s+)?",
304
- r"^verify\s+with\s+tests?",
305
- r"tests?\s+for\s+.*(?:class|function|method|module)",
306
- )
307
-
308
- def _is_test_task(self, title: str, category: str | None) -> bool:
309
- """Check if a subtask is a test task that should be filtered out.
310
-
311
- TDD sandwich pattern creates test tasks automatically. LLM-generated
312
- test tasks would cause duplicates and should be filtered.
313
-
314
- Args:
315
- title: The subtask title
316
- category: The subtask category (if provided)
317
-
318
- Returns:
319
- True if this is a test task that should be filtered
320
- """
321
- # Don't filter refactor tasks - they may legitimately update existing tests
322
- if category and category.lower() == "refactor":
323
- return False
324
-
325
- # Filter by category=test
326
- if category and category.lower() == "test":
327
- return True
328
-
329
- # Filter by title patterns (only when category is not explicitly set to refactor)
330
- title_lower = title.lower().strip()
331
- for pattern in self.TEST_TASK_PATTERNS:
332
- if re.search(pattern, title_lower, re.IGNORECASE):
333
- return True
334
-
335
- return False
336
-
337
- def _parse_subtasks(self, response: str) -> list[SubtaskSpec]:
338
- """
339
- Parse subtask specifications from LLM JSON response.
340
-
341
- Filters out test tasks since TDD sandwich creates them automatically.
342
-
343
- Args:
344
- response: Raw LLM response text (should be JSON)
345
-
346
- Returns:
347
- List of SubtaskSpec objects parsed from the response
348
- """
349
- # Try to extract JSON from the response
350
- json_str = self._extract_json(response)
351
- if not json_str:
352
- logger.warning("No JSON found in response")
353
- return []
354
-
355
- try:
356
- data = json.loads(json_str)
357
- except json.JSONDecodeError as e:
358
- logger.error(f"Failed to parse JSON: {e}")
359
- return []
360
-
361
- # Extract subtasks array
362
- subtasks_data = data.get("subtasks", [])
363
- if not isinstance(subtasks_data, list):
364
- logger.warning(f"Expected 'subtasks' to be a list, got {type(subtasks_data)}")
365
- return []
366
-
367
- # Parse each subtask, filtering out test tasks
368
- subtask_specs = []
369
- filtered_count = 0
370
- for i, item in enumerate(subtasks_data):
371
- if not isinstance(item, dict):
372
- logger.warning(f"Subtask {i} is not a dict, skipping")
373
- continue
374
-
375
- if "title" not in item:
376
- logger.warning(f"Subtask {i} missing title, skipping")
377
- continue
378
-
379
- title = item["title"]
380
- category = item.get("category")
381
-
382
- # Filter out test tasks - TDD sandwich creates them automatically
383
- if self._is_test_task(title, category):
384
- logger.debug(f"Filtered test task: '{title}' (category={category})")
385
- filtered_count += 1
386
- continue
387
-
388
- spec = SubtaskSpec(
389
- title=title,
390
- description=item.get("description"),
391
- priority=item.get("priority", 2),
392
- task_type=item.get("task_type", "task"),
393
- category=category,
394
- validation=item.get("validation"),
395
- depends_on=item.get("depends_on"),
396
- )
397
- subtask_specs.append(spec)
398
-
399
- if filtered_count > 0:
400
- logger.debug(f"Filtered {filtered_count} test tasks from LLM output")
401
-
402
- return subtask_specs
403
-
404
- def _extract_json(self, text: str) -> str | None:
405
- """Extract JSON from text. Delegates to shared utility."""
406
- return extract_json_from_text(text)
407
-
408
- async def _create_subtasks(
409
- self,
410
- parent_task_id: str,
411
- project_id: str,
412
- subtask_specs: list[SubtaskSpec],
413
- expansion_context: ExpansionContext | None = None,
414
- parent_labels: list[str] | None = None,
415
- ) -> list[str]:
416
- """
417
- Create tasks from parsed subtask specifications.
418
-
419
- Handles dependency wiring by mapping depends_on indices to task IDs.
420
- Generates precise validation criteria using expansion context.
421
-
422
- Note: TDD transformation is NOT done here. Use apply_tdd separately
423
- to transform code tasks into test/implement/refactor triplets.
424
-
425
- Args:
426
- parent_task_id: ID of the parent task
427
- project_id: Project ID for the new tasks
428
- subtask_specs: List of parsed subtask specifications
429
- expansion_context: Context gathered during expansion (for criteria generation)
430
- parent_labels: Labels from the parent task (for pattern detection)
431
-
432
- Returns:
433
- List of created task IDs
434
- """
435
- created_ids: list[str] = []
436
- dep_manager = TaskDependencyManager(self.task_manager.db)
437
-
438
- # Map subtask_spec index to task ID for dependency wiring
439
- spec_index_to_id: dict[int, str] = {}
440
-
441
- for i, spec in enumerate(subtask_specs):
442
- # Build description
443
- description = spec.description or ""
444
-
445
- # Use validation from LLM output directly as validation_criteria
446
- # This replaces the post-expansion generate_criteria() loop
447
- validation_criteria = spec.validation
448
-
449
- # If no validation from LLM and context available, generate precise criteria
450
- if not validation_criteria and expansion_context:
451
- precise_criteria = await self._generate_precise_criteria(
452
- spec=spec,
453
- context=expansion_context,
454
- parent_labels=parent_labels or [],
455
- )
456
- if precise_criteria:
457
- validation_criteria = precise_criteria
458
-
459
- # Create the task with validation_criteria from LLM output
460
- task = self.task_manager.create_task(
461
- title=spec.title,
462
- description=description if description else None,
463
- project_id=project_id,
464
- priority=spec.priority,
465
- task_type=spec.task_type,
466
- parent_task_id=parent_task_id,
467
- category=spec.category,
468
- validation_criteria=validation_criteria,
469
- )
470
-
471
- created_ids.append(task.id)
472
- logger.debug(f"Created subtask {task.id}: {spec.title}")
473
-
474
- spec_index_to_id[i] = task.id
475
-
476
- # Add dependencies
477
- if spec.depends_on:
478
- for dep_idx in spec.depends_on:
479
- if dep_idx in spec_index_to_id:
480
- blocker_id = spec_index_to_id[dep_idx]
481
- try:
482
- dep_manager.add_dependency(task.id, blocker_id, "blocks")
483
- logger.debug(f"Added dependency: {task.id} blocked by {blocker_id}")
484
- except Exception as e:
485
- logger.warning(f"Failed to add dependency: {e}")
486
- else:
487
- logger.warning(
488
- f"Subtask {i} references invalid or forward index {dep_idx}, skipping dependency"
489
- )
490
-
491
- return created_ids
492
-
493
- def _save_expansion_context(
494
- self,
495
- task_id: str,
496
- context: "ExpansionContext",
497
- ) -> None:
498
- """
499
- Save expansion context to the task for audit and reuse.
500
-
501
- Stores web research results and other context in the task's
502
- expansion_context field as JSON.
503
-
504
- Args:
505
- task_id: ID of the task to update
506
- context: The expansion context to save
507
- """
508
- try:
509
- # Build a slim context dict focused on web research
510
- context_data: dict[str, Any] = {}
511
-
512
- if context.web_research:
513
- context_data["web_research"] = context.web_research
514
-
515
- if context.agent_findings:
516
- context_data["agent_findings"] = context.agent_findings
517
-
518
- if context.relevant_files:
519
- context_data["relevant_files"] = context.relevant_files
520
-
521
- if not context_data:
522
- logger.debug(f"No expansion context to save for {task_id}")
523
- return
524
-
525
- # Serialize and update the task
526
- context_json = json.dumps(context_data)
527
- self.task_manager.update_task(task_id, expansion_context=context_json)
528
- logger.debug(f"Saved expansion context for {task_id} ({len(context_json)} bytes)")
529
-
530
- except Exception as e:
531
- logger.warning(f"Failed to save expansion context for {task_id}: {e}")
532
-
533
- async def _generate_precise_criteria(
534
- self,
535
- spec: SubtaskSpec,
536
- context: ExpansionContext,
537
- parent_labels: list[str],
538
- ) -> str:
539
- """
540
- Generate precise validation criteria for a subtask using full expansion context.
541
-
542
- Args:
543
- spec: The subtask specification
544
- context: Full expansion context with verification commands, signatures, etc.
545
- parent_labels: Labels from the parent task (for pattern detection)
546
-
547
- Returns:
548
- Markdown-formatted validation criteria string
549
- """
550
- criteria_parts: list[str] = []
551
-
552
- # 1. Start with pattern-specific criteria from parent labels
553
- pattern_criteria = self.criteria_injector.inject_for_labels(
554
- labels=parent_labels,
555
- extra_placeholders=context.verification_commands,
556
- )
557
- if pattern_criteria:
558
- criteria_parts.append(pattern_criteria)
559
-
560
- # 2. Add base criteria from category if present
561
- if spec.category:
562
- # Substitute verification commands into category
563
- strategy = spec.category
564
- if context.verification_commands:
565
- for name, cmd in context.verification_commands.items():
566
- strategy = strategy.replace(f"{{{name}}}", f"`{cmd}`")
567
- criteria_parts.append(f"## Test Strategy\n\n- [ ] {strategy}")
568
-
569
- # 3. Add file-specific criteria if relevant files are mentioned
570
- if context.relevant_files and spec.description:
571
- relevant_for_subtask = [
572
- f
573
- for f in context.relevant_files
574
- if f.lower() in (spec.title + (spec.description or "")).lower()
575
- ]
576
- if relevant_for_subtask:
577
- file_criteria = ["## File Requirements", ""]
578
- for f in relevant_for_subtask:
579
- file_criteria.append(f"- [ ] `{f}` is correctly modified/created")
580
- criteria_parts.append("\n".join(file_criteria))
581
-
582
- # 4. Add function signature criteria if applicable
583
- if context.function_signatures and spec.description:
584
- desc_lower = (spec.description or "").lower()
585
- for _file_path, signatures in context.function_signatures.items():
586
- for sig in signatures:
587
- if not sig:
588
- continue
589
- # Extract function name robustly using regex
590
- # Handles: "def func_name(", "async def func_name(", "func_name("
591
- func_name = None
592
- # Try regex patterns first
593
- match = re.search(r"(?:async\s+)?def\s+(\w+)", sig)
594
- if match:
595
- func_name = match.group(1)
596
- else:
597
- # Fallback: try to get name before first paren
598
- match = re.search(r"(\w+)\s*\(", sig)
599
- if match:
600
- func_name = match.group(1)
601
- else:
602
- # Last resort: use existing split logic
603
- try:
604
- func_name = (
605
- sig.split("(")[0].split()[-1] if "(" in sig else sig.split()[-1]
606
- )
607
- except (IndexError, AttributeError):
608
- continue
609
-
610
- if func_name and func_name.lower() in desc_lower:
611
- criteria_parts.append(
612
- f"## Function Integrity\n\n"
613
- f"- [ ] `{func_name}` signature preserved or updated as intended"
614
- )
615
- break
616
-
617
- # 5. Add verification command criteria
618
- if context.verification_commands:
619
- verification_criteria = ["## Verification", ""]
620
- for name, cmd in context.verification_commands.items():
621
- if name in ["unit_tests", "type_check", "lint"]:
622
- verification_criteria.append(f"- [ ] `{cmd}` passes")
623
- if len(verification_criteria) > 2: # Has items beyond header
624
- criteria_parts.append("\n".join(verification_criteria))
625
-
626
- return "\n\n".join(criteria_parts) if criteria_parts else ""