gobby 0.2.6__py3-none-any.whl → 0.2.7__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 (146) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/codex_impl/__init__.py +28 -0
  4. gobby/adapters/codex_impl/adapter.py +722 -0
  5. gobby/adapters/codex_impl/client.py +679 -0
  6. gobby/adapters/codex_impl/protocol.py +20 -0
  7. gobby/adapters/codex_impl/types.py +68 -0
  8. gobby/agents/definitions.py +11 -1
  9. gobby/agents/isolation.py +395 -0
  10. gobby/agents/sandbox.py +261 -0
  11. gobby/agents/spawn.py +42 -287
  12. gobby/agents/spawn_executor.py +385 -0
  13. gobby/agents/spawners/__init__.py +24 -0
  14. gobby/agents/spawners/command_builder.py +189 -0
  15. gobby/agents/spawners/embedded.py +21 -2
  16. gobby/agents/spawners/headless.py +21 -2
  17. gobby/agents/spawners/prompt_manager.py +125 -0
  18. gobby/cli/install.py +4 -4
  19. gobby/cli/installers/claude.py +6 -0
  20. gobby/cli/installers/gemini.py +6 -0
  21. gobby/cli/installers/shared.py +103 -4
  22. gobby/cli/sessions.py +1 -1
  23. gobby/cli/utils.py +9 -2
  24. gobby/config/__init__.py +12 -97
  25. gobby/config/app.py +10 -94
  26. gobby/config/extensions.py +2 -2
  27. gobby/config/features.py +7 -130
  28. gobby/config/tasks.py +4 -28
  29. gobby/hooks/__init__.py +0 -13
  30. gobby/hooks/event_handlers.py +45 -2
  31. gobby/hooks/hook_manager.py +2 -2
  32. gobby/hooks/plugins.py +1 -1
  33. gobby/hooks/webhooks.py +1 -1
  34. gobby/llm/resolver.py +3 -2
  35. gobby/mcp_proxy/importer.py +62 -4
  36. gobby/mcp_proxy/instructions.py +2 -0
  37. gobby/mcp_proxy/registries.py +1 -4
  38. gobby/mcp_proxy/services/recommendation.py +43 -11
  39. gobby/mcp_proxy/tools/agents.py +31 -731
  40. gobby/mcp_proxy/tools/clones.py +0 -385
  41. gobby/mcp_proxy/tools/memory.py +2 -2
  42. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  43. gobby/mcp_proxy/tools/sessions/_commits.py +232 -0
  44. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  45. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  46. gobby/mcp_proxy/tools/sessions/_handoff.py +499 -0
  47. gobby/mcp_proxy/tools/sessions/_messages.py +138 -0
  48. gobby/mcp_proxy/tools/skills/__init__.py +14 -29
  49. gobby/mcp_proxy/tools/spawn_agent.py +417 -0
  50. gobby/mcp_proxy/tools/tasks/_lifecycle.py +52 -18
  51. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
  52. gobby/mcp_proxy/tools/worktrees.py +0 -343
  53. gobby/memory/ingestion/__init__.py +5 -0
  54. gobby/memory/ingestion/multimodal.py +221 -0
  55. gobby/memory/manager.py +62 -283
  56. gobby/memory/search/__init__.py +10 -0
  57. gobby/memory/search/coordinator.py +248 -0
  58. gobby/memory/services/__init__.py +5 -0
  59. gobby/memory/services/crossref.py +142 -0
  60. gobby/prompts/loader.py +5 -2
  61. gobby/servers/http.py +1 -4
  62. gobby/servers/routes/admin.py +14 -0
  63. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  64. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  65. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  66. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  67. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  68. gobby/servers/routes/mcp/hooks.py +1 -1
  69. gobby/servers/routes/mcp/tools.py +48 -1506
  70. gobby/sessions/lifecycle.py +1 -1
  71. gobby/sessions/processor.py +10 -0
  72. gobby/sessions/transcripts/base.py +1 -0
  73. gobby/sessions/transcripts/claude.py +15 -5
  74. gobby/skills/parser.py +30 -2
  75. gobby/storage/migrations.py +159 -372
  76. gobby/storage/sessions.py +43 -7
  77. gobby/storage/skills.py +37 -4
  78. gobby/storage/tasks/_lifecycle.py +18 -3
  79. gobby/sync/memories.py +1 -1
  80. gobby/tasks/external_validator.py +1 -1
  81. gobby/tasks/validation.py +22 -20
  82. gobby/tools/summarizer.py +91 -10
  83. gobby/utils/project_context.py +2 -3
  84. gobby/utils/status.py +13 -0
  85. gobby/workflows/actions.py +221 -1217
  86. gobby/workflows/artifact_actions.py +31 -0
  87. gobby/workflows/autonomous_actions.py +11 -0
  88. gobby/workflows/context_actions.py +50 -1
  89. gobby/workflows/enforcement/__init__.py +47 -0
  90. gobby/workflows/enforcement/blocking.py +269 -0
  91. gobby/workflows/enforcement/commit_policy.py +283 -0
  92. gobby/workflows/enforcement/handlers.py +269 -0
  93. gobby/workflows/enforcement/task_policy.py +542 -0
  94. gobby/workflows/git_utils.py +106 -0
  95. gobby/workflows/llm_actions.py +30 -0
  96. gobby/workflows/mcp_actions.py +20 -1
  97. gobby/workflows/memory_actions.py +80 -0
  98. gobby/workflows/safe_evaluator.py +183 -0
  99. gobby/workflows/session_actions.py +44 -0
  100. gobby/workflows/state_actions.py +60 -1
  101. gobby/workflows/stop_signal_actions.py +55 -0
  102. gobby/workflows/summary_actions.py +94 -1
  103. gobby/workflows/task_sync_actions.py +347 -0
  104. gobby/workflows/todo_actions.py +34 -1
  105. gobby/workflows/webhook_actions.py +185 -0
  106. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/METADATA +6 -1
  107. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/RECORD +111 -111
  108. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
  109. gobby/adapters/codex.py +0 -1332
  110. gobby/install/claude/commands/gobby/bug.md +0 -51
  111. gobby/install/claude/commands/gobby/chore.md +0 -51
  112. gobby/install/claude/commands/gobby/epic.md +0 -52
  113. gobby/install/claude/commands/gobby/eval.md +0 -235
  114. gobby/install/claude/commands/gobby/feat.md +0 -49
  115. gobby/install/claude/commands/gobby/nit.md +0 -52
  116. gobby/install/claude/commands/gobby/ref.md +0 -52
  117. gobby/mcp_proxy/tools/session_messages.py +0 -1055
  118. gobby/prompts/defaults/expansion/system.md +0 -119
  119. gobby/prompts/defaults/expansion/user.md +0 -48
  120. gobby/prompts/defaults/external_validation/agent.md +0 -72
  121. gobby/prompts/defaults/external_validation/external.md +0 -63
  122. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  123. gobby/prompts/defaults/external_validation/system.md +0 -6
  124. gobby/prompts/defaults/features/import_mcp.md +0 -22
  125. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  126. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  127. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  128. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  129. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  130. gobby/prompts/defaults/features/server_description.md +0 -20
  131. gobby/prompts/defaults/features/server_description_system.md +0 -6
  132. gobby/prompts/defaults/features/task_description.md +0 -31
  133. gobby/prompts/defaults/features/task_description_system.md +0 -6
  134. gobby/prompts/defaults/features/tool_summary.md +0 -17
  135. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  136. gobby/prompts/defaults/handoff/compact.md +0 -63
  137. gobby/prompts/defaults/handoff/session_end.md +0 -57
  138. gobby/prompts/defaults/memory/extract.md +0 -61
  139. gobby/prompts/defaults/research/step.md +0 -58
  140. gobby/prompts/defaults/validation/criteria.md +0 -47
  141. gobby/prompts/defaults/validation/validate.md +0 -38
  142. gobby/storage/migrations_legacy.py +0 -1359
  143. gobby/workflows/task_enforcement_actions.py +0 -1343
  144. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
  145. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
  146. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/top_level.txt +0 -0
