claude_swarm 0.3.10 ā 1.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +63 -0
- data/CLAUDE.md +2 -0
- data/README.md +58 -0
- data/lib/claude_swarm/claude_code_executor.rb +10 -5
- data/lib/claude_swarm/cli.rb +20 -19
- data/lib/claude_swarm/commands/ps.rb +8 -8
- data/lib/claude_swarm/commands/show.rb +4 -5
- data/lib/claude_swarm/configuration.rb +18 -12
- data/lib/claude_swarm/json_handler.rb +91 -0
- data/lib/claude_swarm/mcp_generator.rb +4 -4
- data/lib/claude_swarm/openai/chat_completion.rb +6 -6
- data/lib/claude_swarm/openai/executor.rb +1 -1
- data/lib/claude_swarm/openai/responses.rb +8 -8
- data/lib/claude_swarm/orchestrator.rb +99 -41
- data/lib/claude_swarm/session_cost_calculator.rb +6 -6
- data/lib/claude_swarm/session_path.rb +2 -8
- data/lib/claude_swarm/settings_generator.rb +1 -1
- data/lib/claude_swarm/templates/generation_prompt.md.erb +3 -0
- data/lib/claude_swarm/tools/task_tool.rb +13 -1
- data/lib/claude_swarm/version.rb +1 -1
- data/lib/claude_swarm/worktree_manager.rb +2 -2
- data/lib/claude_swarm.rb +21 -1
- data/team_v2.yml +367 -0
- metadata +8 -6
@@ -97,7 +97,7 @@ module ClaudeSwarm
|
|
97
97
|
end
|
98
98
|
|
99
99
|
# Log the request parameters
|
100
|
-
@executor.logger.info { "Responses API Request (depth=#{depth}): #{
|
100
|
+
@executor.logger.info { "Responses API Request (depth=#{depth}): #{JsonHandler.pretty_generate!(parameters)}" }
|
101
101
|
|
102
102
|
# Append to session JSON
|
103
103
|
append_to_session_json({
|
@@ -112,7 +112,7 @@ module ClaudeSwarm
|
|
112
112
|
response = @openai_client.responses.create(parameters: parameters)
|
113
113
|
rescue StandardError => e
|
114
114
|
@executor.logger.error { "Responses API error: #{e.class} - #{e.message}" }
|
115
|
-
@executor.logger.error { "Request parameters: #{
|
115
|
+
@executor.logger.error { "Request parameters: #{JsonHandler.pretty_generate!(parameters)}" }
|
116
116
|
|
117
117
|
# Try to extract and log the response body for better debugging
|
118
118
|
if e.respond_to?(:response)
|
@@ -140,7 +140,7 @@ module ClaudeSwarm
|
|
140
140
|
end
|
141
141
|
|
142
142
|
# Log the full response
|
143
|
-
@executor.logger.info { "Responses API Full Response (depth=#{depth}): #{
|
143
|
+
@executor.logger.info { "Responses API Full Response (depth=#{depth}): #{JsonHandler.pretty_generate!(response)}" }
|
144
144
|
|
145
145
|
# Append to session JSON
|
146
146
|
append_to_session_json({
|
@@ -230,10 +230,10 @@ module ClaudeSwarm
|
|
230
230
|
|
231
231
|
begin
|
232
232
|
# Parse arguments
|
233
|
-
tool_args =
|
233
|
+
tool_args = JsonHandler.parse!(tool_args_str)
|
234
234
|
|
235
235
|
# Log tool execution
|
236
|
-
@executor.logger.info { "Responses API - Executing tool: #{tool_name} with args: #{
|
236
|
+
@executor.logger.info { "Responses API - Executing tool: #{tool_name} with args: #{JsonHandler.pretty_generate!(tool_args)}" }
|
237
237
|
|
238
238
|
# Execute tool via MCP
|
239
239
|
result = @mcp_client.call_tool(tool_name, tool_args)
|
@@ -283,7 +283,7 @@ module ClaudeSwarm
|
|
283
283
|
end
|
284
284
|
|
285
285
|
@executor.logger.info { "Responses API - Built conversation with #{conversation.size} function outputs" }
|
286
|
-
@executor.logger.debug { "Final conversation structure: #{
|
286
|
+
@executor.logger.debug { "Final conversation structure: #{JsonHandler.pretty_generate!(conversation)}" }
|
287
287
|
conversation
|
288
288
|
end
|
289
289
|
|
@@ -299,10 +299,10 @@ module ClaudeSwarm
|
|
299
299
|
|
300
300
|
begin
|
301
301
|
# Parse arguments
|
302
|
-
tool_args =
|
302
|
+
tool_args = JsonHandler.parse!(tool_args_str)
|
303
303
|
|
304
304
|
# Log tool execution
|
305
|
-
@executor.logger.info { "Responses API - Executing tool: #{tool_name} with args: #{
|
305
|
+
@executor.logger.info { "Responses API - Executing tool: #{tool_name} with args: #{JsonHandler.pretty_generate!(tool_args)}" }
|
306
306
|
|
307
307
|
# Execute tool via MCP
|
308
308
|
result = @mcp_client.call_tool(tool_name, tool_args)
|
@@ -6,7 +6,6 @@ module ClaudeSwarm
|
|
6
6
|
|
7
7
|
attr_reader :config, :session_path, :session_log_path
|
8
8
|
|
9
|
-
RUN_DIR = File.expand_path("~/.claude-swarm/run")
|
10
9
|
["INT", "TERM", "QUIT"].each do |signal|
|
11
10
|
Signal.trap(signal) do
|
12
11
|
puts "\nš Received #{signal} signal."
|
@@ -73,6 +72,20 @@ module ClaudeSwarm
|
|
73
72
|
# Track start time
|
74
73
|
@start_time = Time.now
|
75
74
|
|
75
|
+
begin
|
76
|
+
start_internal
|
77
|
+
rescue StandardError => e
|
78
|
+
# Ensure cleanup happens even on unexpected errors
|
79
|
+
cleanup_processes
|
80
|
+
cleanup_run_symlink
|
81
|
+
cleanup_worktrees
|
82
|
+
raise e
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
private
|
87
|
+
|
88
|
+
def start_internal
|
76
89
|
if @restore_session_path
|
77
90
|
non_interactive_output do
|
78
91
|
puts "š Restoring Claude Swarm: #{@config.swarm_name}"
|
@@ -115,16 +128,24 @@ module ClaudeSwarm
|
|
115
128
|
|
116
129
|
# Setup worktrees if needed
|
117
130
|
if @worktree_manager
|
118
|
-
|
131
|
+
begin
|
132
|
+
non_interactive_output { print("š³ Setting up Git worktrees...") }
|
119
133
|
|
120
|
-
|
121
|
-
|
122
|
-
|
134
|
+
# Get all instances for worktree setup
|
135
|
+
# Note: instances.values already includes the main instance
|
136
|
+
all_instances = @config.instances.values
|
123
137
|
|
124
|
-
|
138
|
+
@worktree_manager.setup_worktrees(all_instances)
|
125
139
|
|
126
|
-
|
127
|
-
|
140
|
+
non_interactive_output do
|
141
|
+
puts "ā Worktrees created with branch: #{@worktree_manager.worktree_name}"
|
142
|
+
end
|
143
|
+
rescue StandardError => e
|
144
|
+
non_interactive_output { print("ā Failed to setup worktrees: #{e.message}") }
|
145
|
+
cleanup_processes
|
146
|
+
cleanup_run_symlink
|
147
|
+
cleanup_worktrees
|
148
|
+
raise
|
128
149
|
end
|
129
150
|
end
|
130
151
|
|
@@ -179,15 +200,29 @@ module ClaudeSwarm
|
|
179
200
|
main_pid_file = File.join(@session_path, "main_pid")
|
180
201
|
File.write(main_pid_file, Process.pid.to_s)
|
181
202
|
|
182
|
-
# Execute
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
203
|
+
# Execute before commands if specified
|
204
|
+
# If the main instance directory exists, run in it for backward compatibility
|
205
|
+
# If it doesn't exist, run in the parent directory so before commands can create it
|
206
|
+
before_commands = @config.before_commands
|
207
|
+
if before_commands.any? && !@restore_session_path
|
208
|
+
non_interactive_output do
|
209
|
+
puts "āļø Executing before commands..."
|
210
|
+
end
|
190
211
|
|
212
|
+
# Determine where to run before commands
|
213
|
+
if File.exist?(main_instance[:directory])
|
214
|
+
# Directory exists, run commands in it (backward compatibility)
|
215
|
+
before_commands_dir = main_instance[:directory]
|
216
|
+
else
|
217
|
+
# Directory doesn't exist, run in parent directory
|
218
|
+
# This allows before commands to create the directory
|
219
|
+
parent_dir = File.dirname(File.expand_path(main_instance[:directory]))
|
220
|
+
# Ensure parent directory exists (important for worktrees)
|
221
|
+
FileUtils.mkdir_p(parent_dir)
|
222
|
+
before_commands_dir = parent_dir
|
223
|
+
end
|
224
|
+
|
225
|
+
Dir.chdir(before_commands_dir) do
|
191
226
|
success = execute_before_commands?(before_commands)
|
192
227
|
unless success
|
193
228
|
non_interactive_output { print("ā Before commands failed. Aborting swarm launch.") }
|
@@ -196,12 +231,29 @@ module ClaudeSwarm
|
|
196
231
|
cleanup_worktrees
|
197
232
|
exit(1)
|
198
233
|
end
|
234
|
+
end
|
199
235
|
|
236
|
+
non_interactive_output do
|
237
|
+
puts "ā Before commands completed successfully"
|
238
|
+
end
|
239
|
+
|
240
|
+
# Validate directories after before commands have run
|
241
|
+
begin
|
242
|
+
@config.validate_directories
|
200
243
|
non_interactive_output do
|
201
|
-
puts "ā
|
244
|
+
puts "ā All directories validated successfully"
|
202
245
|
end
|
246
|
+
rescue ClaudeSwarm::Error => e
|
247
|
+
non_interactive_output { print("ā Directory validation failed: #{e.message}") }
|
248
|
+
cleanup_processes
|
249
|
+
cleanup_run_symlink
|
250
|
+
cleanup_worktrees
|
251
|
+
exit(1)
|
203
252
|
end
|
253
|
+
end
|
204
254
|
|
255
|
+
# Execute the main instance - this will cascade to other instances via MCP
|
256
|
+
Dir.chdir(main_instance[:directory]) do
|
205
257
|
# Execute main Claude instance with unbundled environment to avoid bundler conflicts
|
206
258
|
# This ensures the main instance runs in a clean environment without inheriting
|
207
259
|
# Claude Swarm's BUNDLE_* environment variables
|
@@ -227,9 +279,20 @@ module ClaudeSwarm
|
|
227
279
|
display_summary
|
228
280
|
|
229
281
|
# Execute after commands if specified
|
282
|
+
# Use the same logic as before commands for consistency
|
230
283
|
after_commands = @config.after_commands
|
231
284
|
if after_commands.any? && !@restore_session_path
|
232
|
-
|
285
|
+
# Determine where to run after commands (same logic as before commands)
|
286
|
+
if File.exist?(main_instance[:directory])
|
287
|
+
# Directory exists, run commands in it
|
288
|
+
after_commands_dir = main_instance[:directory]
|
289
|
+
else
|
290
|
+
# Directory doesn't exist (shouldn't happen after main instance runs, but be safe)
|
291
|
+
parent_dir = File.dirname(File.expand_path(main_instance[:directory]))
|
292
|
+
after_commands_dir = parent_dir
|
293
|
+
end
|
294
|
+
|
295
|
+
Dir.chdir(after_commands_dir) do
|
233
296
|
non_interactive_output do
|
234
297
|
print("āļø Executing after commands...")
|
235
298
|
end
|
@@ -249,8 +312,6 @@ module ClaudeSwarm
|
|
249
312
|
cleanup_worktrees
|
250
313
|
end
|
251
314
|
|
252
|
-
private
|
253
|
-
|
254
315
|
def non_interactive_output
|
255
316
|
return if @non_interactive_prompt
|
256
317
|
|
@@ -277,7 +338,7 @@ module ClaudeSwarm
|
|
277
338
|
|
278
339
|
# Save session metadata
|
279
340
|
metadata_file = File.join(session_path, "session_metadata.json")
|
280
|
-
|
341
|
+
JsonHandler.write_file!(metadata_file, build_session_metadata)
|
281
342
|
end
|
282
343
|
|
283
344
|
def build_session_metadata
|
@@ -333,11 +394,11 @@ module ClaudeSwarm
|
|
333
394
|
metadata_file = File.join(@session_path, "session_metadata.json")
|
334
395
|
return unless File.exist?(metadata_file)
|
335
396
|
|
336
|
-
metadata =
|
397
|
+
metadata = JsonHandler.parse_file!(metadata_file)
|
337
398
|
metadata["end_time"] = end_time.utc.iso8601
|
338
399
|
metadata["duration_seconds"] = (end_time - @start_time).to_i
|
339
400
|
|
340
|
-
|
401
|
+
JsonHandler.write_file!(metadata_file, metadata)
|
341
402
|
rescue StandardError => e
|
342
403
|
non_interactive_output { print("ā ļø Error updating session metadata: #{e.message}") }
|
343
404
|
end
|
@@ -375,11 +436,12 @@ module ClaudeSwarm
|
|
375
436
|
def create_run_symlink
|
376
437
|
return unless @session_path
|
377
438
|
|
378
|
-
|
439
|
+
run_dir = ClaudeSwarm.joined_run_dir
|
440
|
+
FileUtils.mkdir_p(run_dir)
|
379
441
|
|
380
442
|
# Session ID is the last part of the session path
|
381
443
|
session_id = File.basename(@session_path)
|
382
|
-
symlink_path =
|
444
|
+
symlink_path = ClaudeSwarm.joined_run_dir(session_id)
|
383
445
|
|
384
446
|
# Remove stale symlink if exists
|
385
447
|
File.unlink(symlink_path) if File.symlink?(symlink_path)
|
@@ -395,7 +457,7 @@ module ClaudeSwarm
|
|
395
457
|
return unless @session_path
|
396
458
|
|
397
459
|
session_id = File.basename(@session_path)
|
398
|
-
symlink_path =
|
460
|
+
symlink_path = ClaudeSwarm.joined_run_dir(session_id)
|
399
461
|
File.unlink(symlink_path) if File.symlink?(symlink_path)
|
400
462
|
rescue StandardError
|
401
463
|
# Ignore errors during cleanup
|
@@ -450,7 +512,7 @@ module ClaudeSwarm
|
|
450
512
|
|
451
513
|
# Find the state file for the main instance
|
452
514
|
state_files.each do |state_file|
|
453
|
-
state_data =
|
515
|
+
state_data = JsonHandler.parse_file!(state_file)
|
454
516
|
next unless state_data["instance_name"] == main_instance_name
|
455
517
|
|
456
518
|
claude_session_id = state_data["claude_session_id"]
|
@@ -535,7 +597,7 @@ module ClaudeSwarm
|
|
535
597
|
metadata_file = File.join(session_path, "session_metadata.json")
|
536
598
|
return unless File.exist?(metadata_file)
|
537
599
|
|
538
|
-
metadata =
|
600
|
+
metadata = JsonHandler.parse_file!(metadata_file)
|
539
601
|
worktree_data = metadata["worktree"]
|
540
602
|
return unless worktree_data && worktree_data["enabled"]
|
541
603
|
|
@@ -567,13 +629,11 @@ module ClaudeSwarm
|
|
567
629
|
|
568
630
|
# Read and process the merged output
|
569
631
|
stdout_and_stderr.each_line do |line|
|
570
|
-
|
571
|
-
|
572
|
-
|
573
|
-
|
574
|
-
|
575
|
-
rescue JSON::ParserError
|
576
|
-
logger.info { line.chomp }
|
632
|
+
logger.info do
|
633
|
+
chomped_line = line.chomp
|
634
|
+
json_data = JsonHandler.parse(chomped_line)
|
635
|
+
json_data == chomped_line ? chomped_line : JsonHandler.pretty_generate!(json_data)
|
636
|
+
end
|
577
637
|
end
|
578
638
|
|
579
639
|
wait_thr.value
|
@@ -602,11 +662,11 @@ module ClaudeSwarm
|
|
602
662
|
line = file.gets
|
603
663
|
if line
|
604
664
|
begin
|
605
|
-
# Parse JSONL entry
|
606
|
-
transcript_entry =
|
665
|
+
# Parse JSONL entry, silently skip unparseable lines
|
666
|
+
transcript_entry = JsonHandler.parse(line)
|
607
667
|
|
608
|
-
# Skip
|
609
|
-
next if transcript_entry["type"] == "summary"
|
668
|
+
# Skip if parsing failed or if it's a summary entry
|
669
|
+
next if transcript_entry == line || transcript_entry["type"] == "summary"
|
610
670
|
|
611
671
|
# Convert to session.log.json format
|
612
672
|
session_entry = convert_transcript_to_session_format(transcript_entry)
|
@@ -620,8 +680,6 @@ module ClaudeSwarm
|
|
620
680
|
log_file.puts(session_entry.to_json)
|
621
681
|
end
|
622
682
|
end
|
623
|
-
rescue JSON::ParserError
|
624
|
-
# Silently skip unparseable lines
|
625
683
|
rescue StandardError
|
626
684
|
# Silently handle other errors to keep thread running
|
627
685
|
end
|
@@ -86,7 +86,9 @@ module ClaudeSwarm
|
|
86
86
|
main_instance_cost = 0.0
|
87
87
|
|
88
88
|
File.foreach(session_log_path) do |line|
|
89
|
-
data =
|
89
|
+
data = JsonHandler.parse(line)
|
90
|
+
next if data == line # Skip unparseable lines
|
91
|
+
|
90
92
|
instance_name = data["instance"]
|
91
93
|
instance_id = data["instance_id"]
|
92
94
|
|
@@ -108,8 +110,6 @@ module ClaudeSwarm
|
|
108
110
|
instance_costs[instance_name] += cost
|
109
111
|
end
|
110
112
|
end
|
111
|
-
rescue JSON::ParserError
|
112
|
-
next
|
113
113
|
end
|
114
114
|
|
115
115
|
# Calculate total: sum of all instance costs + main instance token costs
|
@@ -137,7 +137,9 @@ module ClaudeSwarm
|
|
137
137
|
return instances unless File.exist?(session_log_path)
|
138
138
|
|
139
139
|
File.foreach(session_log_path) do |line|
|
140
|
-
data =
|
140
|
+
data = JsonHandler.parse(line)
|
141
|
+
next if data == line # Skip unparseable lines
|
142
|
+
|
141
143
|
instance_name = data["instance"]
|
142
144
|
instance_id = data["instance_id"]
|
143
145
|
calling_instance = data["calling_instance"]
|
@@ -191,8 +193,6 @@ module ClaudeSwarm
|
|
191
193
|
instances[instance_name][:has_cost_data] = true
|
192
194
|
end
|
193
195
|
end
|
194
|
-
rescue JSON::ParserError
|
195
|
-
next
|
196
196
|
end
|
197
197
|
|
198
198
|
# Set main instance costs (replace, don't add)
|
@@ -2,13 +2,7 @@
|
|
2
2
|
|
3
3
|
module ClaudeSwarm
|
4
4
|
module SessionPath
|
5
|
-
SESSIONS_DIR = "sessions"
|
6
|
-
|
7
5
|
class << self
|
8
|
-
def swarm_home
|
9
|
-
ENV["CLAUDE_SWARM_HOME"] || File.expand_path("~/.claude-swarm")
|
10
|
-
end
|
11
|
-
|
12
6
|
# Convert a directory path to a safe folder name using + as separator
|
13
7
|
def project_folder_name(working_dir = Dir.pwd)
|
14
8
|
# Don't expand path if it's already expanded (avoids double expansion on Windows)
|
@@ -27,7 +21,7 @@ module ClaudeSwarm
|
|
27
21
|
# Generate a full session path for a given directory and session ID
|
28
22
|
def generate(working_dir: Dir.pwd, session_id: SecureRandom.uuid)
|
29
23
|
project_name = project_folder_name(working_dir)
|
30
|
-
|
24
|
+
ClaudeSwarm.joined_sessions_dir(project_name, session_id)
|
31
25
|
end
|
32
26
|
|
33
27
|
# Ensure the session directory exists
|
@@ -35,7 +29,7 @@ module ClaudeSwarm
|
|
35
29
|
FileUtils.mkdir_p(session_path)
|
36
30
|
|
37
31
|
# Add .gitignore to swarm home if it doesn't exist
|
38
|
-
gitignore_path =
|
32
|
+
gitignore_path = ClaudeSwarm.joined_home_dir(".gitignore")
|
39
33
|
File.write(gitignore_path, "*\n") unless File.exist?(gitignore_path)
|
40
34
|
end
|
41
35
|
|
@@ -225,3 +225,6 @@ The more precisely you explain what you want, the better Claude's response will
|
|
225
225
|
* **Provide instructions as sequential steps:** Use numbered lists or bullet points to better ensure that Claude carries out the task the exact way you want it to.
|
226
226
|
|
227
227
|
</prompt_best_practices>
|
228
|
+
|
229
|
+
|
230
|
+
IMPORTANT: Do not generate swarms with circular dependencies. For example, instance A connections to instance B, and instance B connections to instance A.
|
@@ -43,8 +43,20 @@ module ClaudeSwarm
|
|
43
43
|
|
44
44
|
response = executor.execute(final_prompt, options)
|
45
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
|
+
|
46
58
|
# Return just the result text as expected by MCP
|
47
|
-
|
59
|
+
result
|
48
60
|
end
|
49
61
|
end
|
50
62
|
end
|
data/lib/claude_swarm/version.rb
CHANGED
@@ -158,7 +158,7 @@ module ClaudeSwarm
|
|
158
158
|
# Remove session-specific worktree directory if it exists and is empty
|
159
159
|
return unless @session_id
|
160
160
|
|
161
|
-
session_worktree_dir =
|
161
|
+
session_worktree_dir = ClaudeSwarm.joined_worktrees_dir(@session_id)
|
162
162
|
return unless File.exist?(session_worktree_dir)
|
163
163
|
|
164
164
|
# Try to remove the directory tree
|
@@ -214,7 +214,7 @@ module ClaudeSwarm
|
|
214
214
|
unique_repo_name = "#{repo_name}-#{path_hash}"
|
215
215
|
|
216
216
|
# Build external path: ~/.claude-swarm/worktrees/[session_id]/[repo_name-hash]/[worktree_name]
|
217
|
-
base_dir =
|
217
|
+
base_dir = ClaudeSwarm.joined_worktrees_dir
|
218
218
|
|
219
219
|
# Validate base directory is accessible
|
220
220
|
begin
|
data/lib/claude_swarm.rb
CHANGED
@@ -41,7 +41,27 @@ module ClaudeSwarm
|
|
41
41
|
|
42
42
|
class << self
|
43
43
|
def root_dir
|
44
|
-
ENV.fetch("CLAUDE_SWARM_ROOT_DIR"
|
44
|
+
ENV.fetch("CLAUDE_SWARM_ROOT_DIR") { Dir.pwd }
|
45
|
+
end
|
46
|
+
|
47
|
+
def home_dir
|
48
|
+
ENV.fetch("CLAUDE_SWARM_HOME") { File.expand_path("~/.claude-swarm") }
|
49
|
+
end
|
50
|
+
|
51
|
+
def joined_home_dir(*strings)
|
52
|
+
File.join(home_dir, *strings)
|
53
|
+
end
|
54
|
+
|
55
|
+
def joined_run_dir(*strings)
|
56
|
+
joined_home_dir("run", *strings)
|
57
|
+
end
|
58
|
+
|
59
|
+
def joined_sessions_dir(*strings)
|
60
|
+
joined_home_dir("sessions", *strings)
|
61
|
+
end
|
62
|
+
|
63
|
+
def joined_worktrees_dir(*strings)
|
64
|
+
joined_home_dir("worktrees", *strings)
|
45
65
|
end
|
46
66
|
end
|
47
67
|
end
|