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
@@ -42,6 +42,23 @@ def format_turns_for_llm(turns: list[dict[str, Any]]) -> str:
42
42
  text_parts.append(f"[Thinking: {block.get('thinking', '')}]")
43
43
  elif block.get("type") == "tool_use":
44
44
  text_parts.append(f"[Tool: {block.get('name', 'unknown')}]")
45
+ elif block.get("type") == "tool_result":
46
+ result_content = block.get("content", "")
47
+ # Extract text from list of content blocks if needed
48
+ if isinstance(result_content, list):
49
+ extracted = []
50
+ for item in result_content:
51
+ if isinstance(item, dict):
52
+ extracted.append(
53
+ item.get("text", "") or item.get("content", "")
54
+ )
55
+ else:
56
+ extracted.append(str(item))
57
+ result_content = " ".join(extracted)
58
+ content_str = str(result_content)
59
+ preview = content_str[:100]
60
+ suffix = "..." if len(content_str) > 100 else ""
61
+ text_parts.append(f"[Result: {preview}{suffix}]")
45
62
  content = " ".join(text_parts)
46
63
 
47
64
  formatted.append(f"[Turn {i + 1} - {role}]: {content}")
@@ -5,8 +5,11 @@ Provides actions that enforce task tracking before allowing certain tools,
5
5
  and enforce task completion before allowing agent to stop.
