brute 2.0.6 → 3.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/events/handler.rb +3 -1
  3. data/lib/brute/events/prefixed_terminal_output.rb +3 -1
  4. data/lib/brute/events/terminal_output_handler.rb +3 -1
  5. data/lib/brute/message_transport/anthropic.rb +143 -0
  6. data/lib/brute/message_transport/llm.rb +94 -0
  7. data/lib/brute/message_transport/openai.rb +113 -0
  8. data/lib/brute/message_transport/ruby_llm.rb +78 -0
  9. data/lib/brute/message_transport.rb +133 -0
  10. data/lib/brute/messages.rb +120 -0
  11. data/lib/brute/middleware/001_otel_span.rb +4 -2
  12. data/lib/brute/middleware/002_session_log.rb +100 -0
  13. data/lib/brute/middleware/004_summarize.rb +21 -19
  14. data/lib/brute/middleware/006_loop.rb +158 -0
  15. data/lib/brute/middleware/010_max_iterations.rb +9 -7
  16. data/lib/brute/middleware/015_otel_token_usage.rb +3 -1
  17. data/lib/brute/middleware/020_system_prompt.rb +6 -4
  18. data/lib/brute/middleware/040_compaction_check.rb +5 -3
  19. data/lib/brute/middleware/060_questions.rb +3 -1
  20. data/lib/brute/middleware/{070_tool_call.rb → 070_tool_pipeline.rb} +60 -46
  21. data/lib/brute/middleware/073_otel_tool_call.rb +3 -1
  22. data/lib/brute/middleware/075_otel_tool_results.rb +3 -1
  23. data/lib/brute/middleware/event_handler.rb +3 -1
  24. data/lib/brute/middleware/user_queue.rb +3 -1
  25. data/lib/brute/prompts/autonomy.rb +3 -0
  26. data/lib/brute/prompts/base.rb +3 -0
  27. data/lib/brute/prompts/build_switch.rb +3 -1
  28. data/lib/brute/prompts/code_references.rb +3 -0
  29. data/lib/brute/prompts/code_style.rb +3 -0
  30. data/lib/brute/prompts/conventions.rb +3 -0
  31. data/lib/brute/prompts/doing_tasks.rb +3 -0
  32. data/lib/brute/prompts/editing_approach.rb +3 -0
  33. data/lib/brute/prompts/editing_constraints.rb +3 -0
  34. data/lib/brute/prompts/environment.rb +3 -1
  35. data/lib/brute/prompts/frontend_tasks.rb +3 -0
  36. data/lib/brute/prompts/git_safety.rb +3 -0
  37. data/lib/brute/prompts/identity.rb +3 -1
  38. data/lib/brute/prompts/instructions.rb +3 -1
  39. data/lib/brute/prompts/max_steps.rb +3 -1
  40. data/lib/brute/prompts/objectivity.rb +3 -0
  41. data/lib/brute/prompts/plan_reminder.rb +3 -1
  42. data/lib/brute/prompts/proactiveness.rb +3 -0
  43. data/lib/brute/prompts/security_and_safety.rb +3 -0
  44. data/lib/brute/prompts/skills.rb +6 -2
  45. data/lib/brute/prompts/task_management.rb +3 -0
  46. data/lib/brute/prompts/tone_and_style.rb +3 -0
  47. data/lib/brute/prompts/tool_usage.rb +3 -0
  48. data/lib/brute/prompts.rb +5 -0
  49. data/lib/brute/rack/adapter.rb +237 -0
  50. data/lib/brute/skill.rb +192 -15
  51. data/lib/brute/system_prompt.rb +3 -1
  52. data/lib/brute/tool.rb +92 -75
  53. data/lib/brute/tools/adapter.rb +258 -0
  54. data/lib/brute/tools/fs/file_mutation_queue.rb +107 -0
  55. data/lib/brute/tools/fs/snapshot_store.rb +41 -0
  56. data/lib/brute/tools/fs_patch.rb +6 -4
  57. data/lib/brute/tools/fs_read.rb +5 -3
  58. data/lib/brute/tools/fs_remove.rb +3 -3
  59. data/lib/brute/tools/fs_search.rb +4 -2
  60. data/lib/brute/tools/fs_undo.rb +3 -3
  61. data/lib/brute/tools/fs_write.rb +6 -4
  62. data/lib/brute/tools/net_fetch.rb +1 -1
  63. data/lib/brute/tools/question.rb +1 -1
  64. data/lib/brute/tools/shell.rb +4 -2
  65. data/lib/brute/tools/skill_load.rb +156 -0
  66. data/lib/brute/tools/sub_agent.rb +101 -0
  67. data/lib/brute/tools/todo_list/store.rb +36 -0
  68. data/lib/brute/tools/todo_read.rb +2 -2
  69. data/lib/brute/tools/todo_write.rb +2 -2
  70. data/lib/brute/tools.rb +2 -1
  71. data/lib/brute/truncation.rb +3 -1
  72. data/lib/brute/turn/agent_pipeline.rb +169 -0
  73. data/lib/brute/turn/pipeline.rb +95 -0
  74. data/lib/brute/turn/tool_pipeline.rb +96 -0
  75. data/lib/brute/utils/diff.rb +3 -1
  76. data/lib/brute/version.rb +1 -1
  77. data/lib/brute.rb +45 -71
  78. data/lib/{brute → brute_cli}/providers/shell.rb +4 -1
  79. data/lib/{brute → brute_cli}/providers/shell_response.rb +9 -15
  80. metadata +52 -43
  81. data/lib/brute/agent.rb +0 -82
  82. data/lib/brute/middleware/003_tool_result_loop.rb +0 -103
  83. data/lib/brute/middleware/100_llm_call.rb +0 -63
  84. data/lib/brute/pipeline.rb +0 -97
  85. data/lib/brute/queue/file_mutation_queue.rb +0 -102
  86. data/lib/brute/session.rb +0 -51
  87. data/lib/brute/store/snapshot_store.rb +0 -36
  88. data/lib/brute/store/todo_store.rb +0 -30
  89. data/lib/brute/sub_agent.rb +0 -106
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "json"
6
+
7
+ module Brute
8
+ # The mirror image of protocol-rack. Where `Protocol::Rack::Adapter` wraps a
9
+ # *Rack app* so an HTTP server can drive it (env in, `[status, headers, body]`
10
+ # out), `Brute::Rack::Adapter` wraps a *Brute agent* so a Rack server can
11
+ # drive it. It is itself a Rack app — `call(env) -> [status, headers, body]` —
12
+ # so any AgentPipeline drops straight into a config.ru and serves over HTTP
13
+ # behind Falcon/Puma/etc:
14
+ #
15
+ # # config.ru
16
+ # agent = Brute::Turn::AgentPipeline.parse_file("examples/agents/brute.ru")
17
+ # run Brute::Rack::Adapter.for(agent)
18
+ #
19
+ # $ curl -d 'What files are here?' localhost:9292
20
+ # $ curl -H 'content-type: application/json' -d '{"prompt":"hi"}' localhost:9292
21
+ # $ curl 'localhost:9292/?prompt=hi'
22
+ #
23
+ # The whole job is two pure transforms — the two directions the request named:
24
+ #
25
+ # env -> prompt string (#prompt_from) the request half
26
+ # output -> [status, headers, body] (#response_for) the response half
27
+ #
28
+ # `call` just wires them together around one `agent.start`.
29
+ module Rack
30
+ class Adapter
31
+ # Body/JSON keys we accept a prompt under, in priority order. `prompt`
32
+ # is canonical; `message`/`input` are common aliases.
33
+ PROMPT_KEYS = %w[prompt message input].freeze
34
+
35
+ # Convenience factory so the config.ru reads `run Adapter.for(agent)`.
36
+ def self.for(agent) = new(agent)
37
+
38
+ # @parameter agent [#start] Anything with `start(prompt) -> env` — an
39
+ # AgentPipeline, a SubAgent, or any turn-shaped callable.
40
+ def initialize(agent)
41
+ raise ArgumentError, "agent must respond to #start" unless agent.respond_to?(:start)
42
+
43
+ @agent = agent
44
+ end
45
+
46
+ # Rack entry point. Extract the prompt, run one agent turn, render the
47
+ # assistant's reply back as an HTTP response. A missing prompt is a 400
48
+ # (client's fault); anything the turn raises is a 500.
49
+ def call(env)
50
+ prompt = prompt_from(env)
51
+ return response_for(env, 400, "No prompt provided.") if prompt.nil? || prompt.empty?
52
+
53
+ turn = @agent.start(prompt)
54
+ response_for(env, 200, output_of(turn))
55
+ rescue => error
56
+ response_for(env, 500, error.message)
57
+ end
58
+
59
+ # env -> prompt string. Four ways in, most explicit first:
60
+ #
61
+ # 1. a `?prompt=` query param (never touches the body),
62
+ # 2. a JSON body — a known key of an object, or a bare JSON string,
63
+ # 3. a form-encoded `prompt=` field,
64
+ # 4. otherwise the raw request body IS the prompt.
65
+ #
66
+ # Returns nil when nothing usable is present.
67
+ def prompt_from(env)
68
+ request = ::Rack::Request.new(env)
69
+
70
+ if (query = request.GET["prompt"]) && !query.empty?
71
+ return query
72
+ end
73
+
74
+ body = read_body(request)
75
+ return nil if body.nil? || body.strip.empty?
76
+
77
+ if json?(request.media_type)
78
+ case data = parse_json(body)
79
+ when ::Hash then return PROMPT_KEYS.filter_map { |key| data[key] }.first&.to_s || body
80
+ when ::String then return data
81
+ end
82
+ end
83
+
84
+ if request.form_data? && (field = ::Rack::Utils.parse_nested_query(body)["prompt"])
85
+ return field
86
+ end
87
+
88
+ body
89
+ end
90
+
91
+ # output -> [status, headers, body]. Content-negotiated: JSON in (or an
92
+ # `Accept: application/json`) gets `{"response": "..."}` back; everything
93
+ # else gets `text/plain`. Errors ride the same path so a 500 body is
94
+ # shaped like a 200 body.
95
+ def response_for(env, status, output)
96
+ text = output.to_s
97
+
98
+ if wants_json?(env)
99
+ key = status == 200 ? :response : :error
100
+ [status, {"content-type" => "application/json"}, [::JSON.generate(key => text)]]
101
+ else
102
+ [status, {"content-type" => "text/plain; charset=utf-8"}, [text]]
103
+ end
104
+ end
105
+
106
+ private
107
+
108
+ # The agent's answer is the last message it appended to the log.
109
+ def output_of(turn)
110
+ messages = turn.is_a?(::Hash) ? turn[:messages] : turn
111
+ messages&.last&.content.to_s
112
+ end
113
+
114
+ def read_body(request)
115
+ input = request.body
116
+ input&.read
117
+ rescue
118
+ nil
119
+ end
120
+
121
+ def json?(media_type)
122
+ media_type.to_s.include?("json")
123
+ end
124
+
125
+ def parse_json(body)
126
+ ::JSON.parse(body)
127
+ rescue ::JSON::ParserError
128
+ nil
129
+ end
130
+
131
+ # Prefer JSON when the client posted JSON or explicitly accepts it.
132
+ def wants_json?(env)
133
+ json?(env["CONTENT_TYPE"]) || json?(env["HTTP_ACCEPT"])
134
+ end
135
+ end
136
+ end
137
+ end
138
+
139
+ __END__
140
+
141
+ describe "brute/rack/adapter" do
142
+ require "stringio"
143
+ # Specs are eval'd standalone by the scampi runner, so make sure the pipeline
144
+ # is loaded here; otherwise `Brute.agent` may be undefined.
145
+ require "brute/turn/agent_pipeline"
146
+
147
+ # A tiny agent that echoes the prompt straight back through a real turn, and a
148
+ # (stateless, reusable) adapter wrapping it.
149
+ echo_agent = Brute.agent.run(->(env) { env[:messages].assistant("echo: #{env[:messages].last.content}") })
150
+ adapter = Brute::Rack::Adapter.new(echo_agent)
151
+
152
+ # Build a minimal Rack env by hand (no server needed).
153
+ rack_env = lambda do |method: "POST", path: "/", query: "", body: "", type: nil, accept: nil|
154
+ env = {
155
+ "REQUEST_METHOD" => method,
156
+ "PATH_INFO" => path,
157
+ "QUERY_STRING" => query,
158
+ "rack.input" => StringIO.new(body),
159
+ }
160
+ env["CONTENT_TYPE"] = type if type
161
+ env["HTTP_ACCEPT"] = accept if accept
162
+ env
163
+ end
164
+
165
+ it "is a Rack app: env in, [status, headers, body] out" do
166
+ status, headers, body = adapter.call(rack_env.call(body: "hello"))
167
+ status.should == 200
168
+ headers["content-type"].should == "text/plain; charset=utf-8"
169
+ body.first.should == "echo: hello"
170
+ end
171
+
172
+ it "reads a raw text body as the prompt" do
173
+ adapter.prompt_from(rack_env.call(body: "what changed?")).should == "what changed?"
174
+ end
175
+
176
+ it "reads a JSON body under a known key" do
177
+ env = rack_env.call(body: '{"prompt":"from json"}', type: "application/json")
178
+ adapter.prompt_from(env).should == "from json"
179
+ end
180
+
181
+ it "accepts message/input JSON aliases" do
182
+ env = rack_env.call(body: '{"message":"aliased"}', type: "application/json")
183
+ adapter.prompt_from(env).should == "aliased"
184
+ end
185
+
186
+ it "reads a bare JSON string body" do
187
+ env = rack_env.call(body: '"just a string"', type: "application/json")
188
+ adapter.prompt_from(env).should == "just a string"
189
+ end
190
+
191
+ it "reads a form-encoded prompt field" do
192
+ env = rack_env.call(body: "prompt=formed&x=1", type: "application/x-www-form-urlencoded")
193
+ adapter.prompt_from(env).should == "formed"
194
+ end
195
+
196
+ it "reads a ?prompt= query param without consuming the body" do
197
+ env = rack_env.call(method: "GET", query: "prompt=queried", body: "")
198
+ adapter.prompt_from(env).should == "queried"
199
+ end
200
+
201
+ it "returns nil for an empty request" do
202
+ adapter.prompt_from(rack_env.call(body: " ")).should.be.nil
203
+ end
204
+
205
+ it "answers a missing prompt with 400" do
206
+ status, _headers, body = adapter.call(rack_env.call(body: ""))
207
+ status.should == 400
208
+ body.first.should == "No prompt provided."
209
+ end
210
+
211
+ it "content-negotiates JSON responses" do
212
+ env = rack_env.call(body: '{"prompt":"hi"}', type: "application/json")
213
+ status, headers, body = adapter.call(env)
214
+ status.should == 200
215
+ headers["content-type"].should == "application/json"
216
+ JSON.parse(body.first).should == { "response" => "echo: hi" }
217
+ end
218
+
219
+ it "honors an Accept: application/json header" do
220
+ env = rack_env.call(body: "hi", accept: "application/json")
221
+ _status, headers, _body = adapter.call(env)
222
+ headers["content-type"].should == "application/json"
223
+ end
224
+
225
+ it "renders a raised turn as a 500 (JSON error key when negotiated)" do
226
+ boom = Brute.agent.run(->(_env) { raise "kaboom" })
227
+ env = rack_env.call(body: '{"prompt":"hi"}', type: "application/json")
228
+ status, _headers, body = Brute::Rack::Adapter.new(boom).call(env)
229
+ status.should == 500
230
+ JSON.parse(body.first).should == { "error" => "kaboom" }
231
+ end
232
+
233
+ it ".for is a factory and #start is required" do
234
+ Brute::Rack::Adapter.for(echo_agent).should.be.kind_of?(Brute::Rack::Adapter)
235
+ lambda { Brute::Rack::Adapter.new(Object.new) }.should.raise(ArgumentError)
236
+ end
237
+ end
data/lib/brute/skill.rb CHANGED
@@ -2,10 +2,14 @@
2
2
 
