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
gobby/hooks/event_handlers.py
CHANGED
|
@@ -17,7 +17,9 @@ from typing import TYPE_CHECKING, Any
|
|
|
17
17
|
from gobby.hooks.events import HookEvent, HookEventType, HookResponse
|
|
18
18
|
|
|
19
19
|
if TYPE_CHECKING:
|
|
20
|
+
from gobby.config.skills import SkillsConfig
|
|
20
21
|
from gobby.hooks.session_coordinator import SessionCoordinator
|
|
22
|
+
from gobby.hooks.skill_manager import HookSkillManager
|
|
21
23
|
from gobby.sessions.manager import SessionManager
|
|
22
24
|
from gobby.sessions.summary import SummaryFileGenerator
|
|
23
25
|
from gobby.storage.session_messages import LocalSessionMessageManager
|
|
@@ -48,6 +50,8 @@ class EventHandlers:
|
|
|
48
50
|
task_manager: LocalTaskManager | None = None,
|
|
49
51
|
session_coordinator: SessionCoordinator | None = None,
|
|
50
52
|
message_manager: LocalSessionMessageManager | None = None,
|
|
53
|
+
skill_manager: HookSkillManager | None = None,
|
|
54
|
+
skills_config: SkillsConfig | None = None,
|
|
51
55
|
get_machine_id: Callable[[], str] | None = None,
|
|
52
56
|
resolve_project_id: Callable[[str | None, str | None], str] | None = None,
|
|
53
57
|
logger: logging.Logger | None = None,
|
|
@@ -65,6 +69,8 @@ class EventHandlers:
|
|
|
65
69
|
task_manager: LocalTaskManager for task operations
|
|
66
70
|
session_coordinator: SessionCoordinator for session tracking
|
|
67
71
|
message_manager: LocalSessionMessageManager for messages
|
|
72
|
+
skill_manager: HookSkillManager for skill discovery
|
|
73
|
+
skills_config: SkillsConfig for skill injection settings
|
|
68
74
|
get_machine_id: Function to get machine ID
|
|
69
75
|
resolve_project_id: Function to resolve project ID from cwd
|
|
70
76
|
logger: Optional logger instance
|
|
@@ -78,6 +84,8 @@ class EventHandlers:
|
|
|
78
84
|
self._task_manager = task_manager
|
|
79
85
|
self._session_coordinator = session_coordinator
|
|
80
86
|
self._message_manager = message_manager
|
|
87
|
+
self._skill_manager = skill_manager
|
|
88
|
+
self._skills_config = skills_config
|
|
81
89
|
self._get_machine_id = get_machine_id or (lambda: "unknown-machine")
|
|
82
90
|
self._resolve_project_id = resolve_project_id or (lambda p, c: p or "")
|
|
83
91
|
self.logger = logger or logging.getLogger(__name__)
|
|
@@ -213,9 +221,22 @@ class EventHandlers:
|
|
|
213
221
|
self.logger.warning(f"Workflow error: {e}")
|
|
214
222
|
|
|
215
223
|
# Build system message (terminal display only)
|
|
216
|
-
system_message = "\
|
|
224
|
+
system_message = f"\nGobby Session ID: {session_id}"
|
|
225
|
+
system_message += f"\nExternal ID: {external_id}"
|
|
217
226
|
if parent_session_id:
|
|
218
227
|
context_parts.append(f"Parent session: {parent_session_id}")
|
|
228
|
+
|
|
229
|
+
# Add active lifecycle workflows
|
|
230
|
+
if wf_response.metadata and "discovered_workflows" in wf_response.metadata:
|
|
231
|
+
wf_list = wf_response.metadata["discovered_workflows"]
|
|
232
|
+
if wf_list:
|
|
233
|
+
system_message += "\nActive workflows:"
|
|
234
|
+
for w in wf_list:
|
|
235
|
+
source = "project" if w["is_project"] else "global"
|
|
236
|
+
system_message += (
|
|
237
|
+
f"\n - {w['name']} ({source}, priority={w['priority']})"
|
|
238
|
+
)
|
|
239
|
+
|
|
219
240
|
if wf_response.system_message:
|
|
220
241
|
system_message += f"\n\n{wf_response.system_message}"
|
|
221
242
|
|
|
@@ -312,7 +333,18 @@ class EventHandlers:
|
|
|
312
333
|
context_parts.append(f"Parent session: {parent_session_id}")
|
|
313
334
|
|
|
314
335
|
# Build system message (terminal display only)
|
|
315
|
-
system_message = "\
|
|
336
|
+
system_message = f"\nGobby Session ID: {session_id}"
|
|
337
|
+
system_message += f"\nExternal ID: {external_id}"
|
|
338
|
+
|
|
339
|
+
# Add active lifecycle workflows
|
|
340
|
+
if wf_response.metadata and "discovered_workflows" in wf_response.metadata:
|
|
341
|
+
wf_list = wf_response.metadata["discovered_workflows"]
|
|
342
|
+
if wf_list:
|
|
343
|
+
system_message += "\nActive workflows:"
|
|
344
|
+
for w in wf_list:
|
|
345
|
+
source = "project" if w["is_project"] else "global"
|
|
346
|
+
system_message += f"\n - {w['name']} ({source}, priority={w['priority']})"
|
|
347
|
+
|
|
316
348
|
if wf_response.system_message:
|
|
317
349
|
system_message += f"\n\n{wf_response.system_message}"
|
|
318
350
|
|
|
@@ -322,6 +354,11 @@ class EventHandlers:
|
|
|
322
354
|
context_parts.append("\n## Active Task Context\n")
|
|
323
355
|
context_parts.append(f"You are working on task: {task_title} ({event.task_id})")
|
|
324
356
|
|
|
357
|
+
# Inject core skills if enabled (restoring from parent session if available)
|
|
358
|
+
skill_context = self._build_skill_injection_context(parent_session_id)
|
|
359
|
+
if skill_context:
|
|
360
|
+
context_parts.append(skill_context)
|
|
361
|
+
|
|
325
362
|
# Build metadata with terminal context (filter out nulls)
|
|
326
363
|
metadata: dict[str, Any] = {
|
|
327
364
|
"session_id": session_id,
|
|
@@ -434,6 +471,112 @@ class EventHandlers:
|
|
|
434
471
|
|
|
435
472
|
return HookResponse(decision="allow")
|
|
436
473
|
|
|
474
|
+
def _build_skill_injection_context(self, parent_session_id: str | None = None) -> str | None:
|
|
475
|
+
"""Build skill injection context for session-start.
|
|
476
|
+
|
|
477
|
+
Combines alwaysApply skills with skills restored from parent session.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
parent_session_id: Optional parent session ID to restore skills from
|
|
481
|
+
|
|
482
|
+
Returns context string with available skills if injection is enabled,
|
|
483
|
+
or None if disabled.
|
|
484
|
+
"""
|
|
485
|
+
# Skip if no skill manager or config
|
|
486
|
+
if not self._skill_manager or not self._skills_config:
|
|
487
|
+
return None
|
|
488
|
+
|
|
489
|
+
# Check if injection is enabled
|
|
490
|
+
if not self._skills_config.inject_core_skills:
|
|
491
|
+
return None
|
|
492
|
+
|
|
493
|
+
# Check injection format
|
|
494
|
+
if self._skills_config.injection_format == "none":
|
|
495
|
+
return None
|
|
496
|
+
|
|
497
|
+
# Get alwaysApply skills
|
|
498
|
+
try:
|
|
499
|
+
core_skills = self._skill_manager.discover_core_skills()
|
|
500
|
+
always_apply_skills = [s for s in core_skills if s.is_always_apply()]
|
|
501
|
+
|
|
502
|
+
# Get restored skills from parent session
|
|
503
|
+
restored_skills = self._restore_skills_from_parent(parent_session_id)
|
|
504
|
+
|
|
505
|
+
# Combine: alwaysApply skills + any additional restored skills
|
|
506
|
+
skill_names = [s.name for s in always_apply_skills]
|
|
507
|
+
for skill_name in restored_skills:
|
|
508
|
+
if skill_name not in skill_names:
|
|
509
|
+
skill_names.append(skill_name)
|
|
510
|
+
|
|
511
|
+
if not skill_names:
|
|
512
|
+
return None
|
|
513
|
+
|
|
514
|
+
# Build context based on format
|
|
515
|
+
if self._skills_config.injection_format == "summary":
|
|
516
|
+
return (
|
|
517
|
+
"\n## Available Skills\n"
|
|
518
|
+
f"The following skills are always available: {', '.join(skill_names)}\n"
|
|
519
|
+
"Use the /skill-name syntax to invoke them."
|
|
520
|
+
)
|
|
521
|
+
elif self._skills_config.injection_format == "full":
|
|
522
|
+
parts = ["\n## Available Skills\n"]
|
|
523
|
+
# Build a map of always_apply skills for quick lookup
|
|
524
|
+
always_apply_map = {s.name: s for s in always_apply_skills}
|
|
525
|
+
# Iterate over combined skill_names list (always_apply + restored)
|
|
526
|
+
for skill_name in skill_names:
|
|
527
|
+
parts.append(f"### {skill_name}")
|
|
528
|
+
# Get description from always_apply skill if available
|
|
529
|
+
if skill_name in always_apply_map:
|
|
530
|
+
skill = always_apply_map[skill_name]
|
|
531
|
+
if skill.description:
|
|
532
|
+
parts.append(skill.description)
|
|
533
|
+
parts.append("")
|
|
534
|
+
return "\n".join(parts)
|
|
535
|
+
else:
|
|
536
|
+
return None
|
|
537
|
+
|
|
538
|
+
except Exception as e:
|
|
539
|
+
self.logger.warning(f"Failed to build skill injection context: {e}")
|
|
540
|
+
return None
|
|
541
|
+
|
|
542
|
+
def _restore_skills_from_parent(self, parent_session_id: str | None) -> list[str]:
|
|
543
|
+
"""Restore active skills from parent session's handoff context.
|
|
544
|
+
|
|
545
|
+
Args:
|
|
546
|
+
parent_session_id: Parent session ID to restore from
|
|
547
|
+
|
|
548
|
+
Returns:
|
|
549
|
+
List of skill names from the parent session
|
|
550
|
+
"""
|
|
551
|
+
if not parent_session_id or not self._session_storage:
|
|
552
|
+
return []
|
|
553
|
+
|
|
554
|
+
try:
|
|
555
|
+
parent = self._session_storage.get(parent_session_id)
|
|
556
|
+
if not parent:
|
|
557
|
+
return []
|
|
558
|
+
|
|
559
|
+
compact_md = getattr(parent, "compact_markdown", None)
|
|
560
|
+
if not compact_md:
|
|
561
|
+
return []
|
|
562
|
+
|
|
563
|
+
# Parse active skills from markdown
|
|
564
|
+
# Format: "### Active Skills\nSkills available: skill1, skill2, skill3"
|
|
565
|
+
import re
|
|
566
|
+
|
|
567
|
+
match = re.search(r"### Active Skills\s*\nSkills available:\s*([^\n]+)", compact_md)
|
|
568
|
+
if match:
|
|
569
|
+
skills_str = match.group(1).strip()
|
|
570
|
+
skills = [s.strip() for s in skills_str.split(",") if s.strip()]
|
|
571
|
+
self.logger.debug(f"Restored {len(skills)} skills from parent session")
|
|
572
|
+
return skills
|
|
573
|
+
|
|
574
|
+
return []
|
|
575
|
+
|
|
576
|
+
except Exception as e:
|
|
577
|
+
self.logger.warning(f"Failed to restore skills from parent: {e}")
|
|
578
|
+
return []
|
|
579
|
+
|
|
437
580
|
# ==================== AGENT HANDLERS ====================
|
|
438
581
|
|
|
439
582
|
def handle_before_agent(self, event: HookEvent) -> HookResponse:
|
gobby/hooks/hook_manager.py
CHANGED
|
@@ -17,7 +17,7 @@ Example:
|
|
|
17
17
|
|
|
18
18
|
manager = HookManager(
|
|
19
19
|
daemon_host="localhost",
|
|
20
|
-
daemon_port=
|
|
20
|
+
daemon_port=60887
|
|
21
21
|
)
|
|
22
22
|
|
|
23
23
|
result = manager.execute(
|
|
@@ -42,6 +42,7 @@ from gobby.hooks.events import HookEvent, HookEventType, HookResponse
|
|
|
42
42
|
from gobby.hooks.health_monitor import HealthMonitor
|
|
43
43
|
from gobby.hooks.plugins import PluginLoader, run_plugin_handlers
|
|
44
44
|
from gobby.hooks.session_coordinator import SessionCoordinator
|
|
45
|
+
from gobby.hooks.skill_manager import HookSkillManager
|
|
45
46
|
from gobby.hooks.webhooks import WebhookDispatcher
|
|
46
47
|
from gobby.memory.manager import MemoryManager
|
|
47
48
|
from gobby.sessions.manager import SessionManager
|
|
@@ -79,6 +80,21 @@ class HookManager:
|
|
|
79
80
|
- TranscriptProcessor: JSONL parsing and analysis
|
|
80
81
|
- WorkflowEngine: Handles session handoff and LLM-powered summaries
|
|
81
82
|
|
|
83
|
+
Session ID Mapping:
|
|
84
|
+
There are two types of session IDs used throughout the system:
|
|
85
|
+
|
|
86
|
+
| Name | Description | Example |
|
|
87
|
+
|--------------------------|------------------------------------------------|----------------------------------------|
|
|
88
|
+
| external_id / session_id | CLI's internal session UUID (Claude Code, etc) | 683bc13e-091e-4911-9e59-e7546e385cd6 |
|
|
89
|
+
| _platform_session_id | Gobby's internal session.id (database PK) | 0ebb2c00-0f58-4c39-9370-eba1833dec33 |
|
|
90
|
+
|
|
91
|
+
The _platform_session_id is derived from session_manager.get_session_id(external_id, source)
|
|
92
|
+
which looks up Gobby's session by the CLI's external_id.
|
|
93
|
+
|
|
94
|
+
When injecting into agent context:
|
|
95
|
+
- "session_id" in response.metadata = Gobby's _platform_session_id (for MCP tool calls)
|
|
96
|
+
- "external_id" in response.metadata = CLI's session UUID (for transcript lookups)
|
|
97
|
+
|
|
82
98
|
Attributes:
|
|
83
99
|
daemon_host: Host for daemon communication
|
|
84
100
|
daemon_port: Port for daemon communication
|
|
@@ -89,7 +105,7 @@ class HookManager:
|
|
|
89
105
|
def __init__(
|
|
90
106
|
self,
|
|
91
107
|
daemon_host: str = "localhost",
|
|
92
|
-
daemon_port: int =
|
|
108
|
+
daemon_port: int = 60887,
|
|
93
109
|
llm_service: "LLMService | None" = None,
|
|
94
110
|
config: Any | None = None,
|
|
95
111
|
log_file: str | None = None,
|
|
@@ -350,6 +366,9 @@ class HookManager:
|
|
|
350
366
|
logger=self.logger,
|
|
351
367
|
)
|
|
352
368
|
|
|
369
|
+
# Skill manager for core skill injection
|
|
370
|
+
self._skill_manager = HookSkillManager()
|
|
371
|
+
|
|
353
372
|
# Event handlers (delegated to EventHandlers module)
|
|
354
373
|
self._event_handlers = EventHandlers(
|
|
355
374
|
session_manager=self._session_manager,
|
|
@@ -361,6 +380,8 @@ class HookManager:
|
|
|
361
380
|
task_manager=self._task_manager,
|
|
362
381
|
session_coordinator=self._session_coordinator,
|
|
363
382
|
message_manager=self._message_manager,
|
|
383
|
+
skill_manager=self._skill_manager,
|
|
384
|
+
skills_config=self._config.skills if self._config else None,
|
|
364
385
|
get_machine_id=self.get_machine_id,
|
|
365
386
|
resolve_project_id=self._resolve_project_id,
|
|
366
387
|
logger=self.logger,
|
|
@@ -620,6 +641,31 @@ class HookManager:
|
|
|
620
641
|
try:
|
|
621
642
|
response = handler(event)
|
|
622
643
|
|
|
644
|
+
# Copy session metadata from event to response for adapter injection
|
|
645
|
+
# The adapter reads response.metadata to inject session info into agent context
|
|
646
|
+
if event.metadata.get("_platform_session_id"):
|
|
647
|
+
response.metadata["session_id"] = event.metadata["_platform_session_id"]
|
|
648
|
+
if event.session_id: # external_id (e.g., Claude Code's session UUID)
|
|
649
|
+
response.metadata["external_id"] = event.session_id
|
|
650
|
+
if event.machine_id:
|
|
651
|
+
response.metadata["machine_id"] = event.machine_id
|
|
652
|
+
if event.project_id:
|
|
653
|
+
response.metadata["project_id"] = event.project_id
|
|
654
|
+
# Copy terminal context if present
|
|
655
|
+
for key in [
|
|
656
|
+
"terminal_term_program",
|
|
657
|
+
"terminal_tty",
|
|
658
|
+
"terminal_parent_pid",
|
|
659
|
+
"terminal_iterm_session_id",
|
|
660
|
+
"terminal_term_session_id",
|
|
661
|
+
"terminal_kitty_window_id",
|
|
662
|
+
"terminal_tmux_pane",
|
|
663
|
+
"terminal_vscode_terminal_id",
|
|
664
|
+
"terminal_alacritty_socket",
|
|
665
|
+
]:
|
|
666
|
+
if event.metadata.get(key):
|
|
667
|
+
response.metadata[key] = event.metadata[key]
|
|
668
|
+
|
|
623
669
|
# Merge workflow context if present
|
|
624
670
|
if workflow_context:
|
|
625
671
|
if response.context:
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""HookSkillManager - Skill management for the hook system.
|
|
2
|
+
|
|
3
|
+
This module provides skill discovery and management for the hook system,
|
|
4
|
+
allowing hooks to access and use skills (Agent Skills specification).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from gobby.skills.loader import SkillLoader
|
|
13
|
+
from gobby.skills.parser import ParsedSkill
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HookSkillManager:
|
|
19
|
+
"""Manage skills for the hook system.
|
|
20
|
+
|
|
21
|
+
Provides discovery and access to core skills bundled with Gobby,
|
|
22
|
+
as well as project-specific skills.
|
|
23
|
+
|
|
24
|
+
Example usage:
|
|
25
|
+
```python
|
|
26
|
+
from gobby.hooks.skill_manager import HookSkillManager
|
|
27
|
+
|
|
28
|
+
manager = HookSkillManager()
|
|
29
|
+
skills = manager.discover_core_skills()
|
|
30
|
+
|
|
31
|
+
# Get a specific skill
|
|
32
|
+
tasks_skill = manager.get_skill_by_name("gobby-tasks")
|
|
33
|
+
```
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
"""Initialize the skill manager."""
|
|
38
|
+
# Path to built-in skills: src/gobby/hooks/ -> src/gobby/install/shared/skills/
|
|
39
|
+
self._base_dir = Path(__file__).parent.parent
|
|
40
|
+
self._core_skills_path = self._base_dir / "install" / "shared" / "skills"
|
|
41
|
+
|
|
42
|
+
# Loader for parsing skills (use "filesystem" for bundled core skills)
|
|
43
|
+
self._loader = SkillLoader(default_source_type="filesystem")
|
|
44
|
+
|
|
45
|
+
# Cache of discovered skills
|
|
46
|
+
self._core_skills: list[ParsedSkill] | None = None
|
|
47
|
+
|
|
48
|
+
def discover_core_skills(self) -> list[ParsedSkill]:
|
|
49
|
+
"""Discover built-in skills from install/shared/skills/.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
List of ParsedSkill objects for all valid core skills.
|
|
53
|
+
Invalid skills are logged as warnings and skipped.
|
|
54
|
+
"""
|
|
55
|
+
if self._core_skills is not None:
|
|
56
|
+
return self._core_skills
|
|
57
|
+
|
|
58
|
+
if not self._core_skills_path.exists():
|
|
59
|
+
logger.warning(f"Core skills path not found: {self._core_skills_path}")
|
|
60
|
+
self._core_skills = []
|
|
61
|
+
return self._core_skills
|
|
62
|
+
|
|
63
|
+
# Load all skills from the core directory
|
|
64
|
+
self._core_skills = self._loader.load_directory(
|
|
65
|
+
self._core_skills_path,
|
|
66
|
+
validate=True,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
logger.debug(f"Discovered {len(self._core_skills)} core skills")
|
|
70
|
+
return self._core_skills
|
|
71
|
+
|
|
72
|
+
def get_skill_by_name(self, name: str) -> ParsedSkill | None:
|
|
73
|
+
"""Get a skill by name.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
name: The skill name to look up.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
ParsedSkill if found, None otherwise.
|
|
80
|
+
"""
|
|
81
|
+
# Ensure skills are discovered
|
|
82
|
+
skills = self.discover_core_skills()
|
|
83
|
+
|
|
84
|
+
for skill in skills:
|
|
85
|
+
if skill.name == name:
|
|
86
|
+
return skill
|
|
87
|
+
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
def refresh(self) -> None:
|
|
91
|
+
"""Clear the cache and rediscover skills."""
|
|
92
|
+
self._core_skills = None
|
|
93
|
+
|
|
94
|
+
def recommend_skills(self, category: str | None = None) -> list[str]:
|
|
95
|
+
"""Recommend relevant skills based on task category.
|
|
96
|
+
|
|
97
|
+
Maps task categories to relevant core skills that would be helpful
|
|
98
|
+
for that type of work.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
category: Task category (e.g., 'code', 'docs', 'test', 'config')
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
List of skill names that are relevant for the category
|
|
105
|
+
"""
|
|
106
|
+
# Category to skill mappings
|
|
107
|
+
category_skills: dict[str, list[str]] = {
|
|
108
|
+
"code": ["gobby-tasks", "gobby-expand", "gobby-worktrees"],
|
|
109
|
+
"test": ["gobby-tasks", "gobby-expand"],
|
|
110
|
+
"docs": ["gobby-tasks", "gobby-plan"],
|
|
111
|
+
"config": ["gobby-tasks", "gobby-mcp"],
|
|
112
|
+
"refactor": ["gobby-tasks", "gobby-expand", "gobby-worktrees"],
|
|
113
|
+
"planning": ["gobby-tasks", "gobby-plan", "gobby-expand"],
|
|
114
|
+
"research": ["gobby-tasks", "gobby-memory"],
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# Get skills for the category (or empty list if no match)
|
|
118
|
+
recommended = category_skills.get(category or "", [])
|
|
119
|
+
|
|
120
|
+
# Always include alwaysApply skills
|
|
121
|
+
skills = self.discover_core_skills()
|
|
122
|
+
always_apply = [s.name for s in skills if s.is_always_apply()]
|
|
123
|
+
|
|
124
|
+
# Combine and dedupe while preserving order
|
|
125
|
+
result = list(always_apply)
|
|
126
|
+
for skill_name in recommended:
|
|
127
|
+
if skill_name not in result:
|
|
128
|
+
result.append(skill_name)
|
|
129
|
+
|
|
130
|
+
return result
|
|
@@ -23,7 +23,7 @@ from pathlib import Path
|
|
|
23
23
|
# No longer need to import HookManager - we call it via HTTP daemon instead
|
|
24
24
|
|
|
25
25
|
# Default daemon configuration
|
|
26
|
-
DEFAULT_DAEMON_PORT =
|
|
26
|
+
DEFAULT_DAEMON_PORT = 60887
|
|
27
27
|
DEFAULT_CONFIG_PATH = "~/.gobby/config.yaml"
|
|
28
28
|
|
|
29
29
|
|
|
@@ -31,10 +31,10 @@ def get_daemon_url() -> str:
|
|
|
31
31
|
"""Get the daemon HTTP URL from config file.
|
|
32
32
|
|
|
33
33
|
Reads daemon_port from ~/.gobby/config.yaml if it exists,
|
|
34
|
-
otherwise uses the default port
|
|
34
|
+
otherwise uses the default port 60887.
|
|
35
35
|
|
|
36
36
|
Returns:
|
|
37
|
-
Full daemon URL like http://localhost:
|
|
37
|
+
Full daemon URL like http://localhost:60887
|
|
38
38
|
"""
|
|
39
39
|
config_path = Path(DEFAULT_CONFIG_PATH).expanduser()
|
|
40
40
|
|
|
@@ -308,7 +308,7 @@ def main() -> int:
|
|
|
308
308
|
"input_data": input_data,
|
|
309
309
|
"source": "claude", # Required: identifies CLI source
|
|
310
310
|
},
|
|
311
|
-
timeout=
|
|
311
|
+
timeout=90.0, # LLM-powered hooks (pre-compact summary) need more time
|
|
312
312
|
)
|
|
313
313
|
|
|
314
314
|
if response.status_code == 200:
|
|
@@ -26,11 +26,12 @@ Exit Codes:
|
|
|
26
26
|
|
|
27
27
|
import argparse
|
|
28
28
|
import json
|
|
29
|
+
import os
|
|
29
30
|
import sys
|
|
30
31
|
from pathlib import Path
|
|
31
32
|
|
|
32
33
|
# Default daemon configuration
|
|
33
|
-
DEFAULT_DAEMON_PORT =
|
|
34
|
+
DEFAULT_DAEMON_PORT = 60887
|
|
34
35
|
DEFAULT_CONFIG_PATH = "~/.gobby/config.yaml"
|
|
35
36
|
|
|
36
37
|
|
|
@@ -38,10 +39,10 @@ def get_daemon_url() -> str:
|
|
|
38
39
|
"""Get the daemon HTTP URL from config file.
|
|
39
40
|
|
|
40
41
|
Reads daemon_port from ~/.gobby/config.yaml if it exists,
|
|
41
|
-
otherwise uses the default port
|
|
42
|
+
otherwise uses the default port 60887.
|
|
42
43
|
|
|
43
44
|
Returns:
|
|
44
|
-
Full daemon URL like http://localhost:
|
|
45
|
+
Full daemon URL like http://localhost:60887
|
|
45
46
|
"""
|
|
46
47
|
config_path = Path(DEFAULT_CONFIG_PATH).expanduser()
|
|
47
48
|
|
|
@@ -61,6 +62,55 @@ def get_daemon_url() -> str:
|
|
|
61
62
|
return f"http://localhost:{port}"
|
|
62
63
|
|
|
63
64
|
|
|
65
|
+
def get_terminal_context() -> dict[str, str | int | bool | None]:
|
|
66
|
+
"""Capture terminal/process context for session correlation.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Dict with terminal identifiers (values may be None if unavailable)
|
|
70
|
+
"""
|
|
71
|
+
context: dict[str, str | int | bool | None] = {}
|
|
72
|
+
|
|
73
|
+
# Parent process ID (shell or Gemini process)
|
|
74
|
+
try:
|
|
75
|
+
context["parent_pid"] = os.getppid()
|
|
76
|
+
except Exception:
|
|
77
|
+
context["parent_pid"] = None
|
|
78
|
+
|
|
79
|
+
# TTY device name
|
|
80
|
+
try:
|
|
81
|
+
context["tty"] = os.ttyname(0)
|
|
82
|
+
except Exception:
|
|
83
|
+
context["tty"] = None
|
|
84
|
+
|
|
85
|
+
# macOS Terminal.app session ID
|
|
86
|
+
context["term_session_id"] = os.environ.get("TERM_SESSION_ID")
|
|
87
|
+
|
|
88
|
+
# iTerm2 session ID
|
|
89
|
+
context["iterm_session_id"] = os.environ.get("ITERM_SESSION_ID")
|
|
90
|
+
|
|
91
|
+
# VS Code integrated terminal detection
|
|
92
|
+
# VSCODE_IPC_HOOK_CLI is set when running in VS Code's integrated terminal
|
|
93
|
+
# TERM_PROGRAM == "vscode" is also a reliable indicator
|
|
94
|
+
vscode_ipc_hook = os.environ.get("VSCODE_IPC_HOOK_CLI")
|
|
95
|
+
term_program = os.environ.get("TERM_PROGRAM")
|
|
96
|
+
context["vscode_ipc_hook_cli"] = vscode_ipc_hook
|
|
97
|
+
context["vscode_terminal_detected"] = bool(vscode_ipc_hook) or term_program == "vscode"
|
|
98
|
+
|
|
99
|
+
# Tmux pane (if running in tmux)
|
|
100
|
+
context["tmux_pane"] = os.environ.get("TMUX_PANE")
|
|
101
|
+
|
|
102
|
+
# Kitty terminal window ID
|
|
103
|
+
context["kitty_window_id"] = os.environ.get("KITTY_WINDOW_ID")
|
|
104
|
+
|
|
105
|
+
# Alacritty IPC socket path (unique per instance)
|
|
106
|
+
context["alacritty_socket"] = os.environ.get("ALACRITTY_SOCKET")
|
|
107
|
+
|
|
108
|
+
# Generic terminal program identifier (set by many terminals)
|
|
109
|
+
context["term_program"] = os.environ.get("TERM_PROGRAM")
|
|
110
|
+
|
|
111
|
+
return context
|
|
112
|
+
|
|
113
|
+
|
|
64
114
|
def parse_arguments() -> argparse.Namespace:
|
|
65
115
|
"""Parse command line arguments.
|
|
66
116
|
|
|
@@ -128,11 +178,26 @@ def main() -> int:
|
|
|
128
178
|
|
|
129
179
|
# Check if gobby daemon is running before processing hooks
|
|
130
180
|
if not check_daemon_running():
|
|
131
|
-
#
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
181
|
+
# Critical hooks that manage session state MUST have daemon running
|
|
182
|
+
# Per Gemini CLI docs: SessionEnd, Notification, PreCompress are async/non-blocking
|
|
183
|
+
# Only SessionStart is critical for session initialization
|
|
184
|
+
critical_hooks = {"SessionStart"}
|
|
185
|
+
if hook_type in critical_hooks:
|
|
186
|
+
# Block the hook - forces user to start daemon before critical lifecycle events
|
|
187
|
+
print(
|
|
188
|
+
f"Gobby daemon is not running. Start with 'gobby start' before continuing. "
|
|
189
|
+
f"({hook_type} requires daemon for session state management)",
|
|
190
|
+
file=sys.stderr,
|
|
191
|
+
)
|
|
192
|
+
return 2 # Exit 2 = block operation
|
|
193
|
+
else:
|
|
194
|
+
# Non-critical hooks can proceed without daemon
|
|
195
|
+
print(
|
|
196
|
+
json.dumps(
|
|
197
|
+
{"status": "daemon_not_running", "message": "gobby daemon is not running"}
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
return 0 # Exit 0 (allow) - allow operation to continue
|
|
136
201
|
|
|
137
202
|
# Setup logger for dispatcher (not HookManager)
|
|
138
203
|
import logging
|
|
@@ -147,6 +212,11 @@ def main() -> int:
|
|
|
147
212
|
# Read JSON input from stdin
|
|
148
213
|
input_data = json.load(sys.stdin)
|
|
149
214
|
|
|
215
|
+
# Inject terminal context for SessionStart hooks
|
|
216
|
+
# This captures the terminal/process info for session correlation
|
|
217
|
+
if hook_type == "SessionStart":
|
|
218
|
+
input_data["terminal_context"] = get_terminal_context()
|
|
219
|
+
|
|
150
220
|
# Log what Gemini CLI sends us (for debugging hook data issues)
|
|
151
221
|
logger.info(f"[{hook_type}] Received input keys: {list(input_data.keys())}")
|
|
152
222
|
|
|
@@ -228,13 +298,18 @@ def main() -> int:
|
|
|
228
298
|
# Determine exit code based on decision
|
|
229
299
|
decision = result.get("decision", "allow")
|
|
230
300
|
|
|
231
|
-
#
|
|
301
|
+
# Check for block/deny decision - return exit code 2 to signal blocking
|
|
302
|
+
# For blocking, output goes to STDERR (Gemini reads stderr on exit 2)
|
|
303
|
+
if result.get("continue") is False or decision in ("deny", "block"):
|
|
304
|
+
# Output just the reason, not the full JSON
|
|
305
|
+
reason = result.get("stopReason") or result.get("reason") or "Blocked by hook"
|
|
306
|
+
print(reason, file=sys.stderr)
|
|
307
|
+
return 2
|
|
308
|
+
|
|
309
|
+
# Only print output if there's something meaningful to show
|
|
232
310
|
if result and result != {}:
|
|
233
311
|
print(json.dumps(result))
|
|
234
312
|
|
|
235
|
-
# Exit code: 0 = allow, 2 = deny
|
|
236
|
-
if decision == "deny":
|
|
237
|
-
return 2
|
|
238
313
|
return 0
|
|
239
314
|
else:
|
|
240
315
|
# HTTP error from daemon
|