gobby 0.2.7__py3-none-any.whl → 0.2.8__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 (80) hide show
  1. gobby/adapters/claude_code.py +96 -35
  2. gobby/adapters/gemini.py +140 -38
  3. gobby/agents/isolation.py +130 -0
  4. gobby/agents/registry.py +11 -0
  5. gobby/agents/session.py +1 -0
  6. gobby/agents/spawn_executor.py +43 -13
  7. gobby/agents/spawners/macos.py +26 -1
  8. gobby/cli/__init__.py +0 -2
  9. gobby/cli/memory.py +185 -0
  10. gobby/clones/git.py +177 -0
  11. gobby/config/skills.py +31 -0
  12. gobby/hooks/event_handlers.py +109 -10
  13. gobby/hooks/hook_manager.py +19 -1
  14. gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
  15. gobby/mcp_proxy/instructions.py +2 -2
  16. gobby/mcp_proxy/registries.py +21 -4
  17. gobby/mcp_proxy/tools/agent_messaging.py +93 -44
  18. gobby/mcp_proxy/tools/agents.py +45 -9
  19. gobby/mcp_proxy/tools/artifacts.py +43 -9
  20. gobby/mcp_proxy/tools/sessions/_commits.py +31 -24
  21. gobby/mcp_proxy/tools/sessions/_crud.py +5 -5
  22. gobby/mcp_proxy/tools/sessions/_handoff.py +45 -41
  23. gobby/mcp_proxy/tools/sessions/_messages.py +35 -7
  24. gobby/mcp_proxy/tools/spawn_agent.py +44 -6
  25. gobby/mcp_proxy/tools/tasks/_context.py +18 -0
  26. gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
  27. gobby/mcp_proxy/tools/tasks/_lifecycle.py +29 -14
  28. gobby/mcp_proxy/tools/tasks/_session.py +22 -7
  29. gobby/mcp_proxy/tools/workflows.py +84 -34
  30. gobby/mcp_proxy/tools/worktrees.py +32 -7
  31. gobby/memory/extractor.py +15 -1
  32. gobby/runner.py +13 -0
  33. gobby/servers/routes/mcp/hooks.py +50 -3
  34. gobby/servers/websocket.py +57 -1
  35. gobby/sessions/analyzer.py +2 -2
  36. gobby/sessions/manager.py +9 -0
  37. gobby/sessions/transcripts/gemini.py +100 -34
  38. gobby/storage/database.py +9 -2
  39. gobby/storage/memories.py +32 -21
  40. gobby/storage/migrations.py +23 -4
  41. gobby/storage/sessions.py +4 -2
  42. gobby/storage/skills.py +43 -3
  43. gobby/workflows/detection_helpers.py +38 -24
  44. gobby/workflows/enforcement/blocking.py +13 -1
  45. gobby/workflows/engine.py +93 -0
  46. gobby/workflows/evaluator.py +110 -0
  47. gobby/workflows/hooks.py +41 -0
  48. gobby/workflows/memory_actions.py +11 -0
  49. gobby/workflows/safe_evaluator.py +8 -0
  50. gobby/workflows/summary_actions.py +123 -50
  51. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/METADATA +1 -1
  52. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/RECORD +56 -80
  53. gobby/cli/tui.py +0 -34
  54. gobby/tui/__init__.py +0 -5
  55. gobby/tui/api_client.py +0 -278
  56. gobby/tui/app.py +0 -329
  57. gobby/tui/screens/__init__.py +0 -25
  58. gobby/tui/screens/agents.py +0 -333
  59. gobby/tui/screens/chat.py +0 -450
  60. gobby/tui/screens/dashboard.py +0 -377
  61. gobby/tui/screens/memory.py +0 -305
  62. gobby/tui/screens/metrics.py +0 -231
  63. gobby/tui/screens/orchestrator.py +0 -903
  64. gobby/tui/screens/sessions.py +0 -412
  65. gobby/tui/screens/tasks.py +0 -440
  66. gobby/tui/screens/workflows.py +0 -289
  67. gobby/tui/screens/worktrees.py +0 -174
  68. gobby/tui/widgets/__init__.py +0 -21
  69. gobby/tui/widgets/chat.py +0 -210
  70. gobby/tui/widgets/conductor.py +0 -104
  71. gobby/tui/widgets/menu.py +0 -132
  72. gobby/tui/widgets/message_panel.py +0 -160
  73. gobby/tui/widgets/review_gate.py +0 -224
  74. gobby/tui/widgets/task_tree.py +0 -99
  75. gobby/tui/widgets/token_budget.py +0 -166
  76. gobby/tui/ws_client.py +0 -258
  77. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/WHEEL +0 -0
  78. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
  79. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
  80. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/top_level.txt +0 -0