6
6
  """
7
7
 
8
+ import ast
8
9
  import logging
10
+ import operator
9
11
  import subprocess # nosec B404 - subprocess needed for git commands
12
+ from collections.abc import Callable
10
13
  from typing import TYPE_CHECKING, Any
11
14
 
12
15
  from gobby.mcp_proxy.tools.task_readiness import is_descendant_of
@@ -21,6 +24,440 @@ if TYPE_CHECKING:
21
24
  logger = logging.getLogger(__name__)
22
25
 
23
26
 
27
+ # =============================================================================
28
+ # Lazy Evaluation Helpers
29
+ # =============================================================================
30
+
31
+
32
+ class _LazyBool:
33
+ """Lazy boolean that defers computation until first access.
34
+
35
+ Used to avoid expensive operations (git status, DB queries) when
36
+ evaluating block_tools conditions that don't reference certain values.
37
+
38
+ The computation is triggered when the value is used in a boolean context
39
+ (e.g., `if lazy_val:` or `not lazy_val`), which happens during eval().
40
+ """
41
+
42
+ __slots__ = ("_thunk", "_computed", "_value")
43
+
44
+ def __init__(self, thunk: "Callable[[], bool]") -> None:
45
+ self._thunk = thunk
46
+ self._computed = False
47
+ self._value = False
48
+
49
+ def __bool__(self) -> bool:
50
+ if not self._computed:
51
+ self._value = self._thunk()
52
+ self._computed = True
53
+ return self._value
54
+
55
+ def __repr__(self) -> str:
56
+ if self._computed:
57
+ return f"_LazyBool({self._value})"
58
+ return "_LazyBool(<not computed>)"
59
+
60
+
61
+ # =============================================================================
62
+ # Helper Functions
63
+ # =============================================================================
64
+
65
+
66
+ def _is_plan_file(file_path: str, source: str | None = None) -> bool:
67
+ """Check if file path is a Claude Code plan file (platform-agnostic).
68
+
69
+ Only exempts plan files for Claude Code sessions to avoid accidental
70
+ exemptions for Gemini/Codex users.
71
+
72
+ The pattern `/.claude/plans/` matches paths like:
73
+ - Unix: /Users/xxx/.claude/plans/file.md (the / comes from xxx/)
74
+ - Windows: C:/Users/xxx/.claude/plans/file.md (after normalization)
75
+
76
+ Args:
77
+ file_path: The file path being edited
78
+ source: CLI source (e.g., "claude", "gemini", "codex")
79
+
80
+ Returns:
81
+ True if this is a CC plan file that should be exempt from task requirement
82
+ """
83
+ if not file_path:
84
+ return False
85
+ # Only exempt for Claude Code sessions
86
+ if source != "claude":
87
+ return False
88
+ # Normalize path separators (Windows backslash to forward slash)
89
+ normalized = file_path.replace("\\", "/")
90
+ return "/.claude/plans/" in normalized
91
+
92
+
93
+ # =============================================================================
94
+ # Safe Expression Evaluator (AST-based)
95
+ # =============================================================================
96
+
97
+
98
+ class SafeExpressionEvaluator(ast.NodeVisitor):
99
+ """Safe expression evaluator using AST.
100
+
101
+ Evaluates simple Python expressions without using eval().
102
+ Supports boolean operations, comparisons, attribute access, subscripts,
103
+ and a limited set of allowed function calls.
104
+ """
105
+
106
+ # Comparison operators mapping
107
+ CMP_OPS: dict[type[ast.cmpop], Callable[[Any, Any], bool]] = {
108
+ ast.Eq: operator.eq,
109
+ ast.NotEq: operator.ne,
110
+ ast.Lt: operator.lt,
111
+ ast.LtE: operator.le,
112
+ ast.Gt: operator.gt,
113
+ ast.GtE: operator.ge,
114
+ ast.Is: operator.is_,
115
+ ast.IsNot: operator.is_not,
116
+ ast.In: lambda a, b: a in b,
117
+ ast.NotIn: lambda a, b: a not in b,
118
+ }
119
+
120
+ def __init__(
121
+ self, context: dict[str, Any], allowed_funcs: dict[str, Callable[..., Any]]
122
+ ) -> None:
123
+ self.context = context
124
+ self.allowed_funcs = allowed_funcs
125
+
126
+ def evaluate(self, expr: str) -> bool:
127
+ """Evaluate expression and return boolean result."""
128
+ try:
129
+ tree = ast.parse(expr, mode="eval")
130
+ return bool(self.visit(tree.body))
131
+ except Exception as e:
132
+ raise ValueError(f"Invalid expression: {e}") from e
133
+
134
+ def visit_BoolOp(self, node: ast.BoolOp) -> bool:
135
+ """Handle 'and' / 'or' operations."""
136
+ if isinstance(node.op, ast.And):
137
+ return all(self.visit(v) for v in node.values)
138
+ elif isinstance(node.op, ast.Or):
139
+ return any(self.visit(v) for v in node.values)
140
+ raise ValueError(f"Unsupported boolean operator: {type(node.op).__name__}")
141
+
142
+ def visit_Compare(self, node: ast.Compare) -> bool:
143
+ """Handle comparison operations (==, !=, <, >, in, not in, etc.)."""
144
+ left = self.visit(node.left)
145
+ for op, comparator in zip(node.ops, node.comparators, strict=False):
146
+ right = self.visit(comparator)
147
+ op_func = self.CMP_OPS.get(type(op))
148
+ if op_func is None:
149
+ raise ValueError(f"Unsupported comparison: {type(op).__name__}")
150
+ if not op_func(left, right):
151
+ return False
152
+ left = right
153
+ return True
154
+
155
+ def visit_UnaryOp(self, node: ast.UnaryOp) -> Any:
156
+ """Handle unary operations (not, -, +)."""
157
+ operand = self.visit(node.operand)
158
+ if isinstance(node.op, ast.Not):
159
+ return not operand
160
+ elif isinstance(node.op, ast.USub):
161
+ return -operand
162
+ elif isinstance(node.op, ast.UAdd):
163
+ return +operand
164
+ raise ValueError(f"Unsupported unary operator: {type(node.op).__name__}")
165
+
166
+ def visit_Name(self, node: ast.Name) -> Any:
167
+ """Handle variable names."""
168
+ name = node.id
169
+ # Built-in constants
170
+ if name == "True":
171
+ return True
172
+ if name == "False":
173
+ return False
174
+ if name == "None":
175
+ return None
176
+ # Context variables
177
+ if name in self.context:
178
+ return self.context[name]
179
+ raise ValueError(f"Unknown variable: {name}")
180
+
181
+ def visit_Constant(self, node: ast.Constant) -> Any:
182
+ """Handle literal values (strings, numbers, booleans, None)."""
183
+ return node.value
184
+
185
+ def visit_Call(self, node: ast.Call) -> Any:
186
+ """Handle function calls (only allowed functions)."""
187
+ # Get function name
188
+ if isinstance(node.func, ast.Name):
189
+ func_name = node.func.id
190
+ elif isinstance(node.func, ast.Attribute):
191
+ # Handle method calls like tool_input.get('key')
192
+ obj = self.visit(node.func.value)
193
+ method_name = node.func.attr
194
+ if method_name == "get" and isinstance(obj, dict):
195
+ args = [self.visit(arg) for arg in node.args]
196
+ return obj.get(*args)
197
+ raise ValueError(f"Unsupported method call: {method_name}")
198
+ else:
199
+ raise ValueError(f"Unsupported call type: {type(node.func).__name__}")
200
+
201
+ # Check if function is allowed
202
+ if func_name not in self.allowed_funcs:
203
+ raise ValueError(f"Function not allowed: {func_name}")
204
+
205
+ # Evaluate arguments
206
+ args = [self.visit(arg) for arg in node.args]
207
+ kwargs = {kw.arg: self.visit(kw.value) for kw in node.keywords if kw.arg}
208
+
209
+ return self.allowed_funcs[func_name](*args, **kwargs)
210
+
211
+ def visit_Attribute(self, node: ast.Attribute) -> Any:
212
+ """Handle attribute access (e.g., obj.attr)."""
213
+ obj = self.visit(node.value)
214
+ attr = node.attr
215
+ if isinstance(obj, dict):
216
+ # Allow dict-style attribute access for convenience
217
+ if attr in obj:
218
+ return obj[attr]
219
+ raise ValueError(f"Key not found: {attr}")
220
+ if hasattr(obj, attr):
221
+ return getattr(obj, attr)
222
+ raise ValueError(f"Attribute not found: {attr}")
223
+
224
+ def visit_Subscript(self, node: ast.Subscript) -> Any:
225
+ """Handle subscript access (e.g., obj['key'] or obj[0])."""
226
+ obj = self.visit(node.value)
227
+ key = self.visit(node.slice)
228
+ try:
229
+ return obj[key]
230
+ except (KeyError, IndexError, TypeError) as e:
231
+ raise ValueError(f"Subscript access failed: {e}") from e
232
+
233
+ def generic_visit(self, node: ast.AST) -> Any:
234
+ """Reject any unsupported AST nodes."""
235
+ raise ValueError(f"Unsupported expression type: {type(node).__name__}")
236
+
237
+
238
+ # =============================================================================
239
+ # Block Tools Action (Unified Tool Blocking)
240
+ # =============================================================================
241
+
242
+
243
+ def _evaluate_block_condition(
244
+ condition: str | None,
245
+ workflow_state: "WorkflowState | None",
246
+ event_data: dict[str, Any] | None = None,
247
+ tool_input: dict[str, Any] | None = None,
248
+ session_has_dirty_files: "_LazyBool | bool" = False,
249
+ task_has_commits: "_LazyBool | bool" = False,
250
+ source: str | None = None,
251
+ ) -> bool:
252
+ """
253
+ Evaluate a blocking rule condition against workflow state.
254
+
255
+ Supports simple Python expressions with access to:
256
+ - variables: workflow state variables dict
257
+ - task_claimed: shorthand for variables.get('task_claimed')
258
+ - plan_mode: shorthand for variables.get('plan_mode')
259
+ - tool_input: the tool's input arguments (for MCP tool checks)
260
+ - session_has_dirty_files: whether session has NEW dirty files (beyond baseline)
261
+ - task_has_commits: whether the current task has linked commits
262
+ - source: CLI source (e.g., "claude", "gemini", "codex")
263
+
264
+ Args:
265
+ condition: Python expression to evaluate
266
+ workflow_state: Current workflow state
267
+ event_data: Optional hook event data
268
+ tool_input: Tool input arguments (for MCP tools, this is the 'arguments' field)
269
+ session_has_dirty_files: Whether session has dirty files beyond baseline (lazy or bool)
270
+ task_has_commits: Whether claimed task has linked commits (lazy or bool)
271
+ source: CLI source identifier
272
+
273
+ Returns:
274
+ True if condition matches (tool should be blocked), False otherwise.
275
+ """
276
+ if not condition:
277
+ return True # No condition means always match
278
+
279
+ # Build evaluation context
280
+ variables = workflow_state.variables if workflow_state else {}
281
+ context = {
282
+ "variables": variables,
283
+ "task_claimed": variables.get("task_claimed", False),
284
+ "plan_mode": variables.get("plan_mode", False),
285
+ "event": event_data or {},
286
+ "tool_input": tool_input or {},
287
+ "session_has_dirty_files": session_has_dirty_files,
288
+ "task_has_commits": task_has_commits,
289
+ "source": source or "",
290
+ }
291
+
292
+ # Allowed functions for safe evaluation
293
+ allowed_funcs: dict[str, Callable[..., Any]] = {
294
+ "is_plan_file": _is_plan_file,
295
+ "bool": bool,
296
+ "str": str,
297
+ "int": int,
298
+ }
299
+
300
+ try:
301
+ evaluator = SafeExpressionEvaluator(context, allowed_funcs)
302
+ return evaluator.evaluate(condition)
303
+ except Exception as e:
304
+ logger.warning(f"block_tools condition evaluation failed: '{condition}'. Error: {e}")
305
+ return False
306
+
307
+
308
+ async def block_tools(
309
+ rules: list[dict[str, Any]] | None = None,
310
+ event_data: dict[str, Any] | None = None,
311
+ workflow_state: "WorkflowState | None" = None,
312
+ project_path: str | None = None,
313
+ task_manager: "LocalTaskManager | None" = None,
314
+ source: str | None = None,
315
+ **kwargs: Any,
316
+ ) -> dict[str, Any] | None:
317
+ """
318
+ Unified tool blocking with multiple configurable rules.
319
+
320
+ Each rule can specify:
321
+ - tools: List of tool names to block (for native CC tools)
322
+ - mcp_tools: List of "server:tool" patterns to block (for MCP tools)
323
+ - when: Optional condition (evaluated against workflow state)
324
+ - reason: Block message to display
325
+
326
+ For MCP tools, the tool_name in event_data is "call_tool" or "mcp__gobby__call_tool",
327
+ and we look inside tool_input for server_name and tool_name.
328
+
329
+ Condition evaluation has access to:
330
+ - variables: workflow state variables
331
+ - task_claimed, plan_mode: shortcuts
332
+ - tool_input: the MCP tool's arguments (for checking commit_sha etc.)
333
+ - session_has_dirty_files: whether session has NEW dirty files beyond baseline
334
+ - task_has_commits: whether the claimed task has linked commits
335
+ - source: CLI source (e.g., "claude", "gemini", "codex")
336
+
337
+ Args:
338
+ rules: List of blocking rules
339
+ event_data: Hook event data with tool_name, tool_input
340
+ workflow_state: For evaluating conditions
341
+ project_path: Path to project for git status checks
342
+ task_manager: For checking task commit status
343
+ source: CLI source identifier (for is_plan_file checks)
344
+
345
+ Returns:
346
+ Dict with decision="block" and reason if blocked, None to allow.
347
+
348
+ Example rule (native tools):
349
+ {
350
+ "tools": ["TaskCreate", "TaskUpdate"],
351
+ "reason": "CC native task tools are disabled. Use gobby-tasks MCP tools."
352
+ }
353
+
354
+ Example rule with condition:
355
+ {
356
+ "tools": ["Edit", "Write", "NotebookEdit"],
357
+ "when": "not task_claimed and not plan_mode",
358
+ "reason": "Claim a task before using Edit, Write, or NotebookEdit tools."
359
+ }
360
+
361
+ Example rule (MCP tools):
362
+ {
363
+ "mcp_tools": ["gobby-tasks:close_task"],
364
+ "when": "not task_has_commits and not tool_input.get('commit_sha')",
365
+ "reason": "A commit is required before closing this task."
366
+ }
367
+ """
368
+ if not event_data or not rules:
369
+ return None
370
+
371
+ tool_name = event_data.get("tool_name")
372
+ if not tool_name:
373
+ return None
374
+
375
+ tool_input = event_data.get("tool_input", {}) or {}
376
+
377
+ # Create lazy thunks for expensive context values (git status, DB queries).
378
+ # These are only evaluated when actually referenced in a rule condition.
379
+
380
+ def _compute_session_has_dirty_files() -> bool:
381
+ """Lazy thunk: check for new dirty files beyond baseline."""
382
+ if not workflow_state:
383
+ return False
384
+ if project_path is None:
385
+ # Can't compute without project_path - avoid running git in wrong directory
386
+ logger.debug("_compute_session_has_dirty_files: project_path is None, returning False")
387
+ return False
388
+ baseline_dirty = set(workflow_state.variables.get("baseline_dirty_files", []))
389
+ current_dirty = _get_dirty_files(project_path)
390
+ new_dirty = current_dirty - baseline_dirty
391
+ return len(new_dirty) > 0
392
+
393
+ def _compute_task_has_commits() -> bool:
394
+ """Lazy thunk: check if claimed task has linked commits."""
395
+ if not workflow_state or not task_manager:
396
+ return False
397
+ claimed_task_id = workflow_state.variables.get("claimed_task_id")
398
+ if not claimed_task_id:
399
+ return False
400
+ try:
401
+ task = task_manager.get_task(claimed_task_id)
402
+ return bool(task and task.commits)
403
+ except Exception:
404
+ return False # nosec B110 - best-effort check
405
+
406
+ # Wrap in _LazyBool so they're only computed when used in boolean context
407
+ session_has_dirty_files: _LazyBool | bool = _LazyBool(_compute_session_has_dirty_files)
408
+ task_has_commits: _LazyBool | bool = _LazyBool(_compute_task_has_commits)
409
+
410
+ for rule in rules:
411
+ # Determine if this rule matches the current tool
412
+ rule_matches = False
413
+ mcp_tool_args: dict[str, Any] = {}
414
+
415
+ # Check native CC tools (Edit, Write, etc.)
416
+ if "tools" in rule:
417
+ tools = rule.get("tools", [])
418
+ if tool_name in tools:
419
+ rule_matches = True
420
+
421
+ # Check MCP tools (server:tool format)
422
+ elif "mcp_tools" in rule:
423
+ # MCP calls come in as "call_tool" or "mcp__gobby__call_tool"
424
+ if tool_name in ("call_tool", "mcp__gobby__call_tool"):
425
+ mcp_server = tool_input.get("server_name", "")
426
+ mcp_tool = tool_input.get("tool_name", "")
427
+ mcp_key = f"{mcp_server}:{mcp_tool}"
428
+
429
+ mcp_tools = rule.get("mcp_tools", [])
430
+ if mcp_key in mcp_tools:
431
+ rule_matches = True
432
+ # For MCP tools, the actual arguments are in tool_input.arguments
433
+ mcp_tool_args = tool_input.get("arguments", {}) or {}
434
+
435
+ if not rule_matches:
436
+ continue
437
+
438
+ # Check optional condition
439
+ condition = rule.get("when")
440
+ if condition:
441
+ # For MCP tools, use the nested arguments for condition evaluation
442
+ eval_tool_input = mcp_tool_args if mcp_tool_args else tool_input
443
+ if not _evaluate_block_condition(
444
+ condition,
445
+ workflow_state,
446
+ event_data,
447
+ tool_input=eval_tool_input,
448
+ session_has_dirty_files=session_has_dirty_files,
449
+ task_has_commits=task_has_commits,
450
+ source=source,
451
+ ):
452
+ continue
453
+
454
+ reason = rule.get("reason", f"Tool '{tool_name}' is blocked.")
455
+ logger.info(f"block_tools: Blocking '{tool_name}' - {reason[:100]}")
456
+ return {"decision": "block", "reason": reason}
457
+
458
+ return None
459
+
460
+
24
461
  def _get_dirty_files(project_path: str | None = None) -> set[str]:
25
462
  """
