soba-cli 0.1.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 (101) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/commands/osoba/add-backlog.md +173 -0
  3. data/.claude/commands/osoba/implement.md +151 -0
  4. data/.claude/commands/osoba/plan.md +217 -0
  5. data/.claude/commands/osoba/review.md +133 -0
  6. data/.claude/commands/osoba/revise.md +176 -0
  7. data/.claude/commands/soba/implement.md +88 -0
  8. data/.claude/commands/soba/plan.md +93 -0
  9. data/.claude/commands/soba/review.md +91 -0
  10. data/.claude/commands/soba/revise.md +76 -0
  11. data/.devcontainer/.env +2 -0
  12. data/.devcontainer/Dockerfile +3 -0
  13. data/.devcontainer/LICENSE +21 -0
  14. data/.devcontainer/README.md +85 -0
  15. data/.devcontainer/bin/devcontainer-common.sh +50 -0
  16. data/.devcontainer/bin/down +35 -0
  17. data/.devcontainer/bin/rebuild +10 -0
  18. data/.devcontainer/bin/up +11 -0
  19. data/.devcontainer/compose.yaml +28 -0
  20. data/.devcontainer/devcontainer.json +53 -0
  21. data/.devcontainer/post-attach.sh +29 -0
  22. data/.devcontainer/post-create.sh +62 -0
  23. data/.devcontainer/setup/01-os-package.sh +19 -0
  24. data/.devcontainer/setup/02-npm-package.sh +22 -0
  25. data/.devcontainer/setup/03-mcp-server.sh +33 -0
  26. data/.devcontainer/setup/04-tool.sh +17 -0
  27. data/.devcontainer/setup/05-soba-setup.sh +66 -0
  28. data/.devcontainer/setup/scripts/functions/install_apt.sh +77 -0
  29. data/.devcontainer/setup/scripts/functions/install_npm.sh +71 -0
  30. data/.devcontainer/setup/scripts/functions/mcp_config.sh +14 -0
  31. data/.devcontainer/setup/scripts/functions/print_message.sh +59 -0
  32. data/.devcontainer/setup/scripts/setup/mcp-markdownify.sh +39 -0
  33. data/.devcontainer/sync-envs.sh +58 -0
  34. data/.envrc.sample +7 -0
  35. data/.rspec +4 -0
  36. data/.rubocop.yml +70 -0
  37. data/.rubocop_airbnb.yml +2 -0
  38. data/.rubocop_todo.yml +74 -0
  39. data/.tool-versions +1 -0
  40. data/CLAUDE.md +20 -0
  41. data/LICENSE +21 -0
  42. data/README.md +384 -0
  43. data/README_ja.md +384 -0
  44. data/Rakefile +18 -0
  45. data/bin/soba +120 -0
  46. data/config/config.yml.example +36 -0
  47. data/docs/business/INDEX.md +6 -0
  48. data/docs/business/overview.md +42 -0
  49. data/docs/business/workflow.md +143 -0
  50. data/docs/development/INDEX.md +10 -0
  51. data/docs/development/architecture.md +69 -0
  52. data/docs/development/coding-standards.md +152 -0
  53. data/docs/development/distribution.md +26 -0
  54. data/docs/development/implementation-guide.md +103 -0
  55. data/docs/development/testing-strategy.md +128 -0
  56. data/docs/development/tmux-management.md +253 -0
  57. data/docs/document_system.md +58 -0
  58. data/lib/soba/commands/config/show.rb +63 -0
  59. data/lib/soba/commands/init.rb +778 -0
  60. data/lib/soba/commands/open.rb +144 -0
  61. data/lib/soba/commands/start.rb +442 -0
  62. data/lib/soba/commands/status.rb +175 -0
  63. data/lib/soba/commands/stop.rb +147 -0
  64. data/lib/soba/config_loader.rb +32 -0
  65. data/lib/soba/configuration.rb +268 -0
  66. data/lib/soba/container.rb +48 -0
  67. data/lib/soba/domain/issue.rb +38 -0
  68. data/lib/soba/domain/phase_strategy.rb +74 -0
  69. data/lib/soba/infrastructure/errors.rb +23 -0
  70. data/lib/soba/infrastructure/github_client.rb +399 -0
  71. data/lib/soba/infrastructure/lock_manager.rb +129 -0
  72. data/lib/soba/infrastructure/tmux_client.rb +331 -0
  73. data/lib/soba/services/ansi_processor.rb +92 -0
  74. data/lib/soba/services/auto_merge_service.rb +133 -0
  75. data/lib/soba/services/closed_issue_window_cleaner.rb +96 -0
  76. data/lib/soba/services/daemon_service.rb +83 -0
  77. data/lib/soba/services/git_workspace_manager.rb +102 -0
  78. data/lib/soba/services/issue_monitor.rb +29 -0
  79. data/lib/soba/services/issue_processor.rb +215 -0
  80. data/lib/soba/services/issue_watcher.rb +193 -0
  81. data/lib/soba/services/pid_manager.rb +87 -0
  82. data/lib/soba/services/process_info.rb +58 -0
  83. data/lib/soba/services/queueing_service.rb +98 -0
  84. data/lib/soba/services/session_logger.rb +111 -0
  85. data/lib/soba/services/session_resolver.rb +72 -0
  86. data/lib/soba/services/slack_notifier.rb +121 -0
  87. data/lib/soba/services/status_manager.rb +74 -0
  88. data/lib/soba/services/test_process_manager.rb +84 -0
  89. data/lib/soba/services/tmux_session_manager.rb +251 -0
  90. data/lib/soba/services/workflow_blocking_checker.rb +73 -0
  91. data/lib/soba/services/workflow_executor.rb +256 -0
  92. data/lib/soba/services/workflow_integrity_checker.rb +151 -0
  93. data/lib/soba/templates/claude_commands/implement.md +88 -0
  94. data/lib/soba/templates/claude_commands/plan.md +93 -0
  95. data/lib/soba/templates/claude_commands/review.md +91 -0
  96. data/lib/soba/templates/claude_commands/revise.md +76 -0
  97. data/lib/soba/version.rb +5 -0
  98. data/lib/soba.rb +44 -0
  99. data/lib/tasks/gem.rake +75 -0
  100. data/soba-cli.gemspec +59 -0
  101. metadata +430 -0
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../configuration'
4
+ require_relative '../services/tmux_session_manager'
5
+ require_relative '../infrastructure/tmux_client'
6
+
7
+ module Soba
8
+ module Commands
9
+ class Open
10
+ class SessionNotFoundError < StandardError; end
11
+
12
+ def initialize
13
+ @tmux_client = Infrastructure::TmuxClient.new
14
+ @tmux_session_manager = Services::TmuxSessionManager.new(config: nil, tmux_client: @tmux_client)
15
+ end
16
+
17
+ def execute(issue_number, options = {})
18
+ validate_tmux_installation!
19
+
20
+ if options[:list]
21
+ list_issue_sessions
22
+ elsif issue_number
23
+ open_issue_session(issue_number)
24
+ else
25
+ open_repository_session
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def validate_tmux_installation!
32
+ unless @tmux_client.tmux_installed?
33
+ raise Infrastructure::TmuxNotInstalled, 'tmux is not installed. Please install tmux and try again'
34
+ end
35
+ end
36
+
37
+ def open_repository_session
38
+ Configuration.load!
39
+
40
+ repository = Configuration.config.github.repository
41
+
42
+ unless repository
43
+ raise ArgumentError, 'GitHub repository is not configured. Please run "soba init" first.'
44
+ end
45
+
46
+ # First, try standard session search (new format without PID)
47
+ result = @tmux_session_manager.find_repository_session
48
+
49
+ unless result[:success]
50
+ raise ArgumentError, result[:error]
51
+ end
52
+
53
+ if result[:exists]
54
+ session_name = result[:session_name]
55
+ puts "Attaching to repository session #{session_name}..."
56
+ @tmux_client.attach_to_session(session_name)
57
+ else
58
+ # Fallback to find repository session by PID (for backward compatibility)
59
+ pid_result = @tmux_session_manager.find_repository_session_by_pid(repository)
60
+
61
+ if pid_result[:exists]
62
+ session_name = pid_result[:session_name]
63
+ puts "Attaching to repository session #{session_name}... (legacy format)"
64
+ @tmux_client.attach_to_session(session_name)
65
+ else
66
+ raise SessionNotFoundError, <<~MESSAGE
67
+ Repository session not found.
68
+
69
+ A session will be created automatically when you start the workflow:
70
+ soba start
71
+
72
+ Or check active sessions:
73
+ soba open --list
74
+ MESSAGE
75
+ end
76
+ end
77
+ end
78
+
79
+ def open_issue_session(issue_number)
80
+ Configuration.load!
81
+ repository = Configuration.config.github.repository
82
+
83
+ unless repository
84
+ raise ArgumentError, 'GitHub repository is not configured. Please run "soba init" first.'
85
+ end
86
+
87
+ # Convert repository format (e.g., "user/repo" -> "user-repo")
88
+ repository_name = repository.gsub(/[\/._]/, '-')
89
+ window_id = @tmux_session_manager.find_issue_window(repository_name, issue_number)
90
+
91
+ if window_id
92
+ puts "Attaching to Issue ##{issue_number} session..."
93
+ @tmux_client.attach_to_window(window_id)
94
+ else
95
+ raise SessionNotFoundError, <<~MESSAGE
96
+ Issue ##{issue_number} session not found.
97
+
98
+ To start a session:
99
+ soba start #{issue_number}
100
+
101
+ To check active sessions:
102
+ soba open --list
103
+ MESSAGE
104
+ end
105
+ end
106
+
107
+ def list_issue_sessions
108
+ Configuration.load!
109
+ repository = Configuration.config.github.repository
110
+
111
+ unless repository
112
+ raise ArgumentError, 'GitHub repository is not configured. Please run "soba init" first.'
113
+ end
114
+
115
+ # Convert repository format (e.g., "user/repo" -> "user-repo")
116
+ repository_name = repository.gsub(/[\/._]/, '-')
117
+ sessions = @tmux_session_manager.list_issue_windows(repository_name)
118
+
119
+ if sessions.empty?
120
+ puts 'No active Issue sessions'
121
+ puts
122
+ puts 'To start a session:'
123
+ puts ' soba start <issue-number>'
124
+ else
125
+ puts 'Active Issue sessions:'
126
+ puts
127
+ sessions.each do |session|
128
+ issue_number = extract_issue_number(session[:window])
129
+ title = session[:title] || '(fetching title...)'
130
+ puts " ##{issue_number.ljust(6)} #{title}"
131
+ end
132
+ puts
133
+ puts 'To open a session:'
134
+ puts ' soba open <issue-number>'
135
+ end
136
+ end
137
+
138
+ def extract_issue_number(window_name)
139
+ match = window_name.match(/issue-(\d+)/)
140
+ match ? match[1] : window_name
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,442 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require_relative '../configuration'
5
+ require_relative '../infrastructure/github_client'
6
+ require_relative '../infrastructure/tmux_client'
7
+ require_relative '../services/issue_watcher'
8
+ require_relative '../services/issue_processor'
9
+ require_relative '../services/workflow_executor'
10
+ require_relative '../services/tmux_session_manager'
11
+ require_relative '../services/workflow_blocking_checker'
12
+ require_relative '../services/queueing_service'
13
+ require_relative '../services/auto_merge_service'
14
+ require_relative '../services/closed_issue_window_cleaner'
15
+ require_relative '../domain/phase_strategy'
16
+ require_relative '../services/pid_manager'
17
+ require_relative '../services/daemon_service'
18
+ require_relative '../services/status_manager'
19
+ require_relative '../services/process_info'
20
+
21
+ module Soba
22
+ module Commands
23
+ class Start
24
+ attr_reader :configuration, :issue_processor
25
+
26
+ def initialize(configuration: nil, issue_processor: nil)
27
+ @configuration = configuration
28
+ @issue_processor = issue_processor
29
+ end
30
+
31
+ def execute(global_options, options, args)
32
+ # Handle deprecated --foreground option
33
+ if options[:foreground]
34
+ puts "DEPRECATED: The --foreground option is now the default behavior."
35
+ puts "This option will be removed in a future version."
36
+ options.delete(:foreground)
37
+ end
38
+
39
+ if args.empty?
40
+ # ćƒÆćƒ¼ć‚Æćƒ•ćƒ­ćƒ¼å®Ÿč”Œćƒ¢ćƒ¼ćƒ‰ļ¼ˆę—¢å­˜ć®workflow runć®å‹•ä½œļ¼‰
41
+ execute_workflow(global_options, options)
42
+ else
43
+ # å˜äø€Issueå®Ÿč”Œćƒ¢ćƒ¼ćƒ‰ļ¼ˆę—¢å­˜ć®workflow execute_issueć®å‹•ä½œļ¼‰
44
+ # ę—©ęœŸå¼•ę•°ćƒć‚§ćƒƒć‚Æļ¼ˆčØ­å®ščŖ­ćæč¾¼ćæå‰ļ¼‰
45
+ if args[0].blank? || args[0].strip.empty?
46
+ warn "Error: Issue number is required"
47
+ return 1
48
+ end
49
+ execute_issue(args, options)
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def log_output(message, options, daemon_service = nil)
56
+ if options[:daemon]
57
+ daemon_service&.log(message)
58
+ else
59
+ puts message
60
+ end
61
+ end
62
+
63
+ def execute_workflow(global_options, options)
64
+ # Daemon mode setup
65
+ if options[:daemon]
66
+ # Allow test environment to override PID file path
67
+ pid_file = ENV['SOBA_TEST_PID_FILE'] || File.expand_path('~/.soba/soba.pid')
68
+ log_file = ENV['SOBA_TEST_LOG_FILE'] || File.expand_path('~/.soba/logs/daemon.log')
69
+
70
+ pid_manager = Soba::Services::PidManager.new(pid_file)
71
+ daemon_service = Soba::Services::DaemonService.new(
72
+ pid_manager: pid_manager,
73
+ log_file: log_file
74
+ )
75
+
76
+ # Check if already running
77
+ if daemon_service.already_running?
78
+ pid = pid_manager.read
79
+ puts "Daemon is already running (PID: #{pid})"
80
+ puts "Use 'soba stop' to stop the daemon or 'soba status' to check status"
81
+ return 1
82
+ end
83
+
84
+ # Daemonize
85
+ puts "Starting daemon..."
86
+ daemon_service.daemonize!
87
+
88
+ # Log startup
89
+ daemon_service.log("Daemon started successfully (PID: #{Process.pid})")
90
+
91
+ # Setup signal handlers for daemon
92
+ daemon_service.setup_signal_handlers do
93
+ @running = false
94
+ end
95
+ end
96
+
97
+ Soba::Configuration.load!
98
+
99
+ config = Soba::Configuration.config
100
+
101
+ # Initialize status manager (allow test environment to override path)
102
+ status_file = ENV['SOBA_TEST_STATUS_FILE'] || File.expand_path('~/.soba/status.json')
103
+ status_manager = Soba::Services::StatusManager.new(status_file)
104
+
105
+ unless config&.github&.repository
106
+ message = "Error: GitHub repository is not configured\n" \
107
+ "Please run 'soba init' or set repository in .soba/config.yml"
108
+ if options[:foreground]
109
+ puts message
110
+ else
111
+ daemon_service.log(message) if defined?(daemon_service)
112
+ end
113
+ return 1
114
+ end
115
+
116
+ github_client = Soba::Infrastructure::GitHubClient.new
117
+ tmux_client = Soba::Infrastructure::TmuxClient.new
118
+ tmux_session_manager = Soba::Services::TmuxSessionManager.new(
119
+ tmux_client: tmux_client
120
+ )
121
+
122
+ # Create empty tmux session at startup
123
+ session_result = tmux_session_manager.find_or_create_repository_session
124
+ if session_result[:success]
125
+ if session_result[:created]
126
+ message = "Created tmux session: #{session_result[:session_name]}"
127
+ else
128
+ message = "Using existing tmux session: #{session_result[:session_name]}"
129
+ end
130
+ if options[:daemon]
131
+ daemon_service.log(message) if defined?(daemon_service)
132
+ else
133
+ puts message
134
+ end
135
+ end
136
+ workflow_executor = Soba::Services::WorkflowExecutor.new(
137
+ tmux_session_manager: tmux_session_manager
138
+ )
139
+ phase_strategy = Soba::Domain::PhaseStrategy.new
140
+ issue_processor = Soba::Services::IssueProcessor.new(
141
+ github_client: github_client,
142
+ workflow_executor: workflow_executor,
143
+ phase_strategy: phase_strategy,
144
+ config: Soba::Configuration
145
+ )
146
+ blocking_checker = Soba::Services::WorkflowBlockingChecker.new(
147
+ github_client: github_client
148
+ )
149
+ queueing_service = Soba::Services::QueueingService.new(
150
+ github_client: github_client,
151
+ blocking_checker: blocking_checker
152
+ )
153
+ auto_merge_service = Soba::Services::AutoMergeService.new
154
+ cleanup_logger = SemanticLogger["ClosedIssueWindowCleaner"]
155
+ cleaner_service = Soba::Services::ClosedIssueWindowCleaner.new(
156
+ github_client: github_client,
157
+ tmux_client: tmux_client,
158
+ logger: cleanup_logger
159
+ )
160
+
161
+ repository = Soba::Configuration.config.github.repository
162
+ interval = Soba::Configuration.config.workflow.interval || 10
163
+
164
+ issue_watcher = Soba::Services::IssueWatcher.new(
165
+ client: github_client,
166
+ repository: repository,
167
+ interval: interval
168
+ )
169
+
170
+ # Log or print based on mode
171
+ startup_message = [
172
+ "Starting workflow monitor for #{repository}",
173
+ "Polling interval: #{interval} seconds",
174
+ "Auto-merge enabled: #{Soba::Configuration.config.workflow.auto_merge_enabled}",
175
+ "Closed issue cleanup enabled: #{Soba::Configuration.config.workflow.closed_issue_cleanup_enabled}",
176
+ ]
177
+
178
+ if options[:daemon]
179
+ startup_message.each { |msg| daemon_service.log(msg) if defined?(daemon_service) }
180
+ else
181
+ startup_message.each { |msg| puts msg }
182
+ puts "Press Ctrl+C to stop"
183
+ end
184
+
185
+ @running = true
186
+ unless options[:daemon]
187
+ Signal.trap('INT') { @running = false }
188
+ Signal.trap('TERM') { @running = false }
189
+ end
190
+
191
+ # Clean up old stopping files on startup (from crashed processes)
192
+ cleanup_old_stopping_files
193
+
194
+ while @running
195
+ # Check for graceful shutdown request using PID-based stopping file
196
+ stopping_file = File.expand_path("~/.soba/stopping.#{Process.pid}")
197
+ if File.exist?(stopping_file)
198
+ message = "Graceful shutdown requested, completing current workflow..."
199
+ log_output(message, options, daemon_service)
200
+ @running = false
201
+ FileUtils.rm_f(stopping_file)
202
+ next
203
+ end
204
+
205
+ begin
206
+ issues = issue_watcher.fetch_issues
207
+
208
+ # Check for todo issues that need queueing
209
+ todo_issues = issues.select do |issue|
210
+ labels = issue.labels.map { |l| l[:name] }
211
+ labels.include?('soba:todo')
212
+ end
213
+
214
+ # Queue todo issues if no active issues exist
215
+ if todo_issues.any? && !blocking_checker.blocking?(repository, issues: issues)
216
+ queued_issue = queueing_service.queue_next_issue(repository)
217
+ if queued_issue
218
+ puts "\nāœ… Queued Issue ##{queued_issue.number} for processing: #{queued_issue.title}"
219
+ # Refresh issues to include the new queued state
220
+ issues = issue_watcher.fetch_issues
221
+ end
222
+ end
223
+
224
+ # Filter issues that need processing (including queued issues)
225
+ processable_issues = issues.select do |issue|
226
+ # Extract label names from hash array - labels are already hashes
227
+ labels = issue.labels.map { |l| l[:name] }
228
+ phase = phase_strategy.determine_phase(labels)
229
+ # Process queued issues and other phases
230
+ !phase.nil? && phase != :plan # Don't process todo directly, wait for queueing
231
+ end
232
+
233
+ # Sort by issue number (youngest first)
234
+ processable_issues.sort_by!(&:number)
235
+
236
+ # Update memory usage periodically
237
+ if Process.pid
238
+ process_info = Soba::Services::ProcessInfo.new(Process.pid)
239
+ memory_mb = process_info.memory_usage_mb
240
+ status_manager.update_memory(memory_mb) if memory_mb
241
+ end
242
+
243
+ # Check for approved PRs that need auto-merge (if enabled)
244
+ if Soba::Configuration.config.workflow.auto_merge_enabled
245
+ merge_result = auto_merge_service.execute
246
+ if merge_result[:merged_count] > 0
247
+ puts "\nšŸŽÆ Auto-merged #{merge_result[:merged_count]} PR(s)"
248
+ merge_result[:details][:merged].each do |pr|
249
+ puts " āœ… PR ##{pr[:number]}: #{pr[:title]}"
250
+ end
251
+ end
252
+ if merge_result[:failed_count] > 0
253
+ puts "\nāš ļø Failed to merge #{merge_result[:failed_count]} PR(s)"
254
+ merge_result[:details][:failed].each do |pr|
255
+ puts " āŒ PR ##{pr[:number]}: #{pr[:reason]}"
256
+ end
257
+ end
258
+ end
259
+
260
+ # Cleanup closed issue windows (if enabled and interval has passed)
261
+ if cleaner_service.should_clean?
262
+ Soba.logger.info "Running closed issue cleanup..."
263
+ active_sessions = tmux_client.list_soba_sessions
264
+ active_sessions.each do |session|
265
+ cleaner_service.clean(session)
266
+ end
267
+ Soba.logger.info "Closed issue cleanup completed for #{active_sessions.size} session(s)"
268
+ end
269
+
270
+ # Process the first issue if available
271
+ if processable_issues.any?
272
+ issue = processable_issues.first
273
+
274
+ # Additional safety check: ensure no duplicate active issues before processing
275
+ # This prevents race conditions when multiple workflow instances might be running
276
+ active_labels = %w(soba:queued soba:planning soba:doing soba:reviewing soba:revising)
277
+ intermediate_labels = %w(soba:review-requested soba:requires-changes)
278
+
279
+ active_issues = issues.select do |i|
280
+ i_labels = i.labels.map { |l| l[:name] }
281
+ (i_labels & (active_labels + intermediate_labels)).any?
282
+ end
283
+
284
+ if active_issues.size > 1
285
+ puts "\nāš ļø Detected multiple active issues (#{active_issues.size}).\n" \
286
+ " Skipping processing to avoid conflicts."
287
+ active_issues.each do |ai|
288
+ ai_labels = ai.labels.map { |l| l[:name] }
289
+ active_label = (ai_labels & (active_labels + intermediate_labels)).first
290
+ puts " - Issue ##{ai.number}: #{active_label}"
291
+ end
292
+ puts " Please resolve this manually or wait for the next cycle."
293
+ sleep(interval) if @running
294
+ next
295
+ end
296
+
297
+ puts "\nšŸš€ Processing Issue ##{issue.number}: #{issue.title}"
298
+
299
+ # Update status with current issue
300
+ labels = issue.labels.map { |l| l[:name] }
301
+ phase_label = labels.find { |l| l.start_with?('soba:') }
302
+ status_manager.update_current_issue(issue.number, phase_label) if phase_label
303
+
304
+ # Convert Domain::Issue to Hash for issue_processor
305
+ # Extract label names for issue_processor
306
+ issue_hash = {
307
+ number: issue.number,
308
+ title: issue.title,
309
+ labels: issue.labels.map { |l| l[:name] },
310
+ }
311
+
312
+ result = issue_processor.process(issue_hash)
313
+
314
+ # Mark as last processed when done
315
+ if result && result[:success]
316
+ status_manager.update_last_processed
317
+ end
318
+
319
+ if result[:success]
320
+ if result[:skipped]
321
+ puts " Skipped: #{result[:reason]}"
322
+ else
323
+ puts " Phase: #{result[:phase]}"
324
+ puts " Label updated: #{result[:label_updated]}"
325
+ if result[:workflow_skipped]
326
+ puts " Workflow skipped: #{result[:reason]}"
327
+ elsif result[:mode] == 'tmux'
328
+ # Display enhanced tmux information
329
+ if result[:tmux_info]
330
+ session_name = result[:tmux_info][:session] || result[:session_name]
331
+ puts " šŸ“ŗ Session: #{session_name}"
332
+ puts " šŸ’” Monitor: soba monitor #{session_name}"
333
+ puts " šŸ“ Log: ~/.soba/logs/#{session_name}.log"
334
+ else
335
+ # Fallback to legacy output for backward compatibility
336
+ puts " Tmux session started: #{result[:session_name]}" if result[:session_name]
337
+ puts " You can attach with: tmux attach -t #{result[:session_name]}" if result[:session_name]
338
+ end
339
+ elsif result[:output]
340
+ puts " Workflow output: #{result[:output].strip}"
341
+ end
342
+ end
343
+ else
344
+ puts " āŒ Failed: #{result[:error]}"
345
+ end
346
+ end
347
+
348
+ sleep(interval) if @running
349
+ rescue StandardError => e
350
+ Soba.logger.error "Workflow execution error: #{e.message}"
351
+ puts e.backtrace.first(5).join("\n") if ENV['DEBUG']
352
+ sleep(interval) if @running
353
+ end
354
+ end
355
+
356
+ puts "\nWorkflow monitoring stopped"
357
+ end
358
+
359
+ def execute_issue(args, options = {})
360
+ # Check if issue number is provided
361
+ if args.empty? || args[0].nil? || args[0].empty? || args[0].strip.empty?
362
+ warn "Error: Issue number is required"
363
+ return 1
364
+ end
365
+
366
+ # Initialize configuration and issue processor if needed
367
+ @configuration ||= Soba::Configuration.load!
368
+ @issue_processor ||= Soba::Services::IssueProcessor.new
369
+
370
+ issue_number = args[0]
371
+
372
+ # Determine tmux mode based on priority
373
+ use_tmux = determine_tmux_mode(options)
374
+
375
+ # Display execution mode
376
+ if use_tmux
377
+ puts "Running issue ##{issue_number} with tmux"
378
+ else
379
+ if options["no-tmux"]
380
+ puts "Running in direct mode (tmux disabled)"
381
+ elsif ENV["SOBA_NO_TMUX"]
382
+ puts "Running in direct mode (tmux disabled by environment variable)"
383
+ else
384
+ puts "Running in direct mode"
385
+ end
386
+ end
387
+
388
+ begin
389
+ # Process the issue
390
+ @issue_processor.run(issue_number, use_tmux: use_tmux)
391
+ 0
392
+ rescue StandardError => e
393
+ warn "Error: #{e.message}"
394
+ 1
395
+ end
396
+ end
397
+
398
+ def determine_tmux_mode(options)
399
+ # Priority: CLI option > Environment variable > Config file
400
+
401
+ # 1. CLI option (highest priority)
402
+ if options["no-tmux"]
403
+ return false
404
+ end
405
+
406
+ # 2. Environment variable
407
+ env_value = ENV["SOBA_NO_TMUX"]
408
+ if env_value
409
+ # true or 1 means disable tmux
410
+ return !(env_value == "true" || env_value == "1")
411
+ end
412
+
413
+ # 3. Config file (lowest priority)
414
+ config = @configuration.respond_to?(:config) ? @configuration.config : @configuration
415
+ config.workflow.use_tmux
416
+ end
417
+
418
+ def cleanup_old_stopping_files
419
+ # Clean up stopping files from processes that no longer exist
420
+ stopping_dir = File.expand_path('~/.soba')
421
+ return unless File.directory?(stopping_dir)
422
+
423
+ Dir.glob(File.join(stopping_dir, 'stopping.*')).each do |file|
424
+ # Extract PID from filename
425
+ if file =~ /stopping\.(\d+)$/
426
+ pid = Regexp.last_match(1).to_i
427
+ begin
428
+ # Check if process exists
429
+ Process.kill(0, pid)
430
+ rescue Errno::ESRCH
431
+ # Process doesn't exist, remove the file
432
+ FileUtils.rm_f(file)
433
+ puts "Cleaned up stale stopping file: #{File.basename(file)}"
434
+ rescue Errno::EPERM
435
+ # We don't have permission to check this process, keep the file
436
+ end
437
+ end
438
+ end
439
+ end
440
+ end
441
+ end
442
+ end