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.
Files changed (63) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/claude_code.py +3 -26
  3. gobby/app_context.py +59 -0
  4. gobby/cli/utils.py +5 -17
  5. gobby/config/features.py +0 -20
  6. gobby/config/tasks.py +4 -0
  7. gobby/hooks/event_handlers/__init__.py +155 -0
  8. gobby/hooks/event_handlers/_agent.py +175 -0
  9. gobby/hooks/event_handlers/_base.py +87 -0
  10. gobby/hooks/event_handlers/_misc.py +66 -0
  11. gobby/hooks/event_handlers/_session.py +573 -0
  12. gobby/hooks/event_handlers/_tool.py +196 -0
  13. gobby/hooks/hook_manager.py +2 -0
  14. gobby/llm/claude.py +377 -42
  15. gobby/mcp_proxy/importer.py +4 -41
  16. gobby/mcp_proxy/manager.py +13 -3
  17. gobby/mcp_proxy/registries.py +14 -0
  18. gobby/mcp_proxy/services/recommendation.py +2 -28
  19. gobby/mcp_proxy/tools/artifacts.py +3 -3
  20. gobby/mcp_proxy/tools/task_readiness.py +27 -4
  21. gobby/mcp_proxy/tools/workflows/__init__.py +266 -0
  22. gobby/mcp_proxy/tools/workflows/_artifacts.py +225 -0
  23. gobby/mcp_proxy/tools/workflows/_import.py +112 -0
  24. gobby/mcp_proxy/tools/workflows/_lifecycle.py +321 -0
  25. gobby/mcp_proxy/tools/workflows/_query.py +207 -0
  26. gobby/mcp_proxy/tools/workflows/_resolution.py +78 -0
  27. gobby/mcp_proxy/tools/workflows/_terminal.py +139 -0
  28. gobby/memory/components/__init__.py +0 -0
  29. gobby/memory/components/ingestion.py +98 -0
  30. gobby/memory/components/search.py +108 -0
  31. gobby/memory/manager.py +16 -25
  32. gobby/paths.py +51 -0
  33. gobby/prompts/loader.py +1 -35
  34. gobby/runner.py +23 -10
  35. gobby/servers/http.py +186 -149
  36. gobby/servers/routes/admin.py +12 -0
  37. gobby/servers/routes/mcp/endpoints/execution.py +15 -7
  38. gobby/servers/routes/mcp/endpoints/registry.py +8 -8
  39. gobby/sessions/analyzer.py +2 -2
  40. gobby/skills/parser.py +23 -0
  41. gobby/skills/sync.py +5 -4
  42. gobby/storage/artifacts.py +19 -0
  43. gobby/storage/migrations.py +25 -2
  44. gobby/storage/skills.py +47 -7
  45. gobby/tasks/external_validator.py +4 -17
  46. gobby/tasks/validation.py +13 -87
  47. gobby/tools/summarizer.py +18 -51
  48. gobby/utils/status.py +13 -0
  49. gobby/workflows/actions.py +5 -0
  50. gobby/workflows/context_actions.py +21 -24
  51. gobby/workflows/enforcement/__init__.py +11 -1
  52. gobby/workflows/enforcement/blocking.py +96 -0
  53. gobby/workflows/enforcement/handlers.py +35 -1
  54. gobby/workflows/engine.py +6 -3
  55. gobby/workflows/lifecycle_evaluator.py +2 -1
  56. {gobby-0.2.8.dist-info → gobby-0.2.9.dist-info}/METADATA +1 -1
  57. {gobby-0.2.8.dist-info → gobby-0.2.9.dist-info}/RECORD +61 -45
  58. gobby/hooks/event_handlers.py +0 -1008
  59. gobby/mcp_proxy/tools/workflows.py +0 -1023
  60. {gobby-0.2.8.dist-info → gobby-0.2.9.dist-info}/WHEEL +0 -0
  61. {gobby-0.2.8.dist-info → gobby-0.2.9.dist-info}/entry_points.txt +0 -0
  62. {gobby-0.2.8.dist-info → gobby-0.2.9.dist-info}/licenses/LICENSE.md +0 -0
  63. {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:")
@@ -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
- # Add active skills from HookSkillManager
312
- try:
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
- lines = ["### Files Being Modified"]
420
- for f in ctx.files_modified:
421
- lines.append(f"- {f}")
422
- sections.append("\n".join(lines))
423
-
424
- # Initial goal section
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
- sections.append(f"### Original Goal\n{ctx.initial_goal}")
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 skills section
436
- if hasattr(ctx, "active_skills") and ctx.active_skills:
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 block_tools
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 workflow default variables with passed-in variables
557
- merged_variables = dict(definition.variables)
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(
@@ -636,6 +636,7 @@ async def evaluate_all_lifecycle_workflows(
636
636
  "path": str(w.path),
637
637
  }
638
638
  for w in workflows
639
- ]
639
+ ],
640
+ "workflow_variables": context_data,
640
641
  },
641
642
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gobby
3
- Version: 0.2.8
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