brute 0.4.0 → 0.4.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/agent_stream.rb +118 -0
  3. data/lib/brute/diff.rb +34 -0
  4. data/lib/brute/message_store.rb +194 -0
  5. data/lib/brute/middleware/compaction_check.rb +133 -0
  6. data/lib/brute/middleware/doom_loop_detection.rb +100 -0
  7. data/lib/brute/middleware/llm_call.rb +89 -0
  8. data/lib/brute/middleware/message_tracking.rb +177 -0
  9. data/lib/brute/middleware/otel/span.rb +111 -0
  10. data/lib/brute/middleware/otel/token_usage.rb +93 -0
  11. data/lib/brute/middleware/otel/tool_calls.rb +113 -0
  12. data/lib/brute/middleware/otel/tool_results.rb +92 -0
  13. data/lib/brute/middleware/otel.rb +5 -0
  14. data/lib/brute/middleware/reasoning_normalizer.rb +119 -0
  15. data/lib/brute/middleware/retry.rb +93 -0
  16. data/lib/brute/middleware/session_persistence.rb +42 -0
  17. data/lib/brute/middleware/token_tracking.rb +77 -0
  18. data/lib/brute/middleware/tool_error_tracking.rb +101 -0
  19. data/lib/brute/middleware/tool_use_guard.rb +69 -0
  20. data/lib/brute/middleware/tracing.rb +71 -0
  21. data/lib/brute/orchestrator.rb +160 -1
  22. data/lib/brute/patches/buffer_nil_guard.rb +5 -0
  23. data/lib/brute/pipeline.rb +135 -0
  24. data/lib/brute/prompts/build_switch.rb +33 -0
  25. data/lib/brute/prompts/environment.rb +47 -0
  26. data/lib/brute/prompts/identity.rb +36 -0
  27. data/lib/brute/prompts/instructions.rb +24 -0
  28. data/lib/brute/prompts/max_steps.rb +32 -0
  29. data/lib/brute/prompts/plan_reminder.rb +33 -0
  30. data/lib/brute/prompts/skills.rb +35 -0
  31. data/lib/brute/providers/opencode_go.rb +5 -0
  32. data/lib/brute/providers/opencode_zen.rb +7 -2
  33. data/lib/brute/providers/shell_response.rb +5 -0
  34. data/lib/brute/system_prompt.rb +214 -0
  35. data/lib/brute/tools/delegate.rb +129 -0
  36. data/lib/brute/tools/fs_patch.rb +53 -0
  37. data/lib/brute/tools/fs_read.rb +5 -0
  38. data/lib/brute/tools/fs_remove.rb +5 -0
  39. data/lib/brute/tools/fs_search.rb +5 -0
  40. data/lib/brute/tools/fs_undo.rb +5 -0
  41. data/lib/brute/tools/fs_write.rb +50 -0
  42. data/lib/brute/tools/net_fetch.rb +5 -0
  43. data/lib/brute/tools/question.rb +5 -0
  44. data/lib/brute/tools/shell.rb +5 -0
  45. data/lib/brute/tools/todo_read.rb +5 -0
  46. data/lib/brute/tools/todo_write.rb +5 -0
  47. data/lib/brute/version.rb +1 -1
  48. data/lib/brute.rb +8 -8
  49. metadata +1 -1
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  module Brute
4
9
  module Middleware
5
10
  # Logs timing and token usage for every LLM call, and tracks cumulative
@@ -61,3 +66,69 @@ module Brute
61
66
  end
62
67
  end
63
68
  end
