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
gobby/clones/git.py ADDED
@@ -0,0 +1,547 @@
1
+ """Git clone operations manager.
2
+
3
+ Provides operations for managing full git clones, distinct from worktrees.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import shutil
10
+ import subprocess # nosec B404 - subprocess needed for git clone operations
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Literal
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def _sanitize_url(url: str) -> str:
19
+ """Remove credentials from URL for safe logging.
20
+
21
+ Args:
22
+ url: URL that may contain credentials
23
+
24
+ Returns:
25
+ URL with credentials removed
26
+ """
27
+ from urllib.parse import urlparse, urlunparse
28
+
29
+ parsed = urlparse(url)
30
+ if parsed.username or parsed.password:
31
+ # Replace userinfo with placeholder
32
+ netloc = parsed.hostname or ""
33
+ if parsed.port:
34
+ netloc += f":{parsed.port}"
35
+ parsed = parsed._replace(netloc=netloc)
36
+ return urlunparse(parsed)
37
+
38
+
39
+ @dataclass
40
+ class CloneStatus:
41
+ """Status of a git clone including changes and sync state."""
42
+
43
+ has_uncommitted_changes: bool
44
+ has_staged_changes: bool
45
+ has_untracked_files: bool
46
+ branch: str | None
47
+ commit: str | None
48
+
49
+
50
+ @dataclass
51
+ class GitOperationResult:
52
+ """Result of a git operation."""
53
+
54
+ success: bool
55
+ message: str
56
+ output: str | None = None
57
+ error: str | None = None
58
+
59
+
60
+ class CloneGitManager:
61
+ """
62
+ Manager for git clone operations.
63
+
64
+ Provides methods to shallow clone, sync, and delete git clones.
65
+ Unlike worktrees which share a .git directory, clones are full
66
+ repository copies suitable for isolated or cross-machine development.
67
+ """
68
+
69
+ def __init__(self, repo_path: str | Path):
70
+ """
71
+ Initialize with base repository path.
72
+
73
+ Args:
74
+ repo_path: Path to the reference repository (for getting remote URL)
75
+
76
+ Raises:
77
+ ValueError: If the repository path does not exist
78
+ """
79
+ self.repo_path = Path(repo_path)
80
+ if not self.repo_path.exists():
81
+ raise ValueError(f"Repository path does not exist: {repo_path}")
82
+
83
+ def _run_git(
84
+ self,
85
+ args: list[str],
86
+ cwd: str | Path | None = None,
87
+ timeout: int = 60,
88
+ check: bool = False,
89
+ ) -> subprocess.CompletedProcess[str]:
90
+ """
91
+ Run a git command.
92
+
93
+ Args:
94
+ args: Git command arguments (without 'git' prefix)
95
+ cwd: Working directory (defaults to repo_path)
96
+ timeout: Command timeout in seconds
97
+ check: Raise exception on non-zero exit
98
+
99
+ Returns:
100
+ CompletedProcess with stdout/stderr
101
+ """
102
+ if cwd is None:
103
+ cwd = self.repo_path
104
+
105
+ cmd = ["git"] + args
106
+ logger.debug(f"Running: {' '.join(cmd)} in {cwd}")
107
+
108
+ try:
109
+ result = subprocess.run( # nosec B603 B607 - cmd built from hardcoded git arguments
110
+ cmd,
111
+ cwd=cwd,
112
+ capture_output=True,
113
+ text=True,
114
+ timeout=timeout,
115
+ check=check,
116
+ )
117
+ return result
118
+ except subprocess.TimeoutExpired:
119
+ logger.error(f"Git command timed out: {' '.join(cmd)}")
120
+ raise
121
+ except subprocess.CalledProcessError as e:
122
+ logger.error(f"Git command failed: {' '.join(cmd)}, stderr: {e.stderr}")
123
+ raise
124
+
125
+ def get_remote_url(self, remote: str = "origin") -> str | None:
126
+ """
127
+ Get the remote URL for the repository.
128
+
129
+ Args:
130
+ remote: Remote name (default: origin)
131
+
132
+ Returns:
133
+ Remote URL or None if not found
134
+ """
135
+ try:
136
+ result = self._run_git(
137
+ ["remote", "get-url", remote],
138
+ timeout=10,
139
+ )
140
+ if result.returncode == 0:
141
+ return result.stdout.strip()
142
+ return None
143
+ except Exception:
144
+ return None
145
+
146
+ def shallow_clone(
147
+ self,
148
+ remote_url: str,
149
+ clone_path: str | Path,
150
+ branch: str = "main",
151
+ depth: int = 1,
152
+ ) -> GitOperationResult:
153
+ """
154
+ Create a shallow clone of a repository.
155
+
156
+ Args:
157
+ remote_url: URL of the remote repository (HTTPS or SSH)
158
+ clone_path: Path where clone will be created
159
+ branch: Branch to clone
160
+ depth: Clone depth (default: 1 for shallowest)
161
+
162
+ Returns:
163
+ GitOperationResult with success status and message
164
+ """
165
+ clone_path = Path(clone_path)
166
+
167
+ # Check if path already exists
168
+ if clone_path.exists():
169
+ return GitOperationResult(
170
+ success=False,
171
+ message=f"Path already exists: {clone_path}",
172
+ )
173
+
174
+ # Ensure parent directory exists
175
+ clone_path.parent.mkdir(parents=True, exist_ok=True)
176
+
177
+ try:
178
+ # Build clone command
179
+ cmd = [
180
+ "git",
181
+ "clone",
182
+ "--depth",
183
+ str(depth),
184
+ "--single-branch",
185
+ "-b",
186
+ branch,
187
+ remote_url,
188
+ str(clone_path),
189
+ ]
190
+
191
+ # Sanitize URL in command before logging to avoid exposing credentials
192
+ safe_cmd = cmd.copy()
193
+ safe_cmd[safe_cmd.index(remote_url)] = _sanitize_url(remote_url)
194
+ logger.debug(f"Running: {' '.join(safe_cmd)}")
195
+
196
+ result = subprocess.run( # nosec B603 B607 - cmd built from hardcoded git arguments
197
+ cmd,
198
+ capture_output=True,
199
+ text=True,
200
+ timeout=300, # 5 minutes for clone
201
+ )
202
+
203
+ if result.returncode == 0:
204
+ return GitOperationResult(
205
+ success=True,
206
+ message=f"Successfully cloned to {clone_path}",
207
+ output=result.stdout,
208
+ )
209
+ else:
210
+ return GitOperationResult(
211
+ success=False,
212
+ message=f"Clone failed: {result.stderr}",
213
+ error=result.stderr,
214
+ )
215
+
216
+ except subprocess.TimeoutExpired:
217
+ # Clean up partial clone
218
+ if clone_path.exists():
219
+ shutil.rmtree(clone_path, ignore_errors=True)
220
+ return GitOperationResult(
221
+ success=False,
222
+ message="Git clone timed out",
223
+ )
224
+ except Exception as e:
225
+ # Clean up partial clone
226
+ if clone_path.exists():
227
+ shutil.rmtree(clone_path, ignore_errors=True)
228
+ return GitOperationResult(
229
+ success=False,
230
+ message=f"Error cloning repository: {e}",
231
+ error=str(e),
232
+ )
233
+
234
+ def sync_clone(
235
+ self,
236
+ clone_path: str | Path,
237
+ direction: Literal["pull", "push", "both"] = "pull",
238
+ remote: str = "origin",
239
+ ) -> GitOperationResult:
240
+ """
241
+ Sync a clone with its remote.
242
+
243
+ Args:
244
+ clone_path: Path to the clone directory
245
+ direction: Sync direction ("pull", "push", or "both")
246
+ remote: Remote name (default: origin)
247
+
248
+ Returns:
249
+ GitOperationResult with success status and message
250
+ """
251
+ clone_path = Path(clone_path)
252
+
253
+ if not clone_path.exists():
254
+ return GitOperationResult(
255
+ success=False,
256
+ message=f"Clone path does not exist: {clone_path}",
257
+ )
258
+
259
+ try:
260
+ if direction in ("pull", "both"):
261
+ # Pull changes
262
+ pull_result = self._run_git(
263
+ ["pull", remote],
264
+ cwd=clone_path,
265
+ timeout=120,
266
+ )
267
+ if pull_result.returncode != 0:
268
+ return GitOperationResult(
269
+ success=False,
270
+ message=f"Pull failed: {pull_result.stderr or pull_result.stdout}",
271
+ error=pull_result.stderr or pull_result.stdout,
272
+ )
273
+
274
+ if direction in ("push", "both"):
275
+ # Push changes
276
+ push_result = self._run_git(
277
+ ["push", remote],
278
+ cwd=clone_path,
279
+ timeout=120,
280
+ )
281
+ if push_result.returncode != 0:
282
+ return GitOperationResult(
283
+ success=False,
284
+ message=f"Push failed: {push_result.stderr}",
285
+ error=push_result.stderr,
286
+ )
287
+
288
+ return GitOperationResult(
289
+ success=True,
290
+ message=f"Successfully synced ({direction}) with {remote}",
291
+ )
292
+
293
+ except subprocess.TimeoutExpired:
294
+ return GitOperationResult(
295
+ success=False,
296
+ message="Git sync timed out",
297
+ )
298
+ except Exception as e:
299
+ return GitOperationResult(
300
+ success=False,
301
+ message=f"Error syncing clone: {e}",
302
+ error=str(e),
303
+ )
304
+
305
+ def delete_clone(
306
+ self,
307
+ clone_path: str | Path,
308
+ force: bool = False,
309
+ ) -> GitOperationResult:
310
+ """
311
+ Delete a clone directory.
312
+
313
+ Args:
314
+ clone_path: Path to the clone directory
315
+ force: Force deletion even if there are uncommitted changes
316
+
317
+ Returns:
318
+ GitOperationResult with success status and message
319
+ """
320
+ clone_path = Path(clone_path)
321
+
322
+ if not clone_path.exists():
323
+ return GitOperationResult(
324
+ success=True,
325
+ message=f"Clone already does not exist: {clone_path}",
326
+ )
327
+
328
+ try:
329
+ # Check for uncommitted changes unless force
330
+ if not force:
331
+ status = self.get_clone_status(clone_path)
332
+ if status and status.has_uncommitted_changes:
333
+ return GitOperationResult(
334
+ success=False,
335
+ message="Clone has uncommitted changes. Use force=True to delete anyway.",
336
+ )
337
+
338
+ # Remove the directory
339
+ shutil.rmtree(clone_path)
340
+
341
+ return GitOperationResult(
342
+ success=True,
343
+ message=f"Deleted clone at {clone_path}",
344
+ )
345
+
346
+ except Exception as e:
347
+ return GitOperationResult(
348
+ success=False,
349
+ message=f"Error deleting clone: {e}",
350
+ error=str(e),
351
+ )
352
+
353
+ def get_clone_status(
354
+ self,
355
+ clone_path: str | Path,
356
+ ) -> CloneStatus | None:
357
+ """
358
+ Get status of a clone.
359
+
360
+ Args:
361
+ clone_path: Path to the clone directory
362
+
363
+ Returns:
364
+ CloneStatus or None if path is not valid
365
+ """
366
+ clone_path = Path(clone_path)
367
+
368
+ if not clone_path.exists():
369
+ return None
370
+
371
+ try:
372
+ # Get current branch
373
+ branch_result = self._run_git(
374
+ ["branch", "--show-current"],
375
+ cwd=clone_path,
376
+ timeout=5,
377
+ )
378
+ branch = branch_result.stdout.strip() if branch_result.returncode == 0 else None
379
+
380
+ # Get current commit
381
+ commit_result = self._run_git(
382
+ ["rev-parse", "--short", "HEAD"],
383
+ cwd=clone_path,
384
+ timeout=5,
385
+ )
386
+ commit = commit_result.stdout.strip() if commit_result.returncode == 0 else None
387
+
388
+ # Get status (porcelain for parsing)
389
+ status_result = self._run_git(
390
+ ["status", "--porcelain"],
391
+ cwd=clone_path,
392
+ timeout=10,
393
+ )
394
+
395
+ has_staged = False
396
+ has_uncommitted = False
397
+ has_untracked = False
398
+
399
+ if status_result.returncode == 0:
400
+ for line in status_result.stdout.split("\n"):
401
+ if not line:
402
+ continue
403
+ index_status = line[0] if len(line) > 0 else " "
404
+ worktree_status = line[1] if len(line) > 1 else " "
405
+
406
+ if index_status != " " and index_status != "?":
407
+ has_staged = True
408
+ if worktree_status != " " and worktree_status != "?":
409
+ has_uncommitted = True
410
+ if index_status == "?" or worktree_status == "?":
411
+ has_untracked = True
412
+
413
+ return CloneStatus(
414
+ has_uncommitted_changes=has_uncommitted,
415
+ has_staged_changes=has_staged,
416
+ has_untracked_files=has_untracked,
417
+ branch=branch,
418
+ commit=commit,
419
+ )
420
+
421
+ except Exception as e:
422
+ logger.error(f"Error getting clone status: {e}")
423
+ return None
424
+
425
+ def merge_branch(
426
+ self,
427
+ source_branch: str,
428
+ target_branch: str = "main",
429
+ working_dir: str | Path | None = None,
430
+ ) -> GitOperationResult:
431
+ """
432
+ Merge source branch into target branch.
433
+
434
+ Performs:
435
+ 1. Fetch latest from remote
436
+ 2. Checkout target branch
437
+ 3. Attempt merge from source branch
438
+
439
+ Args:
440
+ source_branch: Branch to merge from
441
+ target_branch: Branch to merge into (default: main)
442
+ working_dir: Working directory (defaults to repo_path)
443
+
444
+ Returns:
445
+ GitOperationResult with success status and conflict info
446
+ """
447
+ cwd = Path(working_dir) if working_dir else self.repo_path
448
+
449
+ if not cwd.exists():
450
+ return GitOperationResult(
451
+ success=False,
452
+ message=f"Working directory does not exist: {cwd}",
453
+ error="directory_not_found",
454
+ )
455
+
456
+ try:
457
+ # Fetch latest
458
+ fetch_result = self._run_git(
459
+ ["fetch", "origin"],
460
+ cwd=cwd,
461
+ timeout=60,
462
+ )
463
+ if fetch_result.returncode != 0:
464
+ return GitOperationResult(
465
+ success=False,
466
+ message=f"Failed to fetch: {fetch_result.stderr}",
467
+ error=fetch_result.stderr,
468
+ )
469
+
470
+ # Checkout target branch
471
+ checkout_result = self._run_git(
472
+ ["checkout", target_branch],
473
+ cwd=cwd,
474
+ timeout=30,
475
+ )
476
+ if checkout_result.returncode != 0:
477
+ return GitOperationResult(
478
+ success=False,
479
+ message=f"Failed to checkout {target_branch}: {checkout_result.stderr}",
480
+ error=checkout_result.stderr,
481
+ )
482
+
483
+ # Pull latest on target
484
+ pull_result = self._run_git(
485
+ ["pull", "origin", target_branch],
486
+ cwd=cwd,
487
+ timeout=60,
488
+ )
489
+ if pull_result.returncode != 0:
490
+ return GitOperationResult(
491
+ success=False,
492
+ message=f"Failed to pull {target_branch}: {pull_result.stderr}",
493
+ error=pull_result.stderr,
494
+ )
495
+
496
+ # Attempt merge
497
+ merge_result = self._run_git(
498
+ ["merge", f"origin/{source_branch}", "--no-edit"],
499
+ cwd=cwd,
500
+ timeout=60,
501
+ )
502
+
503
+ if merge_result.returncode != 0:
504
+ # Check if it's a conflict
505
+ if "CONFLICT" in merge_result.stdout or "CONFLICT" in merge_result.stderr:
506
+ # Get list of conflicted files
507
+ status_result = self._run_git(
508
+ ["diff", "--name-only", "--diff-filter=U"],
509
+ cwd=cwd,
510
+ timeout=10,
511
+ )
512
+ conflicted_files = [f for f in status_result.stdout.strip().split("\n") if f]
513
+
514
+ # Abort the merge to leave repo in clean state
515
+ self._run_git(["merge", "--abort"], cwd=cwd, timeout=10)
516
+
517
+ return GitOperationResult(
518
+ success=False,
519
+ message=f"Merge conflict in {len(conflicted_files)} files",
520
+ error="merge_conflict",
521
+ output="\n".join(conflicted_files),
522
+ )
523
+
524
+ return GitOperationResult(
525
+ success=False,
526
+ message=f"Merge failed: {merge_result.stderr}",
527
+ error=merge_result.stderr,
528
+ )
529
+
530
+ return GitOperationResult(
531
+ success=True,
532
+ message=f"Successfully merged {source_branch} into {target_branch}",
533
+ output=merge_result.stdout,
534
+ )
535
+
536
+ except subprocess.TimeoutExpired:
537
+ return GitOperationResult(
538
+ success=False,
539
+ message="Merge operation timed out",
540
+ error="timeout",
541
+ )
542
+ except Exception as e:
543
+ return GitOperationResult(
544
+ success=False,
545
+ message=f"Merge error: {e}",
546
+ error=str(e),
547
+ )
@@ -0,0 +1,16 @@
1
+ """Gobby Conductor module.
2
+
3
+ The Conductor is the orchestration layer for managing complex multi-agent workflows.
4
+ This module provides:
5
+ - ConductorLoop: Main daemon loop orchestrating monitors and agents
6
+ - TokenTracker: LiteLLM-based pricing and token tracking
7
+ - AlertDispatcher: Priority-based alert dispatching with optional callme
8
+ - Budget management and cost monitoring
9
+ - Agent coordination and task distribution
10
+ """
11
+
12
+ from gobby.conductor.alerts import AlertDispatcher
13
+ from gobby.conductor.loop import ConductorLoop
14
+ from gobby.conductor.pricing import TokenTracker
15
+
16
+ __all__ = ["AlertDispatcher", "ConductorLoop", "TokenTracker"]
@@ -0,0 +1,135 @@
1
+ """Alert dispatcher for conductor notifications.
2
+
3
+ Provides alert dispatching with:
4
+ - Multiple priority levels (info, normal, urgent, critical)
5
+ - Logging for all alerts
6
+ - Optional callme integration for critical alerts
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from dataclasses import dataclass, field
13
+ from datetime import UTC, datetime
14
+ from typing import Any, Protocol
15
+
16
+
17
+ class CallmeClient(Protocol):
18
+ """Protocol for callme client interface."""
19
+
20
+ def initiate_call(self, message: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
21
+ """Initiate a phone call alert."""
22
+ ...
23
+
24
+
25
+ @dataclass
26
+ class AlertDispatcher:
27
+ """Dispatcher for system alerts.
28
+
29
+ Handles alerts at different priority levels:
30
+ - info: Informational messages, logged at INFO level
31
+ - normal: Normal alerts, logged at INFO level
32
+ - urgent: Urgent alerts, logged at WARNING level
33
+ - critical: Critical alerts, logged at ERROR level, triggers callme if configured
34
+ """
35
+
36
+ callme_client: CallmeClient | None = None
37
+ """Optional callme client for critical alerts."""
38
+
39
+ _history: list[dict[str, Any]] = field(default_factory=list)
40
+ """Internal history of dispatched alerts."""
41
+
42
+ _logger: logging.Logger = field(default_factory=lambda: logging.getLogger(__name__))
43
+ """Logger instance."""
44
+
45
+ def dispatch(
46
+ self,
47
+ priority: str,
48
+ message: str,
49
+ context: dict[str, Any] | None = None,
50
+ source: str | None = None,
51
+ ) -> dict[str, Any]:
52
+ """
53
+ Dispatch an alert.
54
+
55
+ Args:
56
+ priority: Alert priority (info, normal, urgent, critical)
57
+ message: Alert message
58
+ context: Optional context dict with additional data
59
+ source: Optional source identifier (e.g., "TaskMonitor")
60
+
61
+ Returns:
62
+ Dict with success status and alert details
63
+ """
64
+ now = datetime.now(UTC)
65
+
66
+ # Build alert record
67
+ alert_record = {
68
+ "priority": priority,
69
+ "message": message,
70
+ "context": context,
71
+ "source": source,
72
+ "timestamp": now.isoformat(),
73
+ }
74
+
75
+ # Log based on priority
76
+ log_message = f"[{priority.upper()}] {message}"
77
+ if source:
78
+ log_message = f"[{source}] {log_message}"
79
+ if context:
80
+ log_message += f" context={context}"
81
+
82
+ if priority == "critical":
83
+ self._logger.error(log_message)
84
+ elif priority == "urgent":
85
+ self._logger.warning(log_message)
86
+ else: # info, normal
87
+ self._logger.info(log_message)
88
+
89
+ # Store in history
90
+ self._history.append(alert_record)
91
+
92
+ # Build result
93
+ result: dict[str, Any] = {
94
+ "success": True,
95
+ "priority": priority,
96
+ "timestamp": now.isoformat(),
97
+ }
98
+
99
+ if context:
100
+ result["context"] = context
101
+ if source:
102
+ result["source"] = source
103
+
104
+ # Handle critical alerts with callme
105
+ if priority == "critical":
106
+ result["callme_triggered"] = False
107
+ if self.callme_client is not None:
108
+ try:
109
+ call_result = self.callme_client.initiate_call(
110
+ message=message,
111
+ context=context,
112
+ )
113
+ result["callme_triggered"] = True
114
+ result["callme_result"] = call_result
115
+ except Exception as e:
116
+ self._logger.error(f"Callme failed: {e}")
117
+ result["callme_error"] = str(e)
118
+
119
+ return result
120
+
121
+ def get_history(self, limit: int = 50) -> list[dict[str, Any]]:
122
+ """
123
+ Get alert history.
124
+
125
+ Args:
126
+ limit: Maximum number of alerts to return
127
+
128
+ Returns:
129
+ List of alert records (oldest first)
130
+ """
131
+ return self._history[:limit]
132
+
133
+ def clear_history(self) -> None:
134
+ """Clear alert history."""
135
+ self._history.clear()