gobby 0.2.5__py3-none-any.whl β 0.2.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gobby/adapters/claude_code.py +13 -4
- gobby/adapters/codex.py +43 -3
- gobby/agents/runner.py +8 -0
- gobby/cli/__init__.py +6 -0
- gobby/cli/clones.py +419 -0
- gobby/cli/conductor.py +266 -0
- gobby/cli/installers/antigravity.py +3 -9
- gobby/cli/installers/claude.py +9 -9
- gobby/cli/installers/codex.py +2 -8
- gobby/cli/installers/gemini.py +2 -8
- gobby/cli/installers/shared.py +71 -8
- gobby/cli/skills.py +858 -0
- gobby/cli/tasks/ai.py +0 -440
- gobby/cli/tasks/crud.py +44 -6
- gobby/cli/tasks/main.py +0 -4
- gobby/cli/tui.py +2 -2
- gobby/cli/utils.py +3 -3
- gobby/clones/__init__.py +13 -0
- gobby/clones/git.py +547 -0
- gobby/conductor/__init__.py +16 -0
- gobby/conductor/alerts.py +135 -0
- gobby/conductor/loop.py +164 -0
- gobby/conductor/monitors/__init__.py +11 -0
- gobby/conductor/monitors/agents.py +116 -0
- gobby/conductor/monitors/tasks.py +155 -0
- gobby/conductor/pricing.py +234 -0
- gobby/conductor/token_tracker.py +160 -0
- gobby/config/app.py +63 -1
- gobby/config/search.py +110 -0
- gobby/config/servers.py +1 -1
- gobby/config/skills.py +43 -0
- gobby/config/tasks.py +6 -14
- gobby/hooks/event_handlers.py +145 -2
- gobby/hooks/hook_manager.py +48 -2
- gobby/hooks/skill_manager.py +130 -0
- gobby/install/claude/hooks/hook_dispatcher.py +4 -4
- gobby/install/codex/hooks/hook_dispatcher.py +1 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
- gobby/llm/claude.py +22 -34
- gobby/llm/claude_executor.py +46 -256
- gobby/llm/codex_executor.py +59 -291
- gobby/llm/executor.py +21 -0
- gobby/llm/gemini.py +134 -110
- gobby/llm/litellm_executor.py +143 -6
- gobby/llm/resolver.py +95 -33
- gobby/mcp_proxy/instructions.py +54 -0
- gobby/mcp_proxy/models.py +15 -0
- gobby/mcp_proxy/registries.py +68 -5
- gobby/mcp_proxy/server.py +33 -3
- gobby/mcp_proxy/services/tool_proxy.py +81 -1
- gobby/mcp_proxy/stdio.py +2 -1
- gobby/mcp_proxy/tools/__init__.py +0 -2
- gobby/mcp_proxy/tools/agent_messaging.py +317 -0
- gobby/mcp_proxy/tools/clones.py +903 -0
- gobby/mcp_proxy/tools/memory.py +1 -24
- gobby/mcp_proxy/tools/metrics.py +65 -1
- gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
- gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
- gobby/mcp_proxy/tools/session_messages.py +1 -2
- gobby/mcp_proxy/tools/skills/__init__.py +631 -0
- gobby/mcp_proxy/tools/task_orchestration.py +7 -0
- gobby/mcp_proxy/tools/task_readiness.py +14 -0
- gobby/mcp_proxy/tools/task_sync.py +1 -1
- gobby/mcp_proxy/tools/tasks/_context.py +0 -20
- gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
- gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +60 -29
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
- gobby/mcp_proxy/tools/workflows.py +1 -1
- gobby/mcp_proxy/tools/worktrees.py +5 -0
- gobby/memory/backends/__init__.py +6 -1
- gobby/memory/backends/mem0.py +6 -1
- gobby/memory/extractor.py +477 -0
- gobby/memory/manager.py +11 -2
- gobby/prompts/defaults/handoff/compact.md +63 -0
- gobby/prompts/defaults/handoff/session_end.md +57 -0
- gobby/prompts/defaults/memory/extract.md +61 -0
- gobby/runner.py +37 -16
- gobby/search/__init__.py +48 -6
- gobby/search/backends/__init__.py +159 -0
- gobby/search/backends/embedding.py +225 -0
- gobby/search/embeddings.py +238 -0
- gobby/search/models.py +148 -0
- gobby/search/unified.py +496 -0
- gobby/servers/http.py +23 -8
- gobby/servers/routes/admin.py +280 -0
- gobby/servers/routes/mcp/tools.py +241 -52
- gobby/servers/websocket.py +2 -2
- gobby/sessions/analyzer.py +2 -0
- gobby/sessions/transcripts/base.py +1 -0
- gobby/sessions/transcripts/claude.py +64 -5
- gobby/skills/__init__.py +91 -0
- gobby/skills/loader.py +685 -0
- gobby/skills/manager.py +384 -0
- gobby/skills/parser.py +258 -0
- gobby/skills/search.py +463 -0
- gobby/skills/sync.py +119 -0
- gobby/skills/updater.py +385 -0
- gobby/skills/validator.py +368 -0
- gobby/storage/clones.py +378 -0
- gobby/storage/database.py +1 -1
- gobby/storage/memories.py +43 -13
- gobby/storage/migrations.py +180 -6
- gobby/storage/sessions.py +73 -0
- gobby/storage/skills.py +749 -0
- gobby/storage/tasks/_crud.py +4 -4
- gobby/storage/tasks/_lifecycle.py +41 -6
- gobby/storage/tasks/_manager.py +14 -5
- gobby/storage/tasks/_models.py +8 -3
- gobby/sync/memories.py +39 -4
- gobby/sync/tasks.py +83 -6
- gobby/tasks/__init__.py +1 -2
- gobby/tasks/validation.py +24 -15
- gobby/tui/api_client.py +4 -7
- gobby/tui/app.py +5 -3
- gobby/tui/screens/orchestrator.py +1 -2
- gobby/tui/screens/tasks.py +2 -4
- gobby/tui/ws_client.py +1 -1
- gobby/utils/daemon_client.py +2 -2
- gobby/workflows/actions.py +84 -2
- gobby/workflows/context_actions.py +43 -0
- gobby/workflows/detection_helpers.py +115 -31
- gobby/workflows/engine.py +13 -2
- gobby/workflows/lifecycle_evaluator.py +29 -1
- gobby/workflows/loader.py +19 -6
- gobby/workflows/memory_actions.py +74 -0
- gobby/workflows/summary_actions.py +17 -0
- gobby/workflows/task_enforcement_actions.py +448 -6
- {gobby-0.2.5.dist-info β gobby-0.2.6.dist-info}/METADATA +82 -21
- {gobby-0.2.5.dist-info β gobby-0.2.6.dist-info}/RECORD +136 -107
- gobby/install/codex/prompts/forget.md +0 -7
- gobby/install/codex/prompts/memories.md +0 -7
- gobby/install/codex/prompts/recall.md +0 -7
- gobby/install/codex/prompts/remember.md +0 -13
- gobby/llm/gemini_executor.py +0 -339
- gobby/mcp_proxy/tools/task_expansion.py +0 -591
- gobby/tasks/context.py +0 -747
- gobby/tasks/criteria.py +0 -342
- gobby/tasks/expansion.py +0 -626
- gobby/tasks/prompts/expand.py +0 -327
- gobby/tasks/research.py +0 -421
- gobby/tasks/tdd.py +0 -352
- {gobby-0.2.5.dist-info β gobby-0.2.6.dist-info}/WHEEL +0 -0
- {gobby-0.2.5.dist-info β gobby-0.2.6.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.5.dist-info β gobby-0.2.6.dist-info}/licenses/LICENSE.md +0 -0
- {gobby-0.2.5.dist-info β gobby-0.2.6.dist-info}/top_level.txt +0 -0
|
@@ -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
|
|
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":
|
|
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.
|
|
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
|
-
|
|
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
|
-
#
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
206
|
+
gobby start
|
|
162
207
|
|
|
163
208
|
# In your project directory
|
|
164
|
-
|
|
165
|
-
|
|
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:**
|
|
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": "
|
|
199
|
-
"args": ["
|
|
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 = "
|
|
210
|
-
args = ["
|
|
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 (:
|
|
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.
|