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.
- gobby/__init__.py +1 -1
- gobby/adapters/__init__.py +2 -1
- gobby/adapters/claude_code.py +13 -4
- gobby/adapters/codex_impl/__init__.py +28 -0
- gobby/adapters/codex_impl/adapter.py +722 -0
- gobby/adapters/codex_impl/client.py +679 -0
- gobby/adapters/codex_impl/protocol.py +20 -0
- gobby/adapters/codex_impl/types.py +68 -0
- gobby/agents/definitions.py +11 -1
- gobby/agents/isolation.py +395 -0
- gobby/agents/runner.py +8 -0
- gobby/agents/sandbox.py +261 -0
- gobby/agents/spawn.py +42 -287
- gobby/agents/spawn_executor.py +385 -0
- gobby/agents/spawners/__init__.py +24 -0
- gobby/agents/spawners/command_builder.py +189 -0
- gobby/agents/spawners/embedded.py +21 -2
- gobby/agents/spawners/headless.py +21 -2
- gobby/agents/spawners/prompt_manager.py +125 -0
- gobby/cli/__init__.py +6 -0
- gobby/cli/clones.py +419 -0
- gobby/cli/conductor.py +266 -0
- gobby/cli/install.py +4 -4
- gobby/cli/installers/antigravity.py +3 -9
- gobby/cli/installers/claude.py +15 -9
- gobby/cli/installers/codex.py +2 -8
- gobby/cli/installers/gemini.py +8 -8
- gobby/cli/installers/shared.py +175 -13
- gobby/cli/sessions.py +1 -1
- 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 +12 -5
- 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/__init__.py +12 -97
- gobby/config/app.py +69 -91
- gobby/config/extensions.py +2 -2
- gobby/config/features.py +7 -130
- gobby/config/search.py +110 -0
- gobby/config/servers.py +1 -1
- gobby/config/skills.py +43 -0
- gobby/config/tasks.py +9 -41
- gobby/hooks/__init__.py +0 -13
- gobby/hooks/event_handlers.py +188 -2
- gobby/hooks/hook_manager.py +50 -4
- gobby/hooks/plugins.py +1 -1
- gobby/hooks/skill_manager.py +130 -0
- gobby/hooks/webhooks.py +1 -1
- 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 +98 -35
- gobby/mcp_proxy/importer.py +62 -4
- gobby/mcp_proxy/instructions.py +56 -0
- gobby/mcp_proxy/models.py +15 -0
- gobby/mcp_proxy/registries.py +68 -8
- gobby/mcp_proxy/server.py +33 -3
- gobby/mcp_proxy/services/recommendation.py +43 -11
- 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/agents.py +31 -731
- gobby/mcp_proxy/tools/clones.py +518 -0
- gobby/mcp_proxy/tools/memory.py +3 -26
- 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/sessions/__init__.py +14 -0
- gobby/mcp_proxy/tools/sessions/_commits.py +232 -0
- gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
- gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
- gobby/mcp_proxy/tools/sessions/_handoff.py +499 -0
- gobby/mcp_proxy/tools/sessions/_messages.py +138 -0
- gobby/mcp_proxy/tools/skills/__init__.py +616 -0
- gobby/mcp_proxy/tools/spawn_agent.py +417 -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 +110 -45
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
- gobby/mcp_proxy/tools/workflows.py +1 -1
- gobby/mcp_proxy/tools/worktrees.py +0 -338
- gobby/memory/backends/__init__.py +6 -1
- gobby/memory/backends/mem0.py +6 -1
- gobby/memory/extractor.py +477 -0
- gobby/memory/ingestion/__init__.py +5 -0
- gobby/memory/ingestion/multimodal.py +221 -0
- gobby/memory/manager.py +73 -285
- gobby/memory/search/__init__.py +10 -0
- gobby/memory/search/coordinator.py +248 -0
- gobby/memory/services/__init__.py +5 -0
- gobby/memory/services/crossref.py +142 -0
- gobby/prompts/loader.py +5 -2
- 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 +24 -12
- gobby/servers/routes/admin.py +294 -0
- gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
- gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
- gobby/servers/routes/mcp/endpoints/execution.py +568 -0
- gobby/servers/routes/mcp/endpoints/registry.py +378 -0
- gobby/servers/routes/mcp/endpoints/server.py +304 -0
- gobby/servers/routes/mcp/hooks.py +1 -1
- gobby/servers/routes/mcp/tools.py +48 -1317
- gobby/servers/websocket.py +2 -2
- gobby/sessions/analyzer.py +2 -0
- gobby/sessions/lifecycle.py +1 -1
- gobby/sessions/processor.py +10 -0
- gobby/sessions/transcripts/base.py +2 -0
- gobby/sessions/transcripts/claude.py +79 -10
- gobby/skills/__init__.py +91 -0
- gobby/skills/loader.py +685 -0
- gobby/skills/manager.py +384 -0
- gobby/skills/parser.py +286 -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 +162 -201
- gobby/storage/sessions.py +116 -7
- gobby/storage/skills.py +782 -0
- gobby/storage/tasks/_crud.py +4 -4
- gobby/storage/tasks/_lifecycle.py +57 -7
- gobby/storage/tasks/_manager.py +14 -5
- gobby/storage/tasks/_models.py +8 -3
- gobby/sync/memories.py +40 -5
- gobby/sync/tasks.py +83 -6
- gobby/tasks/__init__.py +1 -2
- gobby/tasks/external_validator.py +1 -1
- gobby/tasks/validation.py +46 -35
- gobby/tools/summarizer.py +91 -10
- 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/utils/project_context.py +2 -3
- gobby/utils/status.py +13 -0
- gobby/workflows/actions.py +221 -1135
- gobby/workflows/artifact_actions.py +31 -0
- gobby/workflows/autonomous_actions.py +11 -0
- gobby/workflows/context_actions.py +93 -1
- gobby/workflows/detection_helpers.py +115 -31
- gobby/workflows/enforcement/__init__.py +47 -0
- gobby/workflows/enforcement/blocking.py +269 -0
- gobby/workflows/enforcement/commit_policy.py +283 -0
- gobby/workflows/enforcement/handlers.py +269 -0
- gobby/workflows/{task_enforcement_actions.py → enforcement/task_policy.py} +29 -388
- gobby/workflows/engine.py +13 -2
- gobby/workflows/git_utils.py +106 -0
- gobby/workflows/lifecycle_evaluator.py +29 -1
- gobby/workflows/llm_actions.py +30 -0
- gobby/workflows/loader.py +19 -6
- gobby/workflows/mcp_actions.py +20 -1
- gobby/workflows/memory_actions.py +154 -0
- gobby/workflows/safe_evaluator.py +183 -0
- gobby/workflows/session_actions.py +44 -0
- gobby/workflows/state_actions.py +60 -1
- gobby/workflows/stop_signal_actions.py +55 -0
- gobby/workflows/summary_actions.py +111 -1
- gobby/workflows/task_sync_actions.py +347 -0
- gobby/workflows/todo_actions.py +34 -1
- gobby/workflows/webhook_actions.py +185 -0
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/METADATA +87 -21
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/RECORD +201 -172
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
- gobby/adapters/codex.py +0 -1292
- gobby/install/claude/commands/gobby/bug.md +0 -51
- gobby/install/claude/commands/gobby/chore.md +0 -51
- gobby/install/claude/commands/gobby/epic.md +0 -52
- gobby/install/claude/commands/gobby/eval.md +0 -235
- gobby/install/claude/commands/gobby/feat.md +0 -49
- gobby/install/claude/commands/gobby/nit.md +0 -52
- gobby/install/claude/commands/gobby/ref.md +0 -52
- 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/session_messages.py +0 -1056
- gobby/mcp_proxy/tools/task_expansion.py +0 -591
- gobby/prompts/defaults/expansion/system.md +0 -119
- gobby/prompts/defaults/expansion/user.md +0 -48
- gobby/prompts/defaults/external_validation/agent.md +0 -72
- gobby/prompts/defaults/external_validation/external.md +0 -63
- gobby/prompts/defaults/external_validation/spawn.md +0 -83
- gobby/prompts/defaults/external_validation/system.md +0 -6
- gobby/prompts/defaults/features/import_mcp.md +0 -22
- gobby/prompts/defaults/features/import_mcp_github.md +0 -17
- gobby/prompts/defaults/features/import_mcp_search.md +0 -16
- gobby/prompts/defaults/features/recommend_tools.md +0 -32
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
- gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
- gobby/prompts/defaults/features/server_description.md +0 -20
- gobby/prompts/defaults/features/server_description_system.md +0 -6
- gobby/prompts/defaults/features/task_description.md +0 -31
- gobby/prompts/defaults/features/task_description_system.md +0 -6
- gobby/prompts/defaults/features/tool_summary.md +0 -17
- gobby/prompts/defaults/features/tool_summary_system.md +0 -6
- gobby/prompts/defaults/research/step.md +0 -58
- gobby/prompts/defaults/validation/criteria.md +0 -47
- gobby/prompts/defaults/validation/validate.md +0 -38
- gobby/storage/migrations_legacy.py +0 -1359
- 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.7.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.7.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,20 +274,39 @@ def unlink_commit(
|
|
|
274
274
|
return False
|
|
275
275
|
|
|
276
276
|
|
|
277
|
-
def delete_task(
|
|
278
|
-
|
|
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
|
|
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
|
|
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,))
|
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",
|
|
@@ -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.
|
|
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
|
|
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"]
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
595
|
-
|
|
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
|
|
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
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
prompt_path
|
|
685
|
-
|
|
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
|
-
|
|
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
|
|
700
|
+
# Inject pattern criteria if labels provided
|
|
701
701
|
if labels:
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
707
|
-
|
|
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
|
|
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
|