69
+
70
+ if __FILE__ == $0
71
+ require_relative "../../../spec/spec_helper"
72
+
73
+ RSpec.describe Brute::Middleware::Tracing do
74
+ let(:response) { MockResponse.new(content: "traced response") }
75
+ let(:inner_app) { ->(_env) { response } }
76
+ let(:log_output) { StringIO.new }
77
+ let(:logger) { Logger.new(log_output) }
78
+ let(:middleware) { described_class.new(inner_app, logger: logger) }
79
+
80
+ it "passes the response through unchanged" do
81
+ env = build_env(tool_results: nil)
82
+ result = middleware.call(env)
83
+ expect(result).to eq(response)
84
+ end
85
+
86
+ it "populates env[:metadata][:timing] with all required keys" do
87
+ env = build_env(tool_results: nil)
88
+ middleware.call(env)
89
+
90
+ timing = env[:metadata][:timing]
91
+ expect(timing).to include(
92
+ :total_elapsed,
93
+ :total_llm_elapsed,
94
+ :llm_call_count,
95
+ :last_call_elapsed
96
+ )
97
+ expect(timing[:llm_call_count]).to eq(1)
98
+ expect(timing[:last_call_elapsed]).to be >= 0
99
+ expect(timing[:total_llm_elapsed]).to be >= 0
100
+ end
101
+
102
+ it "resets turn timing when tool_results is nil (new turn)" do
103
+ env = build_env(tool_results: nil)
104
+ middleware.call(env)
105
+ first_elapsed = env[:metadata][:timing][:total_llm_elapsed]
106
+
107
+ # Simulate continuation within the same turn (tool_results present)
108
+ env[:tool_results] = [["read", { content: "file data" }]]
109
+ middleware.call(env)
110
+
111
+ expect(env[:metadata][:timing][:llm_call_count]).to eq(2)
112
+ expect(env[:metadata][:timing][:total_llm_elapsed]).to be >= first_elapsed
113
+ end
114
+
115
+ it "accumulates call count across multiple calls" do
116
+ env = build_env(tool_results: nil)
117
+ middleware.call(env)
118
+ env[:tool_results] = [["read", {}]]
119
+ middleware.call(env)
120
+ middleware.call(env)
121
+
122
+ expect(env[:metadata][:timing][:llm_call_count]).to eq(3)
123
+ end
124
+
125
+ it "logs debug and info messages" do
126
+ env = build_env(tool_results: nil)
127
+ middleware.call(env)
128
+
129
+ log_text = log_output.string
130
+ expect(log_text).to include("LLM call #1")
131
+ expect(log_text).to include("LLM response #1")
132
+ end
133
+ end
134
+ end
@@ -111,7 +111,7 @@ module Brute
111
111
  # Returns the final assistant response.
112
112
  def run(user_message)
113
113
  unless @provider
114
- raise "No LLM provider configured. Set LLM_API_KEY and optionally LLM_PROVIDER (default: anthropic)"
114
+ raise "No LLM provider configured. Set LLM_API_KEY and optionally LLM_PROVIDER (default: opencode_zen)"
115
115
  end
116
116
 
117
117
  @request_count = 0
@@ -389,3 +389,162 @@ module Brute
389
389
  end
390
390
  end
391
391
  end