@@ -13,13 +13,11 @@ via the downstream proxy pattern (call_tool, list_tools, get_tool_schema).
13
13
  from __future__ import annotations
14
14
 
15
15
  import logging
16
- from pathlib import Path
17
16
  from typing import TYPE_CHECKING, Any, Literal
18
17
 
19
18
  from gobby.mcp_proxy.tools.internal import InternalToolRegistry
20
19
 
21
20
  if TYPE_CHECKING:
22
- from gobby.agents.runner import AgentRunner
23
21
  from gobby.clones.git import CloneGitManager
24
22
  from gobby.storage.clones import LocalCloneManager
25
23
 
@@ -30,7 +28,6 @@ def create_clones_registry(
30
28
  clone_storage: LocalCloneManager,
31
29
  git_manager: CloneGitManager,
32
30
  project_id: str,
33
- agent_runner: AgentRunner | None = None,
34
31
  ) -> InternalToolRegistry:
35
32
  """
36
33
  Create the gobby-clones MCP server registry.
@@ -39,7 +36,6 @@ def create_clones_registry(
39
36
  clone_storage: Clone storage manager for CRUD operations
40
37
  git_manager: Git manager for clone operations
41
38
  project_id: Default project ID for new clones
42
- agent_runner: Optional agent runner for spawning agents in clones
43
39
 
44
40
  Returns:
45
41
  InternalToolRegistry with clone management tools
@@ -519,385 +515,4 @@ def create_clones_registry(
519
515
  func=merge_clone_to_target,
520
516
  )
521
517
 
522
- # ===== spawn_agent_in_clone =====
523
- async def spawn_agent_in_clone(
524
- prompt: str,
525
- branch_name: str,
526
- parent_session_id: str | None = None,
527
- task_id: str | None = None,
528
- base_branch: str = "main",
529
- clone_path: str | None = None,
530
- mode: str = "terminal",
531
- terminal: str = "auto",
532
- provider: Literal["claude", "gemini", "codex", "antigravity"] = "claude",
533
- model: str | None = None,
534
- workflow: str | None = None,
535
- timeout: float = 120.0,
536
- max_turns: int = 10,
537
- ) -> dict[str, Any]:
538
- """
539
- Create a clone (if needed) and spawn an agent in it.
540
-
541
- This combines clone creation with agent spawning for isolated development.
542
- Unlike worktrees, clones are full repository copies that can be worked on
543
- independently without affecting the main repository.
544
-
545
- Args:
546
- prompt: The task/prompt for the agent.
547
- branch_name: Name for the branch in the clone.
548
- parent_session_id: Parent session ID for context (required).
549
- task_id: Optional task ID to link to this clone.
550
- base_branch: Branch to clone from (default: main).
551
- clone_path: Optional custom path for the clone.
552
- mode: Execution mode (terminal, embedded, headless).
553
- terminal: Terminal for terminal/embedded modes (auto, ghostty, etc.).
554
- provider: LLM provider (claude, gemini, etc.).
555
- model: Optional model override.
556
- workflow: Workflow name to execute.
557
- timeout: Execution timeout in seconds (default: 120).
558
- max_turns: Maximum turns (default: 10).
559
-
560
- Returns:
561
- Dict with clone_id, run_id, and status.
562
- """
563
- if agent_runner is None:
564
- return {
565
- "success": False,
566
- "error": "Agent runner not configured. Cannot spawn agent.",
567
- }
568
-
569
- if parent_session_id is None:
570
- return {
571
- "success": False,
572
- "error": "parent_session_id is required for agent spawning.",
573
- }
574
-
575
- # Handle mode aliases and validation
576
- if mode == "interactive":
577
- mode = "terminal"
578
-
579
- valid_modes = ["terminal", "embedded", "headless"]
580
- if mode not in valid_modes:
581
- return {
582
- "success": False,
583
- "error": (
584
- f"Invalid mode '{mode}'. Must be one of: {', '.join(valid_modes)}. "
585
- f"Note: 'in_process' mode is not supported for spawn_agent_in_clone."
586
- ),
587
- }
588
-
589
- # Normalize terminal parameter to lowercase
590
- if isinstance(terminal, str):
591
- terminal = terminal.lower()
592
-
593
- # Check spawn depth limit
594
- can_spawn, reason, _depth = agent_runner.can_spawn(parent_session_id)
595
- if not can_spawn:
596
- return {
597
- "success": False,
598
- "error": reason,
599
- }
600
-
601
- # Check if clone already exists for this branch
602
- existing = clone_storage.get_by_branch(project_id, branch_name)
603
- if existing:
604
- clone = existing
605
- logger.info(f"Using existing clone for branch '{branch_name}'")
606
- else:
607
- # Get remote URL
608
- remote_url = git_manager.get_remote_url() if git_manager else None
609
- if not remote_url:
610
- return {
611
- "success": False,
612
- "error": "No remote URL available. Cannot create clone.",
613
- }
614
-
615
- # Generate clone path if not provided
616
- if clone_path is None:
617
- import platform
618
- import tempfile
619
-
620
- if platform.system() == "Windows":
621
- base = Path(tempfile.gettempdir()) / "gobby-clones"
622
- else:
623
- # nosec B108: /tmp is intentional for clones - they're temporary
624
- base = Path("/tmp").resolve() / "gobby-clones" # nosec B108
625
- base.mkdir(parents=True, exist_ok=True)
626
- safe_branch = branch_name.replace("/", "-")
627
- clone_path = str(base / f"{project_id}-{safe_branch}")
628
-
629
- # Create the clone
630
- result = git_manager.shallow_clone(
631
- remote_url=remote_url,
632
- clone_path=clone_path,
633
- branch=base_branch,
634
- depth=1,
635
- )
636
-
637
- if not result.success:
638
- return {
639
- "success": False,
640
- "error": f"Clone failed: {result.error or result.message}",
641
- }
642
-
643
- # Store clone record
644
- clone = clone_storage.create(
645
- project_id=project_id,
646
- branch_name=branch_name,
647
- clone_path=clone_path,
648
- base_branch=base_branch,
649
- task_id=task_id,
650
- remote_url=remote_url,
651
- )
652
-
653
- # Import AgentConfig and get machine_id
654
- from gobby.agents.runner import AgentConfig
655
- from gobby.utils.machine_id import get_machine_id
656
-
657
- machine_id = get_machine_id()
658
-
659
- # Create agent config
660
- config = AgentConfig(
661
- prompt=prompt,
662
- parent_session_id=parent_session_id,
663
- project_id=project_id,
664
- machine_id=machine_id,
665
- source=provider,
666
- workflow=workflow,
667
- task=task_id,
668
- session_context="summary_markdown",
669
- mode=mode,
670
- terminal=terminal,
671
- provider=provider,
672
- model=model,
673
- max_turns=max_turns,
674
- timeout=timeout,
675
- project_path=clone.clone_path,
676
- )
677
-
678
- # Prepare the run
679
- from gobby.llm.executor import AgentResult
680
-
681
- prepare_result = agent_runner.prepare_run(config)
682
- if isinstance(prepare_result, AgentResult):
683
- return {
684
- "success": False,
685
- "clone_id": clone.id,
686
- "clone_path": clone.clone_path,
687
- "branch_name": clone.branch_name,
688
- "error": prepare_result.error,
689
- }
690
-
691
- context = prepare_result
692
- if context.session is None or context.run is None:
693
- return {
694
- "success": False,
695
- "clone_id": clone.id,
696
- "error": "Internal error: context missing session or run",
697
- }
698
-
699
- child_session = context.session
700
- agent_run = context.run
701
-
702
- # Claim clone for the child session
703
- clone_storage.claim(clone.id, child_session.id)
704
-
705
- # Build enhanced prompt with clone context
706
- context_lines = [
707
- "## CRITICAL: Clone Context",
708
- "You are working in an ISOLATED git clone, NOT the main repository.",
709
- "",
710
- f"**Your workspace:** {clone.clone_path}",
711
- f"**Your branch:** {clone.branch_name}",
712
- ]
713
- if task_id:
714
- context_lines.append(f"**Your task:** {task_id}")
715
- context_lines.extend(
716
- [
717
- "",
718
- "**IMPORTANT RULES:**",
719
- f"1. ALL file operations must be within {clone.clone_path}",
720
- "2. Do NOT access the main repository",
721
- "3. Run `pwd` to verify your location before any file operations",
722
- f"4. Commit to YOUR branch ({clone.branch_name})",
723
- "5. When your assigned task is complete, STOP - do not claim other tasks",
724
- "",
725
- "---",
726
- "",
727
- ]
728
- )
729
- enhanced_prompt = "\n".join(context_lines) + prompt
730
-
731
- # Spawn based on mode
732
- if mode == "terminal":
733
- from gobby.agents.spawn import TerminalSpawner
734
-
735
- terminal_spawner = TerminalSpawner()
736
- terminal_result = terminal_spawner.spawn_agent(
737
- cli=provider,
738
- cwd=clone.clone_path,
739
- session_id=child_session.id,
740
- parent_session_id=parent_session_id,
741
- agent_run_id=agent_run.id,
742
- project_id=project_id,
743
- workflow_name=workflow,
744
- agent_depth=child_session.agent_depth,
745
- max_agent_depth=agent_runner._child_session_manager.max_agent_depth,
746
- terminal=terminal,
747
- prompt=enhanced_prompt,
748
- )
749
-
750
- if not terminal_result.success:
751
- return {
752
- "success": False,
753
- "clone_id": clone.id,
754
- "clone_path": clone.clone_path,
755
- "branch_name": clone.branch_name,
756
- "run_id": agent_run.id,
757
- "child_session_id": child_session.id,
758
- "error": terminal_result.error or terminal_result.message,
759
- }
760
-
761
- return {
762
- "success": True,
763
- "clone_id": clone.id,
764
- "clone_path": clone.clone_path,
765
- "branch_name": clone.branch_name,
766
- "run_id": agent_run.id,
767
- "child_session_id": child_session.id,
768
- "status": "pending",
769
- "message": f"Agent spawned in {terminal_result.terminal_type} (PID: {terminal_result.pid})",
770
- "terminal_type": terminal_result.terminal_type,
771
- "pid": terminal_result.pid,
772
- }
773
-
774
- elif mode == "embedded":
775
- from gobby.agents.spawn import EmbeddedSpawner
776
-
777
- embedded_spawner = EmbeddedSpawner()
778
- embedded_result = embedded_spawner.spawn_agent(
779
- cli=provider,
780
- cwd=clone.clone_path,
781
- session_id=child_session.id,
782
- parent_session_id=parent_session_id,
783
- agent_run_id=agent_run.id,
784
- project_id=project_id,
785
- workflow_name=workflow,
786
- agent_depth=child_session.agent_depth,
787
- max_agent_depth=agent_runner._child_session_manager.max_agent_depth,
788
- prompt=enhanced_prompt,
789
- )
790
-
791
- return {
792
- "success": embedded_result.success,
793
- "clone_id": clone.id,
794
- "clone_path": clone.clone_path,
795
- "branch_name": clone.branch_name,
796
- "run_id": agent_run.id,
797
- "child_session_id": child_session.id,
798
- "status": "pending" if embedded_result.success else "error",
799
- "error": embedded_result.error if not embedded_result.success else None,
800
- }
801
-
802
- else: # headless
803
- from gobby.agents.spawn import HeadlessSpawner
804
-
805
- headless_spawner = HeadlessSpawner()
806
- headless_result = headless_spawner.spawn_agent(
807
- cli=provider,
808
- cwd=clone.clone_path,
809
- session_id=child_session.id,
810
- parent_session_id=parent_session_id,
811
- agent_run_id=agent_run.id,
812
- project_id=project_id,
813
- workflow_name=workflow,
814
- agent_depth=child_session.agent_depth,
815
- max_agent_depth=agent_runner._child_session_manager.max_agent_depth,
816
- prompt=enhanced_prompt,
817
- )
818
-
819
- return {
820
- "success": headless_result.success,
821
- "clone_id": clone.id,
822
- "clone_path": clone.clone_path,
823
- "branch_name": clone.branch_name,
824
- "run_id": agent_run.id,
825
- "child_session_id": child_session.id,
826
- "status": "pending" if headless_result.success else "error",
827
- "pid": headless_result.pid if headless_result.success else None,
828
- "error": headless_result.error if not headless_result.success else None,
829
- }
830
-
831
- registry.register(
832
- name="spawn_agent_in_clone",
833
- description="Create a clone and spawn an agent to work in it",
834
- input_schema={
835
- "type": "object",
836
- "properties": {
837
- "prompt": {
838
- "type": "string",
839
- "description": "The task/prompt for the agent",
840
- },
841
- "branch_name": {
842
- "type": "string",
843
- "description": "Name for the branch in the clone",
844
- },
845
- "parent_session_id": {
846
- "type": "string",
847
- "description": "Parent session ID for context (required)",
848
- },
849
- "task_id": {
850
- "type": "string",
851
- "description": "Optional task ID to link to this clone",
852
- },
853
- "base_branch": {
854
- "type": "string",
855
- "description": "Branch to clone from",
856
- "default": "main",
857
- },
858
- "clone_path": {
859
- "type": "string",
860
- "description": "Optional custom path for the clone",
861
- },
862
- "mode": {
863
- "type": "string",
864
- "description": "Execution mode",
865
- "enum": ["terminal", "embedded", "headless"],
866
- "default": "terminal",
867
- },
868
- "terminal": {
869
- "type": "string",
870
- "description": "Terminal type for terminal/embedded modes",
871
- "default": "auto",
872
- },
873
- "provider": {
874
- "type": "string",
875
- "description": "LLM provider",
876
- "enum": ["claude", "gemini", "codex", "antigravity"],
877
- "default": "claude",
878
- },
879
- "model": {
880
- "type": "string",
881
- "description": "Optional model override",
882
- },
883
- "workflow": {
884
- "type": "string",
885
- "description": "Workflow name to execute",
886
- },
887
- "timeout": {
888
- "type": "number",
889
- "description": "Execution timeout in seconds",
890
- "default": 120.0,
891
- },
892
- "max_turns": {
893
- "type": "integer",
894
- "description": "Maximum turns",
895
- "default": 10,
896
- },
897
- },
898
- "required": ["prompt", "branch_name", "parent_session_id"],
899
- },
900
- func=spawn_agent_in_clone,
901
- )
902
-
903
518
  return registry
@@ -255,7 +255,7 @@ def create_memory_registry(
255
255
  name="get_related_memories",
256
256
  description="Get memories related to a specific memory via cross-references.",
257
257
  )
258
- def get_related_memories(
258
+ async def get_related_memories(
259
259
  memory_id: str,
260
260
  limit: int = 5,
261
261
  min_similarity: float = 0.0,
@@ -272,7 +272,7 @@ def create_memory_registry(
272
272
  min_similarity: Minimum similarity threshold (0.0-1.0)
273
273
  """
