ocak 0.1.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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +268 -0
- data/bin/ocak +7 -0
- data/lib/ocak/agent_generator.rb +171 -0
- data/lib/ocak/claude_runner.rb +169 -0
- data/lib/ocak/cli.rb +28 -0
- data/lib/ocak/commands/audit.rb +25 -0
- data/lib/ocak/commands/clean.rb +30 -0
- data/lib/ocak/commands/debt.rb +21 -0
- data/lib/ocak/commands/design.rb +34 -0
- data/lib/ocak/commands/init.rb +212 -0
- data/lib/ocak/commands/resume.rb +128 -0
- data/lib/ocak/commands/run.rb +60 -0
- data/lib/ocak/commands/status.rb +102 -0
- data/lib/ocak/config.rb +109 -0
- data/lib/ocak/issue_fetcher.rb +137 -0
- data/lib/ocak/logger.rb +192 -0
- data/lib/ocak/merge_manager.rb +158 -0
- data/lib/ocak/pipeline_runner.rb +389 -0
- data/lib/ocak/pipeline_state.rb +51 -0
- data/lib/ocak/planner.rb +68 -0
- data/lib/ocak/process_runner.rb +82 -0
- data/lib/ocak/stack_detector.rb +333 -0
- data/lib/ocak/stream_parser.rb +189 -0
- data/lib/ocak/templates/agents/auditor.md.erb +87 -0
- data/lib/ocak/templates/agents/documenter.md.erb +67 -0
- data/lib/ocak/templates/agents/implementer.md.erb +154 -0
- data/lib/ocak/templates/agents/merger.md.erb +97 -0
- data/lib/ocak/templates/agents/pipeline.md.erb +126 -0
- data/lib/ocak/templates/agents/planner.md.erb +86 -0
- data/lib/ocak/templates/agents/reviewer.md.erb +98 -0
- data/lib/ocak/templates/agents/security_reviewer.md.erb +112 -0
- data/lib/ocak/templates/gitignore_additions.txt +10 -0
- data/lib/ocak/templates/hooks/post_edit_lint.sh.erb +57 -0
- data/lib/ocak/templates/hooks/task_completed_test.sh.erb +34 -0
- data/lib/ocak/templates/ocak.yml.erb +99 -0
- data/lib/ocak/templates/skills/audit/SKILL.md.erb +132 -0
- data/lib/ocak/templates/skills/debt/SKILL.md.erb +128 -0
- data/lib/ocak/templates/skills/design/SKILL.md.erb +131 -0
- data/lib/ocak/templates/skills/scan_file/SKILL.md.erb +113 -0
- data/lib/ocak/verification.rb +83 -0
- data/lib/ocak/worktree_manager.rb +92 -0
- data/lib/ocak.rb +13 -0
- metadata +115 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Ocak
|
|
6
|
+
class StackDetector
|
|
7
|
+
Result = Struct.new(:language, :framework, :test_command, :lint_command,
|
|
8
|
+
:format_command, :security_commands, :setup_command,
|
|
9
|
+
:monorepo, :packages)
|
|
10
|
+
|
|
11
|
+
def initialize(project_dir)
|
|
12
|
+
@dir = project_dir
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def detect
|
|
16
|
+
lang = detect_language
|
|
17
|
+
mono = detect_monorepo
|
|
18
|
+
Result.new(
|
|
19
|
+
language: lang,
|
|
20
|
+
framework: detect_framework(lang),
|
|
21
|
+
test_command: detect_test_command(lang),
|
|
22
|
+
lint_command: detect_lint_command(lang),
|
|
23
|
+
format_command: detect_format_command(lang),
|
|
24
|
+
security_commands: detect_security_commands(lang),
|
|
25
|
+
setup_command: detect_setup_command(lang),
|
|
26
|
+
monorepo: mono[:detected],
|
|
27
|
+
packages: mono[:packages]
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def detect_language
|
|
34
|
+
return 'ruby' if exists?('Gemfile')
|
|
35
|
+
return 'typescript' if exists?('tsconfig.json')
|
|
36
|
+
return 'javascript' if exists?('package.json')
|
|
37
|
+
return 'python' if exists?('pyproject.toml') || exists?('setup.py') || exists?('requirements.txt')
|
|
38
|
+
return 'rust' if exists?('Cargo.toml')
|
|
39
|
+
return 'go' if exists?('go.mod')
|
|
40
|
+
return 'java' if exists?('pom.xml') || exists?('build.gradle')
|
|
41
|
+
return 'elixir' if exists?('mix.exs')
|
|
42
|
+
|
|
43
|
+
'unknown'
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def detect_framework(lang)
|
|
47
|
+
case lang
|
|
48
|
+
when 'ruby' then detect_ruby_framework
|
|
49
|
+
when 'typescript', 'javascript' then detect_js_framework
|
|
50
|
+
when 'python' then detect_python_framework
|
|
51
|
+
when 'rust' then detect_rust_framework
|
|
52
|
+
when 'go' then detect_go_framework
|
|
53
|
+
when 'elixir' then 'phoenix' if gemfile_has?('mix.exs', 'phoenix')
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def detect_test_command(lang)
|
|
58
|
+
case lang
|
|
59
|
+
when 'ruby'
|
|
60
|
+
gemfile_has?('Gemfile', 'rspec') ? 'bundle exec rspec' : 'bundle exec rake test'
|
|
61
|
+
when 'typescript', 'javascript'
|
|
62
|
+
return 'npx vitest run' if pkg_has?('vitest')
|
|
63
|
+
return 'npx jest' if pkg_has?('jest')
|
|
64
|
+
|
|
65
|
+
'npm test'
|
|
66
|
+
when 'python'
|
|
67
|
+
return 'pytest' if exists?('pyproject.toml') && read_file('pyproject.toml').include?('pytest')
|
|
68
|
+
|
|
69
|
+
'python -m pytest'
|
|
70
|
+
when 'rust' then 'cargo test'
|
|
71
|
+
when 'go' then 'go test ./...'
|
|
72
|
+
when 'java' then exists?('gradlew') ? './gradlew test' : 'mvn test'
|
|
73
|
+
when 'elixir' then 'mix test'
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def detect_lint_command(lang)
|
|
78
|
+
case lang
|
|
79
|
+
when 'ruby'
|
|
80
|
+
'bundle exec rubocop -A' if gemfile_has?('Gemfile', 'rubocop')
|
|
81
|
+
when 'typescript', 'javascript'
|
|
82
|
+
return 'npx biome check --write' if pkg_has?('biome') || pkg_has?('@biomejs/biome')
|
|
83
|
+
|
|
84
|
+
'npx eslint --fix .' if pkg_has?('eslint')
|
|
85
|
+
when 'python'
|
|
86
|
+
return 'ruff check --fix .' if exists?('pyproject.toml') && read_file('pyproject.toml').include?('ruff')
|
|
87
|
+
|
|
88
|
+
'flake8'
|
|
89
|
+
when 'rust' then 'cargo clippy --fix --allow-dirty'
|
|
90
|
+
when 'go' then 'golangci-lint run'
|
|
91
|
+
when 'elixir' then 'mix credo'
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def detect_format_command(lang)
|
|
96
|
+
case lang
|
|
97
|
+
when 'ruby' then nil # rubocop handles formatting
|
|
98
|
+
when 'typescript', 'javascript'
|
|
99
|
+
return nil if pkg_has?('biome') || pkg_has?('@biomejs/biome') # biome handles both
|
|
100
|
+
|
|
101
|
+
'npx prettier --write .' if pkg_has?('prettier')
|
|
102
|
+
when 'python'
|
|
103
|
+
return 'ruff format .' if exists?('pyproject.toml') && read_file('pyproject.toml').include?('ruff')
|
|
104
|
+
|
|
105
|
+
'black .' if exists?('pyproject.toml') && read_file('pyproject.toml').include?('black')
|
|
106
|
+
when 'rust' then 'cargo fmt'
|
|
107
|
+
when 'go' then 'gofmt -w .'
|
|
108
|
+
when 'elixir' then 'mix format'
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def detect_security_commands(lang)
|
|
113
|
+
cmds = []
|
|
114
|
+
case lang
|
|
115
|
+
when 'ruby'
|
|
116
|
+
cmds << 'bundle exec brakeman -q' if gemfile_has?('Gemfile', 'brakeman')
|
|
117
|
+
cmds << 'bundle exec bundler-audit check' if gemfile_has?('Gemfile', 'bundler-audit')
|
|
118
|
+
when 'typescript', 'javascript'
|
|
119
|
+
cmds << 'npm audit --omit=dev'
|
|
120
|
+
when 'python'
|
|
121
|
+
cmds << 'bandit -r .' if exists?('pyproject.toml') && read_file('pyproject.toml').include?('bandit')
|
|
122
|
+
cmds << 'safety check' if exists?('pyproject.toml') && read_file('pyproject.toml').include?('safety')
|
|
123
|
+
when 'rust'
|
|
124
|
+
cmds << 'cargo audit' if read_file('Cargo.toml').include?('cargo-audit')
|
|
125
|
+
when 'go'
|
|
126
|
+
cmds << 'gosec ./...'
|
|
127
|
+
end
|
|
128
|
+
cmds
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def detect_setup_command(lang)
|
|
132
|
+
case lang
|
|
133
|
+
when 'ruby' then 'bundle install' if exists?('Gemfile')
|
|
134
|
+
when 'typescript', 'javascript' then detect_js_setup_command
|
|
135
|
+
when 'python' then detect_python_setup_command
|
|
136
|
+
when 'rust' then 'cargo fetch' if exists?('Cargo.toml')
|
|
137
|
+
when 'go' then 'go mod download' if exists?('go.mod')
|
|
138
|
+
when 'elixir' then 'mix deps.get' if exists?('mix.exs')
|
|
139
|
+
when 'java' then detect_java_setup_command
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def detect_js_setup_command
|
|
144
|
+
return 'npm install' if exists?('package-lock.json')
|
|
145
|
+
return 'yarn install' if exists?('yarn.lock')
|
|
146
|
+
return 'pnpm install' if exists?('pnpm-lock.yaml')
|
|
147
|
+
|
|
148
|
+
'npm install' if exists?('package.json')
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def detect_python_setup_command
|
|
152
|
+
return 'pip install -e .' if exists?('pyproject.toml')
|
|
153
|
+
|
|
154
|
+
'pip install -r requirements.txt' if exists?('requirements.txt')
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def detect_java_setup_command
|
|
158
|
+
return './gradlew dependencies' if exists?('gradlew')
|
|
159
|
+
|
|
160
|
+
'mvn dependency:resolve' if exists?('pom.xml')
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Monorepo detection
|
|
164
|
+
|
|
165
|
+
def detect_monorepo
|
|
166
|
+
packages = []
|
|
167
|
+
packages.concat(detect_npm_workspaces)
|
|
168
|
+
packages.concat(detect_pnpm_workspaces)
|
|
169
|
+
packages.concat(detect_cargo_workspaces)
|
|
170
|
+
packages.concat(detect_go_workspaces)
|
|
171
|
+
packages.concat(detect_lerna_packages)
|
|
172
|
+
packages.concat(detect_convention_packages) if packages.empty?
|
|
173
|
+
packages.uniq!
|
|
174
|
+
{ detected: packages.any?, packages: packages }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def detect_npm_workspaces
|
|
178
|
+
return [] unless exists?('package.json')
|
|
179
|
+
|
|
180
|
+
pkg = begin
|
|
181
|
+
JSON.parse(read_file('package.json'))
|
|
182
|
+
rescue JSON::ParserError
|
|
183
|
+
{}
|
|
184
|
+
end
|
|
185
|
+
workspaces = pkg['workspaces']
|
|
186
|
+
workspaces = workspaces['packages'] if workspaces.is_a?(Hash)
|
|
187
|
+
return [] unless workspaces.is_a?(Array) && workspaces.any?
|
|
188
|
+
|
|
189
|
+
expand_workspace_globs(workspaces)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def detect_pnpm_workspaces
|
|
193
|
+
return [] unless exists?('pnpm-workspace.yaml')
|
|
194
|
+
|
|
195
|
+
content = read_file('pnpm-workspace.yaml')
|
|
196
|
+
globs = content.scan(/^\s*-\s*['"]?([^'"#\n]+)/).flatten.map(&:strip)
|
|
197
|
+
expand_workspace_globs(globs)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def detect_cargo_workspaces
|
|
201
|
+
return [] unless exists?('Cargo.toml') && read_file('Cargo.toml').include?('[workspace]')
|
|
202
|
+
|
|
203
|
+
read_file('Cargo.toml').scan(/members\s*=\s*\[(.*?)\]/m).flatten.flat_map do |members|
|
|
204
|
+
globs = members.scan(/"([^"]+)"/).flatten
|
|
205
|
+
expand_workspace_globs(globs)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def detect_go_workspaces
|
|
210
|
+
return [] unless exists?('go.work')
|
|
211
|
+
|
|
212
|
+
read_file('go.work').scan(/use\s+(\S+)/).flatten.select do |pkg|
|
|
213
|
+
Dir.exist?(File.join(@dir, pkg))
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def detect_lerna_packages
|
|
218
|
+
return [] unless exists?('lerna.json')
|
|
219
|
+
|
|
220
|
+
lerna = begin
|
|
221
|
+
JSON.parse(read_file('lerna.json'))
|
|
222
|
+
rescue JSON::ParserError
|
|
223
|
+
{}
|
|
224
|
+
end
|
|
225
|
+
expand_workspace_globs(lerna['packages'] || ['packages/*'])
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def detect_convention_packages
|
|
229
|
+
packages = []
|
|
230
|
+
%w[packages apps services modules libs].each do |candidate|
|
|
231
|
+
path = File.join(@dir, candidate)
|
|
232
|
+
next unless Dir.exist?(path)
|
|
233
|
+
|
|
234
|
+
subdirs = Dir.entries(path).reject { |e| e.start_with?('.') }.select do |e|
|
|
235
|
+
File.directory?(File.join(path, e))
|
|
236
|
+
end
|
|
237
|
+
packages.concat(subdirs.map { |s| "#{candidate}/#{s}" }) if subdirs.size > 1
|
|
238
|
+
end
|
|
239
|
+
packages
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def expand_workspace_globs(globs)
|
|
243
|
+
globs.flat_map do |glob|
|
|
244
|
+
pattern = File.join(@dir, glob)
|
|
245
|
+
Dir.glob(pattern).select { |p| File.directory?(p) }.map do |p|
|
|
246
|
+
p.sub("#{@dir}/", '')
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Framework detection helpers
|
|
252
|
+
|
|
253
|
+
def detect_ruby_framework
|
|
254
|
+
return 'rails' if gemfile_has?('Gemfile', 'rails')
|
|
255
|
+
return 'sinatra' if gemfile_has?('Gemfile', 'sinatra')
|
|
256
|
+
return 'hanami' if gemfile_has?('Gemfile', 'hanami')
|
|
257
|
+
|
|
258
|
+
nil
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def detect_js_framework
|
|
262
|
+
return 'next' if pkg_has?('next')
|
|
263
|
+
return 'remix' if pkg_has?('@remix-run/react')
|
|
264
|
+
return 'nuxt' if pkg_has?('nuxt')
|
|
265
|
+
return 'svelte' if pkg_has?('svelte') || pkg_has?('@sveltejs/kit')
|
|
266
|
+
return 'react' if pkg_has?('react')
|
|
267
|
+
return 'vue' if pkg_has?('vue')
|
|
268
|
+
return 'express' if pkg_has?('express')
|
|
269
|
+
|
|
270
|
+
nil
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def detect_python_framework
|
|
274
|
+
return 'django' if exists?('manage.py') || pip_has?('django')
|
|
275
|
+
return 'flask' if pip_has?('flask')
|
|
276
|
+
return 'fastapi' if pip_has?('fastapi')
|
|
277
|
+
|
|
278
|
+
nil
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def detect_rust_framework
|
|
282
|
+
content = read_file('Cargo.toml')
|
|
283
|
+
return 'actix' if content.include?('actix-web')
|
|
284
|
+
return 'axum' if content.include?('axum')
|
|
285
|
+
return 'rocket' if content.include?('rocket')
|
|
286
|
+
|
|
287
|
+
nil
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def detect_go_framework
|
|
291
|
+
content = read_file('go.mod')
|
|
292
|
+
return 'gin' if content.include?('gin-gonic')
|
|
293
|
+
return 'echo' if content.include?('labstack/echo')
|
|
294
|
+
return 'fiber' if content.include?('gofiber/fiber')
|
|
295
|
+
return 'chi' if content.include?('go-chi/chi')
|
|
296
|
+
|
|
297
|
+
nil
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# File helpers
|
|
301
|
+
|
|
302
|
+
def exists?(filename)
|
|
303
|
+
File.exist?(File.join(@dir, filename))
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def read_file(filename)
|
|
307
|
+
path = File.join(@dir, filename)
|
|
308
|
+
File.exist?(path) ? File.read(path) : ''
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def gemfile_has?(file, gem_name)
|
|
312
|
+
read_file(file).match?(/['"]#{Regexp.escape(gem_name)}[\w-]*['"]/)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def pkg_has?(package)
|
|
316
|
+
@pkg_json ||= begin
|
|
317
|
+
raw = read_file('package.json')
|
|
318
|
+
raw.empty? ? {} : JSON.parse(raw)
|
|
319
|
+
rescue JSON::ParserError
|
|
320
|
+
{}
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
deps = (@pkg_json['dependencies'] || {}).merge(@pkg_json['devDependencies'] || {})
|
|
324
|
+
deps.key?(package)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def pip_has?(package)
|
|
328
|
+
%w[pyproject.toml setup.py requirements.txt].any? do |f|
|
|
329
|
+
read_file(f).downcase.include?(package.downcase)
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Ocak
|
|
6
|
+
# Parses NDJSON lines from `claude --output-format stream-json`.
|
|
7
|
+
class StreamParser
|
|
8
|
+
TEST_CMD_PATTERN = %r{
|
|
9
|
+
\b(rails\stest|bin/rails\stest|rspec|npm\stest|
|
|
10
|
+
npx\svitest|cargo\stest|pytest|go\stest|mix\stest|
|
|
11
|
+
rubocop|biome|clippy|eslint)\b
|
|
12
|
+
}x
|
|
13
|
+
|
|
14
|
+
attr_reader :result_text, :cost_usd, :duration_ms, :num_turns, :files_edited
|
|
15
|
+
|
|
16
|
+
def initialize(agent_name, logger)
|
|
17
|
+
@agent_name = agent_name
|
|
18
|
+
@logger = logger
|
|
19
|
+
@result_text = nil
|
|
20
|
+
@cost_usd = nil
|
|
21
|
+
@duration_ms = nil
|
|
22
|
+
@num_turns = nil
|
|
23
|
+
@success = nil
|
|
24
|
+
@files_edited = []
|
|
25
|
+
@pending_tools = {}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def success?
|
|
29
|
+
@success == true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def parse_line(line)
|
|
33
|
+
stripped = line.strip
|
|
34
|
+
return [] if stripped.empty?
|
|
35
|
+
|
|
36
|
+
data = JSON.parse(stripped)
|
|
37
|
+
|
|
38
|
+
case data['type']
|
|
39
|
+
when 'system' then parse_system(data)
|
|
40
|
+
when 'assistant' then parse_assistant(data)
|
|
41
|
+
when 'user' then parse_user(data)
|
|
42
|
+
when 'result' then parse_result(data)
|
|
43
|
+
else []
|
|
44
|
+
end
|
|
45
|
+
rescue JSON::ParserError
|
|
46
|
+
[]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def parse_system(data)
|
|
52
|
+
return [] unless data['subtype'] == 'init'
|
|
53
|
+
|
|
54
|
+
model = data['model'] || 'unknown'
|
|
55
|
+
@logger.info("[INIT] session (model: #{model})", agent: @agent_name)
|
|
56
|
+
[{ category: :init, model: model, session_id: data['session_id'] }]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def parse_assistant(data)
|
|
60
|
+
content = data.dig('message', 'content')
|
|
61
|
+
return [] unless content.is_a?(Array)
|
|
62
|
+
|
|
63
|
+
content.filter_map do |block|
|
|
64
|
+
case block['type']
|
|
65
|
+
when 'text' then parse_text_block(block)
|
|
66
|
+
when 'tool_use' then parse_tool_use(block)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def parse_text_block(block)
|
|
72
|
+
text = block['text'].to_s
|
|
73
|
+
has_red = text.include?("\u{1F534}")
|
|
74
|
+
has_yellow = text.include?("\u{1F7E1}")
|
|
75
|
+
has_green = text.include?("\u{1F7E2}")
|
|
76
|
+
has_findings = has_red || has_yellow || has_green
|
|
77
|
+
|
|
78
|
+
if has_findings
|
|
79
|
+
severity = if has_red
|
|
80
|
+
'BLOCKING'
|
|
81
|
+
else
|
|
82
|
+
(has_yellow ? 'WARNING' : 'PASS')
|
|
83
|
+
end
|
|
84
|
+
@logger.info("[REVIEW] #{severity}", agent: @agent_name)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
{ category: :text, text: text[0..200], has_findings: has_findings,
|
|
88
|
+
has_red: has_red, has_yellow: has_yellow, has_green: has_green }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def parse_tool_use(block)
|
|
92
|
+
tool_id = block['id']
|
|
93
|
+
tool_name = block['name']
|
|
94
|
+
input = block['input'] || {}
|
|
95
|
+
@pending_tools[tool_id] = { name: tool_name, input: input }
|
|
96
|
+
|
|
97
|
+
build_tool_event(tool_name, input)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def build_tool_event(tool_name, input)
|
|
101
|
+
case tool_name
|
|
102
|
+
when 'Edit', 'Write'
|
|
103
|
+
file_path = input['file_path'].to_s
|
|
104
|
+
@files_edited << file_path unless file_path.empty?
|
|
105
|
+
@logger.info("[EDIT] #{tool_name}: #{file_path}", agent: @agent_name)
|
|
106
|
+
{ category: :tool_call, tool: tool_name, detail: file_path, file_path: file_path }
|
|
107
|
+
when 'Bash'
|
|
108
|
+
cmd = input['command'].to_s
|
|
109
|
+
truncated = cmd.length > 100 ? "#{cmd[0..97]}..." : cmd
|
|
110
|
+
@logger.info("[BASH] #{truncated}", agent: @agent_name)
|
|
111
|
+
{ category: :tool_call, tool: tool_name, detail: truncated, command: cmd }
|
|
112
|
+
else
|
|
113
|
+
build_read_tool_event(tool_name, input)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def build_read_tool_event(tool_name, input)
|
|
118
|
+
case tool_name
|
|
119
|
+
when 'Read'
|
|
120
|
+
{ category: :tool_call, tool: tool_name, detail: input['file_path'].to_s }
|
|
121
|
+
when 'Glob', 'Grep'
|
|
122
|
+
pattern = (input['pattern'] || input['glob']).to_s
|
|
123
|
+
{ category: :tool_call, tool: tool_name, detail: pattern }
|
|
124
|
+
else
|
|
125
|
+
{ category: :tool_call, tool: tool_name, detail: '' }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def parse_user(data)
|
|
130
|
+
content = data.dig('message', 'content')
|
|
131
|
+
return [] unless content.is_a?(Array)
|
|
132
|
+
|
|
133
|
+
content.filter_map do |block|
|
|
134
|
+
next unless block['type'] == 'tool_result'
|
|
135
|
+
|
|
136
|
+
process_tool_result(block)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def process_tool_result(block)
|
|
141
|
+
tool_info = @pending_tools[block['tool_use_id']]
|
|
142
|
+
return unless tool_info&.dig(:name) == 'Bash'
|
|
143
|
+
|
|
144
|
+
command = tool_info[:input]['command'].to_s
|
|
145
|
+
return unless command.match?(TEST_CMD_PATTERN)
|
|
146
|
+
|
|
147
|
+
result_text = extract_tool_text(block['content'])
|
|
148
|
+
passed = detect_test_pass(result_text)
|
|
149
|
+
cmd_label = command[TEST_CMD_PATTERN] || 'test'
|
|
150
|
+
@logger.info("[TEST] #{passed ? 'PASS' : 'FAIL'} (#{cmd_label})", agent: @agent_name)
|
|
151
|
+
|
|
152
|
+
{ category: :tool_result, is_test_result: true, passed: passed, command: cmd_label }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def parse_result(data)
|
|
156
|
+
@result_text = data['result'].to_s
|
|
157
|
+
@cost_usd = data['total_cost_usd']
|
|
158
|
+
@duration_ms = data['duration_ms']
|
|
159
|
+
@num_turns = data['num_turns']
|
|
160
|
+
@success = data['subtype'] == 'success'
|
|
161
|
+
|
|
162
|
+
cost_str = @cost_usd ? format('$%.4f', @cost_usd) : 'n/a'
|
|
163
|
+
dur_str = @duration_ms ? "#{(@duration_ms / 1000.0).round(1)}s" : 'n/a'
|
|
164
|
+
@logger.info("[DONE] #{@success ? 'success' : 'failed'}, #{cost_str}, #{dur_str}", agent: @agent_name)
|
|
165
|
+
|
|
166
|
+
[{ category: :result, subtype: data['subtype'], cost_usd: @cost_usd,
|
|
167
|
+
duration_ms: @duration_ms, num_turns: @num_turns }]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def extract_tool_text(content)
|
|
171
|
+
case content
|
|
172
|
+
when String then content
|
|
173
|
+
when Array then content.filter_map { |c| c['text'] if c['type'] == 'text' }.join("\n")
|
|
174
|
+
else ''
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def detect_test_pass(output)
|
|
179
|
+
return true if output.match?(/0 failures,\s*0 errors/)
|
|
180
|
+
return true if output.match?(/no offenses detected/i)
|
|
181
|
+
return true if output.match?(/test result: ok/i) # cargo test
|
|
182
|
+
return false if output.match?(/[1-9]\d* failures?/) || output.match?(/[1-9]\d* errors?/)
|
|
183
|
+
return false if output.match?(/FAIL/i) && !output.match?(/0 failed/i)
|
|
184
|
+
return true if output.match?(/passed/i) && !output.match?(/failed/i)
|
|
185
|
+
|
|
186
|
+
true # no obvious failure signal
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: auditor
|
|
3
|
+
description: Pre-merge gate audit — reviews changed files for security, patterns, tests, and data issues
|
|
4
|
+
tools: Read, Grep, Glob, Bash
|
|
5
|
+
disallowedTools: Write, Edit
|
|
6
|
+
model: sonnet
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Auditor Agent
|
|
10
|
+
|
|
11
|
+
You audit changed files as a pre-merge gate. You are read-only. Focus ONLY on files changed in the current branch.
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
1. Read CLAUDE.md for project conventions
|
|
16
|
+
2. Get list of changed files:
|
|
17
|
+
```bash
|
|
18
|
+
git diff main --name-only
|
|
19
|
+
```
|
|
20
|
+
3. Read every changed file in full
|
|
21
|
+
|
|
22
|
+
## Audit Checklist
|
|
23
|
+
|
|
24
|
+
For each changed file, check:
|
|
25
|
+
|
|
26
|
+
### Security
|
|
27
|
+
- Auth gaps or missing authorization
|
|
28
|
+
- Unvalidated user inputs
|
|
29
|
+
- Injection risks (SQL, command, XSS)
|
|
30
|
+
- Hardcoded secrets or credentials
|
|
31
|
+
- Missing access control checks
|
|
32
|
+
|
|
33
|
+
### Patterns
|
|
34
|
+
- Convention violations (check CLAUDE.md)
|
|
35
|
+
- Dead code or commented-out code
|
|
36
|
+
- Over-engineering or unnecessary abstractions
|
|
37
|
+
- Inconsistent naming or structure
|
|
38
|
+
|
|
39
|
+
### Error Handling
|
|
40
|
+
<%- if language == "ruby" -%>
|
|
41
|
+
- Bare `rescue` without specific exception types
|
|
42
|
+
- Swallowed exceptions (rescue with no action)
|
|
43
|
+
- Missing transaction boundaries for multi-record operations
|
|
44
|
+
<%- elsif language == "python" -%>
|
|
45
|
+
- Bare `except` without specific exception types
|
|
46
|
+
- Swallowed exceptions
|
|
47
|
+
- Missing database transaction boundaries
|
|
48
|
+
<%- else -%>
|
|
49
|
+
- Generic error catching without specific types
|
|
50
|
+
- Swallowed errors
|
|
51
|
+
- Missing error handling for async operations
|
|
52
|
+
<%- end -%>
|
|
53
|
+
|
|
54
|
+
### Test Coverage
|
|
55
|
+
- Are there tests for the changed code?
|
|
56
|
+
- Do tests cover error paths?
|
|
57
|
+
- Do tests cover edge cases?
|
|
58
|
+
|
|
59
|
+
### Data
|
|
60
|
+
- Potential N+1 queries or unbounded queries
|
|
61
|
+
- Missing database indexes for new queries
|
|
62
|
+
- Incorrect data types for the domain
|
|
63
|
+
|
|
64
|
+
## Output Format
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
## Audit Report
|
|
68
|
+
|
|
69
|
+
### Critical Issues (BLOCK if found)
|
|
70
|
+
[List any critical security or correctness issues. Use the word BLOCK for critical items.]
|
|
71
|
+
|
|
72
|
+
### Warnings
|
|
73
|
+
[Pattern violations, missing tests, minor issues]
|
|
74
|
+
|
|
75
|
+
### Observations
|
|
76
|
+
[Non-blocking notes and suggestions]
|
|
77
|
+
|
|
78
|
+
### Files Audited
|
|
79
|
+
- [file]: [status]
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Use the word **BLOCK** for any finding that should prevent merging. Only use BLOCK for:
|
|
83
|
+
- Authentication/authorization bypass
|
|
84
|
+
- Injection vulnerabilities
|
|
85
|
+
- Secrets exposure
|
|
86
|
+
- Data corruption risks
|
|
87
|
+
- Critical missing tests for dangerous operations
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: documenter
|
|
3
|
+
description: Reviews changes and adds missing documentation — API docs, inline comments, README, CHANGELOG
|
|
4
|
+
tools: Read, Write, Edit, Glob, Grep, Bash
|
|
5
|
+
model: sonnet
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Documenter Agent
|
|
9
|
+
|
|
10
|
+
You review code changes and add missing documentation. You do NOT modify any logic or tests — only documentation.
|
|
11
|
+
|
|
12
|
+
## Setup
|
|
13
|
+
|
|
14
|
+
1. Read CLAUDE.md for project conventions
|
|
15
|
+
2. Get the issue context: `gh issue view <number> --json title,body` (if provided)
|
|
16
|
+
3. Get the diff: `git diff main --stat` then `git diff main`
|
|
17
|
+
4. Read the issue's "Documentation" section for specific requirements
|
|
18
|
+
|
|
19
|
+
## What to Document
|
|
20
|
+
|
|
21
|
+
### Inline Comments
|
|
22
|
+
|
|
23
|
+
Add comments ONLY for non-obvious logic:
|
|
24
|
+
- Complex algorithms or calculations
|
|
25
|
+
- Business rules that aren't self-evident from the code
|
|
26
|
+
- Workarounds with context on why they're needed
|
|
27
|
+
- Regular expressions or complex queries
|
|
28
|
+
|
|
29
|
+
Do NOT add comments for:
|
|
30
|
+
- Self-explanatory code
|
|
31
|
+
- Method signatures that are clear from naming
|
|
32
|
+
- Simple CRUD operations
|
|
33
|
+
- Code you didn't change (unless the issue specifically asks)
|
|
34
|
+
|
|
35
|
+
### CLAUDE.md / Project Documentation Updates
|
|
36
|
+
|
|
37
|
+
If the changes introduce:
|
|
38
|
+
- New API routes or endpoints — document them
|
|
39
|
+
- New conventions or patterns — add to conventions section
|
|
40
|
+
- New environment variables — document configuration
|
|
41
|
+
- New development commands — add to developer guide
|
|
42
|
+
|
|
43
|
+
### README Updates
|
|
44
|
+
|
|
45
|
+
If the changes add user-facing features, update the README if one exists and it documents features.
|
|
46
|
+
|
|
47
|
+
### CHANGELOG
|
|
48
|
+
|
|
49
|
+
If a CHANGELOG exists, add an entry for this change.
|
|
50
|
+
|
|
51
|
+
## Rules
|
|
52
|
+
|
|
53
|
+
- Do NOT modify any application logic, tests, or configuration
|
|
54
|
+
- Do NOT add excessive comments — favor clean, self-documenting code
|
|
55
|
+
- Do NOT create new documentation files unless the issue specifically requests it
|
|
56
|
+
- Keep documentation concise
|
|
57
|
+
- Match the documentation style already in the project
|
|
58
|
+
|
|
59
|
+
## Output
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
## Documentation Changes
|
|
63
|
+
- [file]: [what was documented and why]
|
|
64
|
+
|
|
65
|
+
## Skipped
|
|
66
|
+
- [anything from the issue's documentation requirements that wasn't needed, with reason]
|
|
67
|
+
```
|