392
+
393
+ if __FILE__ == $0
394
+ require_relative "../../spec/spec_helper"
395
+
396
+ RSpec.describe Brute::Orchestrator, "system prompt" do
397
+ let(:provider) { MockProvider.new }
398
+
399
+ def build_orchestrator(agent_name: nil, cwd: Dir.pwd)
400
+ described_class.new(
401
+ provider: provider,
402
+ model: "test-model",
403
+ tools: [],
404
+ cwd: cwd,
405
+ agent_name: agent_name,
406
+ logger: Logger.new(File::NULL),
407
+ )
408
+ end
409
+
410
+ def system_prompt_for(orchestrator)
411
+ orchestrator.instance_variable_get(:@system_prompt)
412
+ end
413
+
414
+ # ── Build mode (default) ──
415
+
416
+ context "build mode (default, agent_name: nil)" do
417
+ subject(:prompt) { system_prompt_for(build_orchestrator(agent_name: nil)) }
418
+
419
+ it "does NOT contain PlanReminder" do
420
+ expect(prompt).not_to include("Plan Mode - System Reminder")
421
+ expect(prompt).not_to include("READ-ONLY")
422
+ end
423
+
424
+ it "does NOT contain BuildSwitch" do
425
+ expect(prompt).not_to include("operational mode has changed")
426
+ end
427
+
428
+ it "does NOT contain MaxSteps" do
429
+ expect(prompt).not_to include("MAXIMUM STEPS REACHED")
430
+ end
431
+
432
+ it "contains identity section" do
433
+ expect(prompt).to include("Brute")
434
+ end
435
+
436
+ it "contains environment section" do
437
+ expect(prompt).to include("<env>")
438
+ expect(prompt).to include("Working directory:")
439
+ end
440
+ end
441
+
442
+ context "build mode (explicit agent_name: 'build')" do
443
+ subject(:prompt) { system_prompt_for(build_orchestrator(agent_name: "build")) }
444
+
445
+ it "does NOT contain PlanReminder" do
446
+ expect(prompt).not_to include("Plan Mode - System Reminder")
447
+ expect(prompt).not_to include("READ-ONLY")
448
+ end
449
+
450
+ it "contains identity section" do
451
+ expect(prompt).to include("Brute")
452
+ end
453
+ end
454
+
455
+ # ── Plan mode ──
456
+
457
+ context "plan mode (agent_name: 'plan')" do
458
+ subject(:prompt) { system_prompt_for(build_orchestrator(agent_name: "plan")) }
459
+
460
+ it "includes PlanReminder" do
461
+ expect(prompt).to include("Plan Mode - System Reminder")
462
+ expect(prompt).to include("<system-reminder>")
463
+ expect(prompt).to include("READ-ONLY")
464
+ end
465
+
466
+ it "includes the supersede warning" do
467
+ expect(prompt).to include("supersedes any other instructions")
468
+ end
469
+
470
+ it "does NOT include BuildSwitch" do
471
+ expect(prompt).not_to include("operational mode has changed")
472
+ end
473
+
474
+ it "still includes identity section" do
475
+ expect(prompt).to include("Brute")
476
+ end
477
+
478
+ it "still includes environment section" do
479
+ expect(prompt).to include("<env>")
480
+ end
481
+ end
482
+
483
+ # ── Switching from plan to build (simulating mid-session agent recreation) ──
484
+
485
+ context "switching from plan to build (new orchestrator, same session)" do
486
+ let(:session) { Brute::Session.new }
487
+
488
+ it "plan orchestrator has PlanReminder, build orchestrator does not" do
489
+ plan_orch = described_class.new(
490
+ provider: provider,
491
+ model: "test-model",
492
+ tools: [],
493
+ session: session,
494
+ agent_name: "plan",
495
+ logger: Logger.new(File::NULL),
496
+ )
497
+ plan_prompt = system_prompt_for(plan_orch)
498
+ expect(plan_prompt).to include("READ-ONLY")
499
+
500
+ build_orch = described_class.new(
501
+ provider: provider,
502
+ model: "test-model",
503
+ tools: [],
504
+ session: session,
505
+ agent_name: "build",
506
+ logger: Logger.new(File::NULL),
507
+ )
508
+ build_prompt = system_prompt_for(build_orch)
509
+ expect(build_prompt).not_to include("READ-ONLY")
510
+ expect(build_prompt).not_to include("Plan Mode")
511
+ end
512
+
513
+ it "build orchestrator does NOT contain BuildSwitch (agent_switched never set)" do
514
+ build_orch = described_class.new(
515
+ provider: provider,
516
+ model: "test-model",
517
+ tools: [],
518
+ session: session,
519
+ agent_name: "build",
520
+ logger: Logger.new(File::NULL),
521
+ )
522
+ build_prompt = system_prompt_for(build_orch)
523
+ # This documents the current behavior: BuildSwitch is dead code
524
+ expect(build_prompt).not_to include("operational mode has changed from plan to build")
525
+ end
526
+ end
527
+
528
+ # ── Provider-specific stacks ──
529
+
530
+ context "provider-specific identity text" do
531
+ it "uses mock provider (falls back to default stack)" do
532
+ prompt = system_prompt_for(build_orchestrator)
533
+ # MockProvider.name returns :mock, which isn't a known stack, so falls back to "default"
534
+ expect(prompt).to be_a(String)
535
+ expect(prompt).not_to be_empty
536
+ end
537
+ end
538
+
539
+ # ── Working directory is embedded ──
540
+
541
+ context "cwd propagation" do
542
+ it "embeds the given cwd in the system prompt" do
543
+ Dir.mktmpdir do |dir|
544
+ prompt = system_prompt_for(build_orchestrator(cwd: dir))
545
+ expect(prompt).to include(dir)
546
+ end
547
+ end
548
+ end
549
+ end
550
+ end
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  # Monkey-patch: Guard LLM::Buffer against nil entries.
4
9
  #
