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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 17cdf9a84ce2be155679a0087de97b587ca6746cab5556eebfac6107b501651b
4
+ data.tar.gz: 972f1ea39c842e6b3719b8a8dfd2af1618f358105c5c9ec1c69e376b1c2ea2f1
5
+ SHA512:
6
+ metadata.gz: a20c0599a4a31c6eda56617a9cbb34c73b6dbbbcb41fdf712917659288474dc9a4eb795f90b848b9732022060aab79a395cce7673848d0dec112410b849b94f8
7
+ data.tar.gz: 96bf0277c41027c9335e62628fc4dca32a77c932cf861cfe5205981c74911278308181449793e9568e4f453a8f7d7630b1981b798e7492333a9d1b58fecf7d61
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Clay Harmon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,268 @@
1
+ # Ocak
2
+
3
+ *Ocak (pronounced "oh-JAHK") is Turkish for "forge" or "hearth" — the place where raw material meets fire and becomes something useful. Also: let 'em cook.*
4
+
5
+ Multi-agent pipeline that processes GitHub issues autonomously with Claude Code. You write an issue, label it, and ocak runs it through implement -> review -> fix -> security review -> document -> audit -> merge. Each issue gets its own worktree so they can run in parallel.
6
+
7
+ ## Quick Start
8
+
9
+ ```bash
10
+ gem install ocak
11
+
12
+ # In your project directory:
13
+ ocak init
14
+
15
+ # Create an issue (interactive):
16
+ # Inside Claude Code, run /design
17
+
18
+ # Process all ready issues:
19
+ ocak run --once --watch
20
+
21
+ # Or run a single issue:
22
+ ocak run --single 42 --watch
23
+ ```
24
+
25
+ ## How It Works
26
+
27
+ ### The Pipeline
28
+
29
+ ```
30
+ /design Label Pipeline
31
+ ┌──────┐ ┌──────────┐ ┌───────────────────────────────────────────┐
32
+ │ User │───>│auto-ready│───>│ implement → review → fix → security → │
33
+ │ idea │ │ label │ │ document → audit → merge PR │
34
+ └──────┘ └──────────┘ └───────────────────────────────────────────┘
35
+ │ │ │
36
+ ▼ ▼ ▼
37
+ worktree read-only sequential
38
+ per issue reviews rebase+merge
39
+ ```
40
+
41
+ 1. **Design** — `/design` in Claude Code walks you through creating an issue thats detailed enough for agents to work from
42
+ 2. **Label** — slap the `auto-ready` label on it
43
+ 3. **Plan** — planner agent figures out which issues can safely run in parallel
44
+ 4. **Execute** — each issue gets a worktree, runs through the pipeline steps
45
+ 5. **Merge** — completed work gets rebased, tested, and merged sequentially
46
+
47
+ ### Agents
48
+
49
+ 8 agents, each with scoped tool permisions:
50
+
51
+ | Agent | Role | Tools | Model |
52
+ |-------|------|-------|-------|
53
+ | **implementer** | Write code and tests | Read, Write, Edit, Bash | opus |
54
+ | **reviewer** | Check patterns, tests, quality | Read, Grep, Glob (read-only) | sonnet |
55
+ | **security-reviewer** | OWASP Top 10, auth, injection | Read, Grep, Glob, Bash | sonnet |
56
+ | **auditor** | Pre-merge gate on changed files | Read, Grep, Glob (read-only) | sonnet |
57
+ | **documenter** | Add missing docs | Read, Write, Edit | sonnet |
58
+ | **merger** | Create PR, merge, close issue | Read, Grep, Bash | sonnet |
59
+ | **planner** | Determine safe parallelization | Read, Grep, Glob (read-only) | sonnet |
60
+ | **pipeline** | Self-contained orchestrator | All tools | opus |
61
+
62
+ ### Skills
63
+
64
+ Interactive skills for when you want to be in the loop:
65
+
66
+ - `/design` — walks through your codebase, asks questions, produces a detailed issue
67
+ - `/audit [scope]` — codebase sweep for security, patterns, tests, data, dependencies
68
+ - `/scan-file <path>` — deep single-file analysis with test coverage check
69
+ - `/debt` — tech debt tracker with risk scoring
70
+
71
+ ### Label State Machine
72
+
73
+ ```
74
+ auto-ready ──→ in-progress ──→ completed
75
+
76
+ └──→ pipeline-failed
77
+ ```
78
+
79
+ ## Configuration
80
+
81
+ `ocak init` generates `ocak.yml` at your project root:
82
+
83
+ ```yaml
84
+ # Auto-detected project stack
85
+ stack:
86
+ language: ruby
87
+ framework: rails
88
+ test_command: "bundle exec rspec"
89
+ lint_command: "bundle exec rubocop -A"
90
+ security_commands:
91
+ - "bundle exec brakeman -q"
92
+ - "bundle exec bundler-audit check"
93
+
94
+ # Pipeline settings
95
+ pipeline:
96
+ max_parallel: 3 # Concurrent worktrees
97
+ poll_interval: 60 # Seconds between polls
98
+ worktree_dir: ".claude/worktrees"
99
+ log_dir: "logs/pipeline"
100
+
101
+ # GitHub labels
102
+ labels:
103
+ ready: "auto-ready"
104
+ in_progress: "in-progress"
105
+ completed: "completed"
106
+ failed: "pipeline-failed"
107
+
108
+ # Pipeline steps — add, remove, reorder as you like
109
+ steps:
110
+ - agent: implementer
111
+ role: implement
112
+ - agent: reviewer
113
+ role: review
114
+ - agent: implementer
115
+ role: fix
116
+ condition: has_findings # Only runs if reviewer found issues
117
+ - agent: reviewer
118
+ role: verify
119
+ condition: had_fixes # Only runs if fixes were made
120
+ - agent: security_reviewer
121
+ role: security
122
+ - agent: implementer
123
+ role: fix
124
+ condition: has_findings
125
+ - agent: documenter
126
+ role: document
127
+ - agent: auditor
128
+ role: audit
129
+ - agent: merger
130
+ role: merge
131
+
132
+ # Override agent files
133
+ agents:
134
+ implementer: .claude/agents/implementer.md
135
+ reviewer: .claude/agents/reviewer.md
136
+ # ...
137
+ ```
138
+
139
+ ## Customization
140
+
141
+ ### Swap Agents
142
+
143
+ Point any agent at a custom file:
144
+
145
+ ```yaml
146
+ agents:
147
+ reviewer: .claude/agents/my-custom-reviewer.md
148
+ ```
149
+
150
+ ### Change Pipeline Steps
151
+
152
+ Remove steps you don't need, add your own, reorder them:
153
+
154
+ ```yaml
155
+ steps:
156
+ - agent: implementer
157
+ role: implement
158
+ - agent: reviewer
159
+ role: review
160
+ - agent: merger
161
+ role: merge
162
+ ```
163
+
164
+ ### Add Custom Agents
165
+
166
+ Create a markdown file with YAML frontmatter:
167
+
168
+ ```markdown
169
+ ---
170
+ name: my-agent
171
+ description: Does something specific
172
+ tools: Read, Glob, Grep, Bash
173
+ model: sonnet
174
+ ---
175
+
176
+ # My Custom Agent
177
+
178
+ [Instructions for the agent...]
179
+ ```
180
+
181
+ Then reference it in `ocak.yml`:
182
+
183
+ ```yaml
184
+ agents:
185
+ my_agent: .claude/agents/my-agent.md
186
+
187
+ steps:
188
+ - agent: my_agent
189
+ role: custom_step
190
+ ```
191
+
192
+ ## Writing Good Issues
193
+
194
+ The `/design` skill produces issues formatted for zero-context agents. Think of it as writing a ticket for a contractor who's never seen your codebase — everthing they need should be in the issue body. The key sections:
195
+
196
+ - **Context** — what part of the system, with specific file paths
197
+ - **Acceptance Criteria** — "when X, then Y" format, each independantly testable
198
+ - **Implementation Guide** — exact files to create/modify
199
+ - **Patterns to Follow** — references to actual files in the codebase
200
+ - **Security Considerations** — auth, validation, data exposure
201
+ - **Test Requirements** — specific test cases with file paths
202
+ - **Out of Scope** — explicit boundaries so it doesnt scope creep
203
+
204
+ ## CLI Reference
205
+
206
+ ```
207
+ ocak init [--force] [--no-ai] Set up pipeline in current project
208
+ ocak run [options] Run the pipeline
209
+ --watch Stream agent activity with color
210
+ --single N Run one issue, no worktrees
211
+ --dry-run Show plan without executing
212
+ --once Process current batch and exit
213
+ --max-parallel N Limit concurrency (default: 3)
214
+ --poll-interval N Seconds between polls (default: 60)
215
+ ocak status Show pipeline state
216
+ ocak clean Remove stale worktrees
217
+ ocak design [description] Launch issue design session
218
+ ocak audit [scope] Run codebase audit
219
+ ocak debt Track technical debt
220
+ ```
221
+
222
+ ## FAQ
223
+
224
+ **How much does it cost?**
225
+
226
+ Depends on the issue. Simple stuff is $2-5, complex issues can be $10-15. The implementer runs on opus which is the expensive part, reviews on sonnet are pretty cheap. You can see costs in the `--watch` output.
227
+
228
+ **Is it safe?**
229
+
230
+ Reasonably. Review agents are read-only (no Write/Edit tools), merging is sequential so you don't get conflicts, and failed piplines get labeled and logged. You can always `--dry-run` first to see what it would do.
231
+
232
+ **What if it breaks?**
233
+
234
+ Issues get labeled `pipeline-failed` with a comment explaining what went wrong. Worktrees get cleaned up automatically. Run `ocak clean` to remove any stragglers, and check `logs/pipeline/` for detailed logs.
235
+
236
+ **Can I run one issue manually?**
237
+
238
+ ```bash
239
+ ocak run --single 42 --watch
240
+ ```
241
+
242
+ Runs the full pipeline for issue #42 in your current checkout (no worktree).
243
+
244
+ **How do I pause it?**
245
+
246
+ Kill the `ocak run` process. Issues that are `in-progress` will keep their label — remove it manually or let the next run pick them back up.
247
+
248
+ **What languages does it support?**
249
+
250
+ `ocak init` auto-detects Ruby, TypeScript/JavaScript, Python, Rust, Go, Java, and Elixir. Agents get generated with stack-specific instructions. For anything else you get generic agents that you can customize.
251
+
252
+ ## Development
253
+
254
+ ```bash
255
+ git clone https://github.com/clayharmon/ocak
256
+ cd ocak
257
+ bundle install
258
+ bundle exec rspec
259
+ bundle exec rubocop
260
+ ```
261
+
262
+ ## Contributing
263
+
264
+ Bug reports and pull requests welcome on GitHub.
265
+
266
+ ## License
267
+
268
+ MIT. See [LICENSE.txt](LICENSE.txt).
data/bin/ocak ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'ocak'
5
+ require 'ocak/cli'
6
+
7
+ Dry::CLI.new(Ocak::CLI::Commands).call
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'fileutils'
5
+
6
+ module Ocak
7
+ class AgentGenerator
8
+ AGENT_TEMPLATES = %w[
9
+ implementer reviewer security_reviewer documenter
10
+ merger pipeline planner auditor
11
+ ].freeze
12
+
13
+ SKILL_TEMPLATES = %w[design audit scan_file debt].freeze
14
+
15
+ def initialize(stack:, project_dir:, use_ai: true, logger: nil)
16
+ @stack = stack
17
+ @project_dir = project_dir
18
+ @use_ai = use_ai
19
+ @logger = logger
20
+ end
21
+
22
+ def generate_agents(output_dir)
23
+ FileUtils.mkdir_p(output_dir)
24
+
25
+ AGENT_TEMPLATES.each do |agent|
26
+ template_path = File.join(Ocak.templates_dir, 'agents', "#{agent}.md.erb")
27
+ output_name = agent.tr('_', '-')
28
+ output_path = File.join(output_dir, "#{output_name}.md")
29
+
30
+ content = render_template(template_path)
31
+ File.write(output_path, content)
32
+ @logger&.info("Generated agent: #{output_name}.md")
33
+ end
34
+
35
+ enhance_with_ai(output_dir) if @use_ai
36
+ end
37
+
38
+ def generate_skills(output_dir)
39
+ SKILL_TEMPLATES.each do |skill|
40
+ skill_dir = File.join(output_dir, skill.tr('_', '-'))
41
+ FileUtils.mkdir_p(skill_dir)
42
+
43
+ template_path = File.join(Ocak.templates_dir, 'skills', skill, 'SKILL.md.erb')
44
+ output_path = File.join(skill_dir, 'SKILL.md')
45
+
46
+ content = render_template(template_path)
47
+ File.write(output_path, content)
48
+ @logger&.info("Generated skill: #{skill.tr('_', '-')}/SKILL.md")
49
+ end
50
+ end
51
+
52
+ def generate_hooks(output_dir)
53
+ FileUtils.mkdir_p(output_dir)
54
+
55
+ %w[post_edit_lint task_completed_test].each do |hook|
56
+ template_path = File.join(Ocak.templates_dir, 'hooks', "#{hook}.sh.erb")
57
+ output_name = hook.tr('_', '-')
58
+ output_path = File.join(output_dir, "#{output_name}.sh")
59
+
60
+ content = render_template(template_path)
61
+ File.write(output_path, content)
62
+ File.chmod(0o755, output_path)
63
+ @logger&.info("Generated hook: #{output_name}.sh")
64
+ end
65
+ end
66
+
67
+ def generate_config(output_path)
68
+ template_path = File.join(Ocak.templates_dir, 'ocak.yml.erb')
69
+ content = render_template(template_path)
70
+ File.write(output_path, content)
71
+ @logger&.info('Generated ocak.yml')
72
+ end
73
+
74
+ private
75
+
76
+ def render_template(template_path)
77
+ template = ERB.new(File.read(template_path), trim_mode: '-')
78
+ template.result(template_binding)
79
+ end
80
+
81
+ def template_binding
82
+ language = @stack.language
83
+ framework = @stack.framework
84
+ test_command = @stack.test_command
85
+ lint_command = @stack.lint_command
86
+ format_command = @stack.format_command
87
+ security_commands = @stack.security_commands
88
+ setup_command = @stack.setup_command
89
+ monorepo = @stack.respond_to?(:monorepo) ? @stack.monorepo : false
90
+ packages = @stack.respond_to?(:packages) ? (@stack.packages || []) : []
91
+ project_dir = @project_dir
92
+
93
+ binding
94
+ end
95
+
96
+ def enhance_with_ai(output_dir)
97
+ return unless claude_available?
98
+
99
+ @logger&.info('Enhancing agents with project analysis via Claude...')
100
+
101
+ # Read project context
102
+ context = gather_project_context
103
+ return if context.empty?
104
+
105
+ AGENT_TEMPLATES.each do |agent|
106
+ output_name = agent.tr('_', '-')
107
+ agent_path = File.join(output_dir, "#{output_name}.md")
108
+ current_content = File.read(agent_path)
109
+
110
+ prompt = build_enhancement_prompt(agent, current_content, context)
111
+ result = run_claude_prompt(prompt)
112
+
113
+ if result && !result.strip.empty? && result.include?('---')
114
+ File.write(agent_path, result)
115
+ @logger&.info("Enhanced agent with project context: #{output_name}.md")
116
+ end
117
+ end
118
+ end
119
+
120
+ def gather_project_context
121
+ parts = []
122
+
123
+ claude_md = File.join(@project_dir, 'CLAUDE.md')
124
+ parts << "## CLAUDE.md\n#{File.read(claude_md)}" if File.exist?(claude_md)
125
+
126
+ readme = File.join(@project_dir, 'README.md')
127
+ parts << "## README.md\n#{File.read(readme)[0..2000]}" if File.exist?(readme)
128
+
129
+ parts.join("\n\n")
130
+ end
131
+
132
+ def build_enhancement_prompt(_agent, template_content, context)
133
+ <<~PROMPT
134
+ You are customizing a Claude Code agent for a specific project.
135
+
136
+ Here is the project context:
137
+ #{context}
138
+
139
+ Here is the base agent template:
140
+ #{template_content}
141
+
142
+ Customize this agent to reference this project's actual conventions, file paths,
143
+ and patterns. Keep the same structure (YAML frontmatter + markdown). Keep the same
144
+ tool permissions. Make the instructions more specific to this project.
145
+
146
+ Output ONLY the complete agent markdown file, nothing else.
147
+ PROMPT
148
+ end
149
+
150
+ def claude_available?
151
+ _, _, status = Open3.capture3('which', 'claude')
152
+ status.success?
153
+ rescue Errno::ENOENT
154
+ false
155
+ end
156
+
157
+ def run_claude_prompt(prompt)
158
+ stdout, _, status = Open3.capture3(
159
+ 'claude', '-p',
160
+ '--output-format', 'text',
161
+ '--model', 'haiku',
162
+ '--allowedTools', 'Read,Glob,Grep',
163
+ '--', prompt,
164
+ chdir: @project_dir
165
+ )
166
+ status.success? ? stdout : nil
167
+ rescue Errno::ENOENT
168
+ nil
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'json'
5
+ require_relative 'process_runner'
6
+ require_relative 'stream_parser'
7
+
8
+ module Ocak
9
+ class ClaudeRunner
10
+ AgentResult = Struct.new(:success, :output, :cost_usd, :duration_ms,
11
+ :num_turns, :files_edited) do
12
+ def success? = success
13
+ def blocking_findings? = output.to_s.include?("\u{1F534}")
14
+ def warnings? = output.to_s.include?("\u{1F7E1}")
15
+ end
16
+
17
+ FailedStatus = Struct.new(:success?) do
18
+ def self.instance = new(false)
19
+ end
20
+
21
+ AGENT_TOOLS = {
22
+ 'implementer' => 'Read,Write,Edit,Glob,Grep,Bash',
23
+ 'reviewer' => 'Read,Grep,Glob,Bash',
24
+ 'security-reviewer' => 'Read,Grep,Glob,Bash',
25
+ 'auditor' => 'Read,Grep,Glob,Bash',
26
+ 'documenter' => 'Read,Write,Edit,Glob,Grep,Bash',
27
+ 'merger' => 'Read,Glob,Grep,Bash',
28
+ 'pipeline' => 'Read,Write,Edit,Glob,Grep,Bash',
29
+ 'planner' => 'Read,Glob,Grep,Bash'
30
+ }.freeze
31
+
32
+ AGENT_MODELS = {
33
+ 'planner' => 'haiku',
34
+ 'reviewer' => 'sonnet',
35
+ 'security-reviewer' => 'sonnet',
36
+ 'auditor' => 'sonnet',
37
+ 'documenter' => 'sonnet',
38
+ 'merger' => 'sonnet',
39
+ 'implementer' => nil,
40
+ 'pipeline' => nil
41
+ }.freeze
42
+
43
+ TIMEOUT = 600 # 10 minutes per agent invocation
44
+ MAX_RETRIES = 2
45
+ RETRY_DELAYS = [5, 15].freeze
46
+
47
+ TRANSIENT_PATTERNS = [
48
+ /connection.*reset/i,
49
+ /timed?\s*out/i,
50
+ /ECONNREFUSED/,
51
+ /rate\s*limit/i,
52
+ /503|502|429/,
53
+ /overloaded/i
54
+ ].freeze
55
+
56
+ def initialize(config:, logger:, watch: nil)
57
+ @config = config
58
+ @logger = logger
59
+ @watch = watch
60
+ end
61
+
62
+ def run_agent(agent_name, prompt, chdir: nil, model: nil, retries: MAX_RETRIES)
63
+ chdir ||= @config.project_dir
64
+ agent_file = @config.agent_path(agent_name)
65
+
66
+ unless File.exist?(agent_file)
67
+ @logger.error("Agent file not found: #{agent_file}", agent: agent_name)
68
+ return AgentResult.new(success: false, output: "Agent file not found: #{agent_file}")
69
+ end
70
+
71
+ instructions = File.read(agent_file)
72
+ full_prompt = "#{instructions}\n\n---\n\nTask: #{prompt}"
73
+ allowed_tools = AGENT_TOOLS.fetch(agent_name, 'Read,Glob,Grep,Bash')
74
+ agent_model = model || AGENT_MODELS[agent_name]
75
+
76
+ run_with_retry(agent_name, full_prompt, allowed_tools, agent_model, chdir: chdir, retries: retries)
77
+ end
78
+
79
+ # Run a raw prompt without agent file (for planner, init analysis, etc.)
80
+ def run_prompt(prompt, allowed_tools: 'Read,Glob,Grep,Bash', chdir: nil, model: nil)
81
+ chdir ||= @config.project_dir
82
+
83
+ stdout, _, status = run_claude(prompt, allowed_tools, chdir: chdir, model: model)
84
+
85
+ # Try to extract result from stream-json, fall back to raw stdout
86
+ result_text = extract_result_from_stream(stdout) || stdout
87
+ success = status.respond_to?(:success?) && status.success?
88
+
89
+ AgentResult.new(success: success, output: result_text)
90
+ end
91
+
92
+ private
93
+
94
+ def run_with_retry(agent_name, full_prompt, allowed_tools, model, chdir:, retries:)
95
+ attempts = 0
96
+
97
+ loop do
98
+ @logger.info("Running agent: #{agent_name}#{" (model: #{model})" if model}", agent: agent_name)
99
+
100
+ parser = StreamParser.new(agent_name, @logger)
101
+ line_handler = build_line_handler(agent_name, parser)
102
+
103
+ _, stderr, status = run_claude(full_prompt, allowed_tools, chdir: chdir, on_line: line_handler, model: model)
104
+ result = build_agent_result(parser, status, stderr, agent_name)
105
+
106
+ return result if result.success? || attempts >= retries || !transient_failure?(result, stderr.to_s)
107
+
108
+ attempts += 1
109
+ delay = RETRY_DELAYS[attempts - 1] || RETRY_DELAYS.last
110
+ @logger.warn("Transient failure, retrying #{agent_name} (attempt #{attempts + 1}/#{retries + 1}) " \
111
+ "after #{delay}s...", agent: agent_name)
112
+ sleep delay
113
+ end
114
+ end
115
+
116
+ def transient_failure?(result, stderr)
117
+ combined = "#{result.output}\n#{stderr}"
118
+ TRANSIENT_PATTERNS.any? { |pat| combined.match?(pat) }
119
+ end
120
+
121
+ def build_agent_result(parser, status, stderr, agent_name)
122
+ output = parser.result_text || ''
123
+ exit_ok = status.respond_to?(:success?) && status.success?
124
+ success = parser.success? && exit_ok
125
+
126
+ @logger.info("Finished (exit: #{exit_ok}, stream: #{parser.success?})", agent: agent_name)
127
+ @logger.warn("Stderr: #{stderr[0..300]}", agent: agent_name) unless stderr.to_s.empty?
128
+
129
+ AgentResult.new(
130
+ success: success,
131
+ output: output,
132
+ cost_usd: parser.cost_usd,
133
+ duration_ms: parser.duration_ms,
134
+ num_turns: parser.num_turns,
135
+ files_edited: parser.files_edited
136
+ )
137
+ end
138
+
139
+ def build_line_handler(agent_name, parser)
140
+ lambda do |line|
141
+ events = parser.parse_line(line)
142
+ events.each { |event| @watch&.emit(agent_name, event) }
143
+ end
144
+ end
145
+
146
+ def run_claude(prompt, allowed_tools, chdir:, on_line: nil, model: nil)
147
+ cmd = [
148
+ 'claude', '-p',
149
+ '--verbose',
150
+ '--output-format', 'stream-json',
151
+ '--allowedTools', allowed_tools
152
+ ]
153
+ cmd.push('--model', model) if model
154
+ cmd.push('--', prompt)
155
+
156
+ ProcessRunner.run(cmd, chdir: chdir, timeout: TIMEOUT, on_line: on_line)
157
+ end
158
+
159
+ def extract_result_from_stream(raw)
160
+ raw.each_line do |line|
161
+ data = JSON.parse(line.strip)
162
+ return data['result'] if data['type'] == 'result'
163
+ rescue JSON::ParserError
164
+ next
165
+ end
166
+ nil
167
+ end
168
+ end
169
+ end
data/lib/ocak/cli.rb ADDED
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/cli'
4
+ require_relative 'commands/init'
5
+ require_relative 'commands/run'
6
+ require_relative 'commands/design'
7
+ require_relative 'commands/audit'
8
+ require_relative 'commands/debt'
9
+ require_relative 'commands/status'
10
+ require_relative 'commands/clean'
11
+ require_relative 'commands/resume'
12
+
13
+ module Ocak
14
+ module CLI
15
+ module Commands
16
+ extend Dry::CLI::Registry
17
+
18
+ register 'init', Ocak::Commands::Init
19
+ register 'run', Ocak::Commands::Run
20
+ register 'design', Ocak::Commands::Design
21
+ register 'audit', Ocak::Commands::Audit
22
+ register 'debt', Ocak::Commands::Debt
23
+ register 'status', Ocak::Commands::Status
24
+ register 'clean', Ocak::Commands::Clean
25
+ register 'resume', Ocak::Commands::Resume
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ocak
4
+ module Commands
5
+ class Audit < Dry::CLI::Command
6
+ desc 'Run codebase audit'
7
+
8
+ argument :scope, type: :string, required: false,
9
+ desc: 'Audit scope: security, tests, patterns, debt, dependencies, or all'
10
+
11
+ def call(scope: nil, **)
12
+ skill_path = File.join(Dir.pwd, '.claude', 'skills', 'audit', 'SKILL.md')
13
+
14
+ unless File.exist?(skill_path)
15
+ warn 'No audit skill found. Run `ocak init` first.'
16
+ exit 1
17
+ end
18
+
19
+ puts "Starting audit#{" (scope: #{scope})" if scope}..."
20
+ puts 'Run this inside Claude Code:'
21
+ puts " /audit#{" #{scope}" if scope}"
22
+ end
23
+ end
24
+ end
25
+ end