brute_cli 0.3.0 → 0.4.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/exe/brute +11 -26
  3. data/lib/brute_cli/buffer_output/error.rb +27 -0
  4. data/lib/brute_cli/buffer_output/model_line.rb +37 -0
  5. data/lib/brute_cli/buffer_output/separator.rb +24 -0
  6. data/lib/brute_cli/buffer_output/stats_bar.rb +43 -0
  7. data/lib/brute_cli/buffer_output.rb +11 -0
  8. data/lib/brute_cli/configuration.rb +14 -0
  9. data/lib/brute_cli/emoji.rb +25 -22
  10. data/lib/brute_cli/execution.rb +260 -0
  11. data/lib/brute_cli/phase/content_phase.rb +29 -0
  12. data/lib/brute_cli/phase/tool_call.rb +21 -0
  13. data/lib/brute_cli/phase/tool_phase.rb +29 -0
  14. data/lib/brute_cli/phase.rb +10 -0
  15. data/lib/brute_cli/repl.rb +99 -444
  16. data/lib/brute_cli/spinner/dots.rb +24 -0
  17. data/lib/brute_cli/spinner/nyan.rb +53 -0
  18. data/lib/brute_cli/spinner/puff_puff_pass.rb +32 -0
  19. data/lib/brute_cli/spinner.rb +30 -0
  20. data/lib/brute_cli/styles.rb +3 -0
  21. data/lib/brute_cli/tool_output/delegate.rb +16 -0
  22. data/lib/brute_cli/tool_output/fetch.rb +16 -0
  23. data/lib/brute_cli/tool_output/fs_search.rb +16 -0
  24. data/lib/brute_cli/tool_output/patch.rb +20 -0
  25. data/lib/brute_cli/tool_output/question.rb +9 -0
  26. data/lib/brute_cli/tool_output/read.rb +16 -0
  27. data/lib/brute_cli/tool_output/remove.rb +16 -0
  28. data/lib/brute_cli/tool_output/shell.rb +25 -0
  29. data/lib/brute_cli/tool_output/todo_read.rb +16 -0
  30. data/lib/brute_cli/tool_output/todo_write.rb +16 -0
  31. data/lib/brute_cli/tool_output/undo.rb +16 -0
  32. data/lib/brute_cli/tool_output/write.rb +20 -0
  33. data/lib/brute_cli/tool_output.rb +141 -0
  34. data/lib/brute_cli/version.rb +1 -1
  35. data/lib/brute_cli.rb +15 -12
  36. metadata +32 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d25022bbf25ee324c11c0517e9186a65e13b4838d33dde2dc4250b9803783631
4
- data.tar.gz: 1831cd59d1263dd3099854dc58cc5ecfb73f1448ac180cac73271ab836541fb6
3
+ metadata.gz: 7faec8b9ffa2108882a1deb9e4eeb1a5747ce41ed8d6afb15414817de129fcca
4
+ data.tar.gz: 0ffc0437131b4244123eed92bb9850062af2f785ff7dcaa873ed8a4d6ace1310
5
5
  SHA512:
6
- metadata.gz: c03d4a80b353b12ab4e28cdddd4aed2c4a3645cbbb8f9d96d9636d6e889873f509ec3ca8dc813013ebbed1f8d38bdb4c6f41cc554998a0fcbfbd27f167e6e5eb
7
- data.tar.gz: e3dc1f1741c4bbb83a406164034788564ce6d5a1c45a286702b2a36470997789ed0a41632d02c467b12ac88dd4f279b4f613b6bddeb4a9aecd49b3de58b540a0
6
+ metadata.gz: 60607a8e7d25dc568a9e29b267985f524bd7f3d5f7b9f791e5a2c51641813f40dfb0f0e5f8a3cd0a43c1d216d33f13782460ceed40f30b5520fafc1b333fd572
7
+ data.tar.gz: a89c64d5621373a349f1f8d27d5ab8d8f2add3f409e69d9caaad43d201171db22ae9b7db2a00cae8aa485a5613466251d791765331067ec5301ff4e92fe0d487
data/exe/brute CHANGED
@@ -18,43 +18,28 @@ end.parse!
18
18
 