@@ -1,440 +0,0 @@
1
- """Tasks screen with tree view and detail panel."""
2
-
3
- from __future__ import annotations
4
-
5
- from dataclasses import dataclass
6
- from typing import Any
7
-
8
- from textual.app import ComposeResult
9
- from textual.containers import Container, Horizontal, Vertical
10
- from textual.message import Message
11
- from textual.reactive import reactive
12
- from textual.widget import Widget
13
- from textual.widgets import (
14
- Button,
15
- LoadingIndicator,
16
- Select,
17
- Static,
18
- Tree,
19
- )
20
- from textual.widgets.tree import TreeNode
21
-
22
- from gobby.tui.api_client import GobbyAPIClient
23
- from gobby.tui.ws_client import GobbyWebSocketClient
24
-
25
-
26
- class TaskTreePanel(Widget):
27
- """Panel displaying task tree with filtering."""
28
-
29
- DEFAULT_CSS = """
30
- TaskTreePanel {
31
- width: 1fr;
32
- height: 1fr;
33
- border-right: solid #45475a;
34
- }
35
-
36
- TaskTreePanel .panel-header {
37
- height: auto;
38
- padding: 1;
39
- background: #313244;
40
- }
41
-
42
- TaskTreePanel .filter-row {
43
- layout: horizontal;
44
- height: 3;
45
- }
46
-
47
- TaskTreePanel .filter-select {
48
- width: 1fr;
49
- margin-right: 1;
50
- }
51
-
52
- TaskTreePanel #task-tree {
53
- height: 1fr;
54
- }
55
- """
56
-
57
- def compose(self) -> ComposeResult:
58
- with Vertical(classes="panel-header"):
59
- yield Static("📋 Tasks", classes="panel-title")
60
- with Horizontal(classes="filter-row"):
61
- yield Select(
62
- [
63
- ("All Status", "all"),
64
- ("Open", "open"),
65
- ("In Progress", "in_progress"),
66
- ("Review", "review"),
67
- ("Closed", "closed"),
68
- ],
69
- value="all",
70
- id="status-filter",
71
- classes="filter-select",
72
- )
73
- yield Select(
74
- [
75
- ("All Types", "all"),
76
- ("Task", "task"),
77
- ("Bug", "bug"),
78
- ("Feature", "feature"),
79
- ("Epic", "epic"),
80
- ],
81
- value="all",
82
- id="type-filter",
83
- classes="filter-select",
84
- )
85
- yield Tree("Tasks", id="task-tree")
86
-
87
- def on_select_changed(self, event: Select.Changed) -> None:
88
- """Handle filter changes."""
89
- status_val = self.query_one("#status-filter", Select).value
90
- type_val = self.query_one("#type-filter", Select).value
91
- self.post_message(
92
- TasksScreen.FilterChanged(
93
- status=str(status_val) if status_val is not Select.BLANK else "all",
94
- task_type=str(type_val) if type_val is not Select.BLANK else "all",
95
- )
96
- )
97
-
98
-
99
- class TaskDetailPanel(Widget):
100
- """Panel displaying task details."""
101
-
102
- DEFAULT_CSS = """
103
- TaskDetailPanel {
104
- width: 1fr;
105
- height: 1fr;
106
- padding: 1;
107
- }
108
-
109
- TaskDetailPanel .detail-header {
110
- height: auto;
111
- padding-bottom: 1;
112
- }
113
-
114
- TaskDetailPanel .detail-title {
115
- text-style: bold;
116
- color: #a78bfa;
117
- }
118
-
119
- TaskDetailPanel .detail-ref {
120
- color: #06b6d4;
121
- }
122
-
123
- TaskDetailPanel .detail-section {
124
- padding: 1 0;
125
- }
126
-
127
- TaskDetailPanel .detail-label {
128
- color: #a6adc8;
129
- width: 12;
130
- }
131
-
132
- TaskDetailPanel .detail-value {
133
- width: 1fr;
134
- }
135
-
136
- TaskDetailPanel .detail-description {
137
- padding: 1;
138
- border: round #45475a;
139
- height: auto;
140
- max-height: 10;
141
- }
142
-
143
- TaskDetailPanel .action-buttons {
144
- layout: horizontal;
145
- height: 3;
146
- padding-top: 1;
147
- }
148
-
149
- TaskDetailPanel .action-buttons Button {
150
- margin-right: 1;
151
- }
152
-
153
- TaskDetailPanel .empty-state {
154
- content-align: center middle;
155
- height: 1fr;
156
- color: #a6adc8;
157
- }
158
- """
159
-
160
- task_data: reactive[dict[str, Any] | None] = reactive(None)
161
-
162
- def compose(self) -> ComposeResult:
163
- if self.task_data is None:
164
- yield Static("Select a task to view details", classes="empty-state")
165
- else:
166
- with Vertical(classes="detail-header"):
167
- yield Static(self.task_data.get("title", "Untitled"), classes="detail-title")
168
- yield Static(self.task_data.get("ref", ""), classes="detail-ref")
169
-
170
- with Vertical(classes="detail-section"):
171
- with Horizontal():
172
- yield Static("Status:", classes="detail-label")
173
- yield Static(
174
- self.task_data.get("status", "unknown"),
175
- classes="detail-value",
176
- id="detail-status",
177
- )
178
- with Horizontal():
179
- yield Static("Type:", classes="detail-label")
180
- yield Static(self.task_data.get("task_type", "task"), classes="detail-value")
181
- with Horizontal():
182
- yield Static("Priority:", classes="detail-label")
183
- yield Static(str(self.task_data.get("priority", 3)), classes="detail-value")
184
- with Horizontal():
185
- yield Static("Assignee:", classes="detail-label")
186
- yield Static(
187
- self.task_data.get("assignee", "Unassigned"), classes="detail-value"
188
- )
189
-
190
- if self.task_data.get("description"):
191
- yield Static("Description:", classes="detail-label")
192
- yield Static(self.task_data.get("description", ""), classes="detail-description")
193
-
194
- with Horizontal(classes="action-buttons"):
195
- status = self.task_data.get("status", "")
196
- if status == "open":
197
- yield Button("Start", variant="primary", id="btn-start")
198
- yield Button("Expand", id="btn-expand")
199
- elif status == "in_progress":
200
- yield Button("Complete", variant="success", id="btn-complete")
201
- elif status == "review":
202
- yield Button("Approve", variant="success", id="btn-approve")
203
- yield Button("Reopen", variant="error", id="btn-reopen")
204
-
205
- def watch_task_data(self, task_data: dict[str, Any] | None) -> None:
206
- """Recompose when task_data changes."""
207
- self.call_after_refresh(self.recompose)
208
-
209
- def update_task(self, task: dict[str, Any] | None) -> None:
210
- """Update the displayed task."""
211
- self.task_data = task
212
-
213
-
214
- class TasksScreen(Widget):
215
- """Tasks screen with tree view and detail panel."""
216
-
217
- DEFAULT_CSS = """
218
- TasksScreen {
219
- width: 1fr;
220
- height: 1fr;
221
- }
222
-
223
- TasksScreen #tasks-container {
224
- layout: horizontal;
225
- height: 1fr;
226
- }
227
-
228
- TasksScreen #tree-panel {
229
- width: 50%;
230
- }
231
-
232
- TasksScreen #detail-panel {
233
- width: 50%;
234
- }
235
-
236
- TasksScreen .loading-container {
237
- width: 1fr;
238
- height: 1fr;
239
- content-align: center middle;
240
- }
241
- """
242
-
243
- @dataclass
244
- class FilterChanged(Message):
245
- """Message sent when filters change."""
246
-
247
- status: str
248
- task_type: str
249
-
250
- @dataclass
251
- class TaskSelected(Message):
252
- """Message sent when a task is selected."""
253
-
254
- task_id: str
255
-
256
- loading = reactive(True)
257
- tasks: reactive[list[dict[str, Any]]] = reactive(list)
258
- selected_task_id: reactive[str | None] = reactive(None)
259
- current_filter_status = "all"
260
- current_filter_type = "all"
261
-
262
- def __init__(
263
- self,
264
- api_client: GobbyAPIClient,
265
- ws_client: GobbyWebSocketClient,
266
- **kwargs: Any,
267
- ) -> None:
268
- super().__init__(**kwargs)
269
- self.api_client = api_client
270
- self.ws_client = ws_client
271
- self._task_map: dict[str, dict[str, Any]] = {}
272
-
273
- def compose(self) -> ComposeResult:
274
- if self.loading:
275
- with Container(classes="loading-container"):
276
- yield LoadingIndicator()
277
- else:
278
- with Horizontal(id="tasks-container"):
279
- yield TaskTreePanel(id="tree-panel")
280
- yield TaskDetailPanel(id="detail-panel")
281
-
282
- async def on_mount(self) -> None:
283
- """Load data when mounted."""
284
- await self.refresh_data()
285
-
286
- async def refresh_data(self) -> None:
287
- """Refresh task list."""
288
- self.loading = True
289
- await self.recompose()
290
-
291
- try:
292
- async with GobbyAPIClient(self.api_client.base_url) as client:
293
- # Build filter args
294
- args: dict[str, Any] = {}
295
- if self.current_filter_status != "all":
296
- args["status"] = self.current_filter_status
297
- if self.current_filter_type != "all":
298
- args["task_type"] = self.current_filter_type
299
-
300
- tasks = await client.list_tasks(**args)
301
- self.task_datas = tasks
302
- self._task_map = {t.get("id", ""): t for t in tasks}
303
-
304
- except Exception as e:
305
- self.notify(f"Failed to load tasks: {e}", severity="error")
306
- finally:
307
- self.loading = False
308
- await self.recompose()
309
- self._populate_tree()
310
-
311
- def _populate_tree(self) -> None:
312
- """Populate the task tree with loaded tasks."""
313
- try:
314
- tree = self.query_one("#task-tree", Tree)
315
- tree.clear()
316
-
317
- # Build parent -> children mapping
318
- children_map: dict[str | None, list[dict[str, Any]]] = {}
319
- for task in self.task_datas:
320
- parent_id = task.get("parent_id")
321
- if parent_id not in children_map:
322
- children_map[parent_id] = []
323
- children_map[parent_id].append(task)
324
-
325
- # Add root level tasks
326
- root_tasks = children_map.get(None, [])
327
- for task in sorted(root_tasks, key=lambda t: t.get("priority", 3)):
328
- self._add_task_to_tree(tree.root, task, children_map)
329
-
330
- tree.root.expand()
331
-
332
- except Exception:
333
- pass # nosec B110 - TUI update failure is non-critical
334
-
335
- def _add_task_to_tree(
336
- self,
337
- parent: TreeNode[str],
338
- task: dict[str, Any],
339
- children_map: dict[str | None, list[dict[str, Any]]],
340
- ) -> None:
341
- """Recursively add a task and its children to the tree."""
342
- status = task.get("status", "open")
343
- status_icon = {
344
- "open": "○",
345
- "in_progress": "◐",
346
- "review": "◑",
347
- "closed": "●",
348
- }.get(status, "○")
349
-
350
- ref = task.get("ref", "")
351
- title = task.get("title", "Untitled")
352
- label = f"{status_icon} {ref} {title}"
353
-
354
- node = parent.add(label, data=task.get("id"))
355
-
356
- # Add children
357
- task_id = task.get("id")
358
- children = children_map.get(task_id, [])
359
- for child in sorted(children, key=lambda t: t.get("priority", 3)):
360
- self._add_task_to_tree(node, child, children_map)
361
-
362
- def on_tree_node_selected(self, event: Tree.NodeSelected[str]) -> None:
363
- """Handle task selection in tree."""
364
- task_id = event.node.data
365
- if task_id and task_id in self._task_map:
366
- self.selected_task_id = task_id
367
- task = self._task_map[task_id]
368
- try:
369
- detail_panel = self.query_one("#detail-panel", TaskDetailPanel)
370
- detail_panel.update_task(task)
371
- except Exception:
372
- pass # nosec B110 - widget may not be mounted yet
373
-
374
- async def on_filter_changed(self, event: FilterChanged) -> None:
375
- """Handle filter changes."""
376
- self.current_filter_status = event.status if event.status != "all" else "all"
377
- self.current_filter_type = event.task_type if event.task_type != "all" else "all"
378
- await self.refresh_data()
379
-
380
- async def on_button_pressed(self, event: Button.Pressed) -> None:
381
- """Handle action button presses."""
382
- if not self.selected_task_id:
383
- return
384
-
385
- button_id = event.button.id
386
- task_id = self.selected_task_id
387
-
388
- try:
389
- async with GobbyAPIClient(self.api_client.base_url) as client:
390
- if button_id == "btn-start":
391
- await client.update_task(task_id, status="in_progress")
392
- self.notify(f"Task started: {task_id}")
393
-
394
- elif button_id == "btn-expand":
395
- await client.call_tool(
396
- "gobby-tasks",
397
- "expand_task",
398
- {"task_id": task_id},
399
- )
400
- self.notify(f"Task expanded: {task_id}")
401
-
402
- elif button_id == "btn-complete":
403
- # Note: In real usage, this would need a commit SHA
404
- await client.close_task(
405
- task_id,
406
- reason="obsolete",
407
- )
408
- self.notify(f"Task completed: {task_id}")
409
-
410
- elif button_id == "btn-approve":
411
- await client.close_task(
412
- task_id,
413
- reason="obsolete",
414
- )
415
- self.notify(f"Task approved: {task_id}")
416
-
417
- elif button_id == "btn-reopen":
418
- await client.call_tool(
419
- "gobby-tasks",
420
- "reopen_task",
421
- {"task_id": task_id},
422
- )
423
- self.notify(f"Task reopened: {task_id}")
424
-
425
- # Refresh after action
426
- await self.refresh_data()
427
-
428
- except Exception as e:
429
- self.notify(f"Action failed: {e}", severity="error")
430
-
431
- def on_ws_event(self, event_type: str, data: dict[str, Any]) -> None:
432
- """Handle WebSocket events."""
433
- if event_type == "hook_event":
434
- hook_type = data.get("event_type", "")
435
- if "task" in hook_type.lower():
436
- self.run_worker(self.refresh_data(), name="refresh_data", exclusive=True)
437
-
438
- def activate_search(self) -> None:
439
- """Activate search mode."""
440
- self.notify("Search not yet implemented", severity="information")