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
@@ -161,3 +161,58 @@ def clear_stop_signal(
161
161
 
162
162
  cleared = stop_registry.clear(session_id)
163
163
  return {"success": True, "cleared": cleared}
164
+
165
+
166
+ # --- ActionHandler factory functions ---
167
+ # These create ActionHandler-compatible wrappers that close over the stop_registry.
168
+ # The ActionExecutor calls these factories in _register_defaults() to create handlers
169
+ # that have access to the executor's stop_registry instance.
170
+
171
+
172
+ def make_handle_check_stop_signal(
173
+ stop_registry: "StopRegistry | None",
174
+ ) -> Any:
175
+ """Factory that creates a check_stop_signal handler with access to stop_registry."""
176
+
177
+ async def handler(context: "Any", **kwargs: Any) -> dict[str, Any] | None:
178
+ """ActionHandler for check_stop_signal."""
179
+ return check_stop_signal(
180
+ stop_registry=stop_registry,
181
+ session_id=context.session_id,
182
+ state=context.state,
183
+ acknowledge=kwargs.get("acknowledge", False),
184
+ )
185
+
186
+ return handler
187
+
188
+
189
+ def make_handle_request_stop(
190
+ stop_registry: "StopRegistry | None",
191
+ ) -> Any:
192
+ """Factory that creates a request_stop handler with access to stop_registry."""
193
+
194
+ async def handler(context: "Any", **kwargs: Any) -> dict[str, Any] | None:
195
+ """ActionHandler for request_stop."""
196
+ return request_stop(
197
+ stop_registry=stop_registry,
198
+ session_id=kwargs.get("session_id", context.session_id),
199
+ source=kwargs.get("source", "workflow"),
200
+ reason=kwargs.get("reason"),
201
+ )
202
+
203
+ return handler
204
+
205
+
206
+ def make_handle_clear_stop_signal(
207
+ stop_registry: "StopRegistry | None",
208
+ ) -> Any:
209
+ """Factory that creates a clear_stop_signal handler with access to stop_registry."""
210
+
211
+ async def handler(context: "Any", **kwargs: Any) -> dict[str, Any] | None:
212
+ """ActionHandler for clear_stop_signal."""
213
+ return clear_stop_signal(
214
+ stop_registry=stop_registry,
215
+ session_id=kwargs.get("session_id", context.session_id),
216
+ )
217
+
218
+ return handler
@@ -9,10 +9,13 @@ from __future__ import annotations
9
9
  import json
10
10
  import logging
11
11
  from pathlib import Path
12
- from typing import Any, Literal
12
+ from typing import TYPE_CHECKING, Any, Literal
13
13
 
14
14
  from gobby.workflows.git_utils import get_file_changes, get_git_status
15
15
 
16
+ if TYPE_CHECKING:
17
+ from gobby.workflows.actions import ActionContext
18
+
16
19
  logger = logging.getLogger(__name__)
17
20
 
18
21
 
@@ -42,6 +45,23 @@ def format_turns_for_llm(turns: list[dict[str, Any]]) -> str:
42
45
  text_parts.append(f"[Thinking: {block.get('thinking', '')}]")
43
46
  elif block.get("type") == "tool_use":
44
47
  text_parts.append(f"[Tool: {block.get('name', 'unknown')}]")
48
+ elif block.get("type") == "tool_result":
49
+ result_content = block.get("content", "")
50
+ # Extract text from list of content blocks if needed
51
+ if isinstance(result_content, list):
52
+ extracted = []
53
+ for item in result_content:
54
+ if isinstance(item, dict):
55
+ extracted.append(
56
+ item.get("text", "") or item.get("content", "")
57
+ )
58
+ else:
59
+ extracted.append(str(item))
60
+ result_content = " ".join(extracted)
61
+ content_str = str(result_content)
62
+ preview = content_str[:100]
63
+ suffix = "..." if len(content_str) > 100 else ""
64
+ text_parts.append(f"[Result: {preview}{suffix}]")
45
65
  content = " ".join(text_parts)
46
66
 
47
67
  formatted.append(f"[Turn {i + 1} - {role}]: {content}")
@@ -342,3 +362,93 @@ async def generate_handoff(
342
362
  return {"error": "Failed to generate summary"}
343
363
 
344
364
  return {"handoff_created": True, "summary_length": summary_result.get("summary_length", 0)}
365
+
366
+
367
+ # --- ActionHandler-compatible wrappers ---
368
+ # These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
369
+
370
+
371
+ async def handle_synthesize_title(context: ActionContext, **kwargs: Any) -> dict[str, Any] | None:
372
+ """ActionHandler wrapper for synthesize_title."""
373
+ # Extract prompt from event data (UserPromptSubmit hook)
374
+ prompt = None
375
+ if context.event_data:
376
+ prompt = context.event_data.get("prompt")
377
+
378
+ return await synthesize_title(
379
+ session_manager=context.session_manager,
380
+ session_id=context.session_id,
381
+ llm_service=context.llm_service,
382
+ transcript_processor=context.transcript_processor,
383
+ template_engine=context.template_engine,
384
+ template=kwargs.get("template"),
385
+ prompt=prompt,
386
+ )
387
+
388
+
389
+ async def handle_generate_summary(context: ActionContext, **kwargs: Any) -> dict[str, Any] | None:
390
+ """ActionHandler wrapper for generate_summary."""
391
+ return await generate_summary(
392
+ session_manager=context.session_manager,
393
+ session_id=context.session_id,
394
+ llm_service=context.llm_service,
395
+ transcript_processor=context.transcript_processor,
396
+ template=kwargs.get("template"),
397
+ mode=kwargs.get("mode", "clear"),
398
+ previous_summary=kwargs.get("previous_summary"),
399
+ )
400
+
401
+
402
+ async def handle_generate_handoff(context: ActionContext, **kwargs: Any) -> dict[str, Any] | None:
403
+ """ActionHandler wrapper for generate_handoff.
404
+
405
+ Handles mode detection from event_data and previous summary fetching for compact mode.
406
+ Also supports loading templates from prompts collection via 'prompt' parameter.
407
+ """
408
+ # Detect mode from kwargs or event data
409
+ mode = kwargs.get("mode", "clear")
410
+
411
+ # Check if this is a compact event based on event_data
412
+ COMPACT_EVENT_TYPES = {"pre_compact", "compact"}
413
+ if context.event_data:
414
+ raw_event_type = context.event_data.get("event_type") or ""
415
+ normalized_event_type = str(raw_event_type).strip().lower()
416
+ if normalized_event_type in COMPACT_EVENT_TYPES:
417
+ mode = "compact"
418
+
419
+ # For compact mode, fetch previous summary for cumulative compression
420
+ previous_summary = None
421
+ if mode == "compact":
422
+ current_session = context.session_manager.get(context.session_id)
423
+ if current_session:
424
+ previous_summary = getattr(current_session, "summary_markdown", None)
425
+ if previous_summary:
426
+ logger.debug(
427
+ f"Compact mode: using previous summary ({len(previous_summary)} chars) "
428
+ f"for cumulative compression"
429
+ )
430
+
431
+ # Load template from prompts collection if 'prompt' parameter provided
432
+ template = kwargs.get("template")
433
+ prompt_path = kwargs.get("prompt")
434
+ if prompt_path and not template:
435
+ try:
436
+ from gobby.prompts.loader import PromptLoader
437
+
438
+ loader = PromptLoader()
439
+ prompt_template = loader.load(prompt_path)
440
+ template = prompt_template.content
441
+ logger.debug(f"Loaded prompt template from: {prompt_path}")
442
+ except Exception as e:
443
+ logger.warning(f"Failed to load prompt from {prompt_path}: {e}")
444
+ # Fall back to inline template or default
445
+
446
+ return await generate_handoff(
447
+ session_manager=context.session_manager,
448
+ session_id=context.session_id,
449
+ llm_service=context.llm_service,
450
+ transcript_processor=context.transcript_processor,
451
+ template=template,
452
+ previous_summary=previous_summary,
453
+ mode=mode,
454
+ )
@@ -0,0 +1,347 @@
1
+ """Task sync workflow actions.
2
+
3
+ Extracted from actions.py as part of strangler fig decomposition.
4
+ These functions handle task sync import/export and workflow task operations.
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ if TYPE_CHECKING:
12
+ from gobby.storage.database import DatabaseProtocol
13
+ from gobby.storage.sessions import LocalSessionManager
14
+ from gobby.workflows.definitions import WorkflowState
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ async def task_sync_import(
20
+ task_sync_manager: Any,
21
+ session_manager: "LocalSessionManager",
22
+ session_id: str,
23
+ ) -> dict[str, Any]:
24
+ """Import tasks from JSONL file.
25
+
26
+ Reads .gobby/tasks.jsonl and imports tasks into SQLite using
27
+ Last-Write-Wins conflict resolution based on updated_at.
28
+
29
+ Args:
30
+ task_sync_manager: TaskSyncManager instance
31
+ session_manager: Session manager for project lookup
32
+ session_id: Current session ID
33
+
34
+ Returns:
35
+ Dict with imported status or error
36
+ """
37
+ if not task_sync_manager:
38
+ logger.debug("task_sync_import: No task_sync_manager available")
39
+ return {"error": "Task Sync Manager not available"}
40
+
41
+ try:
42
+ # Get project_id from session for project-scoped sync
43
+ project_id = None
44
+ session = await asyncio.to_thread(session_manager.get, session_id)
45
+ if session:
46
+ project_id = session.project_id
47
+
48
+ await asyncio.to_thread(task_sync_manager.import_from_jsonl, project_id=project_id)
49
+ logger.info("Task sync import completed")
50
+ return {"imported": True}
51
+ except Exception as e:
52
+ logger.error(f"task_sync_import failed: {e}", exc_info=True)
53
+ return {"error": str(e)}
54
+
55
+
56
+ async def task_sync_export(
57
+ task_sync_manager: Any,
58
+ session_manager: "LocalSessionManager",
59
+ session_id: str,
60
+ ) -> dict[str, Any]:
61
+ """Export tasks to JSONL file.
62
+
63
+ Writes tasks and dependencies to .gobby/tasks.jsonl for Git persistence.
64
+ Uses content hashing to skip writes if nothing changed.
65
+
66
+ Args:
67
+ task_sync_manager: TaskSyncManager instance
68
+ session_manager: Session manager for project lookup
69
+ session_id: Current session ID
70
+
71
+ Returns:
72
+ Dict with exported status or error
73
+ """
74
+ if not task_sync_manager:
75
+ logger.debug("task_sync_export: No task_sync_manager available")
76
+ return {"error": "Task Sync Manager not available"}
77
+
78
+ try:
79
+ # Get project_id from session for project-scoped sync
80
+ project_id = None
81
+ session = await asyncio.to_thread(session_manager.get, session_id)
82
+ if session:
83
+ project_id = session.project_id
84
+
85
+ await asyncio.to_thread(task_sync_manager.export_to_jsonl, project_id=project_id)
86
+ logger.info("Task sync export completed")
87
+ return {"exported": True}
88
+ except Exception as e:
89
+ logger.error(f"task_sync_export failed: {e}", exc_info=True)
90
+ return {"error": str(e)}
91
+
92
+
93
+ async def persist_tasks(
94
+ db: "DatabaseProtocol",
95
+ session_manager: "LocalSessionManager",
96
+ session_id: str,
97
+ state: "WorkflowState",
98
+ tasks: list[dict[str, Any]] | None = None,
99
+ source: str | None = None,
100
+ workflow_name: str | None = None,
101
+ parent_task_id: str | None = None,
102
+ ) -> dict[str, Any]:
103
+ """Persist a list of task dicts to Gobby task system.
104
+
105
+ Enhanced to support workflow integration with ID mapping.
106
+
107
+ Args:
108
+ db: Database instance
109
+ session_manager: Session manager
110
+ session_id: Current session ID
111
+ state: WorkflowState for variables access
112
+ tasks: List of task dicts
113
+ source: Variable name containing task list (alternative to tasks)
114
+ workflow_name: Associate tasks with this workflow
115
+ parent_task_id: Optional parent task for all created tasks
116
+
117
+ Returns:
118
+ Dict with tasks_persisted count, ids list, and id_mapping dict
119
+ """
120
+ # Get tasks from either 'tasks' kwarg or 'source' variable
121
+ task_list = tasks or []
122
+
123
+ if source and state.variables:
124
+ source_data = state.variables.get(source)
125
+ if source_data:
126
+ # Handle nested structure like task_list.tasks
127
+ if isinstance(source_data, dict) and "tasks" in source_data:
128
+ task_list = source_data["tasks"]
129
+ elif isinstance(source_data, list):
130
+ task_list = source_data
131
+
132
+ if not task_list:
133
+ return {"tasks_persisted": 0, "ids": [], "id_mapping": {}}
134
+
135
+ try:
136
+ from gobby.workflows.task_actions import persist_decomposed_tasks
137
+
138
+ current_session = await asyncio.to_thread(session_manager.get, session_id)
139
+ project_id = current_session.project_id if current_session else "default"
140
+
141
+ # Get workflow name from kwargs or state
142
+ wf_name = workflow_name
143
+ if not wf_name and state.workflow_name:
144
+ wf_name = state.workflow_name
145
+
146
+ id_mapping = await asyncio.to_thread(
147
+ persist_decomposed_tasks,
148
+ db=db,
149
+ project_id=project_id,
150
+ tasks_data=task_list,
151
+ workflow_name=wf_name or "unnamed",
152
+ parent_task_id=parent_task_id,
153
+ created_in_session_id=session_id,
154
+ )
155
+
156
+ # Store ID mapping in workflow state for reference
157
+ if not state.variables:
158
+ state.variables = {}
159
+ state.variables["task_id_mapping"] = id_mapping
160
+
161
+ return {
162
+ "tasks_persisted": len(id_mapping),
163
+ "ids": list(id_mapping.values()),
164
+ "id_mapping": id_mapping,
165
+ }
166
+ except Exception as e:
167
+ logger.error(f"persist_tasks: Failed: {e}", exc_info=True)
168
+ return {"error": str(e)}
169
+
170
+
171
+ async def get_workflow_tasks(
172
+ db: "DatabaseProtocol",
173
+ session_manager: "LocalSessionManager",
174
+ session_id: str,
175
+ state: "WorkflowState",
176
+ workflow_name: str | None = None,
177
+ include_closed: bool = False,
178
+ output_as: str | None = None,
179
+ ) -> dict[str, Any]:
180
+ """Get tasks associated with the current workflow.
181
+
182
+ Args:
183
+ db: Database instance
184
+ session_manager: Session manager
185
+ session_id: Current session ID
186
+ state: WorkflowState for variables access
187
+ workflow_name: Override workflow name (defaults to current)
188
+ include_closed: Include closed tasks (default: False)
189
+ output_as: Variable name to store result in
190
+
191
+ Returns:
192
+ Dict with tasks list and count
193
+ """
194
+ from gobby.workflows.task_actions import get_workflow_tasks as _get_workflow_tasks
195
+
196
+ wf_name = workflow_name
197
+ if not wf_name and state.workflow_name:
198
+ wf_name = state.workflow_name
199
+
200
+ if not wf_name:
201
+ return {"error": "No workflow name specified"}
202
+
203
+ try:
204
+ current_session = await asyncio.to_thread(session_manager.get, session_id)
205
+ project_id = current_session.project_id if current_session else None
206
+
207
+ tasks = await asyncio.to_thread(
208
+ _get_workflow_tasks,
209
+ db=db,
210
+ workflow_name=wf_name,
211
+ project_id=project_id,
212
+ include_closed=include_closed,
213
+ )
214
+
215
+ # Convert to dicts for YAML/JSON serialization
216
+ tasks_data = [t.to_dict() for t in tasks]
217
+
218
+ # Store in variable if requested
219
+ if output_as:
220
+ if not state.variables:
221
+ state.variables = {}
222
+ state.variables[output_as] = tasks_data
223
+
224
+ # Also update task_list in state for workflow engine use
225
+ state.task_list = [{"id": t.id, "title": t.title, "status": t.status} for t in tasks]
226
+
227
+ return {"tasks": tasks_data, "count": len(tasks)}
228
+ except Exception as e:
229
+ logger.error(f"get_workflow_tasks: Failed: {e}", exc_info=True)
230
+ return {"error": str(e)}
231
+
232
+
233
+ async def update_workflow_task(
234
+ db: "DatabaseProtocol",
235
+ state: "WorkflowState",
236
+ task_id: str | None = None,
237
+ status: str | None = None,
238
+ verification: str | None = None,
239
+ validation_status: str | None = None,
240
+ validation_feedback: str | None = None,
241
+ ) -> dict[str, Any]:
242
+ """Update a task from workflow context.
243
+
244
+ Args:
245
+ db: Database instance
246
+ state: WorkflowState for task_list access
247
+ task_id: ID of task to update (required)
248
+ status: New status
249
+ verification: Verification result
250
+ validation_status: Validation status
251
+ validation_feedback: Validation feedback
252
+
253
+ Returns:
254
+ Dict with updated task data
255
+ """
256
+ from gobby.workflows.task_actions import update_task_from_workflow
257
+
258
+ tid = task_id
259
+ if not tid:
260
+ # Try to get from current_task_index in state
261
+ if state.task_list and state.current_task_index is not None:
262
+ idx = state.current_task_index
263
+ if 0 <= idx < len(state.task_list):
264
+ tid = state.task_list[idx].get("id")
265
+
266
+ if not tid:
267
+ return {"error": "No task_id specified"}
268
+
269
+ try:
270
+ task = await asyncio.to_thread(
271
+ update_task_from_workflow,
272
+ db=db,
273
+ task_id=tid,
274
+ status=status,
275
+ verification=verification,
276
+ validation_status=validation_status,
277
+ validation_feedback=validation_feedback,
278
+ )
279
+
280
+ if task:
281
+ return {"updated": True, "task": task.to_dict()}
282
+ return {"updated": False, "error": "Task not found"}
283
+ except Exception as e:
284
+ logger.error(f"update_workflow_task: Failed for task {tid}: {e}", exc_info=True)
285
+ return {"updated": False, "error": str(e)}
286
+
287
+
288
+ # --- ActionHandler-compatible wrappers ---
289
+ # These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
290
+
291
+
292
+ async def handle_task_sync_import(context: Any, **kwargs: Any) -> dict[str, Any] | None:
293
+ """ActionHandler wrapper for task_sync_import."""
294
+ return await task_sync_import(
295
+ task_sync_manager=context.task_sync_manager,
296
+ session_manager=context.session_manager,
297
+ session_id=context.session_id,
298
+ )
299
+
300
+
301
+ async def handle_task_sync_export(context: Any, **kwargs: Any) -> dict[str, Any] | None:
302
+ """ActionHandler wrapper for task_sync_export."""
303
+ return await task_sync_export(
304
+ task_sync_manager=context.task_sync_manager,
305
+ session_manager=context.session_manager,
306
+ session_id=context.session_id,
307
+ )
308
+
309
+
310
+ async def handle_persist_tasks(context: Any, **kwargs: Any) -> dict[str, Any] | None:
311
+ """ActionHandler wrapper for persist_tasks."""
312
+ return await persist_tasks(
313
+ db=context.db,
314
+ session_manager=context.session_manager,
315
+ session_id=context.session_id,
316
+ state=context.state,
317
+ tasks=kwargs.get("tasks"),
318
+ source=kwargs.get("source"),
319
+ workflow_name=kwargs.get("workflow_name"),
320
+ parent_task_id=kwargs.get("parent_task_id"),
321
+ )
322
+
323
+
324
+ async def handle_get_workflow_tasks(context: Any, **kwargs: Any) -> dict[str, Any] | None:
325
+ """ActionHandler wrapper for get_workflow_tasks."""
326
+ return await get_workflow_tasks(
327
+ db=context.db,
328
+ session_manager=context.session_manager,
329
+ session_id=context.session_id,
330
+ state=context.state,
331
+ workflow_name=kwargs.get("workflow_name"),
332
+ include_closed=kwargs.get("include_closed", False),
333
+ output_as=kwargs.get("as"),
334
+ )
335
+
336
+
337
+ async def handle_update_workflow_task(context: Any, **kwargs: Any) -> dict[str, Any] | None:
338
+ """ActionHandler wrapper for update_workflow_task."""
339
+ return await update_workflow_task(
340
+ db=context.db,
341
+ state=context.state,
342
+ task_id=kwargs.get("task_id"),
343
+ status=kwargs.get("status"),
344
+ verification=kwargs.get("verification"),
345
+ validation_status=kwargs.get("validation_status"),
346
+ validation_feedback=kwargs.get("validation_feedback"),
347
+ )
@@ -4,9 +4,13 @@ Extracted from actions.py as part of strangler fig decomposition.
4
4
  These functions handle TODO.md file operations.
5
5
  """
6
6
 
7
+ import asyncio
7
8
  import logging
8
9
  import os
9
- from typing import Any
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ if TYPE_CHECKING:
13
+ from gobby.workflows.actions import ActionContext
10
14
 
11
15
  logger = logging.getLogger(__name__)
12
16
 
@@ -82,3 +86,32 @@ def mark_todo_complete(
82
86
  except Exception as e:
83
87
  logger.error(f"mark_todo_complete: Failed: {e}")
84
88
  return {"error": str(e)}
89
+
90
+
91
+ # --- ActionHandler-compatible wrappers ---
92
+ # These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
93
+
94
+
95
+ async def handle_write_todos(context: "ActionContext", **kwargs: Any) -> dict[str, Any] | None:
96
+ """ActionHandler wrapper for write_todos."""
97
+ return await asyncio.to_thread(
98
+ write_todos,
99
+ todos=kwargs.get("todos", []),
100
+ filename=kwargs.get("filename", "TODO.md"),
101
+ mode=kwargs.get("mode", "w"),
102
+ )
103
+
104
+
105
+ async def handle_mark_todo_complete(
106
+ context: "ActionContext", **kwargs: Any
107
+ ) -> dict[str, Any] | None:
108
+ """ActionHandler wrapper for mark_todo_complete."""
109
+ todo_text = kwargs.get("todo_text")
110
+ if not todo_text:
111
+ return {"error": "Missing required parameter: todo_text"}
112
+
113
+ return await asyncio.to_thread(
114
+ mark_todo_complete,
115
+ todo_text,
116
+ kwargs.get("filename", "TODO.md"),
117
+ )