gobby 0.2.8__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.
- gobby/__init__.py +1 -1
- gobby/adapters/claude_code.py +3 -26
- gobby/app_context.py +59 -0
- gobby/cli/utils.py +5 -17
- gobby/config/features.py +0 -20
- gobby/config/tasks.py +4 -0
- gobby/hooks/event_handlers/__init__.py +155 -0
- gobby/hooks/event_handlers/_agent.py +175 -0
- gobby/hooks/event_handlers/_base.py +87 -0
- gobby/hooks/event_handlers/_misc.py +66 -0
- gobby/hooks/event_handlers/_session.py +573 -0
- gobby/hooks/event_handlers/_tool.py +196 -0
- gobby/hooks/hook_manager.py +2 -0
- gobby/llm/claude.py +377 -42
- gobby/mcp_proxy/importer.py +4 -41
- gobby/mcp_proxy/manager.py +13 -3
- gobby/mcp_proxy/registries.py +14 -0
- gobby/mcp_proxy/services/recommendation.py +2 -28
- gobby/mcp_proxy/tools/artifacts.py +3 -3
- gobby/mcp_proxy/tools/task_readiness.py +27 -4
- gobby/mcp_proxy/tools/workflows/__init__.py +266 -0
- gobby/mcp_proxy/tools/workflows/_artifacts.py +225 -0
- gobby/mcp_proxy/tools/workflows/_import.py +112 -0
- gobby/mcp_proxy/tools/workflows/_lifecycle.py +321 -0
- gobby/mcp_proxy/tools/workflows/_query.py +207 -0
- gobby/mcp_proxy/tools/workflows/_resolution.py +78 -0
- gobby/mcp_proxy/tools/workflows/_terminal.py +139 -0
- gobby/memory/components/__init__.py +0 -0
- gobby/memory/components/ingestion.py +98 -0
- gobby/memory/components/search.py +108 -0
- gobby/memory/manager.py +16 -25
- gobby/paths.py +51 -0
- gobby/prompts/loader.py +1 -35
- gobby/runner.py +23 -10
- gobby/servers/http.py +186 -149
- gobby/servers/routes/admin.py +12 -0
- gobby/servers/routes/mcp/endpoints/execution.py +15 -7
- gobby/servers/routes/mcp/endpoints/registry.py +8 -8
- gobby/sessions/analyzer.py +2 -2
- gobby/skills/parser.py +23 -0
- gobby/skills/sync.py +5 -4
- gobby/storage/artifacts.py +19 -0
- gobby/storage/migrations.py +25 -2
- gobby/storage/skills.py +47 -7
- gobby/tasks/external_validator.py +4 -17
- gobby/tasks/validation.py +13 -87
- gobby/tools/summarizer.py +18 -51
- gobby/utils/status.py +13 -0
- gobby/workflows/actions.py +5 -0
- gobby/workflows/context_actions.py +21 -24
- gobby/workflows/enforcement/__init__.py +11 -1
- gobby/workflows/enforcement/blocking.py +96 -0
- gobby/workflows/enforcement/handlers.py +35 -1
- gobby/workflows/engine.py +6 -3
- gobby/workflows/lifecycle_evaluator.py +2 -1
- {gobby-0.2.8.dist-info → gobby-0.2.9.dist-info}/METADATA +1 -1
- {gobby-0.2.8.dist-info → gobby-0.2.9.dist-info}/RECORD +61 -45
- gobby/hooks/event_handlers.py +0 -1008
- gobby/mcp_proxy/tools/workflows.py +0 -1023
- {gobby-0.2.8.dist-info → gobby-0.2.9.dist-info}/WHEEL +0 -0
- {gobby-0.2.8.dist-info → gobby-0.2.9.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.8.dist-info → gobby-0.2.9.dist-info}/licenses/LICENSE.md +0 -0
- {gobby-0.2.8.dist-info → gobby-0.2.9.dist-info}/top_level.txt +0 -0
gobby/utils/status.py
CHANGED
|
@@ -84,6 +84,11 @@ def fetch_rich_status(http_port: int, timeout: float = 2.0) -> dict[str, Any]:
|
|
|
84
84
|
if skills_data:
|
|
85
85
|
status_kwargs["skills_total"] = skills_data.get("total", 0)
|
|
86
86
|
|
|
87
|
+
# Artifacts
|
|
88
|
+
artifacts_data = data.get("artifacts", {})
|
|
89
|
+
if artifacts_data and artifacts_data.get("count", 0) > 0:
|
|
90
|
+
status_kwargs["artifacts_count"] = artifacts_data.get("count", 0)
|
|
91
|
+
|
|
87
92
|
except (httpx.ConnectError, httpx.TimeoutException):
|
|
88
93
|
# Daemon not responding - return empty
|
|
89
94
|
pass
|
|
@@ -124,6 +129,8 @@ def format_status_message(
|
|
|
124
129
|
memories_avg_importance: float | None = None,
|
|
125
130
|
# Skills
|
|
126
131
|
skills_total: int | None = None,
|
|
132
|
+
# Artifacts
|
|
133
|
+
artifacts_count: int | None = None,
|
|
127
134
|
**kwargs: Any,
|
|
128
135
|
) -> str:
|
|
129
136
|
"""
|
|
@@ -254,6 +261,12 @@ def format_status_message(
|
|
|
254
261
|
lines.append(f" {mem_str}")
|
|
255
262
|
lines.append("")
|
|
256
263
|
|
|
264
|
+
# Artifacts section (only show if we have data)
|
|
265
|
+
if artifacts_count is not None:
|
|
266
|
+
lines.append("Artifacts:")
|
|
267
|
+
lines.append(f" Captured: {artifacts_count}")
|
|
268
|
+
lines.append("")
|
|
269
|
+
|
|
257
270
|
# Paths section (only when running)
|
|
258
271
|
if running and (pid_file or log_files):
|
|
259
272
|
lines.append("Paths:")
|
gobby/workflows/actions.py
CHANGED
|
@@ -32,6 +32,7 @@ from gobby.workflows.enforcement import (
|
|
|
32
32
|
handle_require_commit_before_stop,
|
|
33
33
|
handle_require_task_complete,
|
|
34
34
|
handle_require_task_review_or_close_before_stop,
|
|
35
|
+
handle_track_schema_lookup,
|
|
35
36
|
handle_validate_session_task_scope,
|
|
36
37
|
)
|
|
37
38
|
from gobby.workflows.llm_actions import handle_call_llm
|
|
@@ -283,6 +284,9 @@ class ActionExecutor:
|
|
|
283
284
|
async def capture_baseline(context: ActionContext, **kw: Any) -> dict[str, Any] | None:
|
|
284
285
|
return await handle_capture_baseline_dirty_files(context, task_manager=tm, **kw)
|
|
285
286
|
|
|
287
|
+
async def track_schema(context: ActionContext, **kw: Any) -> dict[str, Any] | None:
|
|
288
|
+
return await handle_track_schema_lookup(context, task_manager=tm, **kw)
|
|
289
|
+
|
|
286
290
|
self.register("block_tools", block_tools)
|
|
287
291
|
self.register("require_active_task", require_active)
|
|
288
292
|
self.register("require_task_complete", require_complete)
|
|
@@ -290,6 +294,7 @@ class ActionExecutor:
|
|
|
290
294
|
self.register("require_task_review_or_close_before_stop", require_review)
|
|
291
295
|
self.register("validate_session_task_scope", validate_scope)
|
|
292
296
|
self.register("capture_baseline_dirty_files", capture_baseline)
|
|
297
|
+
self.register("track_schema_lookup", track_schema)
|
|
293
298
|
|
|
294
299
|
def _register_webhook_action(self) -> None:
|
|
295
300
|
"""Register webhook action with config closure."""
|
|
@@ -308,16 +308,8 @@ def extract_handoff_context(
|
|
|
308
308
|
except Exception as wt_err:
|
|
309
309
|
logger.debug(f"Failed to get worktree context: {wt_err}")
|
|
310
310
|
|
|
311
|
-
#
|
|
312
|
-
|
|
313
|
-
from gobby.hooks.skill_manager import HookSkillManager
|
|
314
|
-
|
|
315
|
-
skill_manager = HookSkillManager()
|
|
316
|
-
core_skills = skill_manager.discover_core_skills()
|
|
317
|
-
always_apply_skills = [s.name for s in core_skills if s.is_always_apply()]
|
|
318
|
-
handoff_ctx.active_skills = always_apply_skills
|
|
319
|
-
except Exception as skill_err:
|
|
320
|
-
logger.debug(f"Failed to get active skills: {skill_err}")
|
|
311
|
+
# Note: active_skills population removed - redundant with _build_skill_injection_context()
|
|
312
|
+
# which already handles skill restoration on session start
|
|
321
313
|
|
|
322
314
|
# Format as markdown (like /clear stores formatted summary)
|
|
323
315
|
markdown = format_handoff_as_markdown(handoff_ctx)
|
|
@@ -414,16 +406,24 @@ def format_handoff_as_markdown(ctx: Any, prompt_template: str | None = None) ->
|
|
|
414
406
|
if ctx.git_status:
|
|
415
407
|
sections.append(f"### Uncommitted Changes\n```\n{ctx.git_status}\n```")
|
|
416
408
|
|
|
417
|
-
# Files modified section
|
|
418
|
-
if ctx.files_modified:
|
|
419
|
-
|
|
420
|
-
for f in ctx.files_modified
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
409
|
+
# Files modified section - only show files still dirty (not yet committed)
|
|
410
|
+
if ctx.files_modified and ctx.git_status:
|
|
411
|
+
# Filter to files that appear in git status (still uncommitted)
|
|
412
|
+
dirty_files = [f for f in ctx.files_modified if f in ctx.git_status]
|
|
413
|
+
if dirty_files:
|
|
414
|
+
lines = ["### Files Being Modified"]
|
|
415
|
+
for f in dirty_files:
|
|
416
|
+
lines.append(f"- {f}")
|
|
417
|
+
sections.append("\n".join(lines))
|
|
418
|
+
|
|
419
|
+
# Initial goal section - only if task is still active (not closed/completed)
|
|
425
420
|
if ctx.initial_goal:
|
|
426
|
-
|
|
421
|
+
task_status = None
|
|
422
|
+
if ctx.active_gobby_task:
|
|
423
|
+
task_status = ctx.active_gobby_task.get("status")
|
|
424
|
+
# Only include if no task or task is still open/in_progress
|
|
425
|
+
if task_status in (None, "open", "in_progress"):
|
|
426
|
+
sections.append(f"### Original Goal\n{ctx.initial_goal}")
|
|
427
427
|
|
|
428
428
|
# Recent activity section
|
|
429
429
|
if ctx.recent_activity:
|
|
@@ -432,11 +432,8 @@ def format_handoff_as_markdown(ctx: Any, prompt_template: str | None = None) ->
|
|
|
432
432
|
lines.append(f"- {activity}")
|
|
433
433
|
sections.append("\n".join(lines))
|
|
434
434
|
|
|
435
|
-
# Active
|
|
436
|
-
|
|
437
|
-
lines = ["### Active Skills"]
|
|
438
|
-
lines.append(f"Skills available: {', '.join(ctx.active_skills)}")
|
|
439
|
-
sections.append("\n".join(lines))
|
|
435
|
+
# Note: Active Skills section removed - redundant with _build_skill_injection_context()
|
|
436
|
+
# which already handles skill restoration on session start
|
|
440
437
|
|
|
441
438
|
return "\n\n".join(sections)
|
|
442
439
|
|
|
@@ -4,7 +4,12 @@ This package provides actions that enforce task tracking before allowing
|
|
|
4
4
|
certain tools, and enforce task completion before allowing agent to stop.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
from gobby.workflows.enforcement.blocking import
|
|
7
|
+
from gobby.workflows.enforcement.blocking import (
|
|
8
|
+
block_tools,
|
|
9
|
+
is_discovery_tool,
|
|
10
|
+
is_tool_unlocked,
|
|
11
|
+
track_schema_lookup,
|
|
12
|
+
)
|
|
8
13
|
from gobby.workflows.enforcement.commit_policy import (
|
|
9
14
|
capture_baseline_dirty_files,
|
|
10
15
|
require_commit_before_stop,
|
|
@@ -17,6 +22,7 @@ from gobby.workflows.enforcement.handlers import (
|
|
|
17
22
|
handle_require_commit_before_stop,
|
|
18
23
|
handle_require_task_complete,
|
|
19
24
|
handle_require_task_review_or_close_before_stop,
|
|
25
|
+
handle_track_schema_lookup,
|
|
20
26
|
handle_validate_session_task_scope,
|
|
21
27
|
)
|
|
22
28
|
from gobby.workflows.enforcement.task_policy import (
|
|
@@ -28,6 +34,9 @@ from gobby.workflows.enforcement.task_policy import (
|
|
|
28
34
|
__all__ = [
|
|
29
35
|
# Blocking
|
|
30
36
|
"block_tools",
|
|
37
|
+
"is_discovery_tool",
|
|
38
|
+
"is_tool_unlocked",
|
|
39
|
+
"track_schema_lookup",
|
|
31
40
|
# Commit policy
|
|
32
41
|
"capture_baseline_dirty_files",
|
|
33
42
|
"require_commit_before_stop",
|
|
@@ -43,5 +52,6 @@ __all__ = [
|
|
|
43
52
|
"handle_require_commit_before_stop",
|
|
44
53
|
"handle_require_task_complete",
|
|
45
54
|
"handle_require_task_review_or_close_before_stop",
|
|
55
|
+
"handle_track_schema_lookup",
|
|
46
56
|
"handle_validate_session_task_scope",
|
|
47
57
|
]
|
|
@@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, Any
|
|
|
12
12
|
|
|
13
13
|
from gobby.workflows.git_utils import get_dirty_files
|
|
14
14
|
from gobby.workflows.safe_evaluator import LazyBool, SafeExpressionEvaluator
|
|
15
|
+
from gobby.workflows.templates import TemplateEngine
|
|
15
16
|
|
|
16
17
|
if TYPE_CHECKING:
|
|
17
18
|
from gobby.storage.tasks import LocalTaskManager
|
|
@@ -19,6 +20,89 @@ if TYPE_CHECKING:
|
|
|
19
20
|
|
|
20
21
|
logger = logging.getLogger(__name__)
|
|
21
22
|
|
|
23
|
+
# MCP discovery tools that don't require prior schema lookup
|
|
24
|
+
DISCOVERY_TOOLS = {
|
|
25
|
+
"list_mcp_servers",
|
|
26
|
+
"list_tools",
|
|
27
|
+
"get_tool_schema",
|
|
28
|
+
"search_tools",
|
|
29
|
+
"recommend_tools",
|
|
30
|
+
"list_skills",
|
|
31
|
+
"get_skill",
|
|
32
|
+
"search_skills",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_discovery_tool(tool_name: str | None) -> bool:
|
|
37
|
+
"""Check if the tool is a discovery/introspection tool.
|
|
38
|
+
|
|
39
|
+
These tools are allowed without prior schema lookup since they ARE
|
|
40
|
+
the discovery mechanism.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
tool_name: The MCP tool name (from tool_input.tool_name)
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
True if this is a discovery tool that doesn't need schema unlock
|
|
47
|
+
"""
|
|
48
|
+
return tool_name in DISCOVERY_TOOLS if tool_name else False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_tool_unlocked(
|
|
52
|
+
tool_input: dict[str, Any],
|
|
53
|
+
variables: dict[str, Any],
|
|
54
|
+
) -> bool:
|
|
55
|
+
"""Check if a tool has been unlocked via prior get_tool_schema call.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
tool_input: The tool input containing server_name and tool_name
|
|
59
|
+
variables: Workflow state variables containing unlocked_tools list
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
True if the server:tool combo was previously unlocked via get_tool_schema
|
|
63
|
+
"""
|
|
64
|
+
server = tool_input.get("server_name", "")
|
|
65
|
+
tool = tool_input.get("tool_name", "")
|
|
66
|
+
if not server or not tool:
|
|
67
|
+
return False
|
|
68
|
+
key = f"{server}:{tool}"
|
|
69
|
+
unlocked = variables.get("unlocked_tools", [])
|
|
70
|
+
return key in unlocked
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def track_schema_lookup(
|
|
74
|
+
tool_input: dict[str, Any],
|
|
75
|
+
workflow_state: WorkflowState | None,
|
|
76
|
+
) -> dict[str, Any] | None:
|
|
77
|
+
"""Track a successful get_tool_schema call by adding to unlocked_tools.
|
|
78
|
+
|
|
79
|
+
Called from on_after_tool when tool_name is get_tool_schema and succeeded.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
tool_input: The tool input containing server_name and tool_name
|
|
83
|
+
workflow_state: Workflow state to update
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Dict with tracking result or None
|
|
87
|
+
"""
|
|
88
|
+
if not workflow_state:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
server = tool_input.get("server_name", "")
|
|
92
|
+
tool = tool_input.get("tool_name", "")
|
|
93
|
+
if not server or not tool:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
key = f"{server}:{tool}"
|
|
97
|
+
unlocked = workflow_state.variables.setdefault("unlocked_tools", [])
|
|
98
|
+
|
|
99
|
+
if key not in unlocked:
|
|
100
|
+
unlocked.append(key)
|
|
101
|
+
logger.debug(f"Unlocked tool schema: {key}")
|
|
102
|
+
return {"unlocked": key, "total_unlocked": len(unlocked)}
|
|
103
|
+
|
|
104
|
+
return {"already_unlocked": key}
|
|
105
|
+
|
|
22
106
|
|
|
23
107
|
def _is_plan_file(file_path: str, source: str | None = None) -> bool:
|
|
24
108
|
"""Check if file path is a Claude Code plan file (platform-agnostic).
|
|
@@ -99,6 +183,8 @@ def _evaluate_block_condition(
|
|
|
99
183
|
# Allowed functions for safe evaluation
|
|
100
184
|
allowed_funcs: dict[str, Callable[..., Any]] = {
|
|
101
185
|
"is_plan_file": _is_plan_file,
|
|
186
|
+
"is_discovery_tool": is_discovery_tool,
|
|
187
|
+
"is_tool_unlocked": lambda ti: is_tool_unlocked(ti, variables),
|
|
102
188
|
"bool": bool,
|
|
103
189
|
"str": str,
|
|
104
190
|
"int": int,
|
|
@@ -275,6 +361,16 @@ async def block_tools(
|
|
|
275
361
|
continue
|
|
276
362
|
|
|
277
363
|
reason = rule.get("reason", f"Tool '{tool_name}' is blocked.")
|
|
364
|
+
|
|
365
|
+
# Render Jinja2 template variables in reason message
|
|
366
|
+
if "{{" in reason:
|
|
367
|
+
try:
|
|
368
|
+
engine = TemplateEngine()
|
|
369
|
+
reason = engine.render(reason, {"tool_input": tool_input})
|
|
370
|
+
except Exception as e:
|
|
371
|
+
logger.warning(f"Failed to render reason template: {e}")
|
|
372
|
+
# Keep original reason on failure
|
|
373
|
+
|
|
278
374
|
logger.info(f"block_tools: Blocking '{tool_name}' - {reason[:100]}")
|
|
279
375
|
return {"decision": "block", "reason": reason}
|
|
280
376
|
|
|
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
import logging
|
|
10
10
|
from typing import TYPE_CHECKING, Any
|
|
11
11
|
|
|
12
|
-
from gobby.workflows.enforcement.blocking import block_tools
|
|
12
|
+
from gobby.workflows.enforcement.blocking import block_tools, track_schema_lookup
|
|
13
13
|
from gobby.workflows.enforcement.commit_policy import (
|
|
14
14
|
capture_baseline_dirty_files,
|
|
15
15
|
require_commit_before_stop,
|
|
@@ -33,6 +33,7 @@ __all__ = [
|
|
|
33
33
|
"handle_require_commit_before_stop",
|
|
34
34
|
"handle_require_task_complete",
|
|
35
35
|
"handle_require_task_review_or_close_before_stop",
|
|
36
|
+
"handle_track_schema_lookup",
|
|
36
37
|
"handle_validate_session_task_scope",
|
|
37
38
|
]
|
|
38
39
|
|
|
@@ -267,3 +268,36 @@ async def handle_require_task_complete(
|
|
|
267
268
|
project_id=project_id,
|
|
268
269
|
workflow_state=context.state,
|
|
269
270
|
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
async def handle_track_schema_lookup(
|
|
274
|
+
context: Any,
|
|
275
|
+
task_manager: LocalTaskManager | None = None,
|
|
276
|
+
**kwargs: Any,
|
|
277
|
+
) -> dict[str, Any] | None:
|
|
278
|
+
"""ActionHandler wrapper for track_schema_lookup.
|
|
279
|
+
|
|
280
|
+
Tracks successful get_tool_schema calls to unlock tools for call_tool.
|
|
281
|
+
Should be triggered on on_after_tool when the tool is get_tool_schema.
|
|
282
|
+
"""
|
|
283
|
+
if not context.event_data:
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
tool_name = context.event_data.get("tool_name", "")
|
|
287
|
+
is_failure = context.event_data.get("is_failure", False)
|
|
288
|
+
|
|
289
|
+
# Only track successful get_tool_schema calls
|
|
290
|
+
# Handle both native MCP format and Gobby proxy format
|
|
291
|
+
if tool_name not in ("get_tool_schema", "mcp__gobby__get_tool_schema"):
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
if is_failure:
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
# Extract tool_input - for MCP proxy, it's in tool_input directly
|
|
298
|
+
tool_input = context.event_data.get("tool_input", {}) or {}
|
|
299
|
+
|
|
300
|
+
return track_schema_lookup(
|
|
301
|
+
tool_input=tool_input,
|
|
302
|
+
workflow_state=context.state,
|
|
303
|
+
)
|
gobby/workflows/engine.py
CHANGED
|
@@ -553,10 +553,13 @@ class WorkflowEngine:
|
|
|
553
553
|
}
|
|
554
554
|
step = definition.steps[0].name
|
|
555
555
|
|
|
556
|
-
# Merge
|
|
557
|
-
|
|
556
|
+
# Merge variables: preserve existing lifecycle variables, then apply workflow declarations
|
|
557
|
+
# Priority: existing state < workflow defaults < passed-in variables
|
|
558
|
+
# This preserves lifecycle variables (like unlocked_tools) that the step workflow doesn't declare
|
|
559
|
+
merged_variables = dict(existing.variables) if existing else {}
|
|
560
|
+
merged_variables.update(definition.variables) # Override with workflow-declared defaults
|
|
558
561
|
if variables:
|
|
559
|
-
merged_variables.update(variables)
|
|
562
|
+
merged_variables.update(variables) # Override with passed-in values
|
|
560
563
|
|
|
561
564
|
# Create state
|
|
562
565
|
state = WorkflowState(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gobby
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.9
|
|
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
|