gobby 0.2.7__py3-none-any.whl → 0.2.9__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 (125) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/claude_code.py +99 -61
  3. gobby/adapters/gemini.py +140 -38
  4. gobby/agents/isolation.py +130 -0
  5. gobby/agents/registry.py +11 -0
  6. gobby/agents/session.py +1 -0
  7. gobby/agents/spawn_executor.py +43 -13
  8. gobby/agents/spawners/macos.py +26 -1
  9. gobby/app_context.py +59 -0
  10. gobby/cli/__init__.py +0 -2
  11. gobby/cli/memory.py +185 -0
  12. gobby/cli/utils.py +5 -17
  13. gobby/clones/git.py +177 -0
  14. gobby/config/features.py +0 -20
  15. gobby/config/skills.py +31 -0
  16. gobby/config/tasks.py +4 -0
  17. gobby/hooks/event_handlers/__init__.py +155 -0
  18. gobby/hooks/event_handlers/_agent.py +175 -0
  19. gobby/hooks/event_handlers/_base.py +87 -0
  20. gobby/hooks/event_handlers/_misc.py +66 -0
  21. gobby/hooks/event_handlers/_session.py +573 -0
  22. gobby/hooks/event_handlers/_tool.py +196 -0
  23. gobby/hooks/hook_manager.py +21 -1
  24. gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
  25. gobby/llm/claude.py +377 -42
  26. gobby/mcp_proxy/importer.py +4 -41
  27. gobby/mcp_proxy/instructions.py +2 -2
  28. gobby/mcp_proxy/manager.py +13 -3
  29. gobby/mcp_proxy/registries.py +35 -4
  30. gobby/mcp_proxy/services/recommendation.py +2 -28
  31. gobby/mcp_proxy/tools/agent_messaging.py +93 -44
  32. gobby/mcp_proxy/tools/agents.py +45 -9
  33. gobby/mcp_proxy/tools/artifacts.py +46 -12
  34. gobby/mcp_proxy/tools/sessions/_commits.py +31 -24
  35. gobby/mcp_proxy/tools/sessions/_crud.py +5 -5
  36. gobby/mcp_proxy/tools/sessions/_handoff.py +45 -41
  37. gobby/mcp_proxy/tools/sessions/_messages.py +35 -7
  38. gobby/mcp_proxy/tools/spawn_agent.py +44 -6
  39. gobby/mcp_proxy/tools/task_readiness.py +27 -4
  40. gobby/mcp_proxy/tools/tasks/_context.py +18 -0
  41. gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
  42. gobby/mcp_proxy/tools/tasks/_lifecycle.py +29 -14
  43. gobby/mcp_proxy/tools/tasks/_session.py +22 -7
  44. gobby/mcp_proxy/tools/workflows/__init__.py +266 -0
  45. gobby/mcp_proxy/tools/workflows/_artifacts.py +225 -0
  46. gobby/mcp_proxy/tools/workflows/_import.py +112 -0
  47. gobby/mcp_proxy/tools/workflows/_lifecycle.py +321 -0
  48. gobby/mcp_proxy/tools/workflows/_query.py +207 -0
  49. gobby/mcp_proxy/tools/workflows/_resolution.py +78 -0
  50. gobby/mcp_proxy/tools/workflows/_terminal.py +139 -0
  51. gobby/mcp_proxy/tools/worktrees.py +32 -7
  52. gobby/memory/components/__init__.py +0 -0
  53. gobby/memory/components/ingestion.py +98 -0
  54. gobby/memory/components/search.py +108 -0
  55. gobby/memory/extractor.py +15 -1
  56. gobby/memory/manager.py +16 -25
  57. gobby/paths.py +51 -0
  58. gobby/prompts/loader.py +1 -35
  59. gobby/runner.py +36 -10
  60. gobby/servers/http.py +186 -149
  61. gobby/servers/routes/admin.py +12 -0
  62. gobby/servers/routes/mcp/endpoints/execution.py +15 -7
  63. gobby/servers/routes/mcp/endpoints/registry.py +8 -8
  64. gobby/servers/routes/mcp/hooks.py +50 -3
  65. gobby/servers/websocket.py +57 -1
  66. gobby/sessions/analyzer.py +4 -4
  67. gobby/sessions/manager.py +9 -0
  68. gobby/sessions/transcripts/gemini.py +100 -34
  69. gobby/skills/parser.py +23 -0
  70. gobby/skills/sync.py +5 -4
  71. gobby/storage/artifacts.py +19 -0
  72. gobby/storage/database.py +9 -2
  73. gobby/storage/memories.py +32 -21
  74. gobby/storage/migrations.py +46 -4
  75. gobby/storage/sessions.py +4 -2
  76. gobby/storage/skills.py +87 -7
  77. gobby/tasks/external_validator.py +4 -17
  78. gobby/tasks/validation.py +13 -87
  79. gobby/tools/summarizer.py +18 -51
  80. gobby/utils/status.py +13 -0
  81. gobby/workflows/actions.py +5 -0
  82. gobby/workflows/context_actions.py +21 -24
  83. gobby/workflows/detection_helpers.py +38 -24
  84. gobby/workflows/enforcement/__init__.py +11 -1
  85. gobby/workflows/enforcement/blocking.py +109 -1
  86. gobby/workflows/enforcement/handlers.py +35 -1
  87. gobby/workflows/engine.py +96 -0
  88. gobby/workflows/evaluator.py +110 -0
  89. gobby/workflows/hooks.py +41 -0
  90. gobby/workflows/lifecycle_evaluator.py +2 -1
  91. gobby/workflows/memory_actions.py +11 -0
  92. gobby/workflows/safe_evaluator.py +8 -0
  93. gobby/workflows/summary_actions.py +123 -50
  94. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/METADATA +1 -1
  95. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/RECORD +99 -107
  96. gobby/cli/tui.py +0 -34
  97. gobby/hooks/event_handlers.py +0 -909
  98. gobby/mcp_proxy/tools/workflows.py +0 -973
  99. gobby/tui/__init__.py +0 -5
  100. gobby/tui/api_client.py +0 -278
  101. gobby/tui/app.py +0 -329
  102. gobby/tui/screens/__init__.py +0 -25
  103. gobby/tui/screens/agents.py +0 -333
  104. gobby/tui/screens/chat.py +0 -450
  105. gobby/tui/screens/dashboard.py +0 -377
  106. gobby/tui/screens/memory.py +0 -305
  107. gobby/tui/screens/metrics.py +0 -231
  108. gobby/tui/screens/orchestrator.py +0 -903
  109. gobby/tui/screens/sessions.py +0 -412
  110. gobby/tui/screens/tasks.py +0 -440
  111. gobby/tui/screens/workflows.py +0 -289
  112. gobby/tui/screens/worktrees.py +0 -174
  113. gobby/tui/widgets/__init__.py +0 -21
  114. gobby/tui/widgets/chat.py +0 -210
  115. gobby/tui/widgets/conductor.py +0 -104
  116. gobby/tui/widgets/menu.py +0 -132
  117. gobby/tui/widgets/message_panel.py +0 -160
  118. gobby/tui/widgets/review_gate.py +0 -224
  119. gobby/tui/widgets/task_tree.py +0 -99
  120. gobby/tui/widgets/token_budget.py +0 -166
  121. gobby/tui/ws_client.py +0 -258
  122. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/WHEEL +0 -0
  123. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/entry_points.txt +0 -0
  124. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/licenses/LICENSE.md +0 -0
  125. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,196 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from gobby.hooks.event_handlers._base import EventHandlersBase
