brute 0.4.1 → 1.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/agent.rb +14 -0
  3. data/lib/brute/diff.rb +18 -28
  4. data/lib/brute/loop/agent_stream.rb +118 -0
  5. data/lib/brute/loop/agent_turn.rb +520 -0
  6. data/lib/brute/{compactor.rb → loop/compactor.rb} +2 -0
  7. data/lib/brute/{doom_loop.rb → loop/doom_loop.rb} +2 -0
  8. data/lib/brute/loop/step.rb +332 -0
  9. data/lib/brute/loop/tool_call_step.rb +90 -0
  10. data/lib/brute/middleware/compaction_check.rb +60 -146
  11. data/lib/brute/middleware/doom_loop_detection.rb +95 -92
  12. data/lib/brute/middleware/llm_call.rb +78 -80
  13. data/lib/brute/middleware/message_tracking.rb +115 -162
  14. data/lib/brute/middleware/otel/span.rb +25 -106
  15. data/lib/brute/middleware/otel/token_usage.rb +29 -84
  16. data/lib/brute/middleware/otel/tool_calls.rb +23 -107
  17. data/lib/brute/middleware/otel/tool_results.rb +22 -86
  18. data/lib/brute/middleware/reasoning_normalizer.rb +78 -103
  19. data/lib/brute/middleware/retry.rb +95 -76
  20. data/lib/brute/middleware/session_persistence.rb +38 -37
  21. data/lib/brute/middleware/token_tracking.rb +64 -63
  22. data/lib/brute/middleware/tool_error_tracking.rb +108 -82
  23. data/lib/brute/middleware/tool_use_guard.rb +57 -90
  24. data/lib/brute/middleware/tracing.rb +53 -63
  25. data/lib/brute/middleware.rb +18 -0
  26. data/lib/brute/orchestrator/turn.rb +105 -0
  27. data/lib/brute/pipeline.rb +77 -133
  28. data/lib/brute/prompts/build_switch.rb +21 -25
  29. data/lib/brute/prompts/environment.rb +31 -35
  30. data/lib/brute/prompts/identity.rb +22 -29
  31. data/lib/brute/prompts/instructions.rb +15 -18
  32. data/lib/brute/prompts/max_steps.rb +18 -25
  33. data/lib/brute/prompts/plan_reminder.rb +18 -26
  34. data/lib/brute/prompts/skills.rb +8 -30
  35. data/lib/brute/prompts.rb +28 -0
  36. data/lib/brute/providers/ollama.rb +135 -0
  37. data/lib/brute/providers/shell.rb +2 -2
  38. data/lib/brute/providers/shell_response.rb +2 -2
  39. data/lib/brute/providers.rb +62 -0
  40. data/lib/brute/queue/base_queue.rb +222 -0
  41. data/lib/brute/{file_mutation_queue.rb → queue/file_mutation_queue.rb} +28 -26
  42. data/lib/brute/queue/parallel_queue.rb +66 -0
  43. data/lib/brute/queue/sequential_queue.rb +63 -0
  44. data/lib/brute/store/message_store.rb +362 -0
  45. data/lib/brute/store/session.rb +106 -0
  46. data/lib/brute/{snapshot_store.rb → store/snapshot_store.rb} +2 -0
  47. data/lib/brute/{todo_store.rb → store/todo_store.rb} +2 -0
  48. data/lib/brute/system_prompt.rb +81 -194
  49. data/lib/brute/tools/delegate.rb +46 -116
  50. data/lib/brute/tools/fs_patch.rb +36 -37
  51. data/lib/brute/tools/fs_remove.rb +2 -2
  52. data/lib/brute/tools/fs_undo.rb +2 -2
  53. data/lib/brute/tools/fs_write.rb +29 -41
  54. data/lib/brute/tools/todo_read.rb +1 -1
  55. data/lib/brute/tools/todo_write.rb +1 -1
  56. data/lib/brute/tools.rb +31 -0
  57. data/lib/brute/version.rb +1 -1
  58. data/lib/brute.rb +40 -204
  59. metadata +31 -20
  60. data/lib/brute/agent_stream.rb +0 -181
  61. data/lib/brute/hooks.rb +0 -84
  62. data/lib/brute/message_store.rb +0 -463
  63. data/lib/brute/orchestrator.rb +0 -550
  64. data/lib/brute/session.rb +0 -161
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+
6
+ module Brute
7
+ module Queue
8
+ # A queue that processes steps concurrently up to a limit.
9
+ # Workers match concurrency slots.
10
+ class ParallelQueue < BaseQueue
11
+ def initialize(concurrency: 4, parent: Async::Task.current)
12
+ super(concurrency: concurrency, worker_count: concurrency, parent: parent)
13
+ end
14
+ end
15
+ end
16
+ end
17
+
18
+ test do
19
+ it "runs steps concurrently" do
20
+ Sync do
21
+ concurrent = 0
22
+ max_concurrent = 0
23
+
24
+ steps = 4.times.map do
25
+ Class.new(Brute::Loop::Step) do
26
+ define_method(:perform) do |task|
27
+ concurrent += 1
28
+ max_concurrent = [max_concurrent, concurrent].max
29
+ sleep 0.05
30
+ concurrent -= 1
31
+ end
32
+ end.new
33
+ end
34
+
35
+ q = Brute::Queue::ParallelQueue.new(concurrency: 4)
36
+ steps.each { |s| q << s }
37
+ q.start
38
+ q.drain
39
+ (max_concurrent > 1).should.be.true
40
+ end
41
+ end
42
+
43
+ it "limits concurrency to the specified amount" do
44
+ Sync do
45
+ concurrent = 0
46
+ max_concurrent = 0
47
+
48
+ steps = 8.times.map do
49
+ Class.new(Brute::Loop::Step) do
50
+ define_method(:perform) do |task|
51
+ concurrent += 1
52
+ max_concurrent = [max_concurrent, concurrent].max
53
+ sleep 0.05
54
+ concurrent -= 1
55
+ end
56
+ end.new
57
+ end
58
+
59
+ q = Brute::Queue::ParallelQueue.new(concurrency: 2)
60
+ steps.each { |s| q << s }
61
+ q.start
62
+ q.drain
63
+ (max_concurrent <= 2).should.be.true
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+
6
+ module Brute
7
+ module Queue
8
+ # A queue that processes steps one at a time, in order.
9
+ # One worker, one concurrency slot.
10
+ class SequentialQueue < BaseQueue
11
+ def initialize(parent: Async::Task.current)
12
+ super(concurrency: 1, worker_count: 1, parent: parent)
13
+ end
14
+ end
15
+ end
16
+ end
17
+
18
+ test do
19
+ class OrderStep < Brute::Loop::Step
20
+ def perform(task)
21
+ @attributes[:log] << @attributes[:value]
22
+ sleep(@attributes[:delay]) if @attributes[:delay]
23
+ @attributes[:value]
24
+ end
25
+ end
26
+
27
+ it "processes steps in order" do
28
+ Sync do
29
+ log = []
30
+ q = Brute::Queue::SequentialQueue.new
31
+ q << OrderStep.new(log: log, value: "a", delay: 0.01)
32
+ q << OrderStep.new(log: log, value: "b", delay: 0.01)
33
+ q << OrderStep.new(log: log, value: "c", delay: 0.01)
34
+ q.start
35
+ q.drain
36
+ log.should == ["a", "b", "c"]
37
+ end
38
+ end
39
+
40
+ it "runs only one step at a time" do
41
+ Sync do
42
+ concurrent = 0
43
+ max_concurrent = 0
44
+
45
+ steps = 3.times.map do
46
+ Class.new(Brute::Loop::Step) do
47
+ define_method(:perform) do |task|
48
+ concurrent += 1
49
+ max_concurrent = [max_concurrent, concurrent].max
50
+ sleep 0.02
51
+ concurrent -= 1
52
+ end
53
+ end.new
54
+ end
55
+
56
+ q = Brute::Queue::SequentialQueue.new
57
+ steps.each { |s| q << s }
58
+ q.start
59
+ q.drain
60
+ max_concurrent.should == 1
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,362 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "json"
6
+ require "fileutils"
7
+ require "securerandom"
8
+
9
+ module Brute
10
+ module Store
11
+ # Stores session messages as individual JSON files in the OpenCode
12
+ # {info, parts} format. Each session gets a directory; each message
13
+ # is a numbered JSON file inside it.
14
+ #
15
+ # Storage layout:
16
+ #
17
+ # ~/.brute/sessions/{session-id}/
18
+ # session.meta.json
19
+ # msg_0001.json
20
+ # msg_0002.json
21
+ # ...
22
+ #
23
+ # Message format matches OpenCode's MessageV2.WithParts:
24
+ #
25
+ # { info: { id:, sessionID:, role:, time:, ... },
26
+ # parts: [{ id:, type:, ... }, ...] }
27
+ #
28
+ class MessageStore
29
+ attr_reader :session_id, :dir
30
+
31
+ def initialize(session_id:, dir: nil)
32
+ @session_id = session_id
33
+ @dir = dir || File.join(Dir.home, ".brute", "sessions", session_id)
34
+ @messages = {} # id => { info:, parts: }
35
+ @seq = 0
36
+ @part_seq = 0
37
+ @mutex = Mutex.new
38
+ load_existing
39
+ end
40
+
41
+ def append_user(text:, message_id: nil)
42
+ id = message_id || next_message_id
43
+ msg = {
44
+ info: {
45
+ id: id,
46
+ sessionID: @session_id,
47
+ role: "user",
48
+ time: { created: now_ms },
49
+ },
50
+ parts: [
51
+ { id: next_part_id, sessionID: @session_id, messageID: id,
52
+ type: "text", text: text },
53
+ ],
54
+ }
55
+ save_message(id, msg)
56
+ id
57
+ end
58
+
59
+ def append_assistant(message_id: nil, parent_id: nil, model_id: nil, provider_id: nil)
60
+ id = message_id || next_message_id
61
+ msg = {
62
+ info: {
63
+ id: id,
64
+ sessionID: @session_id,
65
+ role: "assistant",
66
+ parentID: parent_id,
67
+ time: { created: now_ms },
68
+ modelID: model_id,
69
+ providerID: provider_id,
70
+ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
71
+ cost: 0.0,
72
+ },
73
+ parts: [],
74
+ }
75
+ save_message(id, msg)
76
+ id
77
+ end
78
+
79
+ def add_text_part(message_id:, text:)
80
+ @mutex.synchronize do
81
+ msg = @messages[message_id]
82
+ return unless msg
83
+
84
+ part = { id: next_part_id, sessionID: @session_id, messageID: message_id,
85
+ type: "text", text: text }
86
+ msg[:parts] << part
87
+ persist(message_id)
88
+ part[:id]
89
+ end
90
+ end
91
+
92
+ def add_tool_part(message_id:, tool:, call_id:, input:)
93
+ @mutex.synchronize do
94
+ msg = @messages[message_id]
95
+ return unless msg
96
+
97
+ part = {
98
+ id: next_part_id, sessionID: @session_id, messageID: message_id,
99
+ type: "tool", callID: call_id, tool: tool,
100
+ state: {
101
+ status: "running",
102
+ input: input,
103
+ time: { start: now_ms },
104
+ },
105
+ }
106
+ msg[:parts] << part
107
+ persist(message_id)
108
+ part[:id]
109
+ end
110
+ end
111
+
112
+ def complete_tool_part(message_id:, call_id:, output:)
113
+ @mutex.synchronize do
114
+ msg = @messages[message_id]
115
+ return unless msg
116
+
117
+ part = msg[:parts].find { |p| p[:type] == "tool" && p[:callID] == call_id }
118
+ return unless part
119
+
120
+ part[:state][:status] = "completed"
121
+ part[:state][:output] = output
122
+ part[:state][:time][:end] = now_ms
123
+ persist(message_id)
124
+ end
125
+ end
126
+
127
+ def error_tool_part(message_id:, call_id:, error:)
128
+ @mutex.synchronize do
129
+ msg = @messages[message_id]
130
+ return unless msg
131
+
132
+ part = msg[:parts].find { |p| p[:type] == "tool" && p[:callID] == call_id }
133
+ return unless part
134
+
135
+ part[:state][:status] = "error"
136
+ part[:state][:error] = error.to_s
137
+ part[:state][:time][:end] = now_ms
138
+ persist(message_id)
139
+ end
140
+ end
141
+
142
+ def add_step_finish(message_id:, tokens: nil)
143
+ @mutex.synchronize do
144
+ msg = @messages[message_id]
145
+ return unless msg
146
+
147
+ part = {
148
+ id: next_part_id, sessionID: @session_id, messageID: message_id,
149
+ type: "step-finish",
150
+ reason: "stop",
151
+ tokens: tokens || { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
152
+ }
153
+ msg[:parts] << part
154
+ persist(message_id)
155
+ end
156
+ end
157
+
158
+ # Finalize an assistant message with token counts and completion time.
159
+ def complete_assistant(message_id:, tokens: nil)
160
+ @mutex.synchronize do
161
+ msg = @messages[message_id]
162
+ return unless msg
163
+
164
+ msg[:info][:time][:completed] = now_ms
165
+ if tokens
166
+ msg[:info][:tokens] = {
167
+ input: tokens[:input] || tokens[:total_input] || 0,
168
+ output: tokens[:output] || tokens[:total_output] || 0,
169
+ reasoning: tokens[:reasoning] || tokens[:total_reasoning] || 0,
170
+ cache: tokens[:cache] || { read: 0, write: 0 },
171
+ }
172
+ end
173
+ persist(message_id)
174
+ end
175
+ end
176
+
177
+ def messages
178
+ @mutex.synchronize { @messages.values }
179
+ end
180
+
181
+ def message(id)
182
+ @mutex.synchronize { @messages[id] }
183
+ end
184
+
185
+ def count
186
+ @mutex.synchronize { @messages.size }
187
+ end
188
+
189
+ private
190
+
191
+ def next_message_id
192
+ @seq += 1
193
+ format("msg_%04d", @seq)
194
+ end
195
+
196
+ def next_part_id
197
+ @part_seq += 1
198
+ format("prt_%04d", @part_seq)
199
+ end
200
+
201
+ def now_ms
202
+ (Time.now.to_f * 1000).to_i
203
+ end
204
+
205
+ def save_message(id, msg)
206
+ @mutex.synchronize do
207
+ @messages[id] = msg
208
+ persist(id)
209
+ end
210
+ end
211
+
212
+ def persist(id)
213
+ FileUtils.mkdir_p(@dir)
214
+ msg = @messages[id]
215
+ return unless msg
216
+
217
+ path = File.join(@dir, "#{id}.json")
218
+ File.write(path, JSON.pretty_generate(msg))
219
+ end
220
+
221
+ def load_existing
222
+ return unless File.directory?(@dir)
223
+
224
+ Dir.glob(File.join(@dir, "msg_*.json")).sort.each do |path|
225
+ data = JSON.parse(File.read(path), symbolize_names: true)
226
+ id = data.dig(:info, :id)
227
+ next unless id
228
+
229
+ @messages[id] = data
230
+
231
+ # Track sequence numbers so new IDs don't collide
232
+ if (m = id.match(/\Amsg_(\d+)\z/))
233
+ n = m[1].to_i
234
+ @seq = n if n > @seq
235
+ end
236
+
237
+ # Track part sequences too
238
+ (data[:parts] || []).each do |part|
239
+ pid = part[:id]
240
+ if pid.is_a?(String) && (m = pid.match(/\Aprt_(\d+)\z/))
241
+ n = m[1].to_i
242
+ @part_seq = n if n > @part_seq
243
+ end
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end
249
+ end
250
+
251
+ test do
252
+ require "tmpdir"
253
+
254
+ def with_store
255
+ dir = Dir.mktmpdir("brute_test_")
256
+ store = Brute::Store::MessageStore.new(session_id: "test-session-123", dir: dir)
257
+ yield store, dir
258
+ ensure
259
+ FileUtils.rm_rf(dir)
260
+ end
261
+
262
+ it "creates a user message with text part" do
263
+ with_store do |store, _|
264
+ id = store.append_user(text: "Hello")
265
+ store.message(id)[:info][:role].should == "user"
266
+ end
267
+ end
268
+
269
+ it "stores user message text" do
270
+ with_store do |store, _|
271
+ id = store.append_user(text: "Hello")
272
+ store.message(id)[:parts][0][:text].should == "Hello"
273
+ end
274
+ end
275
+
276
+ it "generates sequential message IDs" do
277
+ with_store do |store, _|
278
+ id1 = store.append_user(text: "First")
279
+ id2 = store.append_user(text: "Second")
280
+ id1.should == "msg_0001"
281
+ end
282
+ end
283
+
284
+ it "creates an assistant message" do
285
+ with_store do |store, _|
286
+ uid = store.append_user(text: "Hi")
287
+ aid = store.append_assistant(parent_id: uid, model_id: "claude", provider_id: "anthropic")
288
+ store.message(aid)[:info][:role].should == "assistant"
289
+ end
290
+ end
291
+
292
+ it "appends a text part to an existing message" do
293
+ with_store do |store, _|
294
+ aid = store.append_assistant
295
+ store.add_text_part(message_id: aid, text: "Here is my response")
296
+ store.message(aid)[:parts][0][:text].should == "Here is my response"
297
+ end
298
+ end
299
+
300
+ it "tracks tool lifecycle running to completed" do
301
+ with_store do |store, _|
302
+ aid = store.append_assistant
303
+ store.add_tool_part(message_id: aid, tool: "read", call_id: "call_001", input: {})
304
+ store.message(aid)[:parts].find { |p| p[:type] == "tool" }[:state][:status].should == "running"
305
+ store.complete_tool_part(message_id: aid, call_id: "call_001", output: "done")
306
+ store.message(aid)[:parts].find { |p| p[:type] == "tool" }[:state][:status].should == "completed"
307
+ end
308
+ end
309
+
310
+ it "tracks tool lifecycle running to error" do
311
+ with_store do |store, _|
312
+ aid = store.append_assistant
313
+ store.add_tool_part(message_id: aid, tool: "shell", call_id: "call_002", input: {})
314
+ store.error_tool_part(message_id: aid, call_id: "call_002", error: "denied")
315
+ store.message(aid)[:parts].find { |p| p[:type] == "tool" }[:state][:status].should == "error"
316
+ end
317
+ end
318
+
319
+ it "sets token counts on complete_assistant" do
320
+ with_store do |store, _|
321
+ aid = store.append_assistant
322
+ store.complete_assistant(message_id: aid, tokens: { input: 100, output: 50, reasoning: 10, cache: { read: 0, write: 0 } })
323
+ store.message(aid)[:info][:tokens][:input].should == 100
324
+ end
325
+ end
326
+
327
+ it "returns all messages in order" do
328
+ with_store do |store, _|
329
+ store.append_user(text: "Q1")
330
+ store.append_assistant
331
+ store.append_user(text: "Q2")
332
+ store.messages.size.should == 3
333
+ end
334
+ end
335
+
336
+ it "returns count of stored messages" do
337
+ with_store do |store, _|
338
+ store.count.should == 0
339
+ store.append_user(text: "Q1")
340
+ store.count.should == 1
341
+ end
342
+ end
343
+
344
+ it "restores messages from disk" do
345
+ with_store do |store, dir|
346
+ store.append_user(text: "Persisted Q")
347
+ aid = store.append_assistant(model_id: "claude")
348
+ store.add_text_part(message_id: aid, text: "Persisted A")
349
+ store2 = Brute::Store::MessageStore.new(session_id: "test-session-123", dir: dir)
350
+ store2.count.should == 2
351
+ end
352
+ end
353
+
354
+ it "continues sequence numbering from loaded messages" do
355
+ with_store do |store, dir|
356
+ store.append_user(text: "Q1")
357
+ store.append_user(text: "Q2")
358
+ store2 = Brute::Store::MessageStore.new(session_id: "test-session-123", dir: dir)
359
+ store2.append_user(text: "Q3").should == "msg_0003"
360
+ end
361
+ end
362
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "securerandom"
6
+
7
+ module Brute
8
+ module Store
9
+ # Manages session persistence. Each session is a conversation that can be
10
+ # saved to disk and resumed later.
11
+ #
12
+ # Storage layout (per-session directory):
13
+ #
14
+ # ~/.brute/sessions/{session-id}/
15
+ # session.meta.json # session metadata
16
+ # context.json # serialized conversation history
17
+ # msg_0001.json # structured messages (OpenCode format)
18
+ # msg_0002.json
19
+ # ...
20
+ #
21
+ class Session
22
+ attr_reader :id, :title, :path
23
+
24
+ def initialize(id: nil, dir: nil)
25
+ @id = id || SecureRandom.uuid
26
+ @base_dir = dir || File.join(Dir.home, ".brute", "sessions")
27
+ @session_dir = File.join(@base_dir, @id)
28
+ @path = File.join(@session_dir, "context.json")
29
+ @title = nil
30
+ @metadata = {}
31
+ FileUtils.mkdir_p(@session_dir)
32
+ end
33
+
34
+ def message_store
35
+ @message_store ||= MessageStore.new(session_id: @id, dir: @session_dir)
36
+ end
37
+
38
+ # Serialize an array of LLM::Message objects to disk as JSON.
39
+ def save_messages(messages, title: nil, metadata: {})
40
+ @title = title if title
41
+ @metadata.merge!(metadata)
42
+
43
+ data = {
44
+ schema_version: 1,
45
+ messages: messages.map { |m| { role: m.role.to_s, content: m.content.to_s } },
46
+ }
47
+ FileUtils.mkdir_p(File.dirname(@path))
48
+ File.write(@path, JSON.pretty_generate(data))
49
+
50
+ save_meta
51
+ end
52
+
53
+ # List all saved sessions, newest first.
54
+ def self.list(dir: nil)
55
+ dir ||= File.join(Dir.home, ".brute", "sessions")
56
+
57
+ if File.directory?(dir)
58
+ sessions = Dir.glob(File.join(dir, "*", "session.meta.json")).filter_map do |meta_path|
59
+ data = JSON.parse(File.read(meta_path), symbolize_names: true)
60
+ id = data[:id]
61
+ next unless id
62
+ {
63
+ id: id,
64
+ title: data[:title],
65
+ saved_at: data[:saved_at],
66
+ path: File.join(File.dirname(meta_path), "context.json"),
67
+ }
68
+ end
69
+
70
+ sessions.sort_by { |s| s[:saved_at] || "" }.reverse
71
+ else
72
+ []
73
+ end
74
+ end
75
+
76
+ def delete
77
+ FileUtils.rm_rf(@session_dir) if File.directory?(@session_dir)
78
+ end
79
+
80
+ private
81
+
82
+ def meta_path
83
+ File.join(@session_dir, "session.meta.json")
84
+ end
85
+
86
+ def save_meta
87
+ data = {
88
+ id: @id,
89
+ title: @title,
90
+ saved_at: Time.now.iso8601,
91
+ metadata: @metadata,
92
+ }
93
+ FileUtils.mkdir_p(@session_dir)
94
+ File.write(meta_path, JSON.pretty_generate(data))
95
+ end
96
+
97
+ def load_meta
98
+ return unless File.exist?(meta_path)
99
+
100
+ data = JSON.parse(File.read(meta_path), symbolize_names: true)
101
+ @title = data[:title]
102
+ @metadata = data[:metadata] || {}
103
+ end
104
+ end
105
+ end
106
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Brute
4
+ module Store
4
5
  # Copy-on-write snapshot storage for file undo support.
5
6
  # Saves the previous content of a file before mutation so it can be restored.
6
7
  # Each file maintains a stack of snapshots, supporting multiple undo levels.
@@ -46,4 +47,5 @@ module Brute
46
47
  end
47
48
  end
48
49
  end
50
+ end
49
51
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Brute
4
+ module Store
4
5
  # In-memory todo list storage. The agent uses this to track multi-step tasks.
5
6
  # The list is replaced wholesale on each todo_write call.
6
7
  module TodoStore
@@ -24,4 +25,5 @@ module Brute
24
25
  end
25
26
  end
26
27
  end
28
+ end
27
29
  end