19
19
  BruteCLI.apply_theme!(options[:theme]) if options[:theme]
20
20
 
21
- # ── List sessions ──
22
-
23
21
  if options[:list]
24
- sessions = Brute::Session.list
25
- if sessions.empty?
26
- puts "No saved sessions."
27
- else
28
- title_style = Lipgloss::Style.new.bold(true).foreground(BruteCLI::Styles::PURPLE)
29
- id_style = Lipgloss::Style.new.foreground(BruteCLI::Styles::CYAN)
30
- time_style = Lipgloss::Style.new.foreground(BruteCLI::Styles::DIM)
31
-
32
- sessions.each do |s|
33
- id = id_style.render(s[:id][0..7])
34
- title = title_style.render(s[:title] || "(untitled)")
35
- time = time_style.render(s[:saved_at].to_s)
36
- puts " #{id} #{title} #{time}"
22
+ Brute::Session.list.tap do |sessions|
23
+ if sessions.empty?
24
+ puts "No saved sessions."
25
+ else
26
+ puts "Not implemented"
37
27
  end
38
28
  end
29
+
39
30
  exit
40
31
  end
41
32
 
42
- # ── Collect prompt ──
43
-
44
- prompt = ARGV.join(" ")
45
- prompt = $stdin.read.strip if prompt.empty? && !$stdin.tty?
46
-
47
- # ── Run ──
48
-
49
- repl = BruteCLI::REPL.new(options)
50
-
51
33
  begin
34
+ prompt = ARGV.join(" ")
35
+ prompt = $stdin.read.strip if prompt.empty? && !$stdin.tty?
36
+
52
37
  if prompt.empty?
53
38
  # Interactive mode
54
- repl.run_interactive
39
+ BruteCLI::REPL.new(options).run
55
40
  else
56
41
  # Single prompt mode
57
- repl.run_once(prompt)
42
+ BruteCLI::Execution.new(options).run(prompt)
58
43
  puts
59
44
  end