3
3
  require "yaml"
4
4
 
5
+ require "bundler/setup"
6
+ require "brute"
7
+
5
8
  module Brute
6
- # Discovers and loads SKILL.md files from standard directories.
9
+ # Discovers, validates, and loads SKILL.md files from standard directories.
7
10
  #
8
- # A skill is a markdown file with YAML frontmatter:
11
+ # A skill is a directory containing a SKILL.md markdown file with YAML
12
+ # frontmatter:
9
13
  #
10
14
  # ---
11
15
  # name: debugging
@@ -18,14 +22,28 @@ module Brute
18
22
  # 1. .brute/skills/**/SKILL.md (project-local)
19
23
  # 2. ~/.config/brute/skills/**/SKILL.md (global)
20
24
  #
21
- # The directory name containing SKILL.md becomes the skill name if frontmatter
22
- # doesn't specify one.
25
+ # Parsing and validation mirror the Agent Skills specification
26
+ # (https://agentskills.io/specification) and its reference validator
27
+ # (https://github.com/agentskills/agentskills/tree/main/skills-ref). A skill
28
+ # whose frontmatter violates a rule is skipped with a stderr warning naming
29
+ # the violated rule, never raised.
23
30
  #
24
31
  module Skill
25
- Info = Struct.new(:name, :description, :location, :content, keyword_init: true)
32
+ Info = Struct.new(
33
+ :name, :description, :location, :content,
34
+ :license, :compatibility, :metadata, :allowed_tools,
35
+ keyword_init: true,
36
+ )
26
37
 
