onecoder 0.0.2__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 (73) hide show
  1. onecoder/agent.py +95 -0
  2. onecoder/agentic_tool_search/__init__.py +0 -0
  3. onecoder/agentic_tool_search/dynamic_tool_search.py +64 -0
  4. onecoder/agentic_tool_search/registry.py +33 -0
  5. onecoder/agents/__init__.py +7 -0
  6. onecoder/agents/documentation_agent.py +12 -0
  7. onecoder/agents/file_reader_agent.py +19 -0
  8. onecoder/agents/file_writer_agent.py +19 -0
  9. onecoder/agents/orchestrator_agent.py +51 -0
  10. onecoder/agents/refactoring_agent.py +12 -0
  11. onecoder/agents/research_agent.py +31 -0
  12. onecoder/agents/task_suggestion_agent.py +88 -0
  13. onecoder/alignment.py +236 -0
  14. onecoder/api.py +162 -0
  15. onecoder/api_client.py +112 -0
  16. onecoder/backends/base.py +22 -0
  17. onecoder/backends/local_tui.py +65 -0
  18. onecoder/blackboard.py +102 -0
  19. onecoder/cli.py +108 -0
  20. onecoder/commands/__init__.py +1 -0
  21. onecoder/commands/auth.py +78 -0
  22. onecoder/commands/ci.py +29 -0
  23. onecoder/commands/delegate.py +557 -0
  24. onecoder/commands/doctor.py +40 -0
  25. onecoder/commands/issue.py +136 -0
  26. onecoder/commands/logs.py +45 -0
  27. onecoder/commands/project.py +270 -0
  28. onecoder/commands/server.py +170 -0
  29. onecoder/config_manager.py +87 -0
  30. onecoder/constants.py +9 -0
  31. onecoder/diagnostics/__init__.py +2 -0
  32. onecoder/diagnostics/env_scan.py +207 -0
  33. onecoder/discovery.py +101 -0
  34. onecoder/distillation.py +236 -0
  35. onecoder/evaluation/__init__.py +1 -0
  36. onecoder/evaluation/ttu.py +176 -0
  37. onecoder/governance/__init__.py +0 -0
  38. onecoder/governance/probllm.py +91 -0
  39. onecoder/hooks.py +74 -0
  40. onecoder/ipc_auth.py +200 -0
  41. onecoder/issues.py +188 -0
  42. onecoder/jules_client.py +343 -0
  43. onecoder/knowledge.py +106 -0
  44. onecoder/llm.py +61 -0
  45. onecoder/logger.py +42 -0
  46. onecoder/metrics.py +129 -0
  47. onecoder/models/delegation.py +46 -0
  48. onecoder/onboarding.py +264 -0
  49. onecoder/review.py +233 -0
  50. onecoder/services/delegation_service.py +209 -0
  51. onecoder/services/validation_service.py +104 -0
  52. onecoder/sessions.py +186 -0
  53. onecoder/sprint_collector.py +165 -0
  54. onecoder/sync.py +167 -0
  55. onecoder/tmux.py +86 -0
  56. onecoder/tools/__init__.py +10 -0
  57. onecoder/tools/executor.py +53 -0
  58. onecoder/tools/external_tools.py +106 -0
  59. onecoder/tools/file_tools.py +77 -0
  60. onecoder/tools/interface.py +25 -0
  61. onecoder/tools/jules_tools.py +122 -0
  62. onecoder/tools/kit_tools.py +122 -0
  63. onecoder/tools/registry.py +32 -0
  64. onecoder/tui/__init__.py +5 -0
  65. onecoder/tui/app.py +263 -0
  66. onecoder/tui/commands.py +150 -0
  67. onecoder/tui/widgets.py +92 -0
  68. onecoder/worktree.py +186 -0
  69. onecoder-0.0.2.dist-info/METADATA +17 -0
  70. onecoder-0.0.2.dist-info/RECORD +73 -0
  71. onecoder-0.0.2.dist-info/WHEEL +5 -0
  72. onecoder-0.0.2.dist-info/entry_points.txt +2 -0
  73. onecoder-0.0.2.dist-info/top_level.txt +1 -0
