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.
- gobby/adapters/claude_code.py +13 -4
- gobby/adapters/codex.py +43 -3
- gobby/agents/runner.py +8 -0
- gobby/cli/__init__.py +6 -0
- gobby/cli/clones.py +419 -0
- gobby/cli/conductor.py +266 -0
- gobby/cli/installers/antigravity.py +3 -9
- gobby/cli/installers/claude.py +9 -9
- gobby/cli/installers/codex.py +2 -8
- gobby/cli/installers/gemini.py +2 -8
- gobby/cli/installers/shared.py +71 -8
- gobby/cli/skills.py +858 -0
- gobby/cli/tasks/ai.py +0 -440
- gobby/cli/tasks/crud.py +44 -6
- gobby/cli/tasks/main.py +0 -4
- gobby/cli/tui.py +2 -2
- gobby/cli/utils.py +3 -3
- gobby/clones/__init__.py +13 -0
- gobby/clones/git.py +547 -0
- gobby/conductor/__init__.py +16 -0
- gobby/conductor/alerts.py +135 -0
- gobby/conductor/loop.py +164 -0
- gobby/conductor/monitors/__init__.py +11 -0
- gobby/conductor/monitors/agents.py +116 -0
- gobby/conductor/monitors/tasks.py +155 -0
- gobby/conductor/pricing.py +234 -0
- gobby/conductor/token_tracker.py +160 -0
- gobby/config/app.py +63 -1
- gobby/config/search.py +110 -0
- gobby/config/servers.py +1 -1
- gobby/config/skills.py +43 -0
- gobby/config/tasks.py +6 -14
- gobby/hooks/event_handlers.py +145 -2
- gobby/hooks/hook_manager.py +48 -2
- gobby/hooks/skill_manager.py +130 -0
- gobby/install/claude/hooks/hook_dispatcher.py +4 -4
- gobby/install/codex/hooks/hook_dispatcher.py +1 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
- gobby/llm/claude.py +22 -34
- gobby/llm/claude_executor.py +46 -256
- gobby/llm/codex_executor.py +59 -291
- gobby/llm/executor.py +21 -0
- gobby/llm/gemini.py +134 -110
- gobby/llm/litellm_executor.py +143 -6
- gobby/llm/resolver.py +95 -33
- gobby/mcp_proxy/instructions.py +54 -0
- gobby/mcp_proxy/models.py +15 -0
- gobby/mcp_proxy/registries.py +68 -5
- gobby/mcp_proxy/server.py +33 -3
- gobby/mcp_proxy/services/tool_proxy.py +81 -1
- gobby/mcp_proxy/stdio.py +2 -1
- gobby/mcp_proxy/tools/__init__.py +0 -2
- gobby/mcp_proxy/tools/agent_messaging.py +317 -0
- gobby/mcp_proxy/tools/clones.py +903 -0
- gobby/mcp_proxy/tools/memory.py +1 -24
- gobby/mcp_proxy/tools/metrics.py +65 -1
- gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
- gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
- gobby/mcp_proxy/tools/session_messages.py +1 -2
- gobby/mcp_proxy/tools/skills/__init__.py +631 -0
- gobby/mcp_proxy/tools/task_orchestration.py +7 -0
- gobby/mcp_proxy/tools/task_readiness.py +14 -0
- gobby/mcp_proxy/tools/task_sync.py +1 -1
- gobby/mcp_proxy/tools/tasks/_context.py +0 -20
- gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
- gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +60 -29
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
- gobby/mcp_proxy/tools/workflows.py +1 -1
- gobby/mcp_proxy/tools/worktrees.py +5 -0
- gobby/memory/backends/__init__.py +6 -1
- gobby/memory/backends/mem0.py +6 -1
- gobby/memory/extractor.py +477 -0
- gobby/memory/manager.py +11 -2
- gobby/prompts/defaults/handoff/compact.md +63 -0
- gobby/prompts/defaults/handoff/session_end.md +57 -0
- gobby/prompts/defaults/memory/extract.md +61 -0
- gobby/runner.py +37 -16
- gobby/search/__init__.py +48 -6
- gobby/search/backends/__init__.py +159 -0
- gobby/search/backends/embedding.py +225 -0
- gobby/search/embeddings.py +238 -0
- gobby/search/models.py +148 -0
- gobby/search/unified.py +496 -0
- gobby/servers/http.py +23 -8
- gobby/servers/routes/admin.py +280 -0
- gobby/servers/routes/mcp/tools.py +241 -52
- gobby/servers/websocket.py +2 -2
- gobby/sessions/analyzer.py +2 -0
- gobby/sessions/transcripts/base.py +1 -0
- gobby/sessions/transcripts/claude.py +64 -5
- gobby/skills/__init__.py +91 -0
- gobby/skills/loader.py +685 -0
- gobby/skills/manager.py +384 -0
- gobby/skills/parser.py +258 -0
- gobby/skills/search.py +463 -0
- gobby/skills/sync.py +119 -0
- gobby/skills/updater.py +385 -0
- gobby/skills/validator.py +368 -0
- gobby/storage/clones.py +378 -0
- gobby/storage/database.py +1 -1
- gobby/storage/memories.py +43 -13
- gobby/storage/migrations.py +180 -6
- gobby/storage/sessions.py +73 -0
- gobby/storage/skills.py +749 -0
- gobby/storage/tasks/_crud.py +4 -4
- gobby/storage/tasks/_lifecycle.py +41 -6
- gobby/storage/tasks/_manager.py +14 -5
- gobby/storage/tasks/_models.py +8 -3
- gobby/sync/memories.py +39 -4
- gobby/sync/tasks.py +83 -6
- gobby/tasks/__init__.py +1 -2
- gobby/tasks/validation.py +24 -15
- gobby/tui/api_client.py +4 -7
- gobby/tui/app.py +5 -3
- gobby/tui/screens/orchestrator.py +1 -2
- gobby/tui/screens/tasks.py +2 -4
- gobby/tui/ws_client.py +1 -1
- gobby/utils/daemon_client.py +2 -2
- gobby/workflows/actions.py +84 -2
- gobby/workflows/context_actions.py +43 -0
- gobby/workflows/detection_helpers.py +115 -31
- gobby/workflows/engine.py +13 -2
- gobby/workflows/lifecycle_evaluator.py +29 -1
- gobby/workflows/loader.py +19 -6
- gobby/workflows/memory_actions.py +74 -0
- gobby/workflows/summary_actions.py +17 -0
- gobby/workflows/task_enforcement_actions.py +448 -6
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/METADATA +82 -21
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/RECORD +136 -107
- gobby/install/codex/prompts/forget.md +0 -7
- gobby/install/codex/prompts/memories.md +0 -7
- gobby/install/codex/prompts/recall.md +0 -7
- gobby/install/codex/prompts/remember.md +0 -13
- gobby/llm/gemini_executor.py +0 -339
- gobby/mcp_proxy/tools/task_expansion.py +0 -591
- gobby/tasks/context.py +0 -747
- gobby/tasks/criteria.py +0 -342
- gobby/tasks/expansion.py +0 -626
- gobby/tasks/prompts/expand.py +0 -327
- gobby/tasks/research.py +0 -421
- gobby/tasks/tdd.py +0 -352
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/WHEEL +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/licenses/LICENSE.md +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/top_level.txt +0 -0
gobby/storage/tasks/_crud.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
416
|
-
updates.append("
|
|
417
|
-
params.append(
|
|
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(
|
|
278
|
-
|
|
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
|
|
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
|
|
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
|
gobby/storage/tasks/_manager.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
gobby/storage/tasks/_models.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
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(
|
|
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
|
-
|
|
141
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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__ = ["
|
|
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
|
|
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
|
-
|
|
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
|
|
698
|
+
# Inject pattern criteria if labels provided
|
|
701
699
|
if labels:
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
707
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
188
|
-
args["
|
|
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:
|
|
162
|
-
ws_url: str = "ws://localhost:
|
|
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(
|
|
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
|
-
|
|
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
|
|
gobby/tui/screens/tasks.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
gobby/utils/daemon_client.py
CHANGED
|
@@ -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=
|
|
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 =
|
|
60
|
+
port: int = 60887,
|
|
61
61
|
timeout: float = 5.0,
|
|
62
62
|
logger: logging.Logger | None = None,
|
|
63
63
|
):
|