ares-runtime 2.0.1

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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +10 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +110 -0
  5. data/config/models.yml +25 -0
  6. data/config/ollama.yml +4 -0
  7. data/config/workspaces.yml +6 -0
  8. data/exe/ares +75 -0
  9. data/lib/ares/cli.rb +37 -0
  10. data/lib/ares/runtime/adapters/base_adapter.rb +68 -0
  11. data/lib/ares/runtime/adapters/claude_adapter.rb +35 -0
  12. data/lib/ares/runtime/adapters/codex_adapter.rb +35 -0
  13. data/lib/ares/runtime/adapters/cursor_adapter.rb +32 -0
  14. data/lib/ares/runtime/adapters/ollama_adapter.rb +37 -0
  15. data/lib/ares/runtime/config_cli.rb +18 -0
  16. data/lib/ares/runtime/config_manager.rb +137 -0
  17. data/lib/ares/runtime/context_loader.rb +45 -0
  18. data/lib/ares/runtime/core_subsystem.rb +36 -0
  19. data/lib/ares/runtime/diagnostic_parser.rb +159 -0
  20. data/lib/ares/runtime/doctor.rb +34 -0
  21. data/lib/ares/runtime/engine_chain.rb +108 -0
  22. data/lib/ares/runtime/git_manager.rb +26 -0
  23. data/lib/ares/runtime/initializer.rb +30 -0
  24. data/lib/ares/runtime/logs_cli.rb +35 -0
  25. data/lib/ares/runtime/model_selector.rb +36 -0
  26. data/lib/ares/runtime/ollama_client_factory.rb +43 -0
  27. data/lib/ares/runtime/planner/ollama_planner.rb +51 -0
  28. data/lib/ares/runtime/planner/tiny_task_processor.rb +129 -0
  29. data/lib/ares/runtime/prompt_builder.rb +52 -0
  30. data/lib/ares/runtime/quota_manager.rb +48 -0
  31. data/lib/ares/runtime/router.rb +285 -0
  32. data/lib/ares/runtime/task_logger.rb +37 -0
  33. data/lib/ares/runtime/task_manager.rb +9 -0
  34. data/lib/ares/runtime/terminal_runner.rb +37 -0
  35. data/lib/ares/runtime/tui.rb +211 -0
  36. data/lib/ares/runtime/version.rb +7 -0
  37. data/lib/ares/runtime.rb +5 -0
  38. data/lib/ares_runtime.rb +63 -0
  39. metadata +240 -0
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ollama_client'
4
+ require 'timeout'
5
+ require_relative '../config_manager'
6
+ require_relative '../ollama_client_factory'
7
+
8
+ module Ares
9
+ module Runtime
10
+ class TinyTaskProcessor
11
+ MAX_SUMMARY_INPUT = 5_000
12
+ DEFAULT_TIMEOUT = 30
13
+ HARD_TIMEOUT = 35
14
+
15
+ def initialize(healthy: true)
16
+ @healthy = healthy
17
+ @client = healthy ? OllamaClientFactory.build(timeout_seconds: DEFAULT_TIMEOUT) : nil
18
+ end
19
+
20
+ def summarize_output(output, type: :test)
21
+ return safe_summary_fallback('Safe Mode: Ollama skipped') unless @healthy
22
+
23
+ truncated = filter_and_truncate(output, type)
24
+ prompt = build_summary_prompt(truncated, type)
25
+
26
+ OllamaClientFactory.with_resilience(
27
+ hard_timeout: HARD_TIMEOUT,
28
+ fallback_value: safe_summary_fallback('Summary failed')
29
+ ) do
30
+ @client.generate(prompt: prompt, schema: summary_schema)
31
+ end
32
+ end
33
+
34
+ def summarize_diff(diff)
35
+ return safe_diff_fallback('Safe Mode: Ollama skipped') unless @healthy
36
+
37
+ OllamaClientFactory.with_resilience(
38
+ hard_timeout: HARD_TIMEOUT,
39
+ fallback_value: safe_diff_fallback('Diff summary failed')
40
+ ) do
41
+ @client.generate(prompt: build_diff_prompt(diff), schema: diff_schema)
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def filter_and_truncate(output, type)
48
+ filtered = filter_output(output, type)
49
+ return filtered if filtered.length <= MAX_SUMMARY_INPUT
50
+
51
+ "#{filtered[0, MAX_SUMMARY_INPUT]}\n\n[... output truncated ...]"
52
+ end
53
+
54
+ def build_summary_prompt(truncated, type)
55
+ instruction = case type
56
+ when :lint then 'Summarize RuboCop offenses. Extract file paths and line numbers:'
57
+ when :syntax then 'Summarize Ruby syntax errors. Extract file paths and line numbers:'
58
+ else 'Summarize RSpec failures. Extract file paths and line numbers:'
59
+ end
60
+
61
+ PromptBuilder.new
62
+ .add_instruction(instruction)
63
+ .add_instruction(truncated)
64
+ .build
65
+ end
66
+
67
+ def build_diff_prompt(diff)
68
+ PromptBuilder.new
69
+ .add_instruction('Summarize the following git diff. Identify modified files, describe the core changes, and assess the risk level.')
70
+ .add_instruction(diff)
71
+ .build
72
+ end
73
+
74
+ def summary_schema
75
+ {
76
+ 'type' => 'object',
77
+ 'required' => %w[failed_items error_summary files],
78
+ 'properties' => {
79
+ 'failed_items' => { 'type' => 'array', 'items' => { 'type' => 'string' } },
80
+ 'error_summary' => { 'type' => 'string' },
81
+ 'files' => {
82
+ 'type' => 'array',
83
+ 'items' => {
84
+ 'type' => 'object',
85
+ 'required' => %w[path line],
86
+ 'properties' => { 'path' => { 'type' => 'string' }, 'line' => { 'type' => 'integer' } }
87
+ }
88
+ }
89
+ }
90
+ }
91
+ end
92
+
93
+ def diff_schema
94
+ {
95
+ 'type' => 'object',
96
+ 'required' => %w[modified_files change_summary risk_level],
97
+ 'properties' => {
98
+ 'modified_files' => { 'type' => 'array', 'items' => { 'type' => 'string' } },
99
+ 'change_summary' => { 'type' => 'string' },
100
+ 'risk_level' => { 'type' => 'string', 'enum' => %w[low medium high] }
101
+ }
102
+ }
103
+ end
104
+
105
+ def safe_summary_fallback(reason)
106
+ { 'failed_items' => [], 'error_summary' => "Safe Mode: #{reason}", 'files' => [] }
107
+ end
108
+
109
+ def safe_diff_fallback(reason)
110
+ { 'modified_files' => [], 'change_summary' => "Safe Mode: #{reason}", 'risk_level' => 'medium' }
111
+ end
112
+
113
+ def filter_output(output, type)
114
+ lines = output.split("\n")
115
+ case type
116
+ when :lint
117
+ lines.grep(/:\d+:\d+: [CW]: /).first(20).join("\n")
118
+ when :test
119
+ lines.select do |l|
120
+ l.match?(/\d+\)\s/) || l.match?(%r{\s+Failure/Error:}) ||
121
+ l.match?(/\s+expected:/) || l.match?(/\s+got:/) || l.match?(/\.rb:\d+/)
122
+ end.first(50).join("\n")
123
+ else
124
+ lines.first(100).join("\n")
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ares
4
+ module Runtime
5
+ # Implements the Builder Pattern for constructing complex LLM prompts
6
+ class PromptBuilder
7
+ def initialize
8
+ @sections = []
9
+ end
10
+
11
+ def add_context(context)
12
+ @sections << context unless context.to_s.strip.empty?
13
+ self
14
+ end
15
+
16
+ def add_task(task_description)
17
+ @sections << "TASK:\n#{task_description}"
18
+ self
19
+ end
20
+
21
+ def add_diagnostic(type, failed_items, error_summary)
22
+ @sections << <<~DIAG.strip
23
+ DIAGNOSTIC SUMMARY (#{type.to_s.upcase}):
24
+ Failed Items: #{Array(failed_items).join(', ')}
25
+ Error: #{error_summary}
26
+ DIAG
27
+ self
28
+ end
29
+
30
+ def add_files(files)
31
+ return self if files.nil? || files.empty?
32
+
33
+ files_content = Array(files).filter_map do |f|
34
+ path = File.expand_path(f['path'], Dir.pwd)
35
+ "--- FILE: #{f['path']} ---\n#{File.read(path)}" if File.exist?(path)
36
+ end.join("\n\n")
37
+
38
+ @sections << "FAILING FILE CONTENTS:\n#{files_content}" unless files_content.empty?
39
+ self
40
+ end
41
+
42
+ def add_instruction(instruction)
43
+ @sections << instruction unless instruction.to_s.strip.empty?
44
+ self
45
+ end
46
+
47
+ def build
48
+ @sections.join("\n\n")
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ares
4
+ module Runtime
5
+ class QuotaManager
6
+ GLOBAL_DIR = File.expand_path('~/.ares')
7
+ QUOTA_FILE = File.expand_path("#{GLOBAL_DIR}/.quota.json", __dir__)
8
+ LIMITS = { claude: 50, codex: 100 }.freeze
9
+
10
+ def self.usage
11
+ data = load_data
12
+ today = Date.today.to_s
13
+ {
14
+ claude: data[today] || 0,
15
+ codex: 0 # Tracked separately or placeholder
16
+ }
17
+ end
18
+
19
+ def self.increment_usage(engine)
20
+ return unless engine == :claude
21
+
22
+ data = load_data
23
+ today = Date.today.to_s
24
+
25
+ data[today] ||= 0
26
+ data[today] += 1
27
+
28
+ File.write(QUOTA_FILE, JSON.pretty_generate(data))
29
+ end
30
+
31
+ def self.remaining_quota
32
+ LIMITS[:claude] - usage[:claude]
33
+ end
34
+
35
+ def self.quota_exceeded?
36
+ remaining_quota <= 0
37
+ end
38
+
39
+ def self.load_data
40
+ return {} unless File.exist?(QUOTA_FILE)
41
+
42
+ JSON.parse(File.read(QUOTA_FILE))
43
+ rescue JSON::ParserError
44
+ {}
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ares
4
+ module Runtime
5
+ class Router
6
+ def initialize
7
+ @core = CoreSubsystem.new
8
+ end
9
+
10
+ def run(task, options = {})
11
+ puts "Task ID: #{@core.logger.task_id}"
12
+ check_quota!
13
+
14
+ @spinner = TTY::Spinner.new('[:spinner] :title', format: :dots)
15
+
16
+ shortcut_result = match_shortcut_task(task, options)
17
+ return shortcut_result if shortcut_result
18
+
19
+ plan = plan_task(task)
20
+ selection = select_model_for_plan(plan)
21
+ selection = handle_low_confidence(selection, plan)
22
+
23
+ return if selection.nil? # User aborted
24
+
25
+ execute_engine_task(task, plan, selection, options)
26
+ end
27
+
28
+ private
29
+
30
+ def run_test_diagnostic(options)
31
+ run_diagnostic_loop('bundle exec rspec', options.merge(type: :test, title: 'Running tests'))
32
+ end
33
+
34
+ def run_syntax_check(options)
35
+ # Check syntax for all ruby files in lib, bin, spec individually to catch all errors
36
+ cmd = "ruby -e 'Dir.glob(\"{lib,bin,exe,spec}/**/*.rb\").each { |f| (puts \"Checking \#{f}\"; system(\"ruby -c \#{f}\")) or exit(1) }'"
37
+ run_diagnostic_loop(cmd, options.merge(type: :syntax, title: 'Checking syntax'))
38
+ end
39
+
40
+ def run_diagnostic_loop(command, options)
41
+ title = options[:title] || 'Running verification'
42
+ @spinner.update(title: "#{title}...")
43
+ result = nil
44
+ @spinner.run { result = TerminalRunner.run(command) }
45
+
46
+ if result[:exit_status].zero?
47
+ puts "#{title} passed! ✅"
48
+ return true
49
+ end
50
+
51
+ type = options[:type] || :test
52
+ @spinner.update(title: "#{title} failed. Summarizing diagnostic output...")
53
+ summary = nil
54
+
55
+ @spinner.run do
56
+ # Try fast-path regex/JSON parsing first
57
+ summary = DiagnosticParser.parse(result[:output], type: type)
58
+
59
+ # Fallback to LLM only if fast-path failed to identify files
60
+ if summary['files'].empty? || summary['failed_items'].empty?
61
+ @spinner.update(title: "#{title} failed. LLM Fallback (Slow)...")
62
+ summary = @core.tiny_processor.summarize_output(result[:output], type: type)
63
+ end
64
+ rescue StandardError => e
65
+ @spinner.update(title: "#{title} failed. Error in fast-path: #{e.message}. LLM Fallback...")
66
+ summary = @core.tiny_processor.summarize_output(result[:output], type: type)
67
+ end
68
+
69
+ puts "\n--- Diagnostic Summary (#{type.to_s.upcase}) ---"
70
+ table = TTY::Table.new(header: %w[Attribute Value])
71
+ table << ['Failed Items', Array(summary['failed_items'] || summary['failed_tests']).join("\n")]
72
+ table << ['Error Summary', summary['error_summary']]
73
+ puts table.render(:unicode, multiline: true)
74
+
75
+ if options[:dry_run]
76
+ puts 'Dry run: skipping escalation.'
77
+ return false
78
+ end
79
+
80
+ # Escalate to executor for fix
81
+ if type == :lint
82
+ escalate_lint_one_at_a_time(summary, options.merge(verify_command: command))
83
+ else
84
+ escalate_to_executor(summary, options.merge(verify_command: command))
85
+ end
86
+ end
87
+
88
+ def escalate_lint_one_at_a_time(summary, options)
89
+ max_iterations = 20
90
+ current_summary = summary
91
+
92
+ max_iterations.times do |iteration|
93
+ puts "\n--- Fix iteration #{iteration + 1}/#{max_iterations} ---" if iteration.positive?
94
+
95
+ success = escalate_to_executor(current_summary, options.merge(fix_first_only: true))
96
+ return true if success
97
+
98
+ @spinner.update(title: 'Re-running RuboCop...')
99
+ verify_result = nil
100
+ @spinner.run { verify_result = TerminalRunner.run(options[:verify_command]) }
101
+
102
+ return false if verify_result[:exit_status].zero?
103
+
104
+ @spinner.update(title: 'Summarizing remaining offenses...')
105
+ current_summary = nil
106
+ @spinner.run { current_summary = @core.tiny_processor.summarize_output(verify_result[:output], type: :lint) }
107
+
108
+ puts "\n--- Remaining offenses ---"
109
+ table = TTY::Table.new(header: %w[Attribute Value])
110
+ table << ['Failed Items', Array(current_summary['failed_items']).join("\n")]
111
+ table << ['Error Summary', current_summary['error_summary']]
112
+ puts table.render(:unicode, multiline: true)
113
+ end
114
+
115
+ puts "\nReached max iterations (#{max_iterations}). Some offenses may remain."
116
+ false
117
+ end
118
+
119
+ def escalate_to_executor(summary, options)
120
+ type = options[:type] || :test
121
+ verify_command = options[:verify_command] || 'bundle exec rspec'
122
+ fix_prompt = generate_fix_prompt(summary, options)
123
+
124
+ selection = ModelSelector.select({ 'task_type' => 'refactor', 'risk_level' => 'medium' })
125
+ puts "Selected Engine for fix: #{selection[:engine]} (#{selection[:model] || 'default'})"
126
+
127
+ result = apply_fix_with_fallbacks(fix_prompt, selection)
128
+ return false unless result
129
+
130
+ apply_patches(result) if result['patches']&.any?
131
+
132
+ @spinner.update(title: 'Verifying fix...')
133
+ verify_result = nil
134
+ @spinner.run { verify_result = TerminalRunner.run(verify_command) }
135
+
136
+ handle_verification_result(verify_result, type)
137
+ end
138
+
139
+ def check_quota!
140
+ return unless QuotaManager.quota_exceeded?
141
+
142
+ puts '❌ Quota exceeded for Claude. Please try again later or use a different engine.'
143
+ exit 1
144
+ end
145
+
146
+ def match_shortcut_task(task, options)
147
+ if task.match?(/\A(run\s+|check\s+)?(test|rspec|fix|diagnostic)(s|ing)?\s*\z/i)
148
+ return run_test_diagnostic(options)
149
+ end
150
+ return run_syntax_check(options) if task.match?(/\A(run\s+|check\s+)?(syntax|compile)(\s+check)?\s*\z/i)
151
+ return run_lint(options) if task.match?(/\A(run\s+|check\s+)?(lint|format|style)(ting|ing|s)?\s*\z/i)
152
+
153
+ nil
154
+ end
155
+
156
+ def plan_task(task)
157
+ plan = nil
158
+ @spinner.update(title: 'Planning task...')
159
+ @spinner.run { plan = @core.planner.plan(task) }
160
+ plan
161
+ end
162
+
163
+ def select_model_for_plan(plan)
164
+ selection = nil
165
+ @spinner.update(title: 'Selecting optimal model...')
166
+ @spinner.run { selection = ModelSelector.select(plan) }
167
+ selection
168
+ end
169
+
170
+ def handle_low_confidence(selection, plan)
171
+ return selection if plan['confidence'].to_f >= 0.7
172
+
173
+ prompt = TTY::Prompt.new
174
+ choice = prompt.select('Low confidence detected. How should we proceed?',
175
+ "Execute with suggested #{selection[:engine]} (#{selection[:model] || 'default'})",
176
+ 'Override and use Claude Opus', 'Abort task')
177
+
178
+ case choice
179
+ when /Override/
180
+ puts 'Overridden: Using Claude Opus.'
181
+ { engine: :claude, model: 'opus' }
182
+ when /Abort/
183
+ puts 'Task aborted by user.'
184
+ nil
185
+ else selection
186
+ end
187
+ end
188
+
189
+ def execute_engine_task(task, plan, selection, options)
190
+ puts "Engine Selected: #{selection[:engine]} (#{selection[:model] || 'default'})"
191
+ return if options[:dry_run] && puts('--- DRY RUN MODE ---')
192
+
193
+ @core.logger.log_task(task, plan, selection)
194
+ GitManager.create_branch(@core.logger.task_id, task) if options[:git]
195
+
196
+ capable_engines = %w[claude codex cursor]
197
+ initial_engine = selection[:engine]&.to_s || 'claude'
198
+ fallback_chain = ([initial_engine] + (capable_engines - [initial_engine])).uniq
199
+
200
+ chain = EngineChain.build(fallback_chain)
201
+ prompt = PromptBuilder.new
202
+ .add_context(ContextLoader.load)
203
+ .add_task(task)
204
+ .build
205
+
206
+ adapter_opts = { model: selection[:model], fork_session: true, resume: true, cloud: options[:cloud] }
207
+
208
+ output = chain.call(prompt, adapter_opts, total: fallback_chain.size)
209
+ @core.logger.log_result(output)
210
+
211
+ GitManager.commit_changes(@core.logger.task_id, task) if options[:git]
212
+ puts output
213
+ end
214
+
215
+ def generate_fix_prompt(summary, options)
216
+ type = options[:type] || :test
217
+
218
+ builder = PromptBuilder.new
219
+ .add_context(ContextLoader.load)
220
+ .add_diagnostic(type, summary['failed_items'] || summary['failed_tests'], summary['error_summary'])
221
+ .add_instruction("TASK: Fix the #{type} failures identifying above.")
222
+
223
+ builder.add_instruction('Fix ONLY the first offense listed.') if options[:fix_first_only]
224
+
225
+ builder.add_instruction("You MUST provide JSON with 'explanation' and 'patches' (with 'file' and 'content' fields).")
226
+ .add_files(summary['files'])
227
+ .build
228
+ end
229
+
230
+ def apply_fix_with_fallbacks(fix_prompt, selection)
231
+ capable_engines = %w[claude codex cursor]
232
+ initial_engine = selection[:engine]&.to_s || 'claude'
233
+ fallback_chain = ([initial_engine] + (capable_engines - [initial_engine])).uniq
234
+
235
+ chain = EngineChain.build(fallback_chain)
236
+ adapter_opts = { model: selection[:model], fork_session: true, resume: true }
237
+
238
+ raw = chain.call_fix(fix_prompt, adapter_opts, total: fallback_chain.size) do |current_engine|
239
+ create_checkpoint(current_engine)
240
+ end
241
+ JSON.parse(raw)
242
+ end
243
+
244
+ def apply_patches(result)
245
+ result['patches'].each do |patch|
246
+ path = File.expand_path(patch['file'], Dir.pwd)
247
+ FileUtils.mkdir_p(File.dirname(path))
248
+ File.write(path, patch['content'])
249
+ puts "Applied fix to #{patch['file']} ✅"
250
+ end
251
+ end
252
+
253
+ def handle_verification_result(verify_result, type)
254
+ if verify_result[:exit_status].zero?
255
+ puts "Fix successful! #{type.to_s.capitalize} issues resolved. ✅"
256
+ true
257
+ else
258
+ puts "Fix failed. #{type.to_s.capitalize} issues still persist. ❌"
259
+ false
260
+ end
261
+ end
262
+
263
+ def create_checkpoint(engine)
264
+ # Checkpointing logic varies by engine.
265
+ # For now, we ensure a git-based safety net if not in a git repo or if native fails.
266
+ case engine
267
+ when :claude
268
+ # Claude Code does automatic checkpointing on every prompt.
269
+ @spinner.update(title: 'Leveraging Claude auto-checkpoint...')
270
+ when :codex
271
+ # Codex sessions are automatically persisted.
272
+ @spinner.update(title: 'Leveraging Codex session persistence...')
273
+ else
274
+ # Fallback to git stash or similar if we wanted a hard checkpoint,
275
+ # but since we are often on a task branch, git is our checkpoint.
276
+ @spinner.update(title: "Ensuring state persistence for #{engine}...")
277
+ end
278
+ end
279
+
280
+ def run_lint(options)
281
+ run_diagnostic_loop('bundle exec rubocop -A', options.merge(type: :lint, title: 'Running RuboCop'))
282
+ end
283
+ end
284
+ end
285
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ares
4
+ module Runtime
5
+ class TaskLogger
6
+ attr_reader :task_id
7
+
8
+ def initialize
9
+ @task_id = SecureRandom.uuid
10
+ @log_dir = File.join(ConfigManager.project_root, 'logs')
11
+ FileUtils.mkdir_p(@log_dir)
12
+ end
13
+
14
+ def log_task(task, plan, selection)
15
+ log_file = File.join(@log_dir, "#{@task_id}.json")
16
+ data = {
17
+ task_id: @task_id,
18
+ timestamp: Time.now.iso8601,
19
+ task: task,
20
+ plan: plan,
21
+ selection: selection
22
+ }
23
+ File.write(log_file, JSON.pretty_generate(data))
24
+ end
25
+
26
+ def log_result(result)
27
+ log_file = File.join(@log_dir, "#{@task_id}.json")
28
+ return unless File.exist?(log_file)
29
+
30
+ data = JSON.parse(File.read(log_file))
31
+ data[:result] = result
32
+ data[:completed_at] = Time.now.iso8601
33
+ File.write(log_file, JSON.pretty_generate(data))
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ares
4
+ module Runtime
5
+ class TaskManager
6
+ # Placeholder for future task lifecycle management
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Ares
6
+ module Runtime
7
+ # Core class for executing terminal commands, with support for sandboxing via Codex.
8
+ class TerminalRunner
9
+ def self.run(cmd, stdin_data: nil)
10
+ # Ensure cmd is an array for capture2e if we want to avoid shell injection,
11
+ # but the Router currently passes strings for complex commands.
12
+ # We'll normalize or handle both.
13
+ output, status = if cmd.is_a?(Array)
14
+ Open3.capture2e(*cmd, stdin_data: stdin_data)
15
+ else
16
+ Open3.capture2e(cmd, stdin_data: stdin_data)
17
+ end
18
+
19
+ # Safe join for logging/errors
20
+ cmd_str = cmd.is_a?(Array) ? cmd.join(' ') : cmd
21
+ raise "Command failed: #{cmd_str}\nOutput: #{output}" unless status.success?
22
+
23
+ { output: output, exit_status: status.exitstatus }
24
+ rescue StandardError => e
25
+ # Return a hash consistent with the Router's expectations
26
+ { output: e.message, exit_status: 1 }
27
+ end
28
+
29
+ def self.run_sandboxed(cmd)
30
+ # Use codex sandbox for secure execution
31
+ cmd_array = cmd.is_a?(Array) ? cmd : [cmd]
32
+ full_cmd = ['codex', 'sandbox', '--full-auto', '--', *cmd_array]
33
+ run(full_cmd)
34
+ end
35
+ end
36
+ end
37
+ end