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
@@ -0,0 +1,631 @@
1
+ """
2
+ Internal MCP tools for Skill management.
3
+
4
+ Exposes functionality for:
5
+ - list_skills(): List all skills with lightweight metadata
6
+ - get_skill(): Get skill by ID or name with full content
7
+ - search_skills(): Search skills by query with relevance ranking
8
+ - update_skill(): Update an existing skill by refreshing from source
9
+ - install_skill(): Install skill from local path, GitHub URL, or ZIP archive
10
+ - remove_skill(): Remove a skill by name or ID
11
+
12
+ These tools use LocalSkillManager for storage and SkillSearch for search.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from collections.abc import Callable
19
+ from pathlib import Path
20
+ from typing import TYPE_CHECKING, Any
21
+
22
+ from gobby.mcp_proxy.tools.internal import InternalToolRegistry
23
+ from gobby.skills.loader import SkillLoader, SkillLoadError
24
+ from gobby.skills.search import SearchFilters, SkillSearch
25
+ from gobby.skills.updater import SkillUpdater
26
+ from gobby.storage.skills import LocalSkillManager
27
+
28
+ if TYPE_CHECKING:
29
+ from gobby.storage.database import DatabaseProtocol
30
+
31
+ __all__ = ["create_skills_registry", "SkillsToolRegistry"]
32
+
33
+
34
+ class SkillsToolRegistry(InternalToolRegistry):
35
+ """Registry for skill management tools with test-friendly get_tool method."""
36
+
37
+ search: SkillSearch # Assigned dynamically in create_skills_registry
38
+
39
+ def get_tool(self, name: str) -> Callable[..., Any] | None:
40
+ """Get a tool function by name (for testing)."""
41
+ tool = self._tools.get(name)
42
+ return tool.func if tool else None
43
+
44
+
45
+ def create_skills_registry(
46
+ db: DatabaseProtocol,
47
+ project_id: str | None = None,
48
+ ) -> SkillsToolRegistry:
49
+ """
50
+ Create a skills management tool registry.
51
+
52
+ Args:
53
+ db: Database connection for storage
54
+ project_id: Optional default project scope for skill operations
55
+
56
+ Returns:
57
+ SkillsToolRegistry with skill management tools registered
58
+ """
59
+ registry = SkillsToolRegistry(
60
+ name="gobby-skills",
61
+ description="Skill management - list_skills, get_skill, search_skills, install_skill, update_skill, remove_skill",
62
+ )
63
+
64
+ # Initialize storage
65
+ storage = LocalSkillManager(db)
66
+
67
+ # --- list_skills tool ---
68
+
69
+ @registry.tool(
70
+ name="list_skills",
71
+ description="List all skills with lightweight metadata. Supports filtering by category and enabled status.",
72
+ )
73
+ async def list_skills(
74
+ category: str | None = None,
75
+ enabled: bool | None = None,
76
+ limit: int = 50,
77
+ ) -> dict[str, Any]:
78
+ """
79
+ List skills with lightweight metadata.
80
+
81
+ Returns ~100 tokens per skill: name, description, category, tags, enabled.
82
+ Does NOT include content, allowed_tools, or compatibility.
83
+
84
+ Args:
85
+ category: Optional category filter
86
+ enabled: Optional enabled status filter (True/False/None for all)
87
+ limit: Maximum skills to return (default 50)
88
+
89
+ Returns:
90
+ Dict with success status and list of skill metadata
91
+ """
92
+ try:
93
+ skills = storage.list_skills(
94
+ project_id=project_id,
95
+ category=category,
96
+ enabled=enabled,
97
+ limit=limit,
98
+ include_global=True,
99
+ )
100
+
101
+ # Extract lightweight metadata only
102
+ skill_list = []
103
+ for skill in skills:
104
+ # Get category and tags from metadata
105
+ category_value = None
106
+ tags = []
107
+ if skill.metadata and isinstance(skill.metadata, dict):
108
+ skillport = skill.metadata.get("skillport", {})
109
+ if isinstance(skillport, dict):
110
+ category_value = skillport.get("category")
111
+ tags = skillport.get("tags", [])
112
+
113
+ skill_list.append(
114
+ {
115
+ "id": skill.id,
116
+ "name": skill.name,
117
+ "description": skill.description,
118
+ "category": category_value,
119
+ "tags": tags,
120
+ "enabled": skill.enabled,
121
+ }
122
+ )
123
+
124
+ return {
125
+ "success": True,
126
+ "count": len(skill_list),
127
+ "skills": skill_list,
128
+ }
129
+ except Exception as e:
130
+ return {
131
+ "success": False,
132
+ "error": str(e),
133
+ }
134
+
135
+ # --- get_skill tool ---
136
+
137
+ @registry.tool(
138
+ name="get_skill",
139
+ description="Get full skill content by name or ID. Returns complete skill including content, allowed_tools, etc.",
140
+ )
141
+ async def get_skill(
142
+ name: str | None = None,
143
+ skill_id: str | None = None,
144
+ ) -> dict[str, Any]:
145
+ """
146
+ Get a skill by name or ID with full content.
147
+
148
+ Returns all skill fields including content, allowed_tools, compatibility.
149
+ Use this after list_skills to get the full skill when needed.
150
+
151
+ Args:
152
+ name: Skill name (used if skill_id not provided)
153
+ skill_id: Skill ID (takes precedence over name)
154
+
155
+ Returns:
156
+ Dict with success status and full skill data
157
+ """
158
+ try:
159
+ # Validate input
160
+ if not skill_id and not name:
161
+ return {
162
+ "success": False,
163
+ "error": "Either name or skill_id is required",
164
+ }
165
+
166
+ # Get skill by ID or name
167
+ skill = None
168
+ if skill_id:
169
+ try:
170
+ skill = storage.get_skill(skill_id)
171
+ except ValueError:
172
+ pass
173
+
174
+ if skill is None and name:
175
+ skill = storage.get_by_name(name, project_id=project_id)
176
+
177
+ if skill is None:
178
+ return {
179
+ "success": False,
180
+ "error": f"Skill not found: {skill_id or name}",
181
+ }
182
+
183
+ # Return full skill data
184
+ return {
185
+ "success": True,
186
+ "skill": {
187
+ "id": skill.id,
188
+ "name": skill.name,
189
+ "description": skill.description,
190
+ "content": skill.content,
191
+ "version": skill.version,
192
+ "license": skill.license,
193
+ "compatibility": skill.compatibility,
194
+ "allowed_tools": skill.allowed_tools,
195
+ "metadata": skill.metadata,
196
+ "enabled": skill.enabled,
197
+ "source_path": skill.source_path,
198
+ "source_type": skill.source_type,
199
+ "source_ref": skill.source_ref,
200
+ },
201
+ }
202
+ except Exception as e:
203
+ return {
204
+ "success": False,
205
+ "error": str(e),
206
+ }
207
+
208
+ # --- search_skills tool ---
209
+
210
+ # Initialize search and index skills
211
+ search = SkillSearch()
212
+ # Expose search instance on registry for testing/manual indexing
213
+ registry.search = search
214
+
215
+ def _index_skills() -> None:
216
+ """Index all skills for search."""
217
+ skills = storage.list_skills(
218
+ project_id=project_id,
219
+ limit=10000,
220
+ include_global=True,
221
+ )
222
+ search.index_skills(skills)
223
+
224
+ # Index on registry creation
225
+ _index_skills()
226
+
227
+ @registry.tool(
228
+ name="search_skills",
229
+ description="Search for skills by query. Returns ranked results with relevance scores. Supports filtering by category and tags.",
230
+ )
231
+ async def search_skills(
232
+ query: str,
233
+ category: str | None = None,
234
+ tags_any: list[str] | None = None,
235
+ tags_all: list[str] | None = None,
236
+ top_k: int = 10,
237
+ ) -> dict[str, Any]:
238
+ """
239
+ Search for skills by natural language query.
240
+
241
+ Returns ranked results with relevance scores.
242
+
243
+ Args:
244
+ query: Search query (required, non-empty)
245
+ category: Optional category filter
246
+ tags_any: Optional tags filter - match any of these tags
247
+ tags_all: Optional tags filter - match all of these tags
248
+ top_k: Maximum results to return (default 10)
249
+
250
+ Returns:
251
+ Dict with success status and ranked search results
252
+ """
253
+ try:
254
+ # Validate query
255
+ if not query or not query.strip():
256
+ return {
257
+ "success": False,
258
+ "error": "Query is required and cannot be empty",
259
+ }
260
+
261
+ # Build filters
262
+ filters = None
263
+ if category or tags_any or tags_all:
264
+ filters = SearchFilters(
265
+ category=category,
266
+ tags_any=tags_any,
267
+ tags_all=tags_all,
268
+ )
269
+
270
+ # Perform search
271
+ results = await search.search_async(query=query, top_k=top_k, filters=filters)
272
+
273
+ # Format results with skill metadata
274
+ result_list = []
275
+ for r in results:
276
+ # Look up skill to get description, category, tags
277
+ skill = None
278
+ try:
279
+ skill = storage.get_skill(r.skill_id)
280
+ except ValueError:
281
+ pass
282
+
283
+ # Get category and tags from metadata
284
+ category_value = None
285
+ tags = []
286
+ if skill and skill.metadata and isinstance(skill.metadata, dict):
287
+ skillport = skill.metadata.get("skillport", {})
288
+ if isinstance(skillport, dict):
289
+ category_value = skillport.get("category")
290
+ tags = skillport.get("tags", [])
291
+
292
+ result_list.append(
293
+ {
294
+ "skill_id": r.skill_id,
295
+ "skill_name": r.skill_name,
296
+ "description": skill.description if skill else None,
297
+ "category": category_value,
298
+ "tags": tags,
299
+ "score": r.similarity,
300
+ }
301
+ )
302
+
303
+ return {
304
+ "success": True,
305
+ "count": len(result_list),
306
+ "results": result_list,
307
+ }
308
+ except Exception as e:
309
+ return {
310
+ "success": False,
311
+ "error": str(e),
312
+ }
313
+
314
+ # --- remove_skill tool ---
315
+
316
+ @registry.tool(
317
+ name="remove_skill",
318
+ description="Remove a skill by name or ID. Returns success status and removed skill name.",
319
+ )
320
+ async def remove_skill(
321
+ name: str | None = None,
322
+ skill_id: str | None = None,
323
+ ) -> dict[str, Any]:
324
+ """
325
+ Remove a skill from the database.
326
+
327
+ Args:
328
+ name: Skill name (used if skill_id not provided)
329
+ skill_id: Skill ID (takes precedence over name)
330
+
331
+ Returns:
332
+ Dict with success status and removed skill info
333
+ """
334
+ try:
335
+ # Validate input
336
+ if not skill_id and not name:
337
+ return {
338
+ "success": False,
339
+ "error": "Either name or skill_id is required",
340
+ }
341
+
342
+ # Find the skill first to get its name
343
+ skill = None
344
+ if skill_id:
345
+ try:
346
+ skill = storage.get_skill(skill_id)
347
+ except ValueError:
348
+ pass
349
+
350
+ if skill is None and name:
351
+ skill = storage.get_by_name(name, project_id=project_id)
352
+
353
+ if skill is None:
354
+ return {
355
+ "success": False,
356
+ "error": f"Skill not found: {skill_id or name}",
357
+ }
358
+
359
+ # Store the name before deletion
360
+ skill_name = skill.name
361
+
362
+ # Delete the skill
363
+ storage.delete_skill(skill.id)
364
+
365
+ # Re-index skills after deletion
366
+ skills = storage.list_skills(
367
+ project_id=project_id,
368
+ limit=10000,
369
+ include_global=True,
370
+ )
371
+ await search.index_skills_async(skills)
372
+
373
+ return {
374
+ "success": True,
375
+ "removed": True,
376
+ "skill_name": skill_name,
377
+ }
378
+ except Exception as e:
379
+ return {
380
+ "success": False,
381
+ "error": str(e),
382
+ }
383
+
384
+ # --- update_skill tool ---
385
+
386
+ # Initialize updater
387
+ updater = SkillUpdater(storage)
388
+
389
+ @registry.tool(
390
+ name="update_skill",
391
+ description="Update a skill by refreshing from its source. Returns whether the skill was updated.",
392
+ )
393
+ async def update_skill(
394
+ name: str | None = None,
395
+ skill_id: str | None = None,
396
+ ) -> dict[str, Any]:
397
+ """
398
+ Update a skill by refreshing from its source path.
399
+
400
+ Args:
401
+ name: Skill name (used if skill_id not provided)
402
+ skill_id: Skill ID (takes precedence over name)
403
+
404
+ Returns:
405
+ Dict with success status and update info
406
+ """
407
+ try:
408
+ # Validate input
409
+ if not skill_id and not name:
410
+ return {
411
+ "success": False,
412
+ "error": "Either name or skill_id is required",
413
+ }
414
+
415
+ # Find the skill first
416
+ skill = None
417
+ if skill_id:
418
+ try:
419
+ skill = storage.get_skill(skill_id)
420
+ except ValueError:
421
+ pass
422
+
423
+ if skill is None and name:
424
+ skill = storage.get_by_name(name, project_id=project_id)
425
+
426
+ if skill is None:
427
+ return {
428
+ "success": False,
429
+ "error": f"Skill not found: {skill_id or name}",
430
+ }
431
+
432
+ # Use SkillUpdater to refresh from source
433
+ result = updater.update_skill(skill.id)
434
+
435
+ # Re-index skills if updated
436
+ if result.updated:
437
+ skills = storage.list_skills(
438
+ project_id=project_id,
439
+ limit=10000,
440
+ include_global=True,
441
+ )
442
+ await search.index_skills_async(skills)
443
+
444
+ return {
445
+ "success": result.success,
446
+ "updated": result.updated,
447
+ "skipped": result.skipped,
448
+ "skip_reason": result.skip_reason,
449
+ "error": result.error,
450
+ }
451
+ except Exception as e:
452
+ return {
453
+ "success": False,
454
+ "error": str(e),
455
+ }
456
+
457
+ # --- install_skill tool ---
458
+
459
+ # Initialize loader
460
+ loader = SkillLoader()
461
+
462
+ @registry.tool(
463
+ name="install_skill",
464
+ description="Install a skill from a local path, GitHub URL, or ZIP archive. Auto-detects source type.",
465
+ )
466
+ async def install_skill(
467
+ source: str | None = None,
468
+ project_scoped: bool = False,
469
+ ) -> dict[str, Any]:
470
+ """
471
+ Install a skill from a source location.
472
+
473
+ Auto-detects source type:
474
+ - Local directory or SKILL.md file
475
+ - GitHub URL (owner/repo, github:owner/repo, https://github.com/...)
476
+ - ZIP archive (.zip file)
477
+
478
+ Args:
479
+ source: Path or URL to the skill source (required)
480
+ project_scoped: If True, install skill scoped to the project
481
+
482
+ Returns:
483
+ Dict with success status, skill_id, skill_name, and source_type
484
+ """
485
+ try:
486
+ # Validate input
487
+ if not source or not source.strip():
488
+ return {
489
+ "success": False,
490
+ "error": "source parameter is required",
491
+ }
492
+
493
+ source = source.strip()
494
+
495
+ # Determine source type and load skill
496
+ from gobby.storage.skills import SkillSourceType
497
+
498
+ parsed_skill = None
499
+ source_type: SkillSourceType | None = None
500
+
501
+ # Check if it's a GitHub URL/reference
502
+ # Pattern for owner/repo format (e.g., "anthropic/claude-code")
503
+ # Must match owner/repo pattern without path traversal or absolute paths
504
+ github_owner_repo_pattern = re.compile(
505
+ r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(/[A-Za-z0-9_./-]*)?$"
506
+ )
507
+
508
+ # Explicit GitHub references (always treated as GitHub, no filesystem check)
509
+ is_explicit_github = (
510
+ source.startswith("github:")
511
+ or source.startswith("https://github.com/")
512
+ or source.startswith("http://github.com/")
513
+ )
514
+
515
+ # For implicit owner/repo patterns, check local filesystem first
516
+ is_implicit_github_pattern = (
517
+ not is_explicit_github
518
+ and github_owner_repo_pattern.match(source)
519
+ and not source.startswith("/")
520
+ and ".." not in source # Reject path traversal
521
+ )
522
+
523
+ # Determine if this is a GitHub reference:
524
+ # - Explicit refs are always GitHub
525
+ # - Implicit patterns are GitHub only if local path doesn't exist
526
+ is_github_ref = is_explicit_github or (
527
+ is_implicit_github_pattern and not Path(source).exists()
528
+ )
529
+ if is_github_ref:
530
+ # GitHub URL
531
+ try:
532
+ parsed_skill = loader.load_from_github(source)
533
+ source_type = "github"
534
+ except SkillLoadError as e:
535
+ return {
536
+ "success": False,
537
+ "error": f"Failed to load from GitHub: {e}",
538
+ }
539
+
540
+ # Check if it's a ZIP file
541
+ elif source.endswith(".zip"):
542
+ zip_path = Path(source)
543
+ if not zip_path.exists():
544
+ return {
545
+ "success": False,
546
+ "error": f"ZIP file not found: {source}",
547
+ }
548
+ try:
549
+ parsed_skill = loader.load_from_zip(zip_path)
550
+ source_type = "zip"
551
+ except SkillLoadError as e:
552
+ return {
553
+ "success": False,
554
+ "error": f"Failed to load from ZIP: {e}",
555
+ }
556
+
557
+ # Assume it's a local path
558
+ else:
559
+ local_path = Path(source)
560
+ if not local_path.exists():
561
+ return {
562
+ "success": False,
563
+ "error": f"Path not found: {source}",
564
+ }
565
+ try:
566
+ parsed_skill = loader.load_skill(local_path)
567
+ source_type = "local"
568
+ except SkillLoadError as e:
569
+ return {
570
+ "success": False,
571
+ "error": f"Failed to load skill: {e}",
572
+ }
573
+
574
+ if parsed_skill is None:
575
+ return {
576
+ "success": False,
577
+ "error": "Failed to load skill from source",
578
+ }
579
+
580
+ # Handle case where load_from_github/load_from_zip returns a list
581
+ if isinstance(parsed_skill, list):
582
+ if len(parsed_skill) == 0:
583
+ return {
584
+ "success": False,
585
+ "error": "No skills found in source",
586
+ }
587
+ # Use the first skill if multiple were found
588
+ parsed_skill = parsed_skill[0]
589
+
590
+ # Determine project ID for the skill
591
+ skill_project_id = project_id if project_scoped else None
592
+
593
+ # Store the skill
594
+ skill = storage.create_skill(
595
+ name=parsed_skill.name,
596
+ description=parsed_skill.description,
597
+ content=parsed_skill.content,
598
+ version=parsed_skill.version,
599
+ license=parsed_skill.license,
600
+ compatibility=parsed_skill.compatibility,
601
+ allowed_tools=parsed_skill.allowed_tools,
602
+ metadata=parsed_skill.metadata,
603
+ source_path=parsed_skill.source_path,
604
+ source_type=source_type,
605
+ source_ref=getattr(parsed_skill, "source_ref", None),
606
+ project_id=skill_project_id,
607
+ enabled=True,
608
+ )
609
+
610
+ # Re-index skills
611
+ skills = storage.list_skills(
612
+ project_id=project_id,
613
+ limit=10000,
614
+ include_global=True,
615
+ )
616
+ await search.index_skills_async(skills)
617
+
618
+ return {
619
+ "success": True,
620
+ "installed": True,
621
+ "skill_id": skill.id,
622
+ "skill_name": skill.name,
623
+ "source_type": source_type,
624
+ }
625
+ except Exception as e:
626
+ return {
627
+ "success": False,
628
+ "error": str(e),
629
+ }
630
+
631
+ return registry
@@ -11,6 +11,7 @@ from gobby.mcp_proxy.tools.orchestration.monitor import register_monitor
11
11
  from gobby.mcp_proxy.tools.orchestration.orchestrate import register_orchestrator
12
12
  from gobby.mcp_proxy.tools.orchestration.review import register_reviewer
13
13
  from gobby.mcp_proxy.tools.orchestration.utils import get_current_project_id
14
+ from gobby.mcp_proxy.tools.orchestration.wait import register_wait
14
15
 
15
16
  if TYPE_CHECKING:
16
17
  from gobby.agents.runner import AgentRunner
@@ -74,4 +75,10 @@ def create_orchestration_registry(
74
75
  default_project_id=default_project_id,
75
76
  )
76
77
 
78
+ # Register wait tools
79
+ register_wait(
80
+ registry=registry,
81
+ task_manager=task_manager,
82
+ )
83
+
77
84
  return registry
@@ -9,6 +9,7 @@ Provides tools for task readiness management:
9
9
  Extracted from tasks.py using Strangler Fig pattern for code decomposition.
10
10
  """
11
11
 
12
+ import logging
12
13
  from collections.abc import Callable
13
14
  from typing import TYPE_CHECKING, Any
14
15
 
@@ -20,6 +21,8 @@ from gobby.workflows.state_manager import WorkflowStateManager
20
21
  if TYPE_CHECKING:
21
22
  from gobby.storage.tasks import LocalTaskManager
22
23
 
24
+ logger = logging.getLogger(__name__)
25
+
23
26
  __all__ = [
24
27
  "create_readiness_registry",
25
28
  "is_descendant_of",
@@ -474,6 +477,16 @@ def create_readiness_registry(
474
477
  if best_proximity > 0:
475
478
  reasons.append("same branch as current work")
476
479
 
480
+ # Get recommended skills based on task category
481
+ recommended_skills: list[str] = []
482
+ try:
483
+ from gobby.workflows.context_actions import recommend_skills_for_task
484
+
485
+ task_brief = best_task.to_brief()
486
+ recommended_skills = recommend_skills_for_task(task_brief)
487
+ except Exception as e:
488
+ logger.debug(f"Skill recommendation failed: {e}")
489
+
477
490
  return {
478
491
  "suggestion": best_task.to_brief(),
479
492
  "score": best_score,
@@ -482,6 +495,7 @@ def create_readiness_registry(
482
495
  {"ref": t.to_brief()["ref"], "title": t.title, "score": s}
483
496
  for t, s, _, _ in scored[1:4] # Show top 3 alternatives
484
497
  ],
498
+ "recommended_skills": recommended_skills,
485
499
  }
486
500
 
487
501
  registry.register(
@@ -159,7 +159,7 @@ def create_sync_registry(
159
159
 
160
160
  registry.register(
161
161
  name="link_commit",
162
- description="Link a git commit to a task. Useful for tracking which commits implement a task.",
162
+ description="Link a git commit to a task. NOTE: For closing tasks, prefer close_task(task_id, commit_sha='...') which links and closes in one call. Use link_commit only when you need to link without closing.",
163
163
  input_schema={
164
164
  "type": "object",
165
165
  "properties": {