27
38
  FILENAME = "SKILL.md"
28
39
 
40
+ # Frontmatter keys permitted by the spec. Anything else is a violation.
41
+ ALLOWED_FIELDS = %w[name description license allowed-tools metadata compatibility].freeze
42
+
43
+ MAX_NAME_LENGTH = 64
44
+ MAX_DESCRIPTION_LENGTH = 1024
45
+ MAX_COMPATIBILITY_LENGTH = 500
46
+
29
47
  # Scan all skill directories and return an array of Info structs.
30
48
  def self.all(cwd: Dir.pwd)
31
49
  skills = {}
@@ -62,28 +80,94 @@ module Brute
62
80
  lines.join("\n")
63
81
  end
64
82
 
65
- # Parse a SKILL.md file into an Info struct.
66
- # Returns nil if the file is invalid or missing required fields.
83
+ # Parse and validate a SKILL.md file into an Info struct.
84
+ # Returns nil (with a stderr warning) if the file is invalid.
67
85
  def self.load(path)
68
86
  raw = File.read(path)
69
- frontmatter, content = parse_frontmatter(raw)
87
+ frontmatter, content = parse_frontmatter(path, raw)
70
88
  return nil unless frontmatter
71
89
 
72
- name = frontmatter["name"] || File.basename(File.dirname(path))
73
- description = frontmatter["description"]
74
- return nil unless description && !description.strip.empty?
90
+ dir_name = File.basename(File.dirname(path))
91
+ # Spec requires `name`; brute keeps the convenience of defaulting to the
92
+ # directory name when omitted (which trivially satisfies the dir-match rule).
93
+ frontmatter = { "name" => dir_name }.merge(frontmatter) unless frontmatter.key?("name")
94
+
95
+ # Unknown fields are a soft violation: warn and drop them rather than
96
+ # reject the skill. The reference validator hard-fails here, but a runtime
97
+ # loader must tolerate vendor/forward extensions (e.g. `tags`), which real
98
+ # published skills carry, instead of silently dropping the whole skill.
99
+ extra = frontmatter.keys - ALLOWED_FIELDS
100
+ warn "Skill #{path} has unexpected frontmatter fields (ignored): #{extra.sort.join(', ')}" unless extra.empty?
101
+
102
+ errors = validate(frontmatter, dir_name)
103
+ unless errors.empty?
104
+ warn "Skipping invalid skill #{path}: #{errors.join('; ')}"
105
+ return nil
106
+ end
75
107
 