6
+ from gobby.hooks.events import HookEvent, HookResponse
7
+
8
+ EDIT_TOOLS = {
9
+ "write_file",
10
+ "replace",
11
+ "edit_file",
12
+ "notebook_edit",
13
+ "edit",
14
+ "write",
15
+ }
16
+
17
+
18
+ class ToolEventHandlerMixin(EventHandlersBase):
19
+ """Mixin for handling tool-related events."""
20
+
21
+ def handle_before_tool(self, event: HookEvent) -> HookResponse:
22
+ """Handle BEFORE_TOOL event."""
23
+ input_data = event.data
24
+ tool_name = input_data.get("tool_name", "unknown")
25
+ session_id = event.metadata.get("_platform_session_id")
26
+
27
+ if session_id:
28
+ self.logger.debug(f"BEFORE_TOOL: {tool_name}, session {session_id}")
29
+ else:
30
+ self.logger.debug(f"BEFORE_TOOL: {tool_name}")
31
+
32
+ context_parts = []
33
+
34
+ # Execute lifecycle workflow triggers
35
+ if self._workflow_handler:
36
+ try:
37
+ wf_response = self._workflow_handler.handle_all_lifecycles(event)
38
+ if wf_response.context:
39
+ context_parts.append(wf_response.context)
40
+ if wf_response.decision != "allow":
41
+ return wf_response
42
+ except Exception as e:
43
+ self.logger.error(f"Failed to execute lifecycle workflows: {e}", exc_info=True)
44
+
45
+ return HookResponse(
46
+ decision="allow",
47
+ context="\n\n".join(context_parts) if context_parts else None,
48
+ )
49
+
50
+ def handle_after_tool(self, event: HookEvent) -> HookResponse:
51
+ """Handle AFTER_TOOL event."""
52
+ input_data = event.data
53
+ tool_name = input_data.get("tool_name", "unknown")
54
+ session_id = event.metadata.get("_platform_session_id")
55
+ is_failure = event.metadata.get("is_failure", False)
56
+
57
+ status = "FAIL" if is_failure else "OK"
58
+ if session_id:
59
+ self.logger.debug(f"AFTER_TOOL [{status}]: {tool_name}, session {session_id}")
60
+
61
+ # Track edits for session high-water mark
62
+ # Only if tool succeeded, matches edit tools, and session has claimed a task
63
+ # Skip .gobby/ internal files (tasks.jsonl, memories.jsonl, etc.)
64
+ tool_input = input_data.get("tool_input", {})
65
+
66
+ # Capture artifacts from edit tools
67
+ if not is_failure and self._artifact_capture_hook:
68
+ self._capture_tool_artifact(session_id, tool_name, tool_input)
69
+
70
+ # Simple check for edit tools (case-insensitive)
71
+ is_edit = tool_name.lower() in EDIT_TOOLS
72
+
73
+ # For complex tools (multi_replace, etc), check if they modify files
74
+ # This logic could be expanded, but for now stick to the basic set
75
+
76
+ if not is_failure and is_edit and self._session_storage:
77
+ try:
78
+ # Check if file is internal .gobby file
79
+ file_path = (
80
+ tool_input.get("file_path")
81
+ or tool_input.get("target_file")
82
+ or tool_input.get("path")
83
+ )
84
+ is_internal = file_path and ".gobby/" in str(file_path)
85
+
86
+ if not is_internal:
87
+ # Check if session has any claimed tasks before marking had_edits
88
+ has_claimed_task = False
89
+ if self._task_manager:
90
+ try:
91
+ claimed_tasks = self._task_manager.list_tasks(assignee=session_id)
92
+ has_claimed_task = len(claimed_tasks) > 0
93
+ except Exception as e:
94
+ self.logger.debug(
95
+ f"Failed to check claimed tasks for session {session_id}: {e}"
96
+ )
97
+
98
+ if has_claimed_task:
99
+ self._session_storage.mark_had_edits(session_id)
100
+ except Exception as e:
101
+ # Don't fail the event if tracking fails
102
+ self.logger.warning(f"Failed to process file edit: {e}")
103
+
104
+ else:
105
+ self.logger.debug(f"AFTER_TOOL [{status}]: {tool_name}")
106
+
107
+ # Execute lifecycle workflow triggers
108
+ if self._workflow_handler:
109
+ try:
110
+ wf_response = self._workflow_handler.handle_all_lifecycles(event)
111
+ if wf_response.decision != "allow":
112
+ return wf_response
113
+ if wf_response.context:
114
+ return wf_response
115
+ except Exception as e:
116
+ self.logger.error(f"Failed to execute lifecycle workflows: {e}", exc_info=True)
117
+
118
+ return HookResponse(decision="allow")
119
+
120
+ def handle_before_tool_selection(self, event: HookEvent) -> HookResponse:
121
+ """Handle BEFORE_TOOL_SELECTION event (Gemini only)."""
122
+ session_id = event.metadata.get("_platform_session_id")
123
+
124
+ if session_id:
125
+ self.logger.debug(f"BEFORE_TOOL_SELECTION: session {session_id}")
126
+ else:
127
+ self.logger.debug("BEFORE_TOOL_SELECTION")
128
+
129
+ return HookResponse(decision="allow")
130
+
131
+ def _capture_tool_artifact(
132
+ self, session_id: str, tool_name: str, tool_input: dict[str, Any]
133
+ ) -> None:
134
+ """Capture artifacts from tool inputs for edit/write tools.
135
+
136
+ Args:
137
+ session_id: Platform session ID
138
+ tool_name: Name of the tool
139
+ tool_input: Tool input dictionary
140
+ """
141
+ if not self._artifact_capture_hook:
142
+ return
143
+
144
+ # Get content and file path from tool input
145
+ content = tool_input.get("content") or tool_input.get("new_string")
146
+ file_path = (
147
+ tool_input.get("file_path") or tool_input.get("target_file") or tool_input.get("path")
148
+ )
149
+
150
+ if not content:
151
+ return
152
+
153
+ # Skip internal .gobby files
154
+ if file_path and ".gobby/" in str(file_path):
155
+ return
156
+
157
+ # Detect language from file extension
158
+ language = ""
159
+ if file_path:
160
+ ext_map = {
161
+ ".py": "python",
162
+ ".js": "javascript",
163
+ ".ts": "typescript",
164
+ ".tsx": "tsx",
165
+ ".jsx": "jsx",
166
+ ".rs": "rust",
167
+ ".go": "go",
168
+ ".java": "java",
169
+ ".rb": "ruby",
170
+ ".sh": "bash",
171
+ ".yaml": "yaml",
172
+ ".yml": "yaml",
173
+ ".json": "json",
174
+ ".md": "markdown",
175
+ ".sql": "sql",
176
+ ".html": "html",
177
+ ".css": "css",
178
+ }
179
+ for ext, lang in ext_map.items():
180
+ if str(file_path).endswith(ext):
181
+ language = lang
182
+ break
183
+
184
+ # Wrap content as markdown code block for process_message
185
+ # This reuses the deduplication and classification logic
186
+ markdown_content = f"```{language}\n{content}\n```"
187
+
188
+ try:
189
+ self._artifact_capture_hook.process_message(
190
+ session_id=session_id,
191
+ role="assistant",
192
+ content=markdown_content,
193
+ )
194
+ self.logger.debug(f"Captured artifact from {tool_name}: {file_path or 'unknown'}")
195
+ except Exception as e:
196
+ self.logger.warning(f"Failed to capture artifact from {tool_name}: {e}")
@@ -369,6 +369,10 @@ class HookManager:
369
369
  # Skill manager for core skill injection
