swarm_memory 2.0.0

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 (189) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/lib/claude_swarm/base_executor.rb +133 -0
  4. data/lib/claude_swarm/claude_code_executor.rb +349 -0
  5. data/lib/claude_swarm/claude_mcp_server.rb +77 -0
  6. data/lib/claude_swarm/cli.rb +712 -0
  7. data/lib/claude_swarm/commands/ps.rb +216 -0
  8. data/lib/claude_swarm/commands/show.rb +139 -0
  9. data/lib/claude_swarm/configuration.rb +363 -0
  10. data/lib/claude_swarm/hooks/session_start_hook.rb +42 -0
  11. data/lib/claude_swarm/json_handler.rb +91 -0
  12. data/lib/claude_swarm/mcp_generator.rb +248 -0
  13. data/lib/claude_swarm/openai/chat_completion.rb +264 -0
  14. data/lib/claude_swarm/openai/executor.rb +254 -0
  15. data/lib/claude_swarm/openai/responses.rb +338 -0
  16. data/lib/claude_swarm/orchestrator.rb +879 -0
  17. data/lib/claude_swarm/process_tracker.rb +78 -0
  18. data/lib/claude_swarm/session_cost_calculator.rb +209 -0
  19. data/lib/claude_swarm/session_path.rb +42 -0
  20. data/lib/claude_swarm/settings_generator.rb +77 -0
  21. data/lib/claude_swarm/system_utils.rb +46 -0
  22. data/lib/claude_swarm/templates/generation_prompt.md.erb +230 -0
  23. data/lib/claude_swarm/tools/reset_session_tool.rb +24 -0
  24. data/lib/claude_swarm/tools/session_info_tool.rb +24 -0
  25. data/lib/claude_swarm/tools/task_tool.rb +63 -0
  26. data/lib/claude_swarm/version.rb +5 -0
  27. data/lib/claude_swarm/worktree_manager.rb +475 -0
  28. data/lib/claude_swarm/yaml_loader.rb +22 -0
  29. data/lib/claude_swarm.rb +69 -0
  30. data/lib/swarm_cli/cli.rb +201 -0
  31. data/lib/swarm_cli/command_registry.rb +61 -0
  32. data/lib/swarm_cli/commands/mcp_serve.rb +130 -0
  33. data/lib/swarm_cli/commands/mcp_tools.rb +148 -0
  34. data/lib/swarm_cli/commands/migrate.rb +55 -0
  35. data/lib/swarm_cli/commands/run.rb +173 -0
  36. data/lib/swarm_cli/config_loader.rb +97 -0
  37. data/lib/swarm_cli/formatters/human_formatter.rb +711 -0
  38. data/lib/swarm_cli/formatters/json_formatter.rb +51 -0
  39. data/lib/swarm_cli/interactive_repl.rb +918 -0
  40. data/lib/swarm_cli/mcp_serve_options.rb +44 -0
  41. data/lib/swarm_cli/mcp_tools_options.rb +59 -0
  42. data/lib/swarm_cli/migrate_options.rb +54 -0
  43. data/lib/swarm_cli/migrator.rb +132 -0
  44. data/lib/swarm_cli/options.rb +151 -0
  45. data/lib/swarm_cli/ui/components/agent_badge.rb +33 -0
  46. data/lib/swarm_cli/ui/components/content_block.rb +120 -0
  47. data/lib/swarm_cli/ui/components/divider.rb +57 -0
  48. data/lib/swarm_cli/ui/components/panel.rb +62 -0
  49. data/lib/swarm_cli/ui/components/usage_stats.rb +70 -0
  50. data/lib/swarm_cli/ui/formatters/cost.rb +49 -0
  51. data/lib/swarm_cli/ui/formatters/number.rb +58 -0
  52. data/lib/swarm_cli/ui/formatters/text.rb +77 -0
  53. data/lib/swarm_cli/ui/formatters/time.rb +73 -0
  54. data/lib/swarm_cli/ui/icons.rb +59 -0
  55. data/lib/swarm_cli/ui/renderers/event_renderer.rb +188 -0
  56. data/lib/swarm_cli/ui/state/agent_color_cache.rb +45 -0
  57. data/lib/swarm_cli/ui/state/depth_tracker.rb +40 -0
  58. data/lib/swarm_cli/ui/state/spinner_manager.rb +170 -0
  59. data/lib/swarm_cli/ui/state/usage_tracker.rb +62 -0
  60. data/lib/swarm_cli/version.rb +5 -0
  61. data/lib/swarm_cli.rb +45 -0
  62. data/lib/swarm_memory/adapters/base.rb +140 -0
  63. data/lib/swarm_memory/adapters/filesystem_adapter.rb +789 -0
  64. data/lib/swarm_memory/chat_extension.rb +34 -0
  65. data/lib/swarm_memory/cli/commands.rb +306 -0
  66. data/lib/swarm_memory/core/entry.rb +37 -0
  67. data/lib/swarm_memory/core/frontmatter_parser.rb +108 -0
  68. data/lib/swarm_memory/core/metadata_extractor.rb +68 -0
  69. data/lib/swarm_memory/core/path_normalizer.rb +75 -0
  70. data/lib/swarm_memory/core/semantic_index.rb +244 -0
  71. data/lib/swarm_memory/core/storage.rb +286 -0
  72. data/lib/swarm_memory/core/storage_read_tracker.rb +63 -0
  73. data/lib/swarm_memory/dsl/builder_extension.rb +40 -0
  74. data/lib/swarm_memory/dsl/memory_config.rb +113 -0
  75. data/lib/swarm_memory/embeddings/embedder.rb +36 -0
  76. data/lib/swarm_memory/embeddings/informers_embedder.rb +152 -0
  77. data/lib/swarm_memory/errors.rb +21 -0
  78. data/lib/swarm_memory/integration/cli_registration.rb +30 -0
  79. data/lib/swarm_memory/integration/configuration.rb +43 -0
  80. data/lib/swarm_memory/integration/registration.rb +31 -0
  81. data/lib/swarm_memory/integration/sdk_plugin.rb +531 -0
  82. data/lib/swarm_memory/optimization/analyzer.rb +244 -0
  83. data/lib/swarm_memory/optimization/defragmenter.rb +863 -0
  84. data/lib/swarm_memory/prompts/memory.md.erb +109 -0
  85. data/lib/swarm_memory/prompts/memory_assistant.md.erb +139 -0
  86. data/lib/swarm_memory/prompts/memory_researcher.md.erb +201 -0
  87. data/lib/swarm_memory/prompts/memory_retrieval.md.erb +76 -0
  88. data/lib/swarm_memory/search/semantic_search.rb +112 -0
  89. data/lib/swarm_memory/search/text_search.rb +40 -0
  90. data/lib/swarm_memory/search/text_similarity.rb +80 -0
  91. data/lib/swarm_memory/skills/meta/deep-learning.md +101 -0
  92. data/lib/swarm_memory/skills/meta/deep-learning.yml +14 -0
  93. data/lib/swarm_memory/tools/load_skill.rb +313 -0
  94. data/lib/swarm_memory/tools/memory_defrag.rb +382 -0
  95. data/lib/swarm_memory/tools/memory_delete.rb +99 -0
  96. data/lib/swarm_memory/tools/memory_edit.rb +185 -0
  97. data/lib/swarm_memory/tools/memory_glob.rb +145 -0
  98. data/lib/swarm_memory/tools/memory_grep.rb +209 -0
  99. data/lib/swarm_memory/tools/memory_multi_edit.rb +281 -0
  100. data/lib/swarm_memory/tools/memory_read.rb +123 -0
  101. data/lib/swarm_memory/tools/memory_write.rb +215 -0
  102. data/lib/swarm_memory/utils.rb +50 -0
  103. data/lib/swarm_memory/version.rb +5 -0
  104. data/lib/swarm_memory.rb +166 -0
  105. data/lib/swarm_sdk/agent/RETRY_LOGIC.md +127 -0
  106. data/lib/swarm_sdk/agent/builder.rb +461 -0
  107. data/lib/swarm_sdk/agent/chat/context_tracker.rb +314 -0
  108. data/lib/swarm_sdk/agent/chat/hook_integration.rb +372 -0
  109. data/lib/swarm_sdk/agent/chat/logging_helpers.rb +116 -0
  110. data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +152 -0
  111. data/lib/swarm_sdk/agent/chat.rb +1144 -0
  112. data/lib/swarm_sdk/agent/context.rb +112 -0
  113. data/lib/swarm_sdk/agent/context_manager.rb +309 -0
  114. data/lib/swarm_sdk/agent/definition.rb +556 -0
  115. data/lib/swarm_sdk/claude_code_agent_adapter.rb +205 -0
  116. data/lib/swarm_sdk/configuration.rb +296 -0
  117. data/lib/swarm_sdk/context_compactor/metrics.rb +147 -0
  118. data/lib/swarm_sdk/context_compactor/token_counter.rb +106 -0
  119. data/lib/swarm_sdk/context_compactor.rb +340 -0
  120. data/lib/swarm_sdk/hooks/adapter.rb +359 -0
  121. data/lib/swarm_sdk/hooks/context.rb +197 -0
  122. data/lib/swarm_sdk/hooks/definition.rb +80 -0
  123. data/lib/swarm_sdk/hooks/error.rb +29 -0
  124. data/lib/swarm_sdk/hooks/executor.rb +146 -0
  125. data/lib/swarm_sdk/hooks/registry.rb +147 -0
  126. data/lib/swarm_sdk/hooks/result.rb +150 -0
  127. data/lib/swarm_sdk/hooks/shell_executor.rb +254 -0
  128. data/lib/swarm_sdk/hooks/tool_call.rb +35 -0
  129. data/lib/swarm_sdk/hooks/tool_result.rb +62 -0
  130. data/lib/swarm_sdk/log_collector.rb +51 -0
  131. data/lib/swarm_sdk/log_stream.rb +69 -0
  132. data/lib/swarm_sdk/markdown_parser.rb +75 -0
  133. data/lib/swarm_sdk/model_aliases.json +5 -0
  134. data/lib/swarm_sdk/models.json +1 -0
  135. data/lib/swarm_sdk/models.rb +120 -0
  136. data/lib/swarm_sdk/node/agent_config.rb +49 -0
  137. data/lib/swarm_sdk/node/builder.rb +439 -0
  138. data/lib/swarm_sdk/node/transformer_executor.rb +248 -0
  139. data/lib/swarm_sdk/node_context.rb +170 -0
  140. data/lib/swarm_sdk/node_orchestrator.rb +384 -0
  141. data/lib/swarm_sdk/permissions/config.rb +239 -0
  142. data/lib/swarm_sdk/permissions/error_formatter.rb +121 -0
  143. data/lib/swarm_sdk/permissions/path_matcher.rb +35 -0
  144. data/lib/swarm_sdk/permissions/validator.rb +173 -0
  145. data/lib/swarm_sdk/permissions_builder.rb +122 -0
  146. data/lib/swarm_sdk/plugin.rb +147 -0
  147. data/lib/swarm_sdk/plugin_registry.rb +101 -0
  148. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +243 -0
  149. data/lib/swarm_sdk/providers/openai_with_responses.rb +582 -0
  150. data/lib/swarm_sdk/result.rb +97 -0
  151. data/lib/swarm_sdk/swarm/agent_initializer.rb +334 -0
  152. data/lib/swarm_sdk/swarm/all_agents_builder.rb +140 -0
  153. data/lib/swarm_sdk/swarm/builder.rb +586 -0
  154. data/lib/swarm_sdk/swarm/mcp_configurator.rb +151 -0
  155. data/lib/swarm_sdk/swarm/tool_configurator.rb +416 -0
  156. data/lib/swarm_sdk/swarm.rb +982 -0
  157. data/lib/swarm_sdk/tools/bash.rb +274 -0
  158. data/lib/swarm_sdk/tools/clock.rb +44 -0
  159. data/lib/swarm_sdk/tools/delegate.rb +164 -0
  160. data/lib/swarm_sdk/tools/document_converters/base_converter.rb +83 -0
  161. data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +99 -0
  162. data/lib/swarm_sdk/tools/document_converters/html_converter.rb +101 -0
  163. data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +78 -0
  164. data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +194 -0
  165. data/lib/swarm_sdk/tools/edit.rb +150 -0
  166. data/lib/swarm_sdk/tools/glob.rb +158 -0
  167. data/lib/swarm_sdk/tools/grep.rb +228 -0
  168. data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +43 -0
  169. data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +163 -0
  170. data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +65 -0
  171. data/lib/swarm_sdk/tools/multi_edit.rb +232 -0
  172. data/lib/swarm_sdk/tools/path_resolver.rb +43 -0
  173. data/lib/swarm_sdk/tools/read.rb +251 -0
  174. data/lib/swarm_sdk/tools/registry.rb +93 -0
  175. data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +96 -0
  176. data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +76 -0
  177. data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +91 -0
  178. data/lib/swarm_sdk/tools/stores/read_tracker.rb +61 -0
  179. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +224 -0
  180. data/lib/swarm_sdk/tools/stores/storage.rb +148 -0
  181. data/lib/swarm_sdk/tools/stores/todo_manager.rb +65 -0
  182. data/lib/swarm_sdk/tools/think.rb +95 -0
  183. data/lib/swarm_sdk/tools/todo_write.rb +216 -0
  184. data/lib/swarm_sdk/tools/web_fetch.rb +261 -0
  185. data/lib/swarm_sdk/tools/write.rb +117 -0
  186. data/lib/swarm_sdk/utils.rb +50 -0
  187. data/lib/swarm_sdk/version.rb +5 -0
  188. data/lib/swarm_sdk.rb +167 -0
  189. metadata +313 -0
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeSwarm
4
+ module Tools
5
+ class ResetSessionTool < FastMcp::Tool
6
+ tool_name "reset_session"
7
+ description "Reset the Claude session for this agent, starting fresh on the next task"
8
+
9
+ arguments do
10
+ # No arguments needed
11
+ end
12
+
13
+ def call
14
+ executor = ClaudeMcpServer.executor
15
+ executor.reset_session
16
+
17
+ {
18
+ success: true,
19
+ message: "Session has been reset",
20
+ }
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeSwarm
4
+ module Tools
5
+ class SessionInfoTool < FastMcp::Tool
6
+ tool_name "session_info"
7
+ description "Get information about the current Claude session for this agent"
8
+
9
+ arguments do
10
+ # No arguments needed
11
+ end
12
+
13
+ def call
14
+ executor = ClaudeMcpServer.executor
15
+
16
+ {
17
+ has_session: executor.has_session?,
18
+ session_id: executor.session_id,
19
+ working_directory: executor.working_directory,
20
+ }
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeSwarm
4
+ module Tools
5
+ class TaskTool < FastMcp::Tool
6
+ tool_name "task"
7
+ description "Execute a task using Claude Code. There is no description parameter."
8
+ annotations(read_only_hint: true, open_world_hint: false, destructive_hint: false)
9
+
10
+ arguments do
11
+ required(:prompt).filled(:string).description("The task or question for the agent")
12
+ optional(:new_session).filled(:bool).description("Start a new session (default: false)")
13
+ optional(:system_prompt).filled(:string).description("Override the system prompt for this request")
14
+ optional(:description).filled(:string).description("A description for the request")
15
+ optional(:thinking_budget).filled(:string).description("Thinking budget: \"think\" < \"think hard\" < \"think harder\" < \"ultrathink\". Each level increases Claude's thinking allocation. Auto-select based on task complexity.")
16
+ end
17
+
18
+ def call(prompt:, new_session: false, system_prompt: nil, description: nil, thinking_budget: nil)
19
+ executor = ClaudeMcpServer.executor
20
+ instance_config = ClaudeMcpServer.instance_config
21
+
22
+ # Prepend thinking budget to prompt if provided
23
+ final_prompt = if thinking_budget
24
+ "#{thinking_budget}: #{prompt}"
25
+ else
26
+ prompt
27
+ end
28
+
29
+ options = {
30
+ new_session: new_session,
31
+ system_prompt: system_prompt || instance_config[:prompt],
32
+ description: description,
33
+ }
34
+
35
+ # Add allowed tools from instance config
36
+ options[:allowed_tools] = instance_config[:allowed_tools] if instance_config[:allowed_tools]&.any?
37
+
38
+ # Add disallowed tools from instance config
39
+ options[:disallowed_tools] = instance_config[:disallowed_tools] if instance_config[:disallowed_tools]&.any?
40
+
41
+ # Add connections from instance config
42
+ options[:connections] = instance_config[:connections] if instance_config[:connections]&.any?
43
+
44
+ response = executor.execute(final_prompt, options)
45
+
46
+ # Validate the response has a result
47
+ unless response.is_a?(Hash) && response.key?("result")
48
+ raise "Invalid response from executor: missing 'result' field. Response structure: #{response.keys.join(", ")}"
49
+ end
50
+
51
+ result = response["result"]
52
+
53
+ # Validate the result is not empty
54
+ if result.nil? || (result.is_a?(String) && result.strip.empty?)
55
+ raise "Agent #{instance_config[:name]} returned an empty response. The task was executed but no content was provided."
56
+ end
57
+
58
+ # Return just the result text as expected by MCP
59
+ result
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeSwarm
4
+ VERSION = "1.0.1"
5
+ end
@@ -0,0 +1,475 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeSwarm
4
+ class WorktreeManager
5
+ include SystemUtils
6
+
7
+ attr_reader :shared_worktree_name, :created_worktrees
8
+
9
+ def initialize(cli_worktree_option = nil, session_id: nil)
10
+ @cli_worktree_option = cli_worktree_option
11
+ @session_id = session_id
12
+ # Generate a name based on session ID if no option given, empty string, or default "worktree" from Thor
13
+ @shared_worktree_name = if cli_worktree_option.nil? || cli_worktree_option.empty? || cli_worktree_option == "worktree"
14
+ generate_worktree_name
15
+ else
16
+ cli_worktree_option
17
+ end
18
+ @created_worktrees = {} # Maps "repo_root:worktree_name" to worktree_path
19
+ @instance_worktree_configs = {} # Stores per-instance worktree settings
20
+ @instance_directories = {} # Stores original resolved directories for each instance
21
+ @instance_worktree_paths = {} # Stores worktree paths for each instance
22
+ end
23
+
24
+ def setup_worktrees(instances)
25
+ # First pass: determine worktree configuration for each instance
26
+ instances.each do |instance|
27
+ worktree_config = determine_worktree_config(instance)
28
+ @instance_worktree_configs[instance[:name]] = worktree_config
29
+ end
30
+
31
+ # Second pass: create necessary worktrees
32
+ worktrees_to_create = collect_worktrees_to_create(instances)
33
+ worktrees_to_create.each do |repo_root, worktree_name|
34
+ create_worktree(repo_root, worktree_name)
35
+ end
36
+
37
+ # Third pass: map instance directories to worktree paths
38
+ instances.each do |instance|
39
+ worktree_config = @instance_worktree_configs[instance[:name]]
40
+
41
+ if ENV["CLAUDE_SWARM_DEBUG"]
42
+ puts "Debug [WorktreeManager]: Processing instance #{instance[:name]}"
43
+ puts "Debug [WorktreeManager]: Worktree config: #{worktree_config.inspect}"
44
+ end
45
+
46
+ # Store original directories (resolved)
47
+ original_dirs = instance[:directories] || [instance[:directory]]
48
+ resolved_dirs = original_dirs.map { |dir| dir ? File.expand_path(dir) : nil }.compact
49
+ @instance_directories[instance[:name]] = resolved_dirs
50
+
51
+ if worktree_config[:skip]
52
+ # No worktree, paths remain the same
53
+ @instance_worktree_paths[instance[:name]] = resolved_dirs
54
+ next
55
+ end
56
+
57
+ worktree_name = worktree_config[:name]
58
+ mapped_dirs = original_dirs.map { |dir| map_to_worktree_path(dir, worktree_name) }
59
+
60
+ # Store the worktree paths
61
+ @instance_worktree_paths[instance[:name]] = mapped_dirs
62
+
63
+ if ENV["CLAUDE_SWARM_DEBUG"]
64
+ puts "Debug [WorktreeManager]: Original dirs: #{original_dirs.inspect}"
65
+ puts "Debug [WorktreeManager]: Mapped dirs: #{mapped_dirs.inspect}"
66
+ end
67
+
68
+ if instance[:directories]
69
+ instance[:directories] = mapped_dirs
70
+ # Also update the single directory field for backward compatibility
71
+ instance[:directory] = mapped_dirs.first
72
+ else
73
+ instance[:directory] = mapped_dirs.first
74
+ end
75
+
76
+ puts "Debug [WorktreeManager]: Updated instance[:directory] to: #{instance[:directory]}" if ENV["CLAUDE_SWARM_DEBUG"]
77
+ end
78
+ end
79
+
80
+ def map_to_worktree_path(original_path, worktree_name)
81
+ return original_path unless original_path
82
+
83
+ expanded_path = File.expand_path(original_path)
84
+ repo_root = find_git_root(expanded_path)
85
+
86
+ if ENV["CLAUDE_SWARM_DEBUG"]
87
+ puts "Debug [map_to_worktree_path]: Original path: #{original_path}"
88
+ puts "Debug [map_to_worktree_path]: Expanded path: #{expanded_path}"
89
+ puts "Debug [map_to_worktree_path]: Repo root: #{repo_root}"
90
+ end
91
+
92
+ return original_path unless repo_root
93
+
94
+ # Check if we have a worktree for this repo and name
95
+ worktree_key = "#{repo_root}:#{worktree_name}"
96
+ worktree_path = @created_worktrees[worktree_key]
97
+
98
+ if ENV["CLAUDE_SWARM_DEBUG"]
99
+ puts "Debug [map_to_worktree_path]: Worktree key: #{worktree_key}"
100
+ puts "Debug [map_to_worktree_path]: Worktree path: #{worktree_path}"
101
+ puts "Debug [map_to_worktree_path]: Created worktrees: #{@created_worktrees.inspect}"
102
+ end
103
+
104
+ return original_path unless worktree_path
105
+
106
+ # Calculate relative path from repo root
107
+ relative_path = Pathname.new(expanded_path).relative_path_from(Pathname.new(repo_root)).to_s
108
+
109
+ # Return the equivalent path in the worktree
110
+ result = if relative_path == "."
111
+ worktree_path
112
+ else
113
+ File.join(worktree_path, relative_path)
114
+ end
115
+
116
+ puts "Debug [map_to_worktree_path]: Result: #{result}" if ENV["CLAUDE_SWARM_DEBUG"]
117
+
118
+ result
119
+ end
120
+
121
+ def cleanup_worktrees
122
+ @created_worktrees.each do |worktree_key, worktree_path|
123
+ repo_root = worktree_key.split(":", 2).first
124
+ next unless File.exist?(worktree_path)
125
+
126
+ # Check for uncommitted changes
127
+ if has_uncommitted_changes?(worktree_path)
128
+ puts "⚠️ Warning: Worktree has uncommitted changes, skipping cleanup: #{worktree_path}" unless ENV["CLAUDE_SWARM_PROMPT"]
129
+ next
130
+ end
131
+
132
+ # Check for unpushed commits
133
+ has_unpushed = has_unpushed_commits?(worktree_path)
134
+
135
+ if has_unpushed
136
+ puts "⚠️ Warning: Worktree has unpushed commits, skipping cleanup: #{worktree_path}" unless ENV["CLAUDE_SWARM_PROMPT"]
137
+ next
138
+ end
139
+
140
+ puts "Removing worktree: #{worktree_path}" unless ENV["CLAUDE_SWARM_PROMPT"]
141
+
142
+ # Remove the worktree
143
+ output, status = Open3.capture2e("git", "-C", repo_root, "worktree", "remove", worktree_path)
144
+ next if status.success?
145
+
146
+ puts "Warning: Failed to remove worktree: #{output}"
147
+ # Try force remove
148
+ output, status = Open3.capture2e("git", "-C", repo_root, "worktree", "remove", "--force", worktree_path)
149
+ puts "Force remove result: #{output}" unless status.success?
150
+ end
151
+
152
+ # Clean up external worktree directories
153
+ cleanup_external_directories
154
+ rescue StandardError => e
155
+ puts "Error during worktree cleanup: #{e.message}"
156
+ end
157
+
158
+ def cleanup_external_directories
159
+ # Remove session-specific worktree directory if it exists and is empty
160
+ return unless @session_id
161
+
162
+ session_worktree_dir = ClaudeSwarm.joined_worktrees_dir(@session_id)
163
+ return unless File.exist?(session_worktree_dir)
164
+
165
+ # Try to remove the directory tree
166
+ begin
167
+ # Remove all empty directories recursively
168
+ Dir.glob(File.join(session_worktree_dir, "**/*"), File::FNM_DOTMATCH).reverse_each do |path|
169
+ next if path.end_with?("/.", "/..")
170
+
171
+ FileUtils.rmdir(path) if File.directory?(path) && Dir.empty?(path)
172
+ end
173
+ # Finally try to remove the session directory itself
174
+ FileUtils.rmdir(session_worktree_dir) if Dir.empty?(session_worktree_dir)
175
+ rescue Errno::ENOTEMPTY
176
+ puts "Note: Session worktree directory not empty, leaving in place: #{session_worktree_dir}" unless ENV["CLAUDE_SWARM_PROMPT"]
177
+ rescue StandardError => e
178
+ puts "Warning: Error cleaning up worktree directories: #{e.message}" unless ENV["CLAUDE_SWARM_PROMPT"]
179
+ end
180
+ end
181
+
182
+ def session_metadata
183
+ # Build instance details with resolved paths and worktree mappings
184
+ instance_details = {}
185
+
186
+ @instance_worktree_configs.each do |instance_name, worktree_config|
187
+ instance_details[instance_name] = {
188
+ worktree_config: worktree_config,
189
+ directories: @instance_directories[instance_name] || {},
190
+ worktree_paths: @instance_worktree_paths[instance_name] || {},
191
+ }
192
+ end
193
+
194
+ {
195
+ enabled: true,
196
+ shared_name: @shared_worktree_name,
197
+ created_paths: @created_worktrees.dup,
198
+ instance_configs: instance_details,
199
+ }
200
+ end
201
+
202
+ # Deprecated method for backward compatibility
203
+ def worktree_name
204
+ @shared_worktree_name
205
+ end
206
+
207
+ private
208
+
209
+ def external_worktree_path(repo_root, worktree_name)
210
+ # Get repository name from path
211
+ repo_name = sanitize_repo_name(File.basename(repo_root))
212
+
213
+ # Add a short hash of the full path to handle multiple repos with same name
214
+ path_hash = Digest::SHA256.hexdigest(repo_root)[0..7]
215
+ unique_repo_name = "#{repo_name}-#{path_hash}"
216
+
217
+ # Build external path: ~/.claude-swarm/worktrees/[session_id]/[repo_name-hash]/[worktree_name]
218
+ base_dir = ClaudeSwarm.joined_worktrees_dir
219
+
220
+ # Validate base directory is accessible
221
+ begin
222
+ FileUtils.mkdir_p(base_dir)
223
+ rescue Errno::EACCES
224
+ # Fall back to temp directory if home is not writable
225
+ base_dir = File.join(Dir.tmpdir, ".claude-swarm", "worktrees")
226
+ FileUtils.mkdir_p(base_dir)
227
+ end
228
+
229
+ session_dir = @session_id || "default"
230
+ File.join(base_dir, session_dir, unique_repo_name, worktree_name)
231
+ end
232
+
233
+ def sanitize_repo_name(name)
234
+ # Replace problematic characters with underscores
235
+ name.gsub(/[^a-zA-Z0-9._-]/, "_")
236
+ end
237
+
238
+ def generate_worktree_name
239
+ # Use session ID if available, otherwise generate a random suffix
240
+ if @session_id
241
+ "worktree-#{@session_id}"
242
+ else
243
+ # Fallback to random suffix for tests or when session ID is not available
244
+ random_suffix = SecureRandom.alphanumeric(5).downcase
245
+ "worktree-#{random_suffix}"
246
+ end
247
+ end
248
+
249
+ def determine_worktree_config(instance)
250
+ # Check instance-level worktree setting
251
+ instance_worktree = instance[:worktree]
252
+
253
+ if instance_worktree.nil?
254
+ # No instance-level setting, follow CLI behavior
255
+ if @cli_worktree_option.nil?
256
+ { skip: true }
257
+ else
258
+ { skip: false, name: @shared_worktree_name }
259
+ end
260
+ elsif instance_worktree == false
261
+ # Explicitly disabled for this instance
262
+ { skip: true }
263
+ elsif instance_worktree == true
264
+ # Use shared worktree (either from CLI or auto-generated)
265
+ { skip: false, name: @shared_worktree_name }
266
+ elsif instance_worktree.is_a?(String)
267
+ # Use custom worktree name
268
+ { skip: false, name: instance_worktree }
269
+ else
270
+ raise Error, "Invalid worktree configuration for instance '#{instance[:name]}': #{instance_worktree.inspect}"
271
+ end
272
+ end
273
+
274
+ def collect_worktrees_to_create(instances)
275
+ worktrees_needed = {}
276
+
277
+ instances.each do |instance|
278
+ worktree_config = @instance_worktree_configs[instance[:name]]
279
+ next if worktree_config[:skip]
280
+
281
+ worktree_name = worktree_config[:name]
282
+ directories = instance[:directories] || [instance[:directory]]
283
+
284
+ directories.each do |dir|
285
+ next unless dir
286
+
287
+ expanded_dir = File.expand_path(dir)
288
+ repo_root = find_git_root(expanded_dir)
289
+ next unless repo_root
290
+
291
+ # Track unique repo_root:worktree_name combinations
292
+ worktrees_needed[repo_root] ||= Set.new
293
+ worktrees_needed[repo_root].add(worktree_name)
294
+ end
295
+ end
296
+
297
+ # Convert to array of [repo_root, worktree_name] pairs
298
+ result = []
299
+ worktrees_needed.each do |repo_root, worktree_names|
300
+ worktree_names.each do |worktree_name|
301
+ result << [repo_root, worktree_name]
302
+ end
303
+ end
304
+ result
305
+ end
306
+
307
+ def find_git_root(path)
308
+ current = File.expand_path(path)
309
+
310
+ while current != "/"
311
+ return current if File.exist?(File.join(current, ".git"))
312
+
313
+ current = File.dirname(current)
314
+ end
315
+
316
+ nil
317
+ end
318
+
319
+ def create_worktree(repo_root, worktree_name)
320
+ worktree_key = "#{repo_root}:#{worktree_name}"
321
+ # Create worktrees in external directory
322
+ worktree_path = external_worktree_path(repo_root, worktree_name)
323
+
324
+ # Check if worktree already exists
325
+ if File.exist?(worktree_path)
326
+ puts "Using existing worktree: #{worktree_path}" unless ENV["CLAUDE_SWARM_PROMPT"]
327
+ @created_worktrees[worktree_key] = worktree_path
328
+ return
329
+ end
330
+
331
+ # Ensure parent directory exists with proper error handling
332
+ begin
333
+ FileUtils.mkdir_p(File.dirname(worktree_path))
334
+ rescue Errno::EACCES => e
335
+ raise Error, "Permission denied creating worktree directory: #{e.message}"
336
+ rescue Errno::ENOSPC => e
337
+ raise Error, "Not enough disk space for worktree: #{e.message}"
338
+ rescue StandardError => e
339
+ raise Error, "Failed to create worktree directory: #{e.message}"
340
+ end
341
+
342
+ # Get current branch
343
+ output, status = Open3.capture2e("git", "-C", repo_root, "rev-parse", "--abbrev-ref", "HEAD")
344
+ raise Error, "Failed to get current branch in #{repo_root}: #{output}" unless status.success?
345
+
346
+ current_branch = output.strip
347
+
348
+ # Create worktree with a new branch based on current branch
349
+ branch_name = worktree_name
350
+ puts "Creating worktree: #{worktree_path} with branch: #{branch_name}" unless ENV["CLAUDE_SWARM_PROMPT"]
351
+
352
+ # Create worktree with a new branch
353
+ output, status = Open3.capture2e("git", "-C", repo_root, "worktree", "add", "-b", branch_name, worktree_path, current_branch)
354
+
355
+ # If branch already exists, try without -b flag
356
+ if !status.success? && output.include?("already exists")
357
+ puts "Branch #{branch_name} already exists, using existing branch" unless ENV["CLAUDE_SWARM_PROMPT"]
358
+ output, status = Open3.capture2e("git", "-C", repo_root, "worktree", "add", worktree_path, branch_name)
359
+ end
360
+
361
+ # If worktree path is already in use, it might be from a previous run
362
+ if !status.success? && output.include?("is already used by worktree")
363
+ puts "Worktree path already in use, checking if it's valid" unless ENV["CLAUDE_SWARM_PROMPT"]
364
+ # Check if the worktree actually exists at that path
365
+ if File.exist?(File.join(worktree_path, ".git"))
366
+ puts "Using existing worktree: #{worktree_path}" unless ENV["CLAUDE_SWARM_PROMPT"]
367
+ @created_worktrees[worktree_key] = worktree_path
368
+ return
369
+ else
370
+ # The worktree is registered but the directory doesn't exist, prune and retry
371
+ puts "Pruning stale worktree references" unless ENV["CLAUDE_SWARM_PROMPT"]
372
+ begin
373
+ system!("git", "-C", repo_root, "worktree", "prune")
374
+ rescue Error
375
+ # Ignore errors when pruning
376
+ end
377
+ output, status = Open3.capture2e("git", "-C", repo_root, "worktree", "add", worktree_path, branch_name)
378
+ end
379
+ end
380
+
381
+ raise Error, "Failed to create worktree: #{output}" unless status.success?
382
+
383
+ @created_worktrees[worktree_key] = worktree_path
384
+ end
385
+
386
+ def has_uncommitted_changes?(worktree_path)
387
+ # Check if there are any uncommitted changes (staged or unstaged)
388
+ output, status = Open3.capture2e("git", "-C", worktree_path, "status", "--porcelain")
389
+ return false unless status.success?
390
+
391
+ # If output is not empty, there are changes
392
+ !output.strip.empty?
393
+ end
394
+
395
+ def has_unpushed_commits?(worktree_path)
396
+ # Get the current branch
397
+ branch_output, branch_status = Open3.capture2e("git", "-C", worktree_path, "rev-parse", "--abbrev-ref", "HEAD")
398
+ return false unless branch_status.success?
399
+
400
+ current_branch = branch_output.strip
401
+
402
+ # Check if the branch has an upstream
403
+ _, upstream_status = Open3.capture2e("git", "-C", worktree_path, "rev-parse", "--abbrev-ref", "#{current_branch}@{upstream}")
404
+
405
+ # If branch has upstream, check against it
406
+ if upstream_status.success?
407
+ # Check for unpushed commits against upstream
408
+ unpushed_output, unpushed_status = Open3.capture2e("git", "-C", worktree_path, "rev-list", "HEAD", "^#{current_branch}@{upstream}")
409
+ return false unless unpushed_status.success?
410
+
411
+ # If output is not empty, there are unpushed commits
412
+ return !unpushed_output.strip.empty?
413
+ end
414
+
415
+ # No upstream - this is likely a new branch created by the worktree
416
+ # The key insight: when git worktree add -b creates a branch, it creates it in BOTH
417
+ # the worktree AND the main repository. So we need to check the reflog in the worktree
418
+ # to see if any NEW commits were made after the worktree was created.
419
+
420
+ # Use reflog to check if any commits were made in this worktree
421
+ reflog_output, reflog_status = Open3.capture2e("git", "-C", worktree_path, "reflog", "--format=%H %gs", current_branch)
422
+
423
+ if reflog_status.success? && !reflog_output.strip.empty?
424
+ reflog_lines = reflog_output.strip.split("\n")
425
+
426
+ # Look for commit entries (not branch creation)
427
+ commit_entries = reflog_lines.select { |line| line.include?(" commit:") || line.include?(" commit (amend):") }
428
+
429
+ # If there are commit entries in the reflog, there are unpushed commits
430
+ return !commit_entries.empty?
431
+ end
432
+
433
+ # As a fallback, assume no unpushed commits
434
+ false
435
+ end
436
+
437
+ def find_original_repo_for_worktree(worktree_path)
438
+ # Get the git directory for this worktree
439
+ _, git_dir_status = Open3.capture2e("git", "-C", worktree_path, "rev-parse", "--git-dir")
440
+ return unless git_dir_status.success?
441
+
442
+ # Read the gitdir file to find the main repository
443
+ # Worktree .git files contain: gitdir: /path/to/main/repo/.git/worktrees/worktree-name
444
+ if File.file?(File.join(worktree_path, ".git"))
445
+ gitdir_content = File.read(File.join(worktree_path, ".git")).strip
446
+ if gitdir_content =~ /^gitdir: (.+)$/
447
+ git_path = ::Regexp.last_match(1)
448
+ # Extract the main repo path from the worktree git path
449
+ # Format: /path/to/repo/.git/worktrees/worktree-name
450
+ return ::Regexp.last_match(1) if git_path =~ %r{^(.+)/\.git/worktrees/[^/]+$}
451
+ end
452
+ end
453
+
454
+ nil
455
+ end
456
+
457
+ def find_base_branch(repo_path)
458
+ # Try to find the base branch - check for main, master, or the default branch
459
+ ["main", "master"].each do |branch|
460
+ _, status = Open3.capture2e("git", "-C", repo_path, "rev-parse", "--verify", "refs/heads/#{branch}")
461
+ return branch if status.success?
462
+ end
463
+
464
+ # Try to get the default branch from HEAD
465
+ output, status = Open3.capture2e("git", "-C", repo_path, "symbolic-ref", "refs/remotes/origin/HEAD")
466
+ if status.success?
467
+ # Extract branch name from refs/remotes/origin/main
468
+ branch_match = output.strip.match(%r{refs/remotes/origin/(.+)$})
469
+ return branch_match[1] if branch_match
470
+ end
471
+
472
+ nil
473
+ end
474
+ end
475
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeSwarm
4
+ # Provides consistent YAML loading across the application
5
+ module YamlLoader
6
+ class << self
7
+ # Load a YAML configuration file (enables aliases for configuration flexibility)
8
+ # @param file_path [String] Path to the configuration file
9
+ # @return [Hash] The loaded configuration
10
+ # @raise [ClaudeSwarm::Error] Re-raises with a more descriptive error message
11
+ def load_config_file(file_path)
12
+ YAML.load_file(file_path, aliases: true)
13
+ rescue Errno::ENOENT
14
+ raise ClaudeSwarm::Error, "Configuration file not found: #{file_path}"
15
+ rescue Psych::SyntaxError => e
16
+ raise ClaudeSwarm::Error, "Invalid YAML syntax in #{file_path}: #{e.message}"
17
+ rescue Psych::BadAlias => e
18
+ raise ClaudeSwarm::Error, "Invalid YAML alias in #{file_path}: #{e.message}"
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Standard library dependencies
4
+ require "bundler"
5
+ require "digest"
6
+ require "English"
7
+ require "erb"
8
+ require "fileutils"
9
+ require "io/console"
10
+ require "json"
11
+ require "logger"
12
+ require "open3"
13
+ require "pathname"
14
+ require "pty"
15
+ require "securerandom"
16
+ require "set"
17
+ require "shellwords"
18
+ require "time"
19
+ require "timeout"
20
+ require "tmpdir"
21
+ require "yaml"
22
+
23
+ # External dependencies
24
+ require "claude_sdk"
25
+ require "fast_mcp"
26
+ require "mcp_client"
27
+ require "thor"
28
+
29
+ # Zeitwerk setup
30
+ require "zeitwerk"
31
+ loader = Zeitwerk::Loader.new
32
+ loader.tag = "claude_swarm"
33
+ loader.push_dir("#{__dir__}/claude_swarm", namespace: ClaudeSwarm)
34
+ loader.ignore("#{__dir__}/claude_swarm/templates")
35
+ loader.inflector.inflect(
36
+ "cli" => "CLI",
37
+ "openai" => "OpenAI",
38
+ )
39
+ loader.setup
40
+
41
+ module ClaudeSwarm
42
+ class Error < StandardError; end
43
+
44
+ class << self
45
+ def root_dir
46
+ ENV.fetch("CLAUDE_SWARM_ROOT_DIR") { Dir.pwd }
47
+ end
48
+
49
+ def home_dir
50
+ ENV.fetch("CLAUDE_SWARM_HOME") { File.expand_path("~/.claude-swarm") }
51
+ end
52
+
53
+ def joined_home_dir(*strings)
54
+ File.join(home_dir, *strings)
55
+ end
56
+
57
+ def joined_run_dir(*strings)
58
+ joined_home_dir("run", *strings)
59
+ end
60
+
61
+ def joined_sessions_dir(*strings)
62
+ joined_home_dir("sessions", *strings)
63
+ end
64
+
65
+ def joined_worktrees_dir(*strings)
66
+ joined_home_dir("worktrees", *strings)
67
+ end
68
+ end
69
+ end