5
10
  # llm.rb's Context#talk can sometimes concatenate nil into the message
@@ -79,3 +79,138 @@ module Brute
79
79
  end
80
80
  end
81
81
  end
82
+
83
+ if __FILE__ == $0
84
+ require_relative "../../spec/spec_helper"
85
+
86
+ RSpec.describe "Middleware Pipeline Integration" do
87
+ let(:provider) { MockProvider.new }
88
+ let(:log_output) { StringIO.new }
89
+ let(:logger) { Logger.new(log_output) }
90
+ let(:session) { double("session", save: nil) }
91
+ let(:compactor) { double("compactor", should_compact?: false) }
92
+
93
+ describe "full orchestrator pipeline" do
94
+ it "passes env through all middleware and returns the response" do
95
+ response = MockResponse.new(content: "integrated response")
96
+ allow(provider).to receive(:complete).and_return(response)
97
+
98
+ ctx = LLM::Context.new(provider, tools: [])
99
+ prompt = ctx.prompt { |p| p.system("sys"); p.user("hello") }
100
+
101
+ pipeline = Brute::Pipeline.new
102
+ pipeline.use(Brute::Middleware::Tracing, logger: logger)
103
+ pipeline.use(Brute::Middleware::Retry, max_attempts: 3, base_delay: 2)
104
+ pipeline.use(Brute::Middleware::SessionPersistence, session: session)
105
+ pipeline.use(Brute::Middleware::TokenTracking)
106
+ pipeline.use(Brute::Middleware::CompactionCheck, compactor: compactor, system_prompt: "sys", tools: [])
107
+ pipeline.use(Brute::Middleware::ToolErrorTracking)
108
+ pipeline.use(Brute::Middleware::DoomLoopDetection, threshold: 3)
109
+ pipeline.use(Brute::Middleware::ToolUseGuard)
110
+ pipeline.run(Brute::Middleware::LLMCall.new)
111
+
112
+ env = {
113
+ context: ctx,
114
+ provider: provider,
115
+ input: prompt,
116
+ tools: [],
117
+ params: {},
118
+ metadata: {},
119
+ callbacks: {},
120
+ tool_results: nil,
121
+ streaming: false,
122
+ }
123
+
124
+ result = pipeline.call(env)
125
+
126
+ expect(result).not_to be_nil
127
+ expect(env[:metadata][:timing]).to include(:llm_call_count, :total_llm_elapsed)
128
+ expect(env[:metadata][:tokens]).to include(:total_input, :total_output, :call_count)
129
+ expect(session).to have_received(:save)
130
+ expect(log_output.string).to include("LLM call #1")
131
+ end
132
+ end
133
+
134
+ describe "Retry + Tracing combo" do
135
+ it "Tracing sees the full elapsed time including retries" do
136
+ call_count = 0
137
+ response = MockResponse.new(content: "recovered")
138
+
139
+ allow(provider).to receive(:complete) do |*_args|
140
+ call_count += 1
141
+ raise LLM::RateLimitError, "rate limited" if call_count <= 1
142
+ response
143
+ end
144
+
145
+ ctx = LLM::Context.new(provider, tools: [])
146
+ prompt = ctx.prompt { |p| p.system("sys"); p.user("hi") }
147
+
148
+ pipeline = Brute::Pipeline.new
149
+ pipeline.use(Brute::Middleware::Tracing, logger: logger)
150
+ pipeline.use(Brute::Middleware::Retry, max_attempts: 3, base_delay: 0)
151
+ pipeline.run(Brute::Middleware::LLMCall.new)
152
+
153
+ env = {
154
+ context: ctx,
155
+ provider: provider,
156
+ input: prompt,
157
+ tools: [],
158
+ params: {},
159
+ metadata: {},
160
+ callbacks: {},
161
+ tool_results: nil,
162
+ streaming: false,
163
+ }
164
+
165
+ result = pipeline.call(env)
166
+
167
+ expect(result).not_to be_nil
168
+ expect(env[:metadata][:timing][:llm_call_count]).to eq(1)
169
+ end
170
+ end
171
+
172
+ describe "TokenTracking + SessionPersistence combo" do
173
+ it "session receives save after tokens are tracked" do
174
+ response = MockResponse.new(content: "tracked and saved")
175
+ allow(provider).to receive(:complete).and_return(response)
176
+
177
+ ctx = LLM::Context.new(provider, tools: [])
178
+ prompt = ctx.prompt { |p| p.system("sys"); p.user("hi") }
179
+
180
+ save_args = []
181
+ allow(session).to receive(:save) { |ctx_arg| save_args << ctx_arg }
182
+
183
+ pipeline = Brute::Pipeline.new
184
+ pipeline.use(Brute::Middleware::SessionPersistence, session: session)
185
+ pipeline.use(Brute::Middleware::TokenTracking)
186
+ pipeline.run(Brute::Middleware::LLMCall.new)
187
+
188
+ env = {
189
+ context: ctx,
190
+ provider: provider,
191
+ input: prompt,
192
+ tools: [],
193
+ params: {},
194
+ metadata: {},
195
+ callbacks: {},
196
+ tool_results: nil,
197
+ streaming: false,
198
+ }
199
+
200
+ pipeline.call(env)
201
+
202
+ expect(env[:metadata][:tokens]).to include(:total_input)
203
+ expect(save_args.size).to eq(1)
204
+ end
205
+ end
206
+
207
+ describe "Pipeline builder" do
208
+ it "raises when no terminal app is set" do
209
+ pipeline = Brute::Pipeline.new
210
+ pipeline.use(Brute::Middleware::TokenTracking)
211
+
212
+ expect { pipeline.call({}) }.to raise_error(RuntimeError, /no terminal app/)
213
+ end
214
+ end
215
+ end
216
+ end
@@ -17,3 +17,36 @@ module Brute
17
17
  end