onecoder/tui/app.py ADDED
@@ -0,0 +1,263 @@
1
+ """OneCoder Textual TUI Application."""
2
+
3
+ import asyncio
4
+ import json
5
+ import httpx
6
+ import os
7
+ from typing import Optional, AsyncGenerator
8
+
9
+ from textual.app import App, ComposeResult
10
+ from textual.widgets import Header, Footer, Input, RichLog
11
+ from textual.containers import Vertical, Container
12
+ from textual import events
13
+ from textual.binding import Binding
14
+
15
+ from rich.panel import Panel
16
+ from rich.markdown import Markdown
17
+
18
+ from ..ipc_auth import get_token_from_ipc
19
+ from .widgets import ChatMessage, ToolCallStatus, ErrorMessage, WelcomeMessage
20
+ from .commands import CommandRegistry
21
+
22
+
23
+ class OneCoderApp(App):
24
+ """Modern Textual TUI for OneCoder agent system."""
25
+
26
+ CSS_PATH = "styles.tcss"
27
+
28
+ BINDINGS = [
29
+ Binding("ctrl+c", "quit", "Quit", show=True),
30
+ Binding("ctrl+l", "clear_log", "Clear Log", show=True),
31
+ Binding("ctrl+s", "toggle_theme", "Toggle Theme", show=True),
32
+ Binding("ctrl+d", "toggle_dark", "Dark Mode"),
33
+ ]
34
+
35
+ def __init__(self, api_url: Optional[str] = None):
36
+ super().__init__()
37
+ if api_url:
38
+ self.api_url = api_url
39
+ else:
40
+ self.api_url = os.getenv("ONECODER_API_URL", "http://127.0.0.1:8000")
41
+ self.session_id = "tui-session"
42
+ self.user_id = "local-user"
43
+ self.token = None
44
+ self.is_processing = False
45
+ self.command_registry = CommandRegistry(self)
46
+
47
+ def compose(self) -> ComposeResult:
48
+ yield Header(show_clock=True)
49
+ yield Vertical(
50
+ RichLog(id="chat-log", markup=True, wrap=True, highlight=True),
51
+ Input(
52
+ placeholder="Type your message... (or 'exit' to quit)", id="user-input"
53
+ ),
54
+ id="main-container",
55
+ )
56
+ yield Footer()
57
+
58
+ async def on_mount(self) -> None:
59
+ self.chat_log = self.query_one("#chat-log", RichLog)
60
+ self.input_widget = self.query_one("#user-input", Input)
61
+
62
+ await self._initialize_session()
63
+
64
+ if self.token:
65
+ self.chat_log.write(WelcomeMessage())
66
+ self.chat_log.write("\n")
67
+ self.input_widget.focus()
68
+
69
+ async def _initialize_session(self) -> bool:
70
+ """Initialize TUI and fetch authentication token."""
71
+ self.chat_log.write(
72
+ Panel(
73
+ "[bold blue]OneCoder TUI[/bold blue]",
74
+ title="Initializing secure session...",
75
+ border_style="dim",
76
+ )
77
+ )
78
+
79
+ self.token = await get_token_from_ipc()
80
+
81
+ if not self.token:
82
+ self.chat_log.write(
83
+ ErrorMessage(
84
+ "Could not fetch auth token from IPC.\n"
85
+ "Make sure OneCoder server is running: onecoder serve"
86
+ )
87
+ )
88
+ return False
89
+
90
+ self.chat_log.write(
91
+ Panel(
92
+ "[bold green]✓[/bold green] Session initialized successfully!",
93
+ title="Success",
94
+ border_style="bold green",
95
+ )
96
+ )
97
+
98
+ return True
99
+
100
+ async def on_input_submitted(self, event: Input.Submitted) -> None:
101
+ """Handle user input submission."""
102
+ if self.is_processing:
103
+ return
104
+
105
+ message = event.value.strip()
106
+
107
+ if not message:
108
+ return
109
+
110
+ # Handle slash commands
111
+ if message.startswith("/"):
112
+ self.input_widget.clear()
113
+ await self.command_registry.handle(message)
114
+ if message.lower() in ["/quit", "/exit"]:
115
+ self.exit()
116
+ return
117
+
118
+ if message.lower() in ["exit", "quit"]:
119
+ self.exit()
120
+ return
121
+
122
+ self.input_widget.disabled = True
123
+ self.is_processing = True
124
+
125
+ await self._process_message(message)
126
+
127
+ self.input_widget.disabled = False
128
+ self.is_processing = False
129
+ self.input_widget.focus()
130
+ self.input_widget.clear()
131
+
132
+ async def _process_message(self, message: str):
133
+ """Process user message and stream agent response."""
134
+ await self._write_user_message(message)
135
+
136
+ current_response = ""
137
+ message_content = []
138
+
139
+ async with httpx.AsyncClient(timeout=None) as client:
140
+ params = {
141
+ "user_id": self.user_id,
142
+ "session_id": self.session_id,
143
+ "message": message,
144
+ "token": self.token,
145
+ }
146
+
147
+ try:
148
+ async with client.stream(
149
+ "GET", f"{self.api_url}/stream", params=params
150
+ ) as response:
151
+ if response.status_code != 200:
152
+ await self._write_error(f"Error: {response.status_code}")
153
+ return
154
+
155
+ async for line in response.aiter_lines():
156
+ if line.startswith("data: "):
157
+ try:
158
+ data = json.loads(line[6:])
159
+
160
+ if "text" in data:
161
+ current_response += data["text"]
162
+ elif data.get("type") == "Error":
163
+ await self._write_error(
164
+ data.get("message", "Unknown error")
165
+ )
166
+ elif "tool_call" in data:
167
+ tool_name = data["tool_call"].get("name", "unknown")
168
+ await self._show_tool_call(
169
+ tool_name, {}, status="running"
170
+ )
171
+ elif "tool_result" in data:
172
+ tool_name = data.get("tool_name", "unknown")
173
+ await self._show_tool_call(
174
+ tool_name, {}, status="success"
175
+ )
176
+
177
+ except json.JSONDecodeError:
178
+ pass
179
+
180
+ if current_response:
181
+ await self._write_agent_message(current_response)
182
+
183
+ except httpx.ConnectError:
184
+ await self._write_error(
185
+ "Could not connect to OneCoder API.\n"
186
+ "Is the server running?\n"
187
+ "Run 'onecoder serve' in another terminal."
188
+ )
189
+ except Exception as e:
190
+ await self._write_error(f"Stream Error: {e}")
191
+
192
+ async def _write_user_message(self, message: str):
193
+ """Write user message to chat log."""
194
+ panel = Panel(message, title="You", border_style="bold green", padding=(0, 1))
195
+ self.chat_log.write(panel)
196
+
197
+ async def _write_agent_message(self, text: str):
198
+ """Write agent message to chat log with markdown support."""
199
+ panel = Panel(
200
+ Markdown(text), title="OneCoder", border_style="bold blue", padding=(0, 1)
201
+ )
202
+ self.chat_log.write(panel)
203
+
204
+ async def _show_tool_call(
205
+ self, tool_name: str, tool_args: dict, status: str = "running"
206
+ ):
207
+ """Show tool call status in chat log."""
208
+ if status == "running":
209
+ content = f"Running [bold]{tool_name}[/bold]..."
210
+ border_style = "bold yellow"
211
+ elif status == "success":
212
+ content = f"[green]✓[/green] [bold]{tool_name}[/bold] finished."
213
+ border_style = "bold green"
214
+ else:
215
+ content = f"[red]✗[/red] [bold]{tool_name}[/bold] failed."
216
+ border_style = "bold red"
217
+
218
+ panel = Panel(
219
+ content, title="Tool Call", border_style=border_style, padding=(0, 1)
220
+ )
221
+ self.chat_log.write(panel)
222
+
223
+ async def _write_error(self, message: str):
224
+ """Write error message to chat log."""
225
+ panel = Panel(
226
+ f"Error: {message}",
227
+ title="Error",
228
+ border_style="bold red",
229
+ style="red",
230
+ padding=(0, 1),
231
+ )
232
+ self.chat_log.write(panel)
233
+
234
+ async def action_clear_log(self) -> None:
235
+ """Clear chat log."""
236
+ self.chat_log.clear()
237
+
238
+ async def action_toggle_theme(self) -> None:
239
+ """Toggle between dark and light theme."""
240
+ if "light" in self.theme:
241
+ self.theme = "textual-dark"
242
+ else:
243
+ self.theme = "textual-light"
244
+
245
+ self.chat_log.write(
246
+ Panel(
247
+ f"Theme switched to: {self.theme}",
248
+ title="Theme",
249
+ border_style="dim",
250
+ padding=(0, 1),
251
+ )
252
+ )
253
+
254
+
255
+
256
+ def main():
257
+ """Entry point for running OneCoder TUI."""
258
+ app = OneCoderApp()
259
+ app.run()
260
+
261
+
262
+ if __name__ == "__main__":
263
+ main()
@@ -0,0 +1,150 @@
1
+ from typing import Dict, Any, Callable, Awaitable
2
+ from rich.panel import Panel
3
+ from rich.markdown import Markdown
4
+ from ..evaluation import TTUEvaluator
5
+
6
+ class CommandRegistry:
7
+ def __init__(self, app):
8
+ self.app = app
9
+ self.commands: Dict[str, Callable] = {}
10
+ self._register_default_commands()
11
+
12
+ def _register_default_commands(self):
13
+ self.register("help", self.cmd_help, "Show available commands")
14
+ self.register("clear", self.cmd_clear, "Clear the chat log")
15
+ self.register("status", self.cmd_status, "Show platform-wide sprint status")
16
+ self.register("ttu", self.cmd_ttu, "Evaluate Task Technical Understanding (TTU) for current context")
17
+ self.register("delegate", self.cmd_delegate, "Delegate a task to a local isolated agent. Usage: /delegate <task description>")
18
+
19
+ def register(self, name: str, handler: Callable, description: str):
20
+ self.commands[name] = {"handler": handler, "description": description}
21
+
22
+ async def handle(self, command_str: str) -> bool:
23
+ """
24
+ Handles a command string (e.g., "/help").
25
+ Returns True if handled, False if command not found.
26
+ """
27
+ parts = command_str.lstrip("/").split()
28
+ if not parts:
29
+ return False
30
+
31
+ cmd_name = parts[0].lower()
32
+ args = parts[1:]
33
+
34
+ if cmd_name in self.commands:
35
+ try:
36
+ await self.commands[cmd_name]["handler"](args)
37
+ except Exception as e:
38
+ await self.app._write_error(f"Command error: {str(e)}")
39
+ return True
40
+ else:
41
+ await self.app._write_error(f"Unknown command: /{cmd_name}")
42
+ return True
43
+
44
+ async def cmd_help(self, args):
45
+ lines = ["[bold]Available Commands:[/bold]"]
46
+ for name, data in self.commands.items():
47
+ lines.append(f"• [cyan]/{name}[/cyan]: {data['description']}")
48
+
49
+ panel = Panel("\n".join(lines), title="Help", border_style="blue")
50
+ self.app.chat_log.write(panel)
51
+
52
+ async def cmd_clear(self, args):
53
+ self.app.chat_log.clear()
54
+
55
+ async def cmd_status(self, args):
56
+ # We can implement a simple status check here, possibly calling the same logic as CLI status
57
+ # For now, let's mock or use basic discovery
58
+ from pathlib import Path
59
+ import json
60
+
61
+ root_path = Path(".").resolve() # Or configure root
62
+ sprint_dirs = []
63
+ for p in root_path.rglob(".sprint"):
64
+ if "node_modules" in p.parts or ".git" in p.parts:
65
+ continue
66
+ sprint_dirs.append(p)
67
+
68
+ if not sprint_dirs:
69
+ self.app.chat_log.write(Panel("No active sprints found.", title="Status", border_style="yellow"))
70
+ return
71
+
72
+ status_lines = []
73
+ for sd in sprint_dirs:
74
+ component = sd.parent.name
75
+ subdirs = sorted([d for d in sd.iterdir() if d.is_dir()])
76
+ if subdirs:
77
+ active = subdirs[-1]
78
+ sprint_json = active / "sprint.json"
79
+ state_str = "Unknown"
80
+ if sprint_json.exists():
81
+ try:
82
+ with open(sprint_json) as f:
83
+ data = json.load(f)
84
+ state_str = data.get("status", {}).get("state", "unknown")
85
+ except:
86
+ pass
87
+ status_lines.append(f"• [bold]{component}[/bold] -> {active.name}: [green]{state_str}[/green]")
88
+
89
+ self.app.chat_log.write(Panel("\n".join(status_lines), title="Platform Status", border_style="blue"))
90
+
91
+ async def cmd_ttu(self, args):
92
+ evaluator = TTUEvaluator()
93
+ result = evaluator.evaluate()
94
+
95
+ color = "green" if result.passed else "red"
96
+ icon = "✓" if result.passed else "✗"
97
+
98
+ lines = [
99
+ f"**Score:** {result.score:.2f} / 1.0",
100
+ "",
101
+ "**Context Found:**",
102
+ *[f"- {c}" for c in result.context_found],
103
+ ]
104
+
105
+ if result.failure_modes:
106
+ lines.append("")
107
+ lines.append("**Potential Failure Modes:**")
108
+ lines.extend([f"- [red]{m}[/red]" for m in result.failure_modes])
109
+
110
+ if result.missing_context:
111
+ lines.append("")
112
+ lines.append("**Missing Context:**")
113
+ lines.extend([f"- {c}" for c in result.missing_context])
114
+
115
+ if result.recommendations:
116
+ lines.append("")
117
+ lines.append("**Recommendations:**")
118
+ lines.extend([f"- {r}" for r in result.recommendations])
119
+
120
+ panel = Panel(
121
+ Markdown("\n".join(lines)),
122
+ title=f"{icon} TTU Evaluation",
123
+ border_style=f"bold {color}"
124
+ )
125
+ self.app.chat_log.write(panel)
126
+ async def cmd_delegate(self, args):
127
+ if not args:
128
+ await self.app._write_error("Usage: /delegate <task description>")
129
+ return
130
+
131
+ task_desc = " ".join(args)
132
+ self.app.chat_log.write(Panel(f"Delegating task: [bold]{task_desc}[/bold]", title="Delegation", border_style="yellow"))
133
+
134
+ try:
135
+ from ..services.delegation_service import DelegationService
136
+ service = DelegationService()
137
+ session = service.create_session(task_id=f"delegate-{task_desc[:10]}", command=task_desc)
138
+
139
+ # Start the session (async)
140
+ connection_info = await service.start_session(session)
141
+
142
+ self.app.chat_log.write(Panel(
143
+ f"Delegated successfully!\n\n"
144
+ f"**Backend:** local-tui\n"
145
+ f"**Worktree:** {session.worktree_path}\n"
146
+ f"**To Attach:** [cyan]{connection_info}[/cyan]",
147
+ title="Success", border_style="bold green"
148
+ ))
149
+ except Exception as e:
150
+ await self.app._write_error(f"Delegation failed: {str(e)}")
@@ -0,0 +1,92 @@
1
+ """Custom widgets for OneCoder TUI."""
2
+
3
+ from textual.widgets import Static, LoadingIndicator
4
+ from rich.panel import Panel
5
+ from rich.markdown import Markdown
6
+ from typing import Optional
7
+
8
+
9
+ class ChatMessage(Static):
10
+ """Widget for displaying chat messages with markdown support."""
11
+
12
+ def __init__(self, message: str, role: str = "user", **kwargs):
13
+ super().__init__(**kwargs)
14
+ self.message = message
15
+ self.role = role
16
+
17
+ def render(self):
18
+ if self.role == "user":
19
+ panel = Panel(
20
+ self.message, title="You", border_style="bold blue", padding=(0, 1)
21
+ )
22
+ return panel
23
+ else:
24
+ panel = Panel(
25
+ Markdown(self.message),
26
+ title="OneCoder",
27
+ border_style="bold green",
28
+ padding=(0, 1),
29
+ )
30
+ return panel
31
+
32
+
33
+ class ToolCallStatus(Static):
34
+ """Widget for displaying tool call status."""
35
+
36
+ def __init__(self, tool_name: str, status: str = "running", **kwargs):
37
+ super().__init__(**kwargs)
38
+ self.tool_name = tool_name
39
+ self.status = status
40
+
41
+ def render(self):
42
+ if self.status == "running":
43
+ content = f"Running {self.tool_name}..."
44
+ border_style = "bold yellow"
45
+ elif self.status == "success":
46
+ content = f"✓ {self.tool_name} finished"
47
+ border_style = "bold green"
48
+ else:
49
+ content = f"✗ {self.tool_name} failed"
50
+ border_style = "bold red"
51
+
52
+ panel = Panel(
53
+ content, title="Tool Call", border_style=border_style, padding=(0, 1)
54
+ )
55
+ return panel
56
+
57
+
58
+ class ErrorMessage(Static):
59
+ """Widget for displaying error messages."""
60
+
61
+ def __init__(self, message: str, **kwargs):
62
+ super().__init__(**kwargs)
63
+ self.message = message
64
+
65
+ def render(self):
66
+ panel = Panel(
67
+ self.message,
68
+ title="Error",
69
+ border_style="bold red",
70
+ style="red",
71
+ padding=(0, 1),
72
+ )
73
+ return panel
74
+
75
+
76
+ class WelcomeMessage(Static):
77
+ """Widget for displaying welcome message on startup."""
78
+
79
+ def __init__(self, **kwargs):
80
+ super().__init__(**kwargs)
81
+
82
+ def render(self):
83
+ from rich.text import Text
84
+ from rich.console import Group
85
+
86
+ title = Text("OneCoder TUI", style="bold blue")
87
+ subtitle = Text(
88
+ "Modern terminal interface for AI-powered coding assistance", style="dim"
89
+ )
90
+
91
+ panel = Panel(Group(title, "", subtitle), border_style="blue", padding=(1, 2))
92
+ return panel
onecoder/worktree.py ADDED
@@ -0,0 +1,186 @@
1
+ import os
2
+ import subprocess
3
+ import shutil
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import List, Optional
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ class WorktreeManager:
11
+ """
12
+ Manages Git worktrees for isolated task execution.
13
+ """
14
+
15
+ def __init__(self, project_root: Optional[str] = None):
16
+ if project_root:
17
+ self.project_root = Path(project_root).absolute()
18
+ else:
19
+ self.project_root = self._find_repo_root(Path.cwd())
20
+
21
+ self.worktrees_dir = self.project_root / ".adk" / "worktrees"
22
+ self.worktrees_dir.mkdir(parents=True, exist_ok=True)
23
+
24
+ def get_current_branch(self) -> str:
25
+ """Returns the name of the current active branch."""
26
+ return self._run_git(["rev-parse", "--abbrev-ref", "HEAD"])
27
+
28
+ def _find_repo_root(self, start_path: Path) -> Path:
29
+ """Traverses upwards to find the repository root."""
30
+ curr = start_path
31
+ while curr != curr.parent:
32
+ if (curr / ".git").exists() or (curr / ".sprint").exists():
33
+ return curr
34
+ curr = curr.parent
35
+ return start_path
36
+
37
+ def _run_git(self, args: List[str], cwd: Optional[Path] = None) -> str:
38
+ """Helper to run git commands."""
39
+ command = ["git"] + args
40
+ result = subprocess.run(
41
+ command,
42
+ cwd=str(cwd or self.project_root),
43
+ capture_output=True,
44
+ text=True,
45
+ check=False
46
+ )
47
+ if result.returncode != 0:
48
+ raise RuntimeError(f"Git command failed: {' '.join(command)}\nError: {result.stderr}")
49
+ return result.stdout.strip()
50
+
51
+ def create_worktree(self, task_id: str, base_ref: Optional[str] = None) -> Path:
52
+ """
53
+ Creates a new git worktree for a task.
54
+ Defaults to branching off the current HEAD if base_ref is not provided.
55
+ """
56
+ if base_ref is None:
57
+ base_ref = self.get_current_branch()
58
+ logger.info(f"No base_ref provided, using current branch: {base_ref}")
59
+
60
+ task_dir = self.worktrees_dir / task_id
61
+ if task_dir.exists():
62
+ logger.warning(f"Worktree directory already exists: {task_dir}")
63
+ # Try to remove it first if it's stale
64
+ self.remove_worktree(task_id)
65
+
66
+ branch_name = f"task/{task_id}"
67
+
68
+ # Check if branch exists, if so, use it, otherwise create it
69
+ try:
70
+ # Check if branch already exists
71
+ self._run_git(["rev-parse", "--verify", branch_name])
72
+ logger.info(f"Using existing branch: {branch_name}")
73
+ self._run_git(["worktree", "add", str(task_dir), branch_name])
74
+ except RuntimeError:
75
+ logger.info(f"Creating new worktree and branch: {branch_name} from {base_ref}")
76
+ self._run_git(["worktree", "add", "-b", branch_name, str(task_dir), base_ref])
77
+
78
+ return task_dir
79
+
80
+ def remove_worktree(self, task_id: str, delete_branch: bool = False) -> None:
81
+ """
82
+ Removes a git worktree.
83
+ """
84
+ task_dir = self.worktrees_dir / task_id
85
+ if not task_dir.exists():
86
+ logger.warning(f"Worktree directory does not exist: {task_dir}")
87
+ # Still try to prune just in case
88
+ self._run_git(["worktree", "prune"])
89
+ return
90
+
91
+ logger.info(f"Removing worktree: {task_dir}")
92
+ try:
93
+ self._run_git(["worktree", "remove", "--force", str(task_dir)])
94
+ except RuntimeError as e:
95
+ logger.error(f"Failed to remove worktree via git: {e}")
96
+ # Fallback to manual removal if git remove fails
97
+ if task_dir.exists():
98
+ shutil.rmtree(task_dir, ignore_errors=True)
99
+ self._run_git(["worktree", "prune"])
100
+
101
+ if delete_branch:
102
+ branch_name = f"task/{task_id}"
103
+ try:
104
+ self._run_git(["branch", "-D", branch_name])
105
+ except RuntimeError:
106
+ pass
107
+
108
+ def merge_task_branch(self, task_id: str, target_branch: str) -> bool:
109
+ """
110
+ Merges a task branch into a target branch.
111
+ Uses --no-ff to preserve atomic work history.
112
+ """
113
+ branch_name = f"task/{task_id}"
114
+ logger.info(f"Merging {branch_name} into {target_branch}")
115
+
116
+ try:
117
+ # 1. Ensure we are on the target branch
118
+ current = self.get_current_branch()
119
+ if current != target_branch:
120
+ self._run_git(["checkout", target_branch])
121
+
122
+ # 2. Perform merge
123
+ self._run_git(["merge", "--no-ff", "-m", f"chore: merge delegated task {task_id}", branch_name])
124
+ return True
125
+ except RuntimeError as e:
126
+ logger.error(f"Merge failed: {e}")
127
+ # If we were on target branch, try to abort merge if it's stuck
128
+ try:
129
+ self._run_git(["merge", "--abort"])
130
+ except:
131
+ pass
132
+ return False
133
+
134
+ def rebase_onto(self, task_id: str, upstream: str) -> bool:
135
+ """Rebases the task branch onto an upstream branch to ensure compatibility."""
136
+ branch_name = f"task/{task_id}"
137
+ logger.info(f"Rebasing {branch_name} onto {upstream}")
138
+ original_branch = self.get_current_branch()
139
+ try:
140
+ # Note: git rebase <upstream> <branch> switches HEAD to <branch>
141
+ self._run_git(["rebase", upstream, branch_name])
142
+ return True
143
+ except RuntimeError as e:
144
+ logger.error(f"Rebase failed: {e}")
145
+ try:
146
+ self._run_git(["rebase", "--abort"])
147
+ except:
148
+ pass
149
+ return False
150
+ finally:
151
+ # Always ensure we return to the original branch
152
+ if self.get_current_branch() != original_branch:
153
+ self._run_git(["checkout", original_branch])
154
+
155
+ def list_worktrees(self) -> List[dict]:
156
+ """
157
+ Lists all active worktrees.
158
+ """
159
+ output = self._run_git(["worktree", "list", "--porcelain"])
160
+ worktrees = []
161
+ current_wt = {}
162
+
163
+ for line in output.split("\n"):
164
+ if not line:
165
+ if current_wt:
166
+ worktrees.append(current_wt)
167
+ current_wt = {}
168
+ continue
169
+
170
+ parts = line.split(" ", 1)
171
+ if len(parts) == 2:
172
+ current_wt[parts[0]] = parts[1]
173
+
174
+ if current_wt:
175
+ worktrees.append(current_wt)
176
+
177
+ return worktrees
178
+
179
+ if __name__ == "__main__":
180
+ # Quick test
181
+ logging.basicConfig(level=logging.INFO)
182
+ mgr = WorktreeManager()
183
+ path = mgr.create_worktree("test-task")
184
+ print(f"Worktree created at: {path}")
185
+ mgr.remove_worktree("test-task", delete_branch=True)
186
+ print("Worktree removed.")