gobby 0.2.5__py3-none-any.whl → 0.2.6__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 (148) hide show
  1. gobby/adapters/claude_code.py +13 -4
  2. gobby/adapters/codex.py +43 -3
  3. gobby/agents/runner.py +8 -0
  4. gobby/cli/__init__.py +6 -0
  5. gobby/cli/clones.py +419 -0
  6. gobby/cli/conductor.py +266 -0
  7. gobby/cli/installers/antigravity.py +3 -9
  8. gobby/cli/installers/claude.py +9 -9
  9. gobby/cli/installers/codex.py +2 -8
  10. gobby/cli/installers/gemini.py +2 -8
  11. gobby/cli/installers/shared.py +71 -8
  12. gobby/cli/skills.py +858 -0
  13. gobby/cli/tasks/ai.py +0 -440
  14. gobby/cli/tasks/crud.py +44 -6
  15. gobby/cli/tasks/main.py +0 -4
  16. gobby/cli/tui.py +2 -2
  17. gobby/cli/utils.py +3 -3
  18. gobby/clones/__init__.py +13 -0
  19. gobby/clones/git.py +547 -0
  20. gobby/conductor/__init__.py +16 -0
  21. gobby/conductor/alerts.py +135 -0
  22. gobby/conductor/loop.py +164 -0
  23. gobby/conductor/monitors/__init__.py +11 -0
  24. gobby/conductor/monitors/agents.py +116 -0
  25. gobby/conductor/monitors/tasks.py +155 -0
  26. gobby/conductor/pricing.py +234 -0
  27. gobby/conductor/token_tracker.py +160 -0
  28. gobby/config/app.py +63 -1
  29. gobby/config/search.py +110 -0
  30. gobby/config/servers.py +1 -1
  31. gobby/config/skills.py +43 -0
  32. gobby/config/tasks.py +6 -14
  33. gobby/hooks/event_handlers.py +145 -2
  34. gobby/hooks/hook_manager.py +48 -2
  35. gobby/hooks/skill_manager.py +130 -0
  36. gobby/install/claude/hooks/hook_dispatcher.py +4 -4
  37. gobby/install/codex/hooks/hook_dispatcher.py +1 -1
  38. gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
  39. gobby/llm/claude.py +22 -34
  40. gobby/llm/claude_executor.py +46 -256
  41. gobby/llm/codex_executor.py +59 -291
  42. gobby/llm/executor.py +21 -0
  43. gobby/llm/gemini.py +134 -110
  44. gobby/llm/litellm_executor.py +143 -6
  45. gobby/llm/resolver.py +95 -33
  46. gobby/mcp_proxy/instructions.py +54 -0
  47. gobby/mcp_proxy/models.py +15 -0
  48. gobby/mcp_proxy/registries.py +68 -5
  49. gobby/mcp_proxy/server.py +33 -3
  50. gobby/mcp_proxy/services/tool_proxy.py +81 -1
  51. gobby/mcp_proxy/stdio.py +2 -1
  52. gobby/mcp_proxy/tools/__init__.py +0 -2
  53. gobby/mcp_proxy/tools/agent_messaging.py +317 -0
  54. gobby/mcp_proxy/tools/clones.py +903 -0
  55. gobby/mcp_proxy/tools/memory.py +1 -24
  56. gobby/mcp_proxy/tools/metrics.py +65 -1
  57. gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
  58. gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
  59. gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
  60. gobby/mcp_proxy/tools/session_messages.py +1 -2
  61. gobby/mcp_proxy/tools/skills/__init__.py +631 -0
  62. gobby/mcp_proxy/tools/task_orchestration.py +7 -0
  63. gobby/mcp_proxy/tools/task_readiness.py +14 -0
  64. gobby/mcp_proxy/tools/task_sync.py +1 -1
  65. gobby/mcp_proxy/tools/tasks/_context.py +0 -20
  66. gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
  67. gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
  68. gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
  69. gobby/mcp_proxy/tools/tasks/_lifecycle.py +60 -29
  70. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
  71. gobby/mcp_proxy/tools/workflows.py +1 -1
  72. gobby/mcp_proxy/tools/worktrees.py +5 -0
  73. gobby/memory/backends/__init__.py +6 -1
  74. gobby/memory/backends/mem0.py +6 -1
  75. gobby/memory/extractor.py +477 -0
  76. gobby/memory/manager.py +11 -2
  77. gobby/prompts/defaults/handoff/compact.md +63 -0
  78. gobby/prompts/defaults/handoff/session_end.md +57 -0
  79. gobby/prompts/defaults/memory/extract.md +61 -0
  80. gobby/runner.py +37 -16
  81. gobby/search/__init__.py +48 -6
  82. gobby/search/backends/__init__.py +159 -0
  83. gobby/search/backends/embedding.py +225 -0
  84. gobby/search/embeddings.py +238 -0
  85. gobby/search/models.py +148 -0
  86. gobby/search/unified.py +496 -0
  87. gobby/servers/http.py +23 -8
  88. gobby/servers/routes/admin.py +280 -0
  89. gobby/servers/routes/mcp/tools.py +241 -52
  90. gobby/servers/websocket.py +2 -2
  91. gobby/sessions/analyzer.py +2 -0
  92. gobby/sessions/transcripts/base.py +1 -0
  93. gobby/sessions/transcripts/claude.py +64 -5
  94. gobby/skills/__init__.py +91 -0
  95. gobby/skills/loader.py +685 -0
  96. gobby/skills/manager.py +384 -0
  97. gobby/skills/parser.py +258 -0
  98. gobby/skills/search.py +463 -0
  99. gobby/skills/sync.py +119 -0
  100. gobby/skills/updater.py +385 -0
  101. gobby/skills/validator.py +368 -0
  102. gobby/storage/clones.py +378 -0
  103. gobby/storage/database.py +1 -1
  104. gobby/storage/memories.py +43 -13
  105. gobby/storage/migrations.py +180 -6
  106. gobby/storage/sessions.py +73 -0
  107. gobby/storage/skills.py +749 -0
  108. gobby/storage/tasks/_crud.py +4 -4
  109. gobby/storage/tasks/_lifecycle.py +41 -6
  110. gobby/storage/tasks/_manager.py +14 -5
  111. gobby/storage/tasks/_models.py +8 -3
  112. gobby/sync/memories.py +39 -4
  113. gobby/sync/tasks.py +83 -6
  114. gobby/tasks/__init__.py +1 -2
  115. gobby/tasks/validation.py +24 -15
  116. gobby/tui/api_client.py +4 -7
  117. gobby/tui/app.py +5 -3
  118. gobby/tui/screens/orchestrator.py +1 -2
  119. gobby/tui/screens/tasks.py +2 -4
  120. gobby/tui/ws_client.py +1 -1
  121. gobby/utils/daemon_client.py +2 -2
  122. gobby/workflows/actions.py +84 -2
  123. gobby/workflows/context_actions.py +43 -0
  124. gobby/workflows/detection_helpers.py +115 -31
  125. gobby/workflows/engine.py +13 -2
  126. gobby/workflows/lifecycle_evaluator.py +29 -1
  127. gobby/workflows/loader.py +19 -6
  128. gobby/workflows/memory_actions.py +74 -0
  129. gobby/workflows/summary_actions.py +17 -0
  130. gobby/workflows/task_enforcement_actions.py +448 -6
  131. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/METADATA +82 -21
  132. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/RECORD +136 -107
  133. gobby/install/codex/prompts/forget.md +0 -7
  134. gobby/install/codex/prompts/memories.md +0 -7
  135. gobby/install/codex/prompts/recall.md +0 -7
  136. gobby/install/codex/prompts/remember.md +0 -13
  137. gobby/llm/gemini_executor.py +0 -339
  138. gobby/mcp_proxy/tools/task_expansion.py +0 -591
  139. gobby/tasks/context.py +0 -747
  140. gobby/tasks/criteria.py +0 -342
  141. gobby/tasks/expansion.py +0 -626
  142. gobby/tasks/prompts/expand.py +0 -327
  143. gobby/tasks/research.py +0 -421
  144. gobby/tasks/tdd.py +0 -352
  145. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/WHEEL +0 -0
  146. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/entry_points.txt +0 -0
  147. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/licenses/LICENSE.md +0 -0
  148. {gobby-0.2.5.dist-info → gobby-0.2.6.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,19 +274,23 @@ 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, task_id: str, cascade: bool = False, unlink: bool = False
279
+ ) -> bool:
280
+ """Delete a task.
279
281
 
280
282
  Args:
281
283
  db: Database protocol instance
282
284
  task_id: The task ID to delete
283
- cascade: If True, delete children recursively (default: False)
285
+ cascade: If True, delete children AND dependent tasks recursively
286
+ unlink: If True, remove dependency links but preserve dependent tasks
287
+ (ignored if cascade=True)
284
288
 
285
289
  Returns:
286
290
  True if task was deleted, False if task not found.
287
291
 
288
292
  Raises:
289
- ValueError: If task has children and cascade is False.
293
+ ValueError: If task has children or dependents and neither cascade nor unlink is True.
290
294
  """
