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
@@ -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 = "\nSession enhanced by gobby."
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 = "\nSession enhanced by gobby."
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:
@@ -17,7 +17,7 @@ Example:
17
17
 
18
18
  manager = HookManager(
19
19
  daemon_host="localhost",
20
- daemon_port=8765
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 = 8765,
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 = 8765
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 8765.
34
+ otherwise uses the default port 60887.
35
35
 
36
36
  Returns:
37
- Full daemon URL like http://localhost:8765
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=30.0, # Generous timeout for hook processing
311
+ timeout=90.0, # LLM-powered hooks (pre-compact summary) need more time
312
312
  )
313
313
 
314
314
  if response.status_code == 200:
@@ -19,7 +19,7 @@ from typing import Any
19
19
 
20
20
  import httpx
21
21
 
22
- DEFAULT_DAEMON_PORT = 8765
22
+ DEFAULT_DAEMON_PORT = 60887
23
23
  DEFAULT_CONFIG_PATH = "~/.gobby/config.yaml"
24
24
  DEBUG_ENV_VAR = "GOBBY_CODEX_NOTIFY_DEBUG"
25
25
 
@@ -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 = 8765
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 8765.
42
+ otherwise uses the default port 60887.
42
43
 
43
44
  Returns:
44
- Full daemon URL like http://localhost:8765
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
- # Daemon is not running - return gracefully without processing
132
- print(
133
- json.dumps({"status": "daemon_not_running", "message": "gobby daemon is not running"})
134
- )
135
- return 0 # Exit 0 (allow) - this is expected behavior, not an error
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
- # Print JSON output for Gemini CLI
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