76
108
  Info.new(
77
- name: name.to_s.strip,
78
- description: description.to_s.strip,
109
+ name: frontmatter["name"].to_s.strip,
110
+ description: frontmatter["description"].to_s.strip,
79
111
  location: path,
80
112
  content: content.to_s.strip,
113
+ license: frontmatter["license"]&.to_s,
114
+ compatibility: frontmatter["compatibility"]&.to_s,
115
+ metadata: frontmatter["metadata"],
116
+ allowed_tools: parse_allowed_tools(frontmatter["allowed-tools"]),
81
117
  )
82
118
  rescue => e
83
119
  warn "Failed to load skill #{path}: #{e.message}"
84
120
  nil
85
121
  end
86
122
 
123
+ # Validate frontmatter against the spec. Returns an array of error strings
124
+ # (empty means valid), each naming the violated rule.
125
+ def self.validate(frontmatter, dir_name)
126
+ errors = []
127
+
128
+ errors.concat(validate_name(frontmatter["name"], dir_name))
129
+ errors.concat(validate_description(frontmatter["description"]))
130
+
131
+ if frontmatter.key?("compatibility")
132
+ compatibility = frontmatter["compatibility"]
133
+ if !compatibility.is_a?(String)
134
+ errors << "'compatibility' must be a string"
135
+ elsif compatibility.length > MAX_COMPATIBILITY_LENGTH
136
+ errors << "'compatibility' exceeds #{MAX_COMPATIBILITY_LENGTH} characters"
137
+ end
138
+ end
139
+
140
+ errors
141
+ end
142
+
143
+ def self.validate_name(name, dir_name)
144
+ return ["missing required field 'name'"] unless name.is_a?(String) && !name.strip.empty?
145
+
146
+ name = name.strip.unicode_normalize(:nfkc)
147
+ errors = []
148
+ errors << "'name' exceeds #{MAX_NAME_LENGTH} characters" if name.length > MAX_NAME_LENGTH
149
+ errors << "'name' must be lowercase" if name != name.downcase
150
+ errors << "'name' cannot start or end with a hyphen" if name.start_with?("-") || name.end_with?("-")
151
+ errors << "'name' cannot contain consecutive hyphens" if name.include?("--")
152
+ errors << "'name' may only contain letters, digits, and hyphens" unless name.match?(/\A[\p{Alnum}\-]+\z/)
153
+ errors << "directory name '#{dir_name}' must match skill name '#{name}'" if dir_name.unicode_normalize(:nfkc) != name
154
+ errors
155
+ end
156
+
157
+ def self.validate_description(description)
158
+ return ["missing required field 'description'"] unless description.is_a?(String) && !description.strip.empty?
159
+ return ["'description' exceeds #{MAX_DESCRIPTION_LENGTH} characters"] if description.length > MAX_DESCRIPTION_LENGTH
160
+
161
+ []
162
+ end
163
+
164
+ # `allowed-tools` is an experimental, space-separated list of tool names.
165
+ def self.parse_allowed_tools(value)
166
+ return nil if value.nil?
167
+
168
+ value.to_s.split(/\s+/).reject(&:empty?)
169
+ end
170
+
87
171
  # Directories to scan for skills, in priority order.