291
295
  # Check if task exists first
292
296
  existing = db.fetchone("SELECT 1 FROM tasks WHERE id = ?", (task_id,))
@@ -299,13 +303,44 @@ def delete_task(db: DatabaseProtocol, task_id: str, cascade: bool = False) -> bo
299
303
  if row:
300
304
  raise ValueError(f"Task {task_id} has children. Use cascade=True to delete.")
301
305
 
306
+ if not cascade and not unlink:
307
+ # Check for dependents (tasks that depend on this task)
308
+ dependent_rows = db.fetchall(
309
+ """SELECT t.id, t.seq_num, t.title
310
+ FROM tasks t
311
+ JOIN task_dependencies d ON d.task_id = t.id
312
+ WHERE d.depends_on = ? AND d.dep_type = 'blocks'""",
313
+ (task_id,),
314
+ )
315
+ if dependent_rows:
316
+ refs = [f"#{r['seq_num']}" for r in dependent_rows[:5] if r["seq_num"]]
317
+ refs_str = ", ".join(refs) if refs else str(len(dependent_rows)) + " task(s)"
318
+ if len(dependent_rows) > 5:
319
+ refs_str += f" and {len(dependent_rows) - 5} more"
320
+ raise ValueError(
321
+ f"Task {task_id} has {len(dependent_rows)} dependent task(s): {refs_str}. "
322
+ f"Use cascade=True to delete dependents, or unlink=True to preserve them."
323
+ )
324
+
302
325
  if cascade:
