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
@@ -276,7 +276,7 @@ def update_task(
276
276
  agent_name: Any = UNSET,
277
277
  reference_doc: Any = UNSET,
278
278
  is_expanded: Any = UNSET,
279
- is_tdd_applied: Any = UNSET,
279
+ expansion_status: Any = UNSET,
280
280
  validation_override_reason: Any = UNSET,
281
281
  requires_user_review: Any = UNSET,
282
282
  ) -> bool:
@@ -412,9 +412,9 @@ def update_task(
412
412
  if is_expanded is not UNSET:
413
413
  updates.append("is_expanded = ?")
414
414
  params.append(1 if is_expanded else 0)
415
- if is_tdd_applied is not UNSET:
416
- updates.append("is_tdd_applied = ?")
417
- params.append(1 if is_tdd_applied else 0)
415
+ if expansion_status is not UNSET:
416
+ updates.append("expansion_status = ?")
417
+ params.append(expansion_status)
418
418
  if validation_override_reason is not UNSET:
419
419
  updates.append("validation_override_reason = ?")
420
420
  params.append(validation_override_reason)
@@ -274,20 +274,39 @@ def unlink_commit(
274
274
  return False
275
275
 
276
276
 
277
- def delete_task(db: DatabaseProtocol, task_id: str, cascade: bool = False) -> bool:
278
- """Delete a task. If cascade is True, delete children recursively.
277
+ def delete_task(
278
+ db: DatabaseProtocol,
279
+ task_id: str,
280
+ cascade: bool = False,
281
+ unlink: bool = False,
282
+ _visited: set[str] | None = None,
283
+ ) -> bool:
284
+ """Delete a task.
279
285
 
280
286
  Args:
281
287
  db: Database protocol instance
282
288
  task_id: The task ID to delete
283
- cascade: If True, delete children recursively (default: False)
289
+ cascade: If True, delete children AND dependent tasks recursively
290
+ unlink: If True, remove dependency links but preserve dependent tasks
291
+ (ignored if cascade=True)
292
+ _visited: Internal parameter to track visited tasks and prevent infinite recursion
293
+ when a parent task depends on its children (circular dependency)
284
294
 
285
295
  Returns:
286
296
  True if task was deleted, False if task not found.
287
297
 
288
298
  Raises:
289
- ValueError: If task has children and cascade is False.
299
+ ValueError: If task has children or dependents and neither cascade nor unlink is True.
290
300
  """
301
+ # Initialize visited set on first call to prevent infinite recursion
302
+ if _visited is None:
303
+ _visited = set()
304
+
305
+ # Skip if already being deleted (prevents cycles when parent depends on children)
306
+ if task_id in _visited:
307
+ return True
308
+ _visited.add(task_id)
309
+
291
310
  # Check if task exists first
292
311
  existing = db.fetchone("SELECT 1 FROM tasks WHERE id = ?", (task_id,))
293
312
  if not existing:
@@ -299,12 +318,43 @@ def delete_task(db: DatabaseProtocol, task_id: str, cascade: bool = False) -> bo
299
318
  if row:
300
319
  raise ValueError(f"Task {task_id} has children. Use cascade=True to delete.")
301
320
 
321
+ if not cascade and not unlink:
322
+ # Check for dependents (tasks that depend on this task)
323
+ dependent_rows = db.fetchall(
324
+ """SELECT t.id, t.seq_num, t.title
325
+ FROM tasks t
326
+ JOIN task_dependencies d ON d.task_id = t.id
327
+ WHERE d.depends_on = ? AND d.dep_type = 'blocks'""",
328
+ (task_id,),
329
+ )
330
+ if dependent_rows:
331
+ refs = [f"#{r['seq_num']}" for r in dependent_rows[:5] if r["seq_num"]]
332
+ refs_str = ", ".join(refs) if refs else str(len(dependent_rows)) + " task(s)"
333
+ if len(dependent_rows) > 5:
334
+ refs_str += f" and {len(dependent_rows) - 5} more"
335
+ raise ValueError(
336
+ f"Task {task_id} has {len(dependent_rows)} dependent task(s): {refs_str}. "
337
+ f"Use cascade=True to delete dependents, or unlink=True to preserve them."
338
+ )
339
+
302
340
  if cascade:
303
- # Recursive delete
304
- # Find all children
341
+ # Recursive delete children
305
342
  children = db.fetchall("SELECT id FROM tasks WHERE parent_task_id = ?", (task_id,))
306
343
  for child in children:
307
- delete_task(db, child["id"], cascade=True)
344
+ delete_task(db, child["id"], cascade=True, _visited=_visited)
345
+
346
+ # Delete tasks that depend on this task (only 'blocks' dependencies)
347
+ dependents = db.fetchall(
348
+ """SELECT t.id FROM tasks t
349
+ JOIN task_dependencies d ON d.task_id = t.id
350
+ WHERE d.depends_on = ? AND d.dep_type = 'blocks'""",
351
+ (task_id,),
352
+ )
353
+ for dep in dependents:
354
+ delete_task(db, dep["id"], cascade=True, _visited=_visited)
355
+
356
+ # Note: if unlink=True, dependency links are removed by ON DELETE CASCADE
357
+ # when the task is deleted - no explicit action needed
308
358
 
309
359
  with db.transaction() as conn:
310
360
  conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
@@ -306,7 +306,7 @@ class LocalTaskManager:
306
306
  agent_name: str | None | Any = UNSET,
307
307
  reference_doc: str | None | Any = UNSET,
308
308
  is_expanded: bool | None | Any = UNSET,
309
- is_tdd_applied: bool | None | Any = UNSET,
309
+ expansion_status: str | None | Any = UNSET,
310
310
  validation_override_reason: str | None | Any = UNSET,
311
311
  requires_user_review: bool | None | Any = UNSET,
312
312
  ) -> Task:
@@ -344,7 +344,7 @@ class LocalTaskManager:
344
344
  agent_name=agent_name,
345
345
  reference_doc=reference_doc,
346
346
  is_expanded=is_expanded,
347
- is_tdd_applied=is_tdd_applied,
347
+ expansion_status=expansion_status,
348
348
  validation_override_reason=validation_override_reason,
349
349
  requires_user_review=requires_user_review,
350
350
  )
@@ -462,13 +462,22 @@ class LocalTaskManager:
462
462
  self._notify_listeners()
463
463
  return self.get_task(task_id)
464
464
 
465
- def delete_task(self, task_id: str, cascade: bool = False) -> bool:
466
- """Delete a task. If cascade is True, delete children recursively.
465
+ def delete_task(self, task_id: str, cascade: bool = False, unlink: bool = False) -> bool:
466
+ """Delete a task.
467
+
468
+ Args:
469
+ task_id: The task ID to delete
470
+ cascade: If True, delete children AND dependent tasks recursively
471
+ unlink: If True, remove dependency links but preserve dependent tasks
472
+ (ignored if cascade=True)
467
473
 
468
474
  Returns:
469
475
  True if task was deleted, False if task not found.
476
+
477
+ Raises:
478
+ ValueError: If task has children or dependents and neither cascade nor unlink is True.
470
479
  """
471
- result = _delete_task(self.db, task_id, cascade)
480
+ result = _delete_task(self.db, task_id, cascade=cascade, unlink=unlink)
472
481
  if result:
473
482
  self._notify_listeners()
474
483
  return result
@@ -133,7 +133,8 @@ class Task:
133
133
  reference_doc: str | None = None # Path to source specification document
134
134
  # Processing flags for idempotent operations
135
135
  is_expanded: bool = False # Subtasks have been created
136
- is_tdd_applied: bool = False # TDD pairs have been generated
136
+ # Skill-based expansion status (for new /gobby-expand flow)
137
+ expansion_status: Literal["none", "pending", "completed"] = "none"
137
138
  # Review status fields (HITL support)
138
139
  requires_user_review: bool = False # Task requires user sign-off before closing
139
140
  accepted_by_user: bool = False # Set True when user moves review → closed
@@ -213,7 +214,11 @@ class Task:
213
214
  agent_name=row["agent_name"] if "agent_name" in keys else None,
214
215
  reference_doc=row["reference_doc"] if "reference_doc" in keys else None,
215
216
  is_expanded=bool(row["is_expanded"]) if "is_expanded" in keys else False,
216
- is_tdd_applied=bool(row["is_tdd_applied"]) if "is_tdd_applied" in keys else False,
217
+ expansion_status=(
218
+ row["expansion_status"]
219
+ if "expansion_status" in keys and row["expansion_status"]
220
+ else "none"
221
+ ),
217
222
  requires_user_review=(
218
223
  bool(row["requires_user_review"]) if "requires_user_review" in keys else False
219
224
  ),
@@ -268,7 +273,7 @@ class Task:
268
273
  "agent_name": self.agent_name,
269
274
  "reference_doc": self.reference_doc,
270
275
  "is_expanded": self.is_expanded,
271
- "is_tdd_applied": self.is_tdd_applied,
276
+ "expansion_status": self.expansion_status,
272
277
  "requires_user_review": self.requires_user_review,
273
278
  "accepted_by_user": self.accepted_by_user,
274
279
  "id": self.id, # UUID at end for backwards compat
gobby/sync/memories.py CHANGED
@@ -16,8 +16,10 @@ Classes:
16
16
  import asyncio
17
17
  import json
18
18
  import logging
19
+ import os
19
20
  import time
20
21
  from pathlib import Path
22
+ from typing import Any
21
23
 
22
24
  __all__ = [
23
25
  "MemoryBackupManager",
@@ -27,7 +29,7 @@ __all__ = [
27
29
  # TODO: Rename MemorySyncConfig to MemoryBackupConfig in gobby.config.persistence
28
30
  # for consistency with MemoryBackupManager naming. Keeping current name for now
29
31
  # to minimize breaking changes across the codebase.
30
- from gobby.config.app import MemorySyncConfig
32
+ from gobby.config.persistence import MemorySyncConfig
31
33
  from gobby.memory.manager import MemoryManager
32
34
  from gobby.storage.database import DatabaseProtocol
33
35
 
@@ -251,19 +253,52 @@ class MemoryBackupManager:
251
253
 
252
254
  return count
253
255
 
256
+ def _sanitize_content(self, content: str) -> str:
257
+ """Replace user home directories with ~ for privacy.
258
+
259
+ Prevents absolute user paths like /Users/josh from being
260
+ committed to version control.
261
+ """
262
+ home = os.path.expanduser("~")
263
+ return content.replace(home, "~")
264
+
265
+ def _deduplicate_memories(self, memories: list[Any]) -> list[Any]:
266
+ """Deduplicate memories by normalized content, keeping earliest.
267
+
268
+ Args:
269
+ memories: List of memory objects
270
+
271
+ Returns:
272
+ List of unique memories (by content), keeping the earliest created_at
273
+ """
274
+ seen_content: dict[str, Any] = {} # normalized_content -> memory
275
+ for memory in memories:
276
+ normalized = memory.content.strip()
277
+ if normalized not in seen_content:
278
+ seen_content[normalized] = memory
279
+ else:
280
+ # Keep the one with earlier created_at
281
+ existing = seen_content[normalized]
282
+ if memory.created_at < existing.created_at:
283
+ seen_content[normalized] = memory
284
+ return list(seen_content.values())
285
+
254
286
  def _export_memories_sync(self, file_path: Path) -> int:
255
- """Export memories to JSONL file (sync)."""
287
+ """Export memories to JSONL file (sync) with deduplication and path sanitization."""
256
288
  if not self.memory_manager:
257
289
  return 0
258
290
 
259
291
  try:
260
292
  memories = self.memory_manager.list_memories()
261
293
 
294
+ # Deduplicate by content before export
295
+ unique_memories = self._deduplicate_memories(memories)
296
+
262
297
  with open(file_path, "w", encoding="utf-8") as f:
263
- for memory in memories:
298
+ for memory in unique_memories:
264
299
  data = {
265
300
  "id": memory.id,
266
- "content": memory.content,
301
+ "content": self._sanitize_content(memory.content),
267
302
  "type": memory.memory_type,
268
303
  "importance": memory.importance,
269
304
  "tags": memory.tags,
@@ -274,7 +309,7 @@ class MemoryBackupManager:
274
309
  }
275
310
  f.write(json.dumps(data, ensure_ascii=False) + "\n")
276
311
 
277
- return len(memories)
312
+ return len(unique_memories)
278
313
  except Exception as e:
279
314
  logger.error(f"Failed to export memories: {e}")
280
315
  return 0
gobby/sync/tasks.py CHANGED
@@ -15,6 +15,56 @@ from gobby.utils.git import normalize_commit_sha
15
15
  logger = logging.getLogger(__name__)
16
16
 
17
17
 
18
+ def _parse_timestamp(ts: str) -> datetime:
19
+ """Parse ISO 8601 timestamp string to datetime.
20
+
21
+ Handles both Z suffix and +HH:MM offset formats for compatibility
22
+ with existing data that may use either format.
23
+
24
+ Args:
25
+ ts: ISO 8601 timestamp string (e.g., "2026-01-25T01:43:54Z" or
26
+ "2026-01-25T01:43:54.123456+00:00")
27
+
28
+ Returns:
29
+ Timezone-aware datetime object in UTC
30
+ """
31
+ # Handle Z suffix for fromisoformat compatibility
32
+ parse_ts = ts[:-1] + "+00:00" if ts.endswith("Z") else ts
33
+ dt = datetime.fromisoformat(parse_ts)
34
+
35
+ # Ensure timezone is UTC
36
+ if dt.tzinfo is None:
37
+ return dt.replace(tzinfo=UTC)
38
+ return dt.astimezone(UTC)
39
+
40
+
41
+ def _normalize_timestamp(ts: str | None) -> str | None:
42
+ """Normalize timestamp to consistent RFC 3339 format.
43
+
44
+ Ensures all timestamps have:
45
+ - Microsecond precision (.ffffff)
46
+ - UTC timezone as +00:00 suffix
47
+
48
+ Args:
49
+ ts: ISO 8601 timestamp string
50
+
51
+ Returns:
52
+ Timestamp in format YYYY-MM-DDTHH:MM:SS.ffffff+00:00, or None if input was None
53
+ """
54
+ if ts is None:
55
+ return None
56
+
57
+ try:
58
+ dt = _parse_timestamp(ts)
59
+ except ValueError:
60
+ # If parsing fails, return original (shouldn't happen with valid ISO 8601)
61
+ return ts
62
+
63
+ # Format with consistent microseconds and +00:00 suffix
64
+ base = dt.strftime("%Y-%m-%dT%H:%M:%S")
65
+ return f"{base}.{dt.microsecond:06d}+00:00"
66
+
67
+
18
68
  class TaskSyncManager:
19
69
  """
20
70
  Manages synchronization of tasks to the filesystem (JSONL) for Git versioning.
@@ -137,8 +187,9 @@ class TaskSyncManager:
137
187
  "title": task.title,
138
188
  "description": task.description,
139
189
  "status": task.status,
140
- "created_at": task.created_at,
141
- "updated_at": task.updated_at,
190
+ # Normalize timestamps to ensure RFC 3339 compliance (with timezone)
191
+ "created_at": _normalize_timestamp(task.created_at),
192
+ "updated_at": _normalize_timestamp(task.updated_at),
142
193
  "project_id": task.project_id,
143
194
  "parent_id": task.parent_task_id,
144
195
  "deps_on": sorted(deps_map.get(task.id, [])), # Sort deps for stability
@@ -157,8 +208,8 @@ class TaskSyncManager:
157
208
  if task.validation_status
158
209
  else None
159
210
  ),
160
- # Escalation fields
161
- "escalated_at": task.escalated_at,
211
+ # Escalation fields (normalize timestamps)
212
+ "escalated_at": _normalize_timestamp(task.escalated_at),
162
213
  "escalation_reason": task.escalation_reason,
163
214
  # Human-friendly IDs (preserve across sync)
164
215
  "seq_num": task.seq_num,
@@ -248,7 +299,21 @@ class TaskSyncManager:
248
299
 
249
300
  data = json.loads(line)
250
301
  task_id = data["id"]
251
- updated_at_file = datetime.fromisoformat(data["updated_at"])
302
+ # Guard against None/missing updated_at in JSONL
303
+ raw_updated_at = data.get("updated_at")
304
+ if raw_updated_at is None:
305
+ # Skip tasks without timestamps or use a safe default
306
+ logger.warning(f"Task {task_id} missing updated_at, skipping")
307
+ skipped_count += 1
308
+ continue
309
+ try:
310
+ updated_at_file = _parse_timestamp(raw_updated_at)
311
+ except ValueError as e:
312
+ logger.warning(
313
+ f"Task {task_id}: malformed timestamp '{raw_updated_at}': {e}, skipping"
314
+ )
315
+ skipped_count += 1
316
+ continue
252
317
 
253
318
  # Check if task exists (also fetch seq_num/path_cache to preserve)
254
319
  existing_row = self.db.fetchone(
@@ -263,7 +328,19 @@ class TaskSyncManager:
263
328
  should_update = True
264
329
  imported_count += 1
265
330
  else:
266
- updated_at_db = datetime.fromisoformat(existing_row["updated_at"])
331
+ # Handle NULL timestamps in DB (treat as infinitely old)
332
+ db_updated_at = existing_row["updated_at"]
333
+ if db_updated_at is None:
334
+ updated_at_db = datetime.min.replace(tzinfo=UTC)
335
+ else:
336
+ try:
337
+ updated_at_db = _parse_timestamp(db_updated_at)
338
+ except ValueError as e:
339
+ logger.warning(
340
+ f"Task {task_id}: failed to parse DB timestamp "
341
+ f"'{db_updated_at}': {e}, treating as old"
342
+ )
343
+ updated_at_db = datetime.min.replace(tzinfo=UTC)
267
344
  existing_seq_num = existing_row["seq_num"]
268
345
  existing_path_cache = existing_row["path_cache"]
269
346
  if updated_at_file > updated_at_db:
gobby/tasks/__init__.py CHANGED
@@ -2,7 +2,6 @@
2
2
  Task management components.
3
3
  """
4
4
 
5
- from gobby.tasks.expansion import TaskExpander
6
5
  from gobby.tasks.validation import TaskValidator, ValidationResult
7
6
 
8
- __all__ = ["TaskExpander", "TaskValidator", "ValidationResult"]
7
+ __all__ = ["TaskValidator", "ValidationResult"]
@@ -14,7 +14,7 @@ from dataclasses import dataclass, field
14
14
  from pathlib import Path
15
15
  from typing import TYPE_CHECKING, Any
16
16
 
17
- from gobby.config.app import TaskValidationConfig
17
+ from gobby.config.tasks import TaskValidationConfig
18
18
  from gobby.llm import LLMService
19
19
  from gobby.prompts import PromptLoader
20
20
  from gobby.tasks.commits import (
gobby/tasks/validation.py CHANGED
@@ -20,11 +20,9 @@ from dataclasses import dataclass
20
20
  from pathlib import Path
21
21
  from typing import Literal
22
22
 
23
- from gobby.config.app import TaskValidationConfig
24
- from gobby.config.tasks import PatternCriteriaConfig
23
+ from gobby.config.tasks import TaskValidationConfig
25
24
  from gobby.llm import LLMService
26
25
  from gobby.prompts import PromptLoader
27
- from gobby.tasks.criteria import PatternCriteriaInjector
28
26
  from gobby.utils.json_helpers import extract_json_object
29
27
 
30
28
  logger = logging.getLogger(__name__)
@@ -591,14 +589,8 @@ class TaskValidator:
591
589
  category_section += "\n"
592
590
 
593
591
  # Build prompt using PromptLoader or legacy config
594
- if self.config.prompt:
595
- # Legacy inline config (deprecated)
596
- prompt = self.config.prompt
597
- if file_context:
598
- prompt += f"\nFile Context:\n{file_context[:50000]}\n"
599
- else:
600
- # Use PromptLoader
601
- prompt_path = self.config.prompt_path or "validation/validate"
592
+ if self.config.prompt_path:
593
+ prompt_path = self.config.prompt_path
602
594
  template_context = {
603
595
  "title": title,
604
596
  "category_section": category_section,
@@ -613,6 +605,18 @@ class TaskValidator:
613
605
  prompt = DEFAULT_VALIDATE_PROMPT.format(**template_context)
614
606
  if file_context:
615
607
  prompt += f"\nFile Context:\n{file_context[:50000]}\n"
608
+ else:
609
+ # Default behavior
610
+ template_context = {
611
+ "title": title,
612
+ "category_section": category_section,
613
+ "criteria_text": criteria_text,
614
+ "changes_section": changes_section,
615
+ "file_context": file_context[:50000] if file_context else "",
616
+ }
617
+ prompt = DEFAULT_VALIDATE_PROMPT.format(**template_context)
618
+ if file_context:
619
+ prompt += f"\nFile Context:\n{file_context[:50000]}\n"
616
620
 
617
621
  try:
618
622
  provider = self.llm_service.get_provider(self.config.provider)
@@ -655,14 +659,10 @@ class TaskValidator:
655
659
  """
656
660
  Generate validation criteria from task title and description.
657
661
 
658
- When labels are provided (e.g., 'tdd', 'strangler-fig', 'refactoring'),
659
- pattern-specific criteria from PatternCriteriaConfig are appended to
660
- the LLM-generated criteria.
661
-
662
662
  Args:
663
663
  title: Task title
664
664
  description: Task description (optional)
665
- labels: Task labels for pattern criteria injection (optional)
665
+ labels: Task labels (currently unused, kept for API compatibility)
666
666
 
667
667
  Returns:
668
668
  Generated validation criteria string, or None if generation fails
@@ -676,17 +676,13 @@ class TaskValidator:
676
676
  "description": description or "(no description)",
677
677
  }
678
678
 
679
- if self.config.criteria_prompt:
680
- # Legacy inline config (deprecated)
681
- prompt = self.config.criteria_prompt.format(**template_context)
682
- else:
683
- # Use PromptLoader
684
- prompt_path = self.config.criteria_prompt_path or "validation/criteria"
685
- try:
686
- prompt = self._loader.render(prompt_path, template_context)
687
- except FileNotFoundError:
688
- logger.debug(f"Prompt template '{prompt_path}' not found, using fallback")
689
- prompt = DEFAULT_CRITERIA_PROMPT.format(**template_context)
679
+ # Use PromptLoader
680
+ prompt_path = self.config.criteria_prompt_path or "validation/criteria"
681
+ try:
682
+ prompt = self._loader.render(prompt_path, template_context)
683
+ except FileNotFoundError:
684
+ logger.debug(f"Prompt template '{prompt_path}' not found, using fallback")
685
+ prompt = DEFAULT_CRITERIA_PROMPT.format(**template_context)
690
686
 
691
687
  try:
692
688
  provider = self.llm_service.get_provider(self.config.provider)
@@ -695,18 +691,33 @@ class TaskValidator:
695
691
  system_prompt=self.config.criteria_system_prompt,
696
692
  model=self.config.model,
697
693
  )
698
- llm_criteria = response.strip()
694
+ if not response or not response.strip():
695
+ logger.warning("Empty LLM response for criteria generation")
696
+ return None
697
+
698
+ llm_result = response.strip()
699
699
 
700
- # Inject pattern-specific criteria if labels are provided
700
+ # Inject pattern criteria if labels provided
701
701
  if labels:
702
- pattern_config = PatternCriteriaConfig()
703
- injector = PatternCriteriaInjector(pattern_config=pattern_config)
704
- pattern_criteria = injector.inject_for_labels(labels=labels)
702
+ try:
703
+ from gobby.config.tasks import PatternCriteriaConfig
704
+
705
+ pattern_config = PatternCriteriaConfig()
706
+ pattern_sections = []
707
+
708
+ for label in labels:
709
+ if label in pattern_config.patterns:
710
+ criteria_list = pattern_config.patterns[label]
711
+ section = f"\n\n## {label.title().replace('-', ' ')} Pattern Criteria\n"
712
+ section += "\n".join(f"- [ ] {c}" for c in criteria_list)
713
+ pattern_sections.append(section)
705
714
 
706
- if pattern_criteria:
707
- llm_criteria = f"{llm_criteria}\n\n{pattern_criteria}"
715
+ if pattern_sections:
716
+ llm_result += "".join(pattern_sections)
717
+ except Exception as e:
718
+ logger.warning(f"Failed to inject pattern criteria: {e}")
708
719
 
709
- return llm_criteria
720
+ return llm_result
710
721
  except Exception as e:
711
722
  logger.error(f"Failed to generate validation criteria: {e}")
712
723
  return None