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/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()
|