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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +268 -0
  4. data/bin/ocak +7 -0
  5. data/lib/ocak/agent_generator.rb +171 -0
  6. data/lib/ocak/claude_runner.rb +169 -0
  7. data/lib/ocak/cli.rb +28 -0
  8. data/lib/ocak/commands/audit.rb +25 -0
  9. data/lib/ocak/commands/clean.rb +30 -0
  10. data/lib/ocak/commands/debt.rb +21 -0
  11. data/lib/ocak/commands/design.rb +34 -0
  12. data/lib/ocak/commands/init.rb +212 -0
  13. data/lib/ocak/commands/resume.rb +128 -0
  14. data/lib/ocak/commands/run.rb +60 -0
  15. data/lib/ocak/commands/status.rb +102 -0
  16. data/lib/ocak/config.rb +109 -0
  17. data/lib/ocak/issue_fetcher.rb +137 -0
  18. data/lib/ocak/logger.rb +192 -0
  19. data/lib/ocak/merge_manager.rb +158 -0
  20. data/lib/ocak/pipeline_runner.rb +389 -0
  21. data/lib/ocak/pipeline_state.rb +51 -0
  22. data/lib/ocak/planner.rb +68 -0
  23. data/lib/ocak/process_runner.rb +82 -0
  24. data/lib/ocak/stack_detector.rb +333 -0
  25. data/lib/ocak/stream_parser.rb +189 -0
  26. data/lib/ocak/templates/agents/auditor.md.erb +87 -0
  27. data/lib/ocak/templates/agents/documenter.md.erb +67 -0
  28. data/lib/ocak/templates/agents/implementer.md.erb +154 -0
  29. data/lib/ocak/templates/agents/merger.md.erb +97 -0
  30. data/lib/ocak/templates/agents/pipeline.md.erb +126 -0
  31. data/lib/ocak/templates/agents/planner.md.erb +86 -0
  32. data/lib/ocak/templates/agents/reviewer.md.erb +98 -0
  33. data/lib/ocak/templates/agents/security_reviewer.md.erb +112 -0
  34. data/lib/ocak/templates/gitignore_additions.txt +10 -0
  35. data/lib/ocak/templates/hooks/post_edit_lint.sh.erb +57 -0
  36. data/lib/ocak/templates/hooks/task_completed_test.sh.erb +34 -0
  37. data/lib/ocak/templates/ocak.yml.erb +99 -0
  38. data/lib/ocak/templates/skills/audit/SKILL.md.erb +132 -0
  39. data/lib/ocak/templates/skills/debt/SKILL.md.erb +128 -0
  40. data/lib/ocak/templates/skills/design/SKILL.md.erb +131 -0
  41. data/lib/ocak/templates/skills/scan_file/SKILL.md.erb +113 -0
  42. data/lib/ocak/verification.rb +83 -0
  43. data/lib/ocak/worktree_manager.rb +92 -0
  44. data/lib/ocak.rb +13 -0
  45. 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
+ ```