370
370
  self._skill_manager = HookSkillManager()
371
371
 
372
+ # Track sessions that have received full metadata injection
373
+ # Key: "{platform_session_id}:{source}" - cleared on daemon restart
374
+ self._injected_sessions: set[str] = set()
375
+
372
376
  # Event handlers (delegated to EventHandlers module)
373
377
  self._event_handlers = EventHandlers(
374
378
  session_manager=self._session_manager,
@@ -382,6 +386,8 @@ class HookManager:
382
386
  message_manager=self._message_manager,
383
387
  skill_manager=self._skill_manager,
384
388
  skills_config=self._config.skills if self._config else None,
389
+ artifact_capture_hook=self._artifact_capture_hook,
390
+ workflow_config=self._config.workflow if self._config else None,
385
391
  get_machine_id=self.get_machine_id,
386
392
  resolve_project_id=self._resolve_project_id,
387
393
  logger=self.logger,
@@ -644,7 +650,21 @@ class HookManager:
644
650
  # Copy session metadata from event to response for adapter injection
645
651
  # The adapter reads response.metadata to inject session info into agent context
646
652
  if event.metadata.get("_platform_session_id"):
647
- response.metadata["session_id"] = event.metadata["_platform_session_id"]
653
+ platform_session_id = event.metadata["_platform_session_id"]
654
+ response.metadata["session_id"] = platform_session_id
655
+ # Look up seq_num for session_ref (#N format)
656
+ if self._session_storage:
657
+ session_obj = self._session_storage.get(platform_session_id)
658
+ if session_obj and session_obj.seq_num:
659
+ response.metadata["session_ref"] = f"#{session_obj.seq_num}"
660
+
661
+ # Track first hook per session for token optimization
662
+ # Adapters use this flag to inject full metadata only on first hook
663
+ session_key = f"{platform_session_id}:{event.source.value}"
664
+ is_first = session_key not in self._injected_sessions
665
+ if is_first:
666
+ self._injected_sessions.add(session_key)
667
+ response.metadata["_first_hook_for_session"] = is_first
648
668
  if event.session_id: # external_id (e.g., Claude Code's session UUID)
649
669
  response.metadata["external_id"] = event.session_id
650
670
  if event.machine_id:
@@ -216,52 +216,111 @@ def main() -> int:
216
216
  # This captures the terminal/process info for session correlation
217
217
  if hook_type == "SessionStart":
218
218
  input_data["terminal_context"] = get_terminal_context()
219
+ # Note: gobby_context (parent_session_id, workflow, etc.) is no longer
220
+ # injected from env vars. For spawned agents, the session is pre-created
221
+ # with all linkage via preflight+resume pattern, so the daemon already
222
+ # has the context when SessionStart fires.
219
223
 
220
224
  # Log what Gemini CLI sends us (for debugging hook data issues)
221
- logger.info(f"[{hook_type}] Received input keys: {list(input_data.keys())}")
225
+ # Extract common context fields for structured logging
226
+ session_id = input_data.get("session_id")
227
+ task_id = input_data.get("task_id")
228
+ project_id = input_data.get("project_id")
229
+ base_context = {
230
+ "hook_type": hook_type,
231
+ "session_id": session_id,
232
+ "task_id": task_id,
233
+ "project_id": project_id,
234
+ }
235
+
236
+ logger.info(
237
+ "[%s] Received input keys: %s",
238
+ hook_type,
239
+ list(input_data.keys()),
240
+ extra=base_context,
241
+ )
222
242
 
223
243
  # Log hook-specific critical fields
224
244
  if hook_type == "SessionStart":
225
- logger.info(f"[SessionStart] session_id={input_data.get('session_id')}")
245
+ logger.info(
246
+ "[SessionStart] session_id=%s",
247
+ session_id,
248
+ extra=base_context,
249
+ )
226
250
  elif hook_type == "SessionEnd":
251
+ reason = input_data.get("reason")
227
252
  logger.info(
228
- f"[SessionEnd] session_id={input_data.get('session_id')}, "
229
- f"reason={input_data.get('reason')}"
253
+ "[SessionEnd] session_id=%s, reason=%s",
254
+ session_id,
255
+ reason,
256
+ extra={**base_context, "reason": reason},
230
257
  )
231
258
  elif hook_type == "BeforeAgent":
232
259
  prompt = input_data.get("prompt", "")
233
260
  prompt_preview = prompt[:100] + "..." if len(prompt) > 100 else prompt
234
261
  logger.info(
235
- f"[BeforeAgent] session_id={input_data.get('session_id')}, prompt={prompt_preview}"
262
+ "[BeforeAgent] session_id=%s, prompt=%s",
263
+ session_id,
264
+ prompt_preview,
265
+ extra={**base_context, "prompt_preview": prompt_preview},
236
266
  )
237
267
  elif hook_type == "BeforeTool":
238
268
  tool_name = input_data.get("tool_name") or input_data.get("function_name", "unknown")
239
269
  logger.info(
240
- f"[BeforeTool] tool_name={tool_name}, session_id={input_data.get('session_id')}"
270
+ "[BeforeTool] tool_name=%s, session_id=%s",
271
+ tool_name,
272
+ session_id,
273
+ extra={**base_context, "tool_name": tool_name},
241
274
  )
242
275
  elif hook_type == "AfterTool":
243
276
  tool_name = input_data.get("tool_name") or input_data.get("function_name", "unknown")
277
+ error = input_data.get("error")
244
278
  logger.info(
245
- f"[AfterTool] tool_name={tool_name}, session_id={input_data.get('session_id')}"
279
+ "[AfterTool] tool_name=%s, session_id=%s",
280
+ tool_name,
281
+ session_id,
282
+ extra={**base_context, "tool_name": tool_name, "error": error},
246
283
  )
247
284
  elif hook_type == "BeforeToolSelection":
248
- logger.info(f"[BeforeToolSelection] session_id={input_data.get('session_id')}")
285
+ logger.info(
286
+ "[BeforeToolSelection] session_id=%s",
287
+ session_id,
288
+ extra=base_context,
289
+ )
249
290
  elif hook_type == "BeforeModel":
291
+ model = input_data.get("model", "unknown")
250
292
  logger.info(
251
- f"[BeforeModel] session_id={input_data.get('session_id')}, "
252
- f"model={input_data.get('model', 'unknown')}"
293
+ "[BeforeModel] session_id=%s, model=%s",
294
+ session_id,
295
+ model,
296
+ extra={**base_context, "model": model},
253
297
  )
254
298
  elif hook_type == "AfterModel":
255
- logger.info(f"[AfterModel] session_id={input_data.get('session_id')}")
299
+ logger.info(
300
+ "[AfterModel] session_id=%s",
301
+ session_id,
302
+ extra=base_context,
303
+ )
256
304
  elif hook_type == "PreCompress":
257
- logger.info(f"[PreCompress] session_id={input_data.get('session_id')}")
305
+ logger.info(
306
+ "[PreCompress] session_id=%s",
307
+ session_id,
308
+ extra=base_context,
309
+ )
258
310
  elif hook_type == "Notification":
311
+ message = input_data.get("message")
259
312
  logger.info(
260
- f"[Notification] session_id={input_data.get('session_id')}, "
261
- f"message={input_data.get('message')}"
313
+ "[Notification] session_id=%s, message=%s",
314
+ session_id,
315
+ message,
316
+ extra={**base_context, "notification_message": message},
262
317
  )
263
318
  elif hook_type == "AfterAgent":
264
- logger.info(f"[AfterAgent] session_id={input_data.get('session_id')}")
319
+ logger.info(
320
+ "[AfterAgent] session_id=%s",
321
+ session_id,
322
+ extra=base_context,
323
+ )
265
324
 
266
325
  if debug_mode:
267
326
  logger.debug(f"Input data: {input_data}")