303
- # Recursive delete
304
- # Find all children
326
+ # Recursive delete children
305
327
  children = db.fetchall("SELECT id FROM tasks WHERE parent_task_id = ?", (task_id,))
306
328
  for child in children:
307
329
  delete_task(db, child["id"], cascade=True)
308
330
 
331
+ # Delete tasks that depend on this task (only 'blocks' dependencies)
332
+ dependents = db.fetchall(
333
+ """SELECT t.id FROM tasks t
334
+ JOIN task_dependencies d ON d.task_id = t.id
335
+ WHERE d.depends_on = ? AND d.dep_type = 'blocks'""",
336
+ (task_id,),
337
+ )
338
+ for dep in dependents:
339
+ delete_task(db, dep["id"], cascade=True)
340
+
341
+ # Note: if unlink=True, dependency links are removed by ON DELETE CASCADE
342
+ # when the task is deleted - no explicit action needed
343
+
309
344
  with db.transaction() as conn:
310
345
  conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
311
346
  return True
@@ -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",
@@ -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"]
gobby/tasks/validation.py CHANGED
@@ -21,10 +21,8 @@ from pathlib import Path
21
21
  from typing import Literal
22
22
 
23
23
  from gobby.config.app import TaskValidationConfig
24
- from gobby.config.tasks import PatternCriteriaConfig
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__)
@@ -655,14 +653,10 @@ class TaskValidator:
655
653
  """
656
654
  Generate validation criteria from task title and description.
657
655
 
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
656
  Args:
663
657
  title: Task title
664
658
  description: Task description (optional)
665
- labels: Task labels for pattern criteria injection (optional)
659
+ labels: Task labels (currently unused, kept for API compatibility)
666
660
 
667
661
  Returns:
668
662
  Generated validation criteria string, or None if generation fails
@@ -695,18 +689,33 @@ class TaskValidator:
695
689
  system_prompt=self.config.criteria_system_prompt,
696
690
  model=self.config.model,
697
691
  )
698
- llm_criteria = response.strip()
692
+ if not response or not response.strip():
693
+ logger.warning("Empty LLM response for criteria generation")
694
+ return None
695
+
696
+ llm_result = response.strip()
699
697
 
700
- # Inject pattern-specific criteria if labels are provided
698
+ # Inject pattern criteria if labels provided
701
699
  if labels:
702
- pattern_config = PatternCriteriaConfig()
703
- injector = PatternCriteriaInjector(pattern_config=pattern_config)
704
- pattern_criteria = injector.inject_for_labels(labels=labels)
700
+ try:
701
+ from gobby.config.tasks import PatternCriteriaConfig
702
+
703
+ pattern_config = PatternCriteriaConfig()
704
+ pattern_sections = []
705
+
706
+ for label in labels:
707
+ if label in pattern_config.patterns:
708
+ criteria_list = pattern_config.patterns[label]
709
+ section = f"\n\n## {label.title().replace('-', ' ')} Pattern Criteria\n"
710
+ section += "\n".join(f"- [ ] {c}" for c in criteria_list)
711
+ pattern_sections.append(section)
705
712
 
706
- if pattern_criteria:
707
- llm_criteria = f"{llm_criteria}\n\n{pattern_criteria}"
713
+ if pattern_sections:
714
+ llm_result += "".join(pattern_sections)
715
+ except Exception as e:
716
+ logger.warning(f"Failed to inject pattern criteria: {e}")
708
717
 
709
- return llm_criteria
718
+ return llm_result
710
719
  except Exception as e:
711
720
  logger.error(f"Failed to generate validation criteria: {e}")
712
721
  return None
gobby/tui/api_client.py CHANGED
@@ -10,7 +10,7 @@ import httpx
10
10
  class GobbyAPIClient:
11
11
  """HTTP client for communicating with Gobby daemon."""
12
12
 
13
- def __init__(self, base_url: str = "http://localhost:8765") -> None:
13
+ def __init__(self, base_url: str = "http://localhost:60887") -> None:
14
14
  self.base_url = base_url.rstrip("/")
15
15
  self._client: httpx.AsyncClient | None = None
16
16
 
@@ -177,17 +177,14 @@ class GobbyAPIClient:
177
177
  self,
178
178
  task_id: str,
179
179
  commit_sha: str | None = None,
180
- no_commit_needed: bool = False,
181
- override_justification: str | None = None,
180
+ reason: str | None = None,
182
181
  ) -> dict[str, Any]:
183
182
  """Close a task."""
184
183
  args: dict[str, Any] = {"task_id": task_id}
185
184
  if commit_sha:
186
185
  args["commit_sha"] = commit_sha
187
- if no_commit_needed:
188
- args["no_commit_needed"] = True
189
- if override_justification:
190
- args["override_justification"] = override_justification
186
+ if reason:
187
+ args["reason"] = reason
191
188
  return await self.call_tool("gobby-tasks", "close_task", args)
192
189
 
193
190
  async def suggest_next_task(self) -> dict[str, Any]:
gobby/tui/app.py CHANGED
@@ -158,8 +158,8 @@ class GobbyApp(App[None]):
158
158
 
159
159
  def __init__(
160
160
  self,
161
- daemon_url: str = "http://localhost:8765",
162
- ws_url: str = "ws://localhost:8766",
161
+ daemon_url: str = "http://localhost:60887",
162
+ ws_url: str = "ws://localhost:60888",
163
163
  ) -> None:
164
164
  super().__init__()
165
165
  self.daemon_url = daemon_url
@@ -321,7 +321,9 @@ class GobbyApp(App[None]):
321
321
  await self.ws_client.disconnect()
322
322
 
323
323
 
324
- def run_tui(daemon_url: str = "http://localhost:8765", ws_url: str = "ws://localhost:8766") -> None:
324
+ def run_tui(
325
+ daemon_url: str = "http://localhost:60887", ws_url: str = "ws://localhost:60888"
326
+ ) -> None:
325
327
  """Entry point for the TUI application."""
326
328
  app = GobbyApp(daemon_url=daemon_url, ws_url=ws_url)
327
329
  app.run()
@@ -826,8 +826,7 @@ class OrchestratorScreen(Widget):
826
826
  return
827
827
  await self.api_client.close_task(
828
828
  task_id,
829
- no_commit_needed=True,
830
- override_justification="Orchestrator approval - manual user review",
829
+ reason="completed",
831
830
  )
832
831
  self.notify(f"Approved: {task.get('ref', task_id)}")
833
832
 
@@ -403,16 +403,14 @@ class TasksScreen(Widget):
403
403
  # Note: In real usage, this would need a commit SHA
404
404
  await client.close_task(
405
405
  task_id,
406
- no_commit_needed=True,
407
- override_justification="TUI completion - manual user action",
406
+ reason="obsolete",
408
407
  )
409
408
  self.notify(f"Task completed: {task_id}")
410
409
 
411
410
  elif button_id == "btn-approve":
412
411
  await client.close_task(
413
412
  task_id,
414
- no_commit_needed=True,
415
- override_justification="TUI approval - manual user review",
413
+ reason="obsolete",
416
414
  )
417
415
  self.notify(f"Task approved: {task_id}")
418
416
 
gobby/tui/ws_client.py CHANGED
@@ -19,7 +19,7 @@ class GobbyWebSocketClient:
19
19
 
20
20
  def __init__(
21
21
  self,
22
- ws_url: str = "ws://localhost:8766",
22
+ ws_url: str = "ws://localhost:60888",
23
23
  reconnect_interval: float = 5.0,
24
24
  max_reconnect_attempts: int = 10,
25
25
  ) -> None:
@@ -11,7 +11,7 @@ Example:
11
11
  ```python
12
12
  from gobby.utils.daemon_client import DaemonClient
13
13
 
14
- client = DaemonClient(host="localhost", port=8765)
14
+ client = DaemonClient(host="localhost", port=60887)
15
15
 
16
16
  # Check daemon health
17
17
  is_healthy, error = client.check_health()
@@ -57,7 +57,7 @@ class DaemonClient:
57
57
  def __init__(
58
58
  self,
59
59
  host: str = "localhost",
60
- port: int = 8765,
60
+ port: int = 60887,
61
61
  timeout: float = 5.0,
62
62
  logger: logging.Logger | None = None,
63
63
  ):