18
18
  end
19
19
  end
20
+
21
+ if __FILE__ == $0
22
+ require_relative "../../../spec/spec_helper"
23
+
24
+ RSpec.describe Brute::Prompts::BuildSwitch do
25
+ subject(:text) { described_class.call({}) }
26
+
27
+ it "returns a string" do
28
+ expect(text).to be_a(String)
29
+ end
30
+
31
+ it "wraps content in system-reminder tags" do
32
+ expect(text).to include("<system-reminder>")
33
+ expect(text).to include("</system-reminder>")
34
+ end
35
+
36
+ it "announces the mode change from plan to build" do
37
+ expect(text).to include("plan to build")
38
+ end
39
+
40
+ it "states the agent is no longer in read-only mode" do
41
+ expect(text).to include("no longer in read-only mode")
42
+ end
43
+
44
+ it "permits tool use" do
45
+ expect(text).to include("permitted to make file changes")
46
+ end
47
+
48
+ it "ignores context (static content)" do
49
+ expect(described_class.call({ agent_switched: "build" })).to eq(described_class.call({}))
50
+ end
51
+ end
52
+ end
@@ -23,3 +23,50 @@ module Brute
23
23
  end
24
24
  end
25
25
  end
26
+
27
+ if __FILE__ == $0
28
+ require_relative "../../../spec/spec_helper"
29
+
30
+ RSpec.describe Brute::Prompts::Environment do
31
+ it "includes cwd from context" do
32
+ text = described_class.call(cwd: "/some/path", model_name: "test")
33
+ expect(text).to include("/some/path")
34
+ end
35
+
36
+ it "includes model name from context" do
37
+ text = described_class.call(cwd: "/tmp", model_name: "claude-sonnet-4-20250514")
38
+ expect(text).to include("claude-sonnet-4-20250514")
39
+ end
40
+
41
+ it "wraps environment info in <env> tags" do
42
+ text = described_class.call(cwd: "/tmp", model_name: "test")
43
+ expect(text).to include("<env>")
44
+ expect(text).to include("</env>")
45
+ end
46
+
47
+ it "detects git repo when .git exists" do
48
+ Dir.mktmpdir do |dir|
49
+ Dir.mkdir(File.join(dir, ".git"))
50
+ text = described_class.call(cwd: dir, model_name: "test")
51
+ expect(text).to include("Is directory a git repo: yes")
52
+ end
53
+ end
54
+
55
+ it "detects non-git directory" do
56
+ Dir.mktmpdir do |dir|
57
+ text = described_class.call(cwd: dir, model_name: "test")
58
+ expect(text).to include("Is directory a git repo: no")
59
+ end
60
+ end
61
+
62
+ it "includes the platform" do
63
+ text = described_class.call(cwd: "/tmp", model_name: "test")
64
+ expect(text).to include(RUBY_PLATFORM)
65
+ end
66
+
67
+ it "defaults cwd to Dir.pwd when not provided" do
68
+ text = described_class.call(model_name: "test")
69
+ expect(text).to include(Dir.pwd)
70
+ end
71
+ end
72
+ end
@@ -9,3 +9,39 @@ module Brute
9
9
  end