60
45
  rescue Interrupt
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "brute_cli/styles"
5
+ require "brute_cli/emoji"
6
+
7
+ module BruteCLI
8
+ module BufferOutput
9
+ # Renderable error badge with pretty-printed message.
10
+ #
11
+ # puts BufferOutput::Error.new(err)
12
+ # # => "✖ ERROR"
13
+ # # => "\"Something went wrong\""
14
+ #
15
+ class Error
16
+ def initialize(err)
17
+ @err = err
18
+ end
19
+
20
+ def to_s
21
+ header = "#{Emoji::CROSS} #{"ERROR".colorize(ERROR_BG)}"
22
+ parsed = JSON.parse(@err.message) rescue @err.message
23
+ "#{header}\n#{parsed.pretty_inspect.chomp}"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "brute_cli/styles"
4
+
5
+ module BruteCLI
6
+ module BufferOutput
7
+ # Renderable provider / model / agent status line.
8
+ #
9
+ # puts BufferOutput::ModelLine.new(
10
+ # provider_name: "anthropic",
11
+ # model_short: "3.5-sonnet",
12
+ # current_agent: "build"
13
+ # )
14
+ # # => "anthropic 3.5-sonnet · agent build"
15
+ #
16
+ class ModelLine
17
+ def initialize(provider_name:, model_short:, current_agent:)
18
+ @provider_name = provider_name
19
+ @model_short = model_short
20
+ @current_agent = current_agent
21
+ end
22
+
23
+ def to_s
24
+ parts = []
25
+ parts << stat_span(@provider_name, @model_short) if @provider_name && @model_short
26
+ parts << stat_span("agent", @current_agent)
27
+ parts.join(" \u00b7 ".colorize(DIM))
28
+ end
29
+
30
+ private
31
+
32
+ def stat_span(label, value)
33
+ "#{label} ".colorize(DIM) + value.to_s.colorize(ACCENT)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "brute_cli/styles"
4
+
5
+ module BruteCLI
6
+ module BufferOutput
7
+ # Renderable horizontal rule for terminal output.
8
+ #
9
+ # puts BufferOutput::Separator.new(width: 80)
10
+ # puts BufferOutput::Separator.new(width: 80, thick: true)
11
+ #
12
+ class Separator
13
+ def initialize(width:, thick: false)
14
+ @width = width
15
+ @thick = thick
16
+ end
17
+
18
+ def to_s
19
+ char = @thick ? "\u2550" : "\u2500"
20
+ (char * [@width, 40].max).colorize(DIM)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "brute_cli/styles"
4
+
5
+ module BruteCLI
6
+ module BufferOutput
7
+ # Renderable token / timing / tool-call metrics line.
8
+ #
9
+ # puts BufferOutput::StatsBar.new(metadata, width: 80)
10
+ # # => "tokens 150 | in 100 | out 50 | time 45.5s | tools 5"
11
+ #
12
+ class StatsBar
13
+ def initialize(metadata, width:)
14
+ @metadata = metadata
15
+ @width = width
16
+ end
17
+
18
+ def to_s
19
+ tokens = @metadata[:tokens] || {}
20
+ timing = @metadata[:timing] || {}
21
+ tool_calls = @metadata[:tool_calls] || 0
22
+ sep = " | ".colorize(DIM)
23
+ parts = []
24
+ parts << stat_span("tokens", (tokens[:total] || 0).to_s)
25
+ parts << stat_span("in", (tokens[:total_input] || 0).to_s)
26
+ parts << stat_span("out", (tokens[:total_output] || 0).to_s)
27
+ parts << stat_span("time", format_time(timing[:total_elapsed] || 0))
28
+ parts << stat_span("tools", tool_calls.to_s) if tool_calls > 0
29
+ parts.join(sep)
30
+ end
31
+
32
+ private
33
+
34
+ def stat_span(label, value)
35
+ "#{label} ".colorize(DIM) + value.to_s.colorize(ACCENT)
36
+ end
37
+
38
+ def format_time(s)
39
+ s < 60 ? "#{s.round(1)}s" : "#{(s / 60).floor}m#{(s % 60).round(1)}s"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "brute_cli/buffer_output/separator"
4
+ require "brute_cli/buffer_output/stats_bar"
5
+ require "brute_cli/buffer_output/error"
6
+ require "brute_cli/buffer_output/model_line"
7
+
8
+ module BruteCLI
9
+ module BufferOutput
10
+ end
11
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BruteCLI
4
+ Configuration = Struct.new(:spinner) do
5
+ def initialize
6
+ super
7
+ self.spinner = BruteCLI::Spinner::Nyan
8
+ end
9
+ end
10
+
11
+ def self.config
12
+ @config ||= Configuration.new
13
+ end
14
+ end
@@ -4,30 +4,33 @@ require 'gemoji'
4
4
 
5
5
  module BruteCLI
6
6
  module Emoji
7
- def self.find(name)
7
+ def self.💩(name)
8
8
  ::Emoji.find_by_alias(name)&.raw || ''
9
9
  end
10
10
 
11
- EYES = find('eyes')
12
- PENCIL = find('pencil2')
13
- PAGE = find('page_facing_up')
14
- COMPUTER = find('computer')
15
- SPARKLES = find('sparkles')
16
- GLOBE = find('globe_with_meridians')
17
- WASTEBASKET = find('wastebasket')
18
- REWIND = find('rewind')
19
- DIAMOND = find('diamond_shape_with_a_dot_inside')
20
- GEAR = find('gear')
21
- MAG = find('mag')
22
- HAMMER = find('hammer_and_wrench')
23
- PACKAGE = find('package')
24
- CLIPBOARD = find('clipboard')
25
- CHECK = find('white_check_mark')
26
- CROSS = find('x')
27
- WRITING = find('writing_hand')
28
- ROBOT = find('robot')
29
- FOLDER = find('file_folder')
30
- SQUARE = find('white_large_square')
31
- ARROWS = find('arrows_counterclockwise')
11
+ # brutal mate...
12
+
13
+ EYES = 💩 'eyes'
14
+ PENCIL = 💩 'pencil2'
15
+ PAGE = 💩 'page_facing_up'
16
+ COMPUTER = 💩 'computer'
17
+ SPARKLES = 💩 'sparkles'
18
+ GLOBE = 💩 'globe_with_meridians'
19
+ WASTEBASKET = 💩 'wastebasket'
20
+ REWIND = 💩 'rewind'
21
+ DIAMOND = 💩 'diamond_shape_with_a_dot_inside'
22
+ GEAR = 💩 'gear'
23
+ MAG = 💩 'mag'
24
+ HAMMER = 💩 'hammer_and_wrench'
25
+ PACKAGE = 💩 'package'
26
+ CLIPBOARD = 💩 'clipboard'
27
+ CHECK = 💩 'white_check_mark'
28
+ CROSS = 💩 'x'
29
+ WRITING = 💩 'writing_hand'
30
+ ROBOT = 💩 'robot'
31
+ FOLDER = 💩 'file_folder'
32
+ SQUARE = 💩 'white_large_square'
33
+ ARROWS = 💩 'arrows_counterclockwise'
34
+ SMOKE = 💩 'dash'
32
35
  end
