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
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,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
|