10
10
  end
11
11
  end
12
+
13
+ if __FILE__ == $0
14
+ require_relative "../../../spec/spec_helper"
15
+
16
+ RSpec.describe Brute::Prompts::Identity do
17
+ it "returns provider-specific text for anthropic" do
18
+ text = described_class.call(provider_name: "anthropic")
19
+ expect(text).to be_a(String)
20
+ expect(text).not_to be_empty
21
+ end
22
+
23
+ it "returns provider-specific text for openai" do
24
+ text = described_class.call(provider_name: "openai")
25
+ expect(text).to be_a(String)
26
+ expect(text).not_to be_empty
27
+ end
28
+
29
+ it "returns provider-specific text for google" do
30
+ text = described_class.call(provider_name: "google")
31
+ expect(text).to be_a(String)
32
+ expect(text).not_to be_empty
33
+ end
34
+
35
+ it "falls back to default.txt for unknown providers" do
36
+ default_text = described_class.call(provider_name: "default")
37
+ unknown_text = described_class.call(provider_name: "nonexistent_provider")
38
+ expect(unknown_text).to eq(default_text)
39
+ end
40
+
41
+ it "returns different text for different providers" do
42
+ anthropic = described_class.call(provider_name: "anthropic")
43
+ openai = described_class.call(provider_name: "openai")
44
+ expect(anthropic).not_to eq(openai)
45
+ end
46
+ end
47
+ end
@@ -16,3 +16,27 @@ module Brute
16
16
  end
17
17
  end
18
18
  end
19
+
20
+ if __FILE__ == $0
21
+ require_relative "../../../spec/spec_helper"
22
+
23
+ RSpec.describe Brute::Prompts::Instructions do
24
+ it "returns nil when custom_rules is nil" do
25
+ expect(described_class.call(custom_rules: nil)).to be_nil
26
+ end
27
+
28
+ it "returns nil when custom_rules is empty" do
29
+ expect(described_class.call(custom_rules: "")).to be_nil
30
+ end
31
+
32
+ it "returns nil when custom_rules is whitespace-only" do
33
+ expect(described_class.call(custom_rules: " \n ")).to be_nil
34
+ end
35
+
36
+ it "wraps custom_rules in a Project-Specific Rules header" do
37
+ text = described_class.call(custom_rules: "Always use tabs.")
38
+ expect(text).to include("Project-Specific Rules")
39
+ expect(text).to include("Always use tabs.")
40
+ end
41
+ end
42
+ end
@@ -28,3 +28,35 @@ module Brute
28
28
  end