88
172
  def self.scan_dirs(cwd)
89
173
  dirs = []
@@ -101,7 +185,7 @@ module Brute
101
185
 
102
186
  # Split YAML frontmatter from markdown body.
103
187
  # Returns [hash, string] or [nil, nil].
104
- def self.parse_frontmatter(raw)
188
+ def self.parse_frontmatter(path, raw)
105
189
  return [nil, nil] unless raw.start_with?("---")
106
190
 
107
191
  parts = raw.split(/^---\s*$/, 3)
@@ -111,8 +195,101 @@ module Brute
111
195
  return [nil, nil] unless frontmatter.is_a?(Hash)
112
196
 
113
197
  [frontmatter, parts[2]]
198
+ rescue => e
199
+ warn "Failed to parse frontmatter in #{path}: #{e.message}"
200
+ [nil, nil]
201
+ end
202
+
203
+ private_class_method :scan_dirs, :parse_frontmatter,
204
+ :validate, :validate_name, :validate_description, :parse_allowed_tools
205
+ end
206
+ end
207
+
208
+ __END__
209
+
210
+ describe "brute/skill" do
211
+ require "tmpdir"
212
+ require "fileutils"
213
+
214
+ def make_skill_dir(root, dir_name, frontmatter, body: "Body.")
215
+ dir = File.join(root, ".brute", "skills", dir_name)
216
+ FileUtils.mkdir_p(dir)
217
+ path = File.join(dir, "SKILL.md")
218
+ File.write(path, "---\n#{frontmatter}\n---\n\n#{body}\n")
219
+ path
220
+ end
221
+
222
+ it "loads a valid skill" do
223
+ Dir.mktmpdir do |root|
224
+ path = make_skill_dir(root, "debugging", "name: debugging\ndescription: Debug things")
225
+ info = Brute::Skill.load(path)
226
+ info.name.should == "debugging"
227
+ info.description.should == "Debug things"
228
+ end
229
+ end
230
+
231
+ it "defaults the name to the directory when omitted" do
232
+ Dir.mktmpdir do |root|
233
+ path = make_skill_dir(root, "deploy", "description: Deploy things")
234
+ Brute::Skill.load(path).name.should == "deploy"
235
+ end
236
+ end
237
+
238
+ it "skips a skill whose name does not match its directory" do
239
+ Dir.mktmpdir do |root|
240
+ path = make_skill_dir(root, "deploy", "name: shipit\ndescription: Ship it")
241
+ Brute::Skill.load(path).should.be.nil
242
+ end
243
+ end
244
+
245
+ it "skips a skill with an uppercase name" do
246
+ Dir.mktmpdir do |root|
247
+ path = make_skill_dir(root, "Deploy", "name: Deploy\ndescription: Deploy")
248
+ Brute::Skill.load(path).should.be.nil
249
+ end
250
+ end
251
+
252
+ it "skips a skill with consecutive hyphens in the name" do
253
+ Dir.mktmpdir do |root|
254
+ path = make_skill_dir(root, "a--b", "name: a--b\ndescription: nope")
255
+ Brute::Skill.load(path).should.be.nil
256
+ end
257
+ end
258
+
259
+ it "skips a skill without a description" do
260
+ Dir.mktmpdir do |root|
261
+ path = make_skill_dir(root, "nodesc", "name: nodesc")
262
+ Brute::Skill.load(path).should.be.nil
263
+ end
264
+ end
265
+
266
+ it "loads a skill with an unexpected frontmatter field, dropping the extra" do
267
+ Dir.mktmpdir do |root|
268
+ path = make_skill_dir(root, "extra", "name: extra\ndescription: x\ntags: [a, b]")
269
+ info = Brute::Skill.load(path)
270
+ info.name.should == "extra"
271
+ info.respond_to?(:tags).should.be.false
114
272
  end
273
+ end
115
274
 
116
- private_class_method :scan_dirs, :parse_frontmatter
275
+ it "parses optional spec fields" do
276
+ Dir.mktmpdir do |root|
277
+ path = make_skill_dir(
278
+ root, "full",
279
+ "name: full\ndescription: x\nlicense: MIT\ncompatibility: claude\nallowed-tools: read shell\nmetadata:\n team: core",
280
+ )
281
+ info = Brute::Skill.load(path)
282
+ info.license.should == "MIT"
283
+ info.compatibility.should == "claude"
284
+ info.allowed_tools.should == %w[read shell]
285
+ info.metadata.should == { "team" => "core" }
286
+ end
287
+ end
288
+
289
+ it "skips a skill whose description exceeds the length bound" do
290
+ Dir.mktmpdir do |root|
291
+ path = make_skill_dir(root, "long", "name: long\ndescription: #{"x" * 1025}")
292
+ Brute::Skill.load(path).should.be.nil
293
+ end
117
294
  end
118
295
  end
@@ -180,6 +180,8 @@ module Brute
180
180
  end
181
181
  end
182
182
 
183
- test do
183
+ __END__
184
+
185
+ describe "brute/system_prompt" do
184
186
  # not implemented
185
187
  end