33
36
  end
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "io/console"
5
+ require "json"
6
+ require "pp"
7
+ require "brute_cli/styles"
8
+ require "brute_cli/phase"
9
+ require "brute_cli/tool_output"
10
+
11
+ module BruteCLI
12
+ # Execution encapsulates running a single prompt (or repeated prompts) against
13
+ # an agent, streaming output to the terminal. It owns the agent lifecycle,
14
+ # streaming callbacks, spinner, and stats rendering.
15
+ #
16
+ # Use directly for non-interactive (pipe / single-prompt) mode:
17
+ #
18
+ # BruteCLI::Execution.new(options).run(prompt)
19
+ #
20
+ # Or let BruteCLI::REPL wrap it for interactive use.
21
+ class Execution
22
+ AGENTS = %w[build plan bash ruby python nix].freeze
23
+
24
+ SAVE_CURSOR = "\e7"
25
+ RESTORE_CURSOR = "\e8"
26
+ CLEAR_TO_END = "\e[J"
27
+
28
+ # Shell-mode agents: agent name -> shell interpreter (model name).
29
+ # These agents use the Shell provider instead of the current LLM provider.
30
+ SHELL_AGENTS = {
31
+ "bash" => "bash",
32
+ "ruby" => "ruby",
33
+ "python" => "python",
34
+ "nix" => "nix",
35
+ }.freeze
36
+
37
+ attr_reader :current_agent, :provider_name, :model_name
38
+ attr_accessor :agent
39
+ attr_writer :selected_model, :current_agent
40
+
41
+ def initialize(options = {})
42
+ @options = options
43
+ @current_agent = AGENTS.first
44
+ @agent = nil
45
+ @session = nil
46
+ @selected_model = nil # user-chosen model override (nil = provider default)
47
+ @width = TTY::Screen.width
48
+ @streamer = StreamFormatter.new(width: @width)
49
+ spinner_class = options[:spinner] || BruteCLI.config.spinner
50
+ @spinner = spinner_class.new
51
+ @last_output = nil # :separator, :content, or :tool — used to deduplicate separators
52
+ @current_phase = nil
53
+ end
54
+
55
+ # Run a single prompt against the agent. This is the primary public API.
56
+ def run(prompt)
57
+ ensure_agent!
58
+ execute(prompt)
59
+ end
60
+
61
+ def execute(prompt)
62
+ @current_phase = nil
63
+ @streamer.reset
64
+ @last_output = nil
65
+
66
+ start_spinner
67
+
68
+ begin
69
+ @agent.run(prompt)
70
+ rescue Interrupt
71
+ stop_spinner
72
+ flush_content
73
+ puts "Aborted.".colorize(DIM)
74
+ print_stats_bar
75
+ return
76
+ rescue => e
77
+ stop_spinner
78
+ flush_content
79
+ print_error(e)
80
+ print_stats_bar
81
+ return
82
+ end
83
+
84
+ stop_spinner
85
+ flush_content
86
+ print_stats_bar
87
+ end
88
+
89
+
90
+ # ── Provider ──
91
+
92
+ def resolve_provider_info
93
+ if (shell_model = SHELL_AGENTS[@current_agent])
94
+ @provider_name = "shell"
95
+ @model_name = shell_model
96
+ else
97
+ provider = Brute.provider rescue nil
98
+ @provider_name = provider&.name&.to_s
99
+ @model_name = @selected_model || provider&.default_model&.to_s
100
+ end
101
+ end
102
+
103
+ def model_short
104
+ @model_name&.sub(/^claude-/, "")&.sub(/-\d{8}$/, "") || @model_name
105
+ end
106
+
107
+ # ── Agent ──
108
+
109
+ def ensure_session!
110
+ @session ||= Brute::Session.new(id: @options[:session_id])
111
+ end
112
+
113
+ def ensure_agent!
114
+ return if @agent
115
+
116
+ ensure_session!
117
+
118
+ @agent = Brute.agent(
119
+ cwd: @options[:cwd] || Dir.pwd,
120
+ model: @selected_model,
121
+ agent_name: @current_agent,
122
+ session: @session,
123
+ logger: Logger.new(File::NULL),
124
+ on_content: method(:on_content),
125
+ on_reasoning: method(:on_reasoning),
126
+ on_tool_call_start: method(:on_tool_call_start),
127
+ on_tool_result: method(:on_tool_result),
128
+ # on_question: disabled until bubbletea terminal integration is fixed
129
+ )
130
+ @session.restore(@agent.context) if @options[:session_id]
131
+ end
132
+
133
+ def detect_width
134
+ TTY::Screen.width
135
+ end
136
+
137
+ private
138
+
139
+ # ── Spinner ──
140
+
141
+ def start_spinner
142
+ stop_spinner
143
+
144
+ unless @last_output == :separator
145
+ puts BufferOutput::Separator.new(width: @width)
146
+ @last_output = :separator
147
+ end
148
+
149
+ @spinner.start
150
+ end
151
+
152
+ def stop_spinner
153
+ if @spinner.spinning?
154
+ @spinner.stop
155
+ end
156
+ end
157
+
158
+ # ── Callbacks ──
159
+ #
160
+ # All callbacks fire sequentially on the same thread — no
161
+ # synchronization needed. The orchestrator guarantees:
162
+ #
163
+ # on_content* → on_tool_call_start → on_tool_result* → (repeat)
164
+ #
165
+
166
+ def on_content(text)
167
+ stop_spinner
168
+ unless @current_phase.is_a?(Phase::ContentPhase)
169
+ puts BufferOutput::Separator.new(width: @width) unless @last_output == :separator
170
+ @current_phase = Phase::ContentPhase.new(@streamer)
171
+ end
172
+ @current_phase.append(text)
173
+ @last_output = :content
174
+ end
175
+
176
+ def on_reasoning(_text); end
177
+
178
+ # Receives the full batch of tool calls for this LLM turn.
179
+ # Renders all tool call headers upfront.
180
+ def on_tool_call_start(calls)
181
+ stop_spinner
182
+ flush_content
183
+
184
+ @current_phase = Phase::ToolPhase.new(calls)
185
+
186
+ puts BufferOutput::Separator.new(width: @width) unless @last_output == :separator
187
+ print SAVE_CURSOR
188
+ render_tool_phase
189
+ @last_output = :tool
190
+
191
+ start_spinner
192
+ end
193
+
194
+ # Fires once per tool as each completes.
195
+ # Re-renders the entire tool phase block.
196
+ def on_tool_result(name, result)
197
+ stop_spinner
198
+
199
+ if @current_phase.is_a?(Phase::ToolPhase)
200
+ @current_phase.resolve(name, result)
201
+
202
+ print RESTORE_CURSOR
203
+ print CLEAR_TO_END
204
+ render_tool_phase
205
+ @last_output = :tool
206
+ start_spinner
207
+ end
208
+ end
209
+
210
+ # TODO: Interactive question forms are disabled while the bubbletea
211
+ # terminal integration is being worked on. For now, auto-select the
212
+ # first option for each question so the agent can continue.
213
+ def on_question(questions, reply_queue)
214
+ answers = questions.map do |q|
215
+ q = q.respond_to?(:transform_keys) ? q.transform_keys(&:to_s) : q
216
+ options = (q["options"] || []).map { |o| o.respond_to?(:transform_keys) ? o.transform_keys(&:to_s) : o }
217
+ first = options.first
218
+ first ? [first["label"].to_s] : []
219
+ end
220
+
221
+ reply_queue.push(answers)
222
+ end
223
+
224
+ # ── Output ──
225
+
226
+ def flush_content
227
+ if @current_phase.is_a?(Phase::ContentPhase)
228
+ @current_phase.finish
229
+ @last_output = :content unless @current_phase.empty?
230
+ end
231
+ end
232
+
233
+ # Render every tool call in the current ToolPhase.
234
+ # Resolved calls show header + body + error; pending calls show header only.
235
+ def render_tool_phase
236
+ @current_phase.tool_calls.each do |call|
237
+ puts ToolOutput.for(call, width: @width)
238
+ end
239
+ end
240
+
241
+ def render_markdown(text)
242
+ BruteCLI::Bat.markdown_mode(text.strip, width: @width)
243
+ end
244
+
245
+ # ── Stats ──
246
+
247
+ def print_stats_bar
248
+ metadata = @agent&.env&.dig(:metadata) || {}
249
+ puts BufferOutput::Separator.new(width: @width) unless @last_output == :separator
250
+ puts BufferOutput::StatsBar.new(metadata, width: @width)
251
+ puts BufferOutput::Separator.new(width: @width, thick: true)
252
+ end
253
+
254
+ # ── Error ──
255
+
256
+ def print_error(err)
257
+ puts BufferOutput::Error.new(err)
258
+ end
259
+ end
260
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BruteCLI
4
+ module Phase
5
+ # Accumulates streamed text tokens. Owns a StreamFormatter for
6
+ # incremental terminal output.
7
+ class ContentPhase
8
+ attr_reader :buf
9
+
10
+ def initialize(streamer)
11
+ @streamer = streamer
12
+ @buf = +""
13
+ end
14
+
15
+ def append(text)
16
+ @buf << text
17
+ @streamer << text
18
+ end
19
+
20
+ def finish
21
+ return if @buf.strip.empty?
22
+ @streamer.flush
23
+ @buf = +""
24
+ end
25
+
26
+ def empty? = @buf.strip.empty?
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BruteCLI
4
+ module Phase
5
+ # Pure data object representing a single tool invocation.
6
+ # Holds name, arguments, and (once resolved) the result.
7
+ class ToolCall
8
+ attr_reader :name, :arguments
9
+ attr_accessor :result
10
+
11
+ def initialize(name:, arguments:)
12
+ @name = name
13
+ @arguments = arguments || {}
14
+ @result = nil
15
+ end
16
+
17
+ def resolved? = !@result.nil?
18
+ def pending? = @result.nil?
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BruteCLI
4
+ module Phase
5
+ # Holds a batch of ToolCall objects for a single LLM turn.
6
+ # The orchestrator fires on_tool_call_start with the full batch,
7
+ # then on_tool_result per-tool as each completes.
8
+ class ToolPhase
9
+ attr_reader :tool_calls
10
+
11
+ def initialize(calls)
12
+ @tool_calls = calls.map do |c|
13
+ ToolCall.new(name: c[:name], arguments: c[:arguments])
14
+ end
15
+ end
16
+
17
+ # Resolve the first unresolved call matching +name+.
18
+ # Returns the ToolCall, or nil if no match.
19
+ def resolve(name, result)
20
+ call = @tool_calls.find { |c| c.name == name && c.pending? }
21
+ return unless call
22
+ call.result = result
23
+ call
24
+ end
25
+
26
+ def finished? = @tool_calls.all?(&:resolved?)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "brute_cli/phase/tool_call"
4
+ require "brute_cli/phase/tool_phase"
5
+ require "brute_cli/phase/content_phase"
6
+
7
+ module BruteCLI
8
+ module Phase
9
+ end
10
+ end