29
29
  end
30
30
  end
31
+
32
+ if __FILE__ == $0
33
+ require_relative "../../../spec/spec_helper"
34
+
35
+ RSpec.describe Brute::Prompts::MaxSteps do
36
+ subject(:text) { described_class.call({}) }
37
+
38
+ it "returns a string" do
39
+ expect(text).to be_a(String)
40
+ end
41
+
42
+ it "announces maximum steps reached" do
43
+ expect(text).to include("MAXIMUM STEPS REACHED")
44
+ end
45
+
46
+ it "states tools are disabled" do
47
+ expect(text).to include("Tools are disabled")
48
+ end
49
+
50
+ it "requires a text-only response" do
51
+ expect(text).to include("text ONLY")
52
+ end
53
+
54
+ it "requires summary of work done" do
55
+ expect(text).to include("Summary of what has been accomplished")
56
+ end
57
+
58
+ it "ignores context (static content)" do
59
+ expect(described_class.call({ max_steps_reached: true })).to eq(described_class.call({}))
60
+ end
61
+ end
62
+ end
@@ -38,3 +38,36 @@ module Brute
38
38
  end
39
39
  end
40
40
  end
41
+
42
+ if __FILE__ == $0
43
+ require_relative "../../../spec/spec_helper"
44
+
45
+ RSpec.describe Brute::Prompts::PlanReminder do
46
+ subject(:text) { described_class.call({}) }
47
+
48
+ it "returns a string" do
49
+ expect(text).to be_a(String)
50
+ end
51
+
52
+ it "wraps content in system-reminder tags" do
53
+ expect(text).to include("<system-reminder>")
54
+ expect(text).to include("</system-reminder>")
55
+ end
56
+
57
+ it "declares READ-ONLY mode" do
58
+ expect(text).to include("READ-ONLY")
59
+ end
60
+
61
+ it "forbids file edits" do
62
+ expect(text).to include("STRICTLY FORBIDDEN")
63
+ end
64
+
65
+ it "states it supersedes other instructions" do
66
+ expect(text).to include("supersedes any other instructions")
67
+ end
68
+
69
+ it "ignores context (static content)" do
70
+ expect(described_class.call({ agent: "plan" })).to eq(described_class.call({}))
71
+ end
72
+ end
73
+ end
@@ -20,3 +20,38 @@ module Brute
20
20
  end
21
21
  end
22
22
  end
23
+
24
+ if __FILE__ == $0
25
+ require_relative "../../../spec/spec_helper"
26
+
27
+ RSpec.describe Brute::Prompts::Skills do
28
+ it "returns nil when no skills are found" do
29
+ Dir.mktmpdir do |dir|
30
+ text = described_class.call(cwd: dir)
31
+ expect(text).to be_nil
32
+ end
33
+ end
34
+
35
+ it "lists discovered skills when present" do
36
+ Dir.mktmpdir do |dir|
37
+ # Create a minimal SKILL.md file
38
+ skill_dir = File.join(dir, ".brute", "skills")
39
+ FileUtils.mkdir_p(skill_dir)
40
+ File.write(File.join(skill_dir, "SKILL.md"), <<~MD)
41
+ ---
42
+ name: test-skill
43
+ description: A test skill
44
+ ---
45
+ # Test Skill Content
46
+ MD
47
+
48
+ text = described_class.call(cwd: dir)
49
+ if text
50
+ expect(text).to include("skill")
51
+ else
52
+ skip "Skill discovery does not find skills in .brute/skills/"
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  module LLM
4
9
  ##
5
10
  # OpenAI-compatible provider for the OpenCode Go API gateway.