26
463
  Get the set of dirty files from git status --porcelain.
@@ -371,8 +808,7 @@ async def require_task_review_or_close_before_stop(
371
808
  "decision": "block",
372
809
  "reason": (
373
810
  f"Task '{claimed_task_id}' is still in_progress. "
374
- f"Close it with close_task() before stopping, or set to review "
375
- f"if user intervention is needed."
811
+ f"Close it with close_task() before stopping."
376
812
  ),
377
813
  "task_id": claimed_task_id,
378
814
  "task_status": task.status,
@@ -748,10 +1184,14 @@ async def require_active_task(
748
1184
  if error_already_shown:
749
1185
  return {
750
1186
  "decision": "block",
751
- "reason": "No task claimed. See previous **Task Required** error for instructions.",
1187
+ "reason": (
1188
+ "No task claimed. See previous **Task Required** error for instructions.\n"
1189
+ "See skill: **claiming-tasks** for help."
1190
+ ),
752
1191
  "inject_context": (
753
1192
  f"**Task Required**: `{tool_name}` blocked. "
754
- f"Create or claim a task before editing files (see previous error for details)."
1193
+ f"Create or claim a task before editing files (see previous error for details).\n"
1194
+ f'For detailed guidance: `get_skill(name="claiming-tasks")`'
755
1195
  f"{project_task_hint}"
756
1196
  ),
757
1197
  }
@@ -764,7 +1204,8 @@ async def require_active_task(
764
1204
  f"- Create a task: call_tool(server_name='gobby-tasks', tool_name='create_task', arguments={{...}})\n"
765
1205
  f"- Claim an existing task: call_tool(server_name='gobby-tasks', tool_name='update_task', "
766
1206
  f"arguments={{'task_id': '...', 'status': 'in_progress'}})"
767
- f"{project_task_hint}"
1207
+ f"{project_task_hint}\n\n"
1208
+ f"See skill: **claiming-tasks** for detailed guidance."
768
1209
  ),
769
1210
  "inject_context": (
770
1211
  f"**Task Required**: The `{tool_name}` tool is blocked until you claim a task for this session.\n\n"
@@ -772,7 +1213,8 @@ async def require_active_task(
772
1213
  f'1. **Create a new task**: `create_task(title="...", description="...")`\n'
773
1214
  f'2. **Claim an existing task**: `update_task(task_id="...", status="in_progress")`\n\n'
774
1215
  f"Use `list_ready_tasks()` to see available tasks."
775
- f"{project_task_hint}"
1216
+ f"{project_task_hint}\n\n"
1217
+ f'For detailed guidance: `get_skill(name="claiming-tasks")`'
776
1218
  ),
777
1219
  }
778
1220
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gobby
3
- Version: 0.2.5
3
+ Version: 0.2.6
4
4
  Summary: A local-first daemon to unify your AI coding tools. Session tracking and handoffs across Claude Code, Gemini CLI, and Codex. An MCP proxy that discovers tools without flooding context. Task management with dependencies, validation, and TDD expansion. Agent spawning and worktree orchestration. Persistent memory, extensible workflows, and hooks.
5
5
  Author-email: Josh Wilhelmi <josh@gobby.ai>
6
6
  License-Expression: MIT
@@ -38,8 +38,9 @@ Requires-Dist: msgspec>=0.20.0
38
38
  Requires-Dist: gitingest>=0.3.1
39
39
  Requires-Dist: scikit-learn>=1.0.0
40
40
  Requires-Dist: textual>=7.3.0
41
- Requires-Dist: mem0ai
42
41
  Requires-Dist: memu-py>=1.0.0
42
+ Provides-Extra: mem0
43
+ Requires-Dist: mem0ai; extra == "mem0"
43
44
  Dynamic: license-file
44
45
 
45
46
  <!-- markdownlint-disable MD033 MD041 -->
@@ -149,35 +150,92 @@ call_tool("gobby-worktrees", "spawn_agent_in_worktree", {
149
150
  })
150
151
  ```
151
152
 
152
- ## Quick Start
153
+ ### πŸ”— Claude Code Task Integration
154
+
155
+ Gobby transparently intercepts Claude Code's built-in task system (TaskCreate, TaskUpdate, etc.) and syncs operations to Gobby's persistent task store. Benefits:
156
+
157
+ - **Tasks persist** across sessions (unlike CC's session-scoped tasks)
158
+ - **Commit linking** β€” tasks auto-link to git commits
159
+ - **Validation gates** β€” define criteria for task completion
160
+ - **LLM expansion** β€” break complex tasks into subtasks
161
+
162
+ No configuration needed β€” just use Claude Code's native task tools and Gobby handles the rest.
163
+
164
+ ### πŸ“š Skills System
165
+
166
+ Reusable instructions that teach agents how to perform specific tasks. Compatible with the [Agent Skills specification](https://agentskills.io) and SkillPort.
167
+
168
+ - **Core skills** bundled with Gobby for tasks, sessions, memory, workflows
169
+ - **Project skills** in `.gobby/skills/` for team-specific patterns
170
+ - **Install from anywhere** β€” GitHub repos, local paths, ZIP archives
171
+ - **Search and discovery** β€” TF-IDF and semantic search across your skill library
153
172
 
154
173
  ```bash
155
- # Clone and install
156
- git clone https://github.com/GobbyAI/gobby.git
157
- cd gobby
158
- uv sync
174
+ # Install a skill from GitHub
175
+ gobby skills install github:user/repo/skills/my-skill
176
+
177
+ # Search for relevant skills
178
+ gobby skills search "testing coverage"
179
+ ```
180
+
181
+ ## Installation
182
+
183
+ ### Try it instantly
184
+ ```bash
185
+ uvx gobby --help
186
+ ```
187
+
188
+ ### Install globally
189
+ ```bash
190
+ # With uv (recommended)
191
+ uv tool install gobby
192
+
193
+ # With pipx
194
+ pipx install gobby
195
+
196
+ # With pip
197
+ pip install gobby
198
+ ```
199
+
200
+ **Requirements:** Python 3.13+
159
201
 
202
+ ## Quick Start
203
+
204
+ ```bash
160
205
  # Start the daemon
161
- uv run gobby start
206
+ gobby start
162
207
 
163
208
  # In your project directory
164
- uv run gobby init
165
- uv run gobby install # Installs hooks for detected CLIs
166
-
167
- # Optional: install globally
168
- uv pip install -e .
209
+ gobby init
210
+ gobby install # Installs hooks for detected CLIs
169
211
  ```
170
212
 
171
- **Requirements:** Python 3.11+, [uv](https://github.com/astral-sh/uv), at least one AI CLI ([Claude Code](https://claude.ai/code), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Codex CLI](https://github.com/openai/codex))
213
+ **Requirements:** At least one AI CLI ([Claude Code](https://claude.ai/code), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Codex CLI](https://github.com/openai/codex))
172
214
 
173
215
  Works with your Claude, Gemini, or Codex subscriptionsβ€”or bring your own API keys. Local model support coming soon.
174
216
 
175
217
  ## Configure Your AI CLI
176
218
 
177
- Add Gobby as an MCP server:
219
+ Add Gobby as an MCP server. Choose the `command` and `args` that match your installation:
220
+
221
+ - **pip/pipx install**: `"command": "gobby"`, `"args": ["mcp-server"]`
222
+ - **uv tool install**: `"command": "uv"`, `"args": ["run", "gobby", "mcp-server"]`
178
223
 
179
224
  **Claude Code** (`.mcp.json` or `~/.claude.json`):
180
225
 
226
+ ```json
227
+ {
228
+ "mcpServers": {
229
+ "gobby": {
230
+ "command": "gobby",
231
+ "args": ["mcp-server"]
232
+ }
233
+ }
234
+ }
235
+ ```
236
+
237
+ Or with uv:
238
+
181
239
  ```json
182
240
  {
183
241
  "mcpServers": {
@@ -195,8 +253,8 @@ Add Gobby as an MCP server:
195
253
  {
196
254
  "mcpServers": {
197
255
  "gobby": {
198
- "command": "uv",
199
- "args": ["run", "gobby", "mcp-server"]
256
+ "command": "gobby",
257
+ "args": ["mcp-server"]
200
258
  }
201
259
  }
202
260
  }
@@ -206,8 +264,8 @@ Add Gobby as an MCP server:
206
264
 
207
265
  ```toml
208
266
  [mcp_servers.gobby]
209
- command = "uv"
210
- args = ["run", "gobby", "mcp-server"]
267
+ command = "gobby"
268
+ args = ["mcp-server"]
211
269
  ```
212
270
 
213
271
  **Gemini Antigravity** (`~/.gemini/antigravity/mcp_config.json`):
@@ -256,7 +314,7 @@ AI CLI (Claude/Gemini/Codex)
256
314
  Hook Dispatcher
257
315
  β”‚ HTTP POST
258
316
  β–Ό
259
- Gobby Daemon (:8765)
317
+ Gobby Daemon (:60887)
260
318
  β”‚
261
319
  β”Œβ”€β”€β”€β”€β”΄β”€β”€β”€β”€β”
262
320
  β–Ό β–Ό
@@ -298,9 +356,12 @@ Gobby exposes tools via MCP that your AI coding assistant can use:
298
356
  **Worktrees** (`gobby-worktrees`)
299
357
  `create_worktree`, `spawn_agent_in_worktree`, `list_worktrees`
300
358
 
301
- **MCP Proxy**
359
+ **MCP Proxy**
302
360
  `list_mcp_servers`, `add_mcp_server`, `import_mcp_server`, `list_tools`, `get_tool_schema`, `call_tool`, `recommend_tools`
303
361
 
362
+ **Skills** (`gobby-skills`)
363
+ `list_skills`, `get_skill`, `search_skills`, `install_skill`, `remove_skill`, `update_skill`
364
+
304
365
  ## For AI Agents
305
366
 
306
367
  **If you're an AI agent reading this:** Check out [docs/AGENT_INSTRUCTIONS.md](docs/AGENT_INSTRUCTIONS.md) for your operating procedures, and [CLAUDE.md](CLAUDE.md) for Claude Code-specific guidance.