274
274
  try:
275
- memories = memory_manager.get_related(
275
+ memories = await memory_manager.get_related(
276
276
  memory_id=memory_id,
277
277
  limit=limit,
278
278
  min_similarity=min_similarity,
@@ -0,0 +1,14 @@
1
+ """Session tools package.
2
+
3
+ This package provides MCP tools for session management. Re-exports maintain
4
+ backwards compatibility with the original session_messages.py module.
5
+
6
+ Public API:
7
+ - create_session_messages_registry: Factory function to create the session tool registry
8
+ """
9
+
10
+ from gobby.mcp_proxy.tools.sessions._factory import create_session_messages_registry
11
+
12
+ __all__ = [
13
+ "create_session_messages_registry",
14
+ ]
@@ -0,0 +1,232 @@
1
+ """Commits and workflow tools for session management.
2
+
3
+ This module contains MCP tools for:
4
+ - Getting session commits (get_session_commits)
5
+ - Marking autonomous loop complete (mark_loop_complete)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import UTC
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ if TYPE_CHECKING:
14
+ from gobby.mcp_proxy.tools.internal import InternalToolRegistry
15
+ from gobby.storage.sessions import LocalSessionManager
16
+
17
+
18
+ def register_commits_tools(
19
+ registry: InternalToolRegistry,
20
+ session_manager: LocalSessionManager,
21
+ ) -> None:
22
+ """
23
+ Register commits and workflow tools with a registry.
24
+
25
+ Args:
26
+ registry: The InternalToolRegistry to register tools with
27
+ session_manager: LocalSessionManager instance for session operations
28
+ """
29
+
30
+ @registry.tool(
31
+ name="get_session_commits",
32
+ description="Get git commits made during a session timeframe.",
33
+ )
34
+ def get_session_commits(
35
+ session_id: str,
36
+ max_commits: int = 20,
37
+ ) -> dict[str, Any]:
38
+ """
39
+ Get git commits made during a session's active timeframe.
40
+
41
+ Uses session.created_at and session.updated_at to filter
42
+ git log within that timeframe.
43
+
44
+ Args:
45
+ session_id: Session ID
46
+ max_commits: Maximum commits to return (default 20)
47
+
48
+ Returns:
49
+ Session ID, list of commits, and count
50
+ """
51
+ import subprocess # nosec B404 - subprocess needed for git commands
52
+ from datetime import datetime
53
+ from pathlib import Path
54
+
55
+ if session_manager is None:
56
+ return {"error": "Session manager not available"}
57
+
58
+ # Get session
59
+ session = session_manager.get(session_id)
60
+ if not session:
61
+ # Try prefix match
62
+ sessions = session_manager.list(limit=100)
63
+ matches = [s for s in sessions if s.id.startswith(session_id)]
64
+ if len(matches) == 1:
65
+ session = matches[0]
66
+ elif len(matches) > 1:
67
+ return {
68
+ "error": f"Ambiguous session ID prefix '{session_id}'",
69
+ "matches": [s.id for s in matches[:5]],
70
+ }
71
+ else:
72
+ return {"error": f"Session {session_id} not found"}
73
+
74
+ # Get working directory from transcript path or project
75
+ cwd = None
76
+ if session.jsonl_path:
77
+ cwd = str(Path(session.jsonl_path).parent)
78
+
79
+ # Format timestamps for git --since/--until
80
+ # Git expects ISO format or relative dates
81
+ # Session timestamps may be ISO strings or datetime objects
82
+ if isinstance(session.created_at, str):
83
+ since_time = datetime.fromisoformat(session.created_at.replace("Z", "+00:00"))
84
+ else:
85
+ since_time = session.created_at
86
+
87
+ if session.updated_at:
88
+ if isinstance(session.updated_at, str):
89
+ until_time = datetime.fromisoformat(session.updated_at.replace("Z", "+00:00"))
90
+ else:
91
+ until_time = session.updated_at
92
+ else:
93
+ until_time = datetime.now(UTC)
94
+
95
+ # Format as ISO 8601 for git
96
+ since_str = since_time.strftime("%Y-%m-%dT%H:%M:%S")
97
+ until_str = until_time.strftime("%Y-%m-%dT%H:%M:%S")
98
+
99
+ try:
100
+ # Get commits within timeframe
101
+ cmd = [
102
+ "git",
103
+ "log",
104
+ f"--since={since_str}",
105
+ f"--until={until_str}",
106
+ f"-{max_commits}",
107
+ "--format=%H|%s|%aI", # hash|subject|author-date-iso
108
+ ]
109
+
110
+ result = subprocess.run( # nosec B603 - cmd built from hardcoded git arguments
111
+ cmd,
112
+ capture_output=True,
113
+ text=True,
114
+ timeout=10,
115
+ cwd=cwd,
116
+ )
117
+
118
+ if result.returncode != 0:
119
+ return {
120
+ "session_id": session.id,
121
+ "error": "Git command failed",
122
+ "stderr": result.stderr.strip(),
123
+ }
124
+
125
+ commits = []
126
+ for line in result.stdout.strip().split("\n"):
127
+ if "|" in line:
128
+ parts = line.split("|", 2)
129
+ if len(parts) >= 2:
130
+ commit = {
131
+ "hash": parts[0],
132
+ "message": parts[1],
133
+ }
134
+ if len(parts) >= 3:
135
+ commit["timestamp"] = parts[2]
136
+ commits.append(commit)
137
+
138
+ return {
139
+ "session_id": session.id,
140
+ "commits": commits,
141
+ "count": len(commits),
142
+ "timeframe": {
143
+ "since": since_str,
144
+ "until": until_str,
145
+ },
146
+ }
147
+
148
+ except subprocess.TimeoutExpired:
149
+ return {
150
+ "session_id": session.id,
151
+ "error": "Git command timed out",
152
+ }
153
+ except FileNotFoundError:
154
+ return {
155
+ "session_id": session.id,
156
+ "error": "Git not found or not a git repository",
157
+ }
158
+ except Exception as e:
159
+ return {
160
+ "session_id": session.id,
161
+ "error": f"Failed to get commits: {e!s}",
162
+ }
163
+
164
+ @registry.tool(
165
+ name="mark_loop_complete",
166
+ description="""Mark the autonomous loop as complete, preventing session chaining.
167
+
168
+ Args:
169
+ session_id: (REQUIRED) Your session ID. Get it from:
170
+ 1. Your injected context (look for 'session_id: xxx')
171
+ 2. Or call get_current(external_id, source) first""",
172
+ )
173
+ def mark_loop_complete(session_id: str) -> dict[str, Any]:
174
+ """
175
+ Mark the autonomous loop as complete for a session.
176
+
177
+ This sets stop_reason='completed' in the workflow state, which
178
+ signals the auto-loop workflow to NOT chain a new session
179
+ when this session ends.
180
+
181
+ Use this when:
182
+ - A task is fully complete and no more work is needed
183
+ - You want to exit the autonomous loop gracefully
184
+ - The user has explicitly asked to stop
185
+
186
+ Args:
187
+ session_id: Session ID (REQUIRED)
188
+
189
+ Returns:
190
+ Success status and session details
191
+ """
192
+ if not session_manager:
193
+ raise RuntimeError("Session manager not available")
194
+
195
+ # Find session - session_id is now required
196
+ session = session_manager.get(session_id)
197
+
198
+ if not session:
199
+ return {"error": f"Session {session_id} not found", "session_id": session_id}
200
+
201
+ # Load and update workflow state
202
+ from gobby.storage.database import LocalDatabase
203
+ from gobby.workflows.definitions import WorkflowState
204
+ from gobby.workflows.state_manager import WorkflowStateManager
205
+
206
+ db = LocalDatabase()
207
+ state_manager = WorkflowStateManager(db)
208
+
209
+ # Get or create state for session
210
+ state = state_manager.get_state(session.id)
211
+ if not state:
212
+ # Create minimal state just to hold the variable
213
+ state = WorkflowState(
214
+ session_id=session.id,
215
+ workflow_name="auto-loop",
216
+ step="active",
217
+ )
218
+
219
+ # Mark loop complete using the action function
220
+ from gobby.workflows.state_actions import mark_loop_complete as action_mark_complete
221
+
222
+ action_mark_complete(state)
223
+
224
+ # Save updated state
225
+ state_manager.save_state(state)
226
+
227
+ return {
228
+ "success": True,
229
+ "session_id": session.id,
230
+ "stop_reason": "completed",
231
+ "message": "Autonomous loop marked complete - session will not chain",
232
+ }