ocak 0.5.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bb7c3ddd6ff9b6dacc60c4ee6f3fabae90089eace4e457a28e824be15a901cda
4
- data.tar.gz: c9b5a76e36361dddb4731fff6298388f0246421272923951f83fdcebde9e5d4b
3
+ metadata.gz: 21e35193d8d3fc3b9c24f7e75502749ce4f9a57352da20d9bb0c980c2191ad9a
4
+ data.tar.gz: 2204cd3797a57348f3a3742fa17385375050d8b66fe37d4bdb466a4f603f31dd
5
5
  SHA512:
6
- metadata.gz: 9f37e6ad101953a378c73a1c76817e194386424174a87841d9f13321a4b465c5708ecb374f0526d2e2744bbcd19bd2473727ea288205dd731325ba80164fcec8
7
- data.tar.gz: 18301a7948e7b979f7ee0daecd75369477ddbcd147d12d67305fdc55a7aeab44e371bd41859135f5c8105d5886ff49663f77cc61afaad9b9872a13581bac15f1
6
+ metadata.gz: 7fddde997f1ba1c8f5e5e9db9e3f6dc9281ac1db82743b651f72f658967fcefeca67b05c5e338dbbaad9be798adae10f24807d1fec3ddd9975e72daba853f709
7
+ data.tar.gz: 0d5f6f2f9e7300eb8b242e654296dd156192e4993976a40d0ebb050a1bf417e9b36e85f1b6b4f4e8e99cc1e375275be5175f6ef9489056c2dcc1d3025cffaa9e
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Ocak
2
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.*
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
4
 
5
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 parrallel.
6
6
 
@@ -83,8 +83,8 @@ stateDiagram-v2
83
83
  ### Complexity Classification
84
84
 
85
85
  The planner classifes each issue as `simple` or `full`:
86
- - **Simple** skips security review, second fix pass, documenter, and auditor
87
- - **Full** runs the whole thing
86
+ - **Simple** - skips security review, second fix pass, documenter, and auditor
87
+ - **Full** - runs the whole thing
88
88
 
89
89
  `--fast` forces all issues to `simple`, giving you: implement → review → fix (if needed) → verify (if fixed) → merge.
90
90
 
@@ -102,7 +102,7 @@ flowchart LR
102
102
  E --> F["Merger agent<br>create PR + merge"]
103
103
  ```
104
104
 
105
- Merging is sequential one at a time so you dont get conflicts between parallel worktrees.
105
+ Merging is sequential - one at a time - so you dont get conflicts between parallel worktrees.
106
106
 
107
107
  ## Agents
108
108
 
@@ -119,7 +119,7 @@ Merging is sequential — one at a time — so you dont get conflicts between pa
119
119
  | **planner** | Batch issues, classify complexity | Read, Glob, Grep, Bash (read-only) | haiku |
120
120
  | **pipeline** | Self-contained single-agent mode | All tools | opus |
121
121
 
122
- Review agents (reviewer, security-reviewer, auditor) have no Write/Edit access they can only read and report stuff back.
122
+ Review agents (reviewer, security-reviewer, auditor) have no Write/Edit access - they can only read and report stuff back.
123
123
 
124
124
  ## Skills
125
125
 
@@ -128,13 +128,13 @@ Interactive skills for use inside Claude Code:
128
128
  | Skill | Description |
129
129
  |-------|-------------|
130
130
  | `/design [description]` | Researches your codebase, asks clarifying questions, produces a detailed implementation-ready issue |
131
- | `/audit [scope]` | Codebase sweep scopes: `security`, `errors`, `patterns`, `tests`, `data`, `dependencies`, or `all` |
131
+ | `/audit [scope]` | Codebase sweep - scopes: `security`, `errors`, `patterns`, `tests`, `data`, `dependencies`, or `all` |
132
132
  | `/scan-file <path>` | Deep single-file analysis with test coverage check, scored 1-10 per method |
133
133
  | `/debt` | Tech debt tracker with risk scoring (churn, coverage, suppressions, age, blast radius) |
134
134
 
135
135
  ## Modes
136
136
 
137
- ### Full Pipeline `ocak run`
137
+ ### Full Pipeline - `ocak run`
138
138
 
139
139
  The default. Polls for `auto-ready` issues, plans batches, runs the full step sequence in parallel worktrees, merges sequentally.
140
140
 
@@ -146,7 +146,7 @@ ocak run --audit --watch # Auditor as merge gate
146
146
  ocak run --fast --watch # Skip security/docs/audit steps
147
147
  ```
148
148
 
149
- ### Fast Mode `ocak hiz`
149
+ ### Fast Mode - `ocak hiz`
150
150
 
151
151
  Lightweight alternative for quick PRs you'll review youself:
152
152
 
@@ -186,11 +186,41 @@ ocak run 1 --watch
186
186
 
187
187
  Issues live in `.ocak/issues/0001.md`, `.ocak/issues/0002.md`, etc. Labels, complexity, and pipeline comments are all tracked in the file. Merging goes straight to main (no PRs) via `LocalMergeManager`.
188
188
 
189
+ ### Multi-Repo Mode
190
+
191
+ Run issues across multiple repos from a single issue tracker. One "god repo" holds all the issues, agents run in worktrees of whatever repo the issue targets.
192
+
193
+ Each issue specifies its target with YAML front-matter at the top of the body:
194
+
195
+ ```
196
+ ---
197
+ target_repo: my-service
198
+ ---
199
+
200
+ Actual issue description here...
201
+ ```
202
+
203
+ Enable it in `ocak.yml`:
204
+
205
+ ```yaml
206
+ multi_repo: true
207
+ ```
208
+
209
+ Map repo names to local paths in `~/.config/ocak/config.yml`:
210
+
211
+ ```yaml
212
+ repos:
213
+ my-service: ~/dev/my-service
214
+ other-thing: ~/dev/other-thing
215
+ ```
216
+
217
+ Labels and comments stay on the god repo's issues. PRs get created in the target repo. Worktree isolation, parallel batches, sequential merging all work the same.
218
+
189
219
  ### Graceful Shutdown
190
220
 
191
- `Ctrl+C` once current agent step finishes, then the pipeline stops. WIP gets committed, labels reset to `auto-ready`, and resume commands are printed.
221
+ `Ctrl+C` once - current agent step finishes, then the pipeline stops. WIP gets committed, labels reset to `auto-ready`, and resume commands are printed.
192
222
 
193
- `Ctrl+C` twice kills active subprocesses immediatley (SIGTERM → wait → SIGKILL), then same cleanup runs.
223
+ `Ctrl+C` twice - kills active subprocesses immediatley (SIGTERM → wait → SIGKILL), then same cleanup runs.
194
224
 
195
225
  ```bash
196
226
  ocak resume 42 --watch # Pick up from where it stopped
@@ -214,7 +244,7 @@ stack:
214
244
 
215
245
  # Issue backend (omit for GitHub, or set to "local" for offline mode)
216
246
  issues:
217
- backend: github # or "local" auto-detected if .ocak/issues/ exists
247
+ backend: github # or "local" - auto-detected if .ocak/issues/ exists
218
248
 
219
249
  # Pipeline settings
220
250
  pipeline:
@@ -241,7 +271,7 @@ labels:
241
271
  reready: "auto-reready"
242
272
  awaiting_review: "auto-pending-human"
243
273
 
244
- # Pipeline steps add, remove, reorder as you like
274
+ # Pipeline steps - add, remove, reorder as you like
245
275
  steps:
246
276
  - agent: implementer
247
277
  role: implement
@@ -276,6 +306,25 @@ agents:
276
306
  # ...
277
307
  ```
278
308
 
309
+ ### User Config
310
+
311
+ `ocak init` scaffolds `~/.config/ocak/config.yml` (or `$XDG_CONFIG_HOME/ocak/config.yml`) for machine-specific settings. When both files exist, project `ocak.yml` wins on conflicts.
312
+
313
+ ```yaml
314
+ # ~/.config/ocak/config.yml
315
+
316
+ # Repo path mappings for multi-repo mode
317
+ repos:
318
+ my-service: ~/dev/my-service
319
+
320
+ # Machine-level overrides
321
+ pipeline:
322
+ max_parallel: 3
323
+ cost_budget: 10.00
324
+ ```
325
+
326
+ The `repos:` key only comes from user config so repo paths dont leak into project config.
327
+
279
328
  ## Customization
280
329
 
281
330
  ### Swap Agents
@@ -351,8 +400,8 @@ steps:
351
400
  | Python | Django, Flask, FastAPI | pytest | ruff, flake8 | bandit, safety |
352
401
  | Rust | Actix, Axum, Rocket | cargo test | cargo clippy | cargo audit |
353
402
  | Go | Gin, Echo, Fiber, Chi | go test | golangci-lint | gosec |
354
- | Java | | gradle test | | |
355
- | Elixir | Phoenix | mix test | mix credo | |
403
+ | Java | - | gradle test | - | - |
404
+ | Elixir | Phoenix | mix test | mix credo | - |
356
405
 
357
406
  Monorepo detection: npm/pnpm workspaces, Cargo workspaces, Go workspaces, Lerna, and convention-based (`packages/`, `apps/`, `services/`).
358
407
 
@@ -368,15 +417,15 @@ Shows per-run stats (cost, duration, steps completed, failures) and aggregates a
368
417
 
369
418
  ## Writing Good Issues
370
419
 
371
- 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:
420
+ 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:
372
421
 
373
- - **Context** what part of the system, with specific file paths
374
- - **Acceptance Criteria** "when X, then Y" format, each independantly testable
375
- - **Implementation Guide** exact files to create/modify
376
- - **Patterns to Follow** references to actual files in the codebase
377
- - **Security Considerations** auth, validation, data exposure
378
- - **Test Requirements** specific test cases with file paths
379
- - **Out of Scope** explicit boundaries so it doesnt scope creep
422
+ - **Context** - what part of the system, with specific file paths
423
+ - **Acceptance Criteria** - "when X, then Y" format, each independantly testable
424
+ - **Implementation Guide** - exact files to create/modify
425
+ - **Patterns to Follow** - references to actual files in the codebase
426
+ - **Security Considerations** - auth, validation, data exposure
427
+ - **Test Requirements** - specific test cases with file paths
428
+ - **Out of Scope** - explicit boundaries so it doesnt scope creep
380
429
 
381
430
  ## CLI Reference
382
431
 
@@ -440,6 +489,10 @@ Depends on the issue. Simple stuff is $2-5, complex issues can be $10-15. The im
440
489
 
441
490
  Reasonably. Review agents are read-only (no Write/Edit tools), merging is sequential so you dont get conflicts, and failed piplines get labeled and logged. You can always `--dry-run` first to see what it would do.
442
491
 
492
+ **Can I trust `ocak.yml` from someone else's repo?**
493
+
494
+ Treat it like a Makefile. Commands in `test_command`, `lint_command`, `security_commands`, etc. run with your user privileges. First time you run ocak in a new repo it'll print a warning. Review the commands before running `ocak run` on repos you didnt write.
495
+
443
496
  **What if it breaks?**
444
497
 
445
498
  Issues get labeled `pipeline-failed` with a comment explaining what went wrong. Worktrees get cleaned up automaticaly. Run `ocak clean` to remove any stragglers, and check `logs/pipeline/` for detailed logs.
@@ -4,9 +4,12 @@ require 'erb'
4
4
  require 'fileutils'
5
5
  require 'open3'
6
6
  require_relative 'claude_runner'
7
+ require_relative 'command_runner'
7
8
 
8
9
  module Ocak
9
10
  class AgentGenerator
11
+ include CommandRunner
12
+
10
13
  AGENT_TEMPLATES = %w[
11
14
  implementer reviewer security_reviewer documenter
12
15
  merger pipeline planner auditor
@@ -137,10 +140,14 @@ module Ocak
137
140
  You are customizing a Claude Code agent for a specific project.
138
141
 
139
142
  Here is the project context:
143
+ <project_context>
140
144
  #{context}
145
+ </project_context>
141
146
 
142
147
  Here is the base agent template:
148
+ <base_template>
143
149
  #{template_content}
150
+ </base_template>
144
151
 
145
152
  Customize this agent to reference this project's actual conventions, file paths,
146
153
  and patterns. Keep the same structure (YAML frontmatter + markdown). Keep the same
@@ -151,6 +158,7 @@ module Ocak
151
158
  end
152
159
 
153
160
  def claude_available?
161
+ # Uses Open3 directly; 'which' is not a git/gh command
154
162
  _, _, status = Open3.capture3('which', 'claude')
155
163
  status.success?
156
164
  rescue Errno::ENOENT => e
@@ -159,6 +167,7 @@ module Ocak
159
167
  end
160
168
 
161
169
  def run_claude_prompt(prompt)
170
+ # Uses Open3 directly; 'claude' is not a git/gh command
162
171
  stdout, _, status = Open3.capture3(
163
172
  'claude', '-p',
164
173
  '--output-format', 'text',
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'target_resolver'
4
+
3
5
  module Ocak
4
6
  # Batch processing logic — process_issues, run_batch, process_one_issue, build_issue_result.
5
7
  # Extracted from PipelineRunner to reduce file size.
@@ -12,6 +14,8 @@ module Ocak
12
14
  ready_issues = ready_issues.first(@config.max_issues_per_run)
13
15
  end
14
16
 
17
+ ready_issues = resolve_targets(ready_issues, logger: logger) if @config.multi_repo?
18
+
15
19
  claude = build_claude(logger)
16
20
  batches = @executor.plan_batches(ready_issues, logger: logger, claude: claude)
17
21
 
@@ -29,10 +33,10 @@ module Ocak
29
33
  end
30
34
 
31
35
  def run_batch(batch_issues, logger:, issues:)
32
- worktrees = WorktreeManager.new(config: @config, logger: logger)
36
+ shared_worktrees = @config.multi_repo? ? nil : WorktreeManager.new(config: @config, logger: logger)
33
37
 
34
38
  threads = batch_issues.map do |issue|
35
- Thread.new { process_one_issue(issue, worktrees: worktrees, issues: issues) }
39
+ Thread.new { process_one_issue(issue, worktrees: shared_worktrees, issues: issues) }
36
40
  end
37
41
  results = threads.map(&:value)
38
42
 
@@ -47,7 +51,8 @@ module Ocak
47
51
  next unless result[:worktree]
48
52
  next if result[:interrupted]
49
53
 
50
- worktrees.remove(result[:worktree])
54
+ worktree_manager_for(result[:worktree], shared: shared_worktrees, logger: logger)
55
+ .remove(result[:worktree])
51
56
  rescue StandardError => e
52
57
  logger.warn("Failed to clean worktree for ##{result[:issue_number]}: #{e.message}")
53
58
  end
@@ -65,8 +70,15 @@ module Ocak
65
70
  @active_mutex.synchronize do
66
71
  @active_issues << issue_number
67
72
  end
68
- issues.transition(issue_number, from: @config.label_ready, to: @config.label_in_progress)
69
- worktree = worktrees.create(issue_number, setup_command: @config.setup_command)
73
+ @state_machine.mark_in_progress(issue_number)
74
+
75
+ target = issue['_target']
76
+ worktree_mgr = if target
77
+ WorktreeManager.new(config: @config, repo_dir: target[:path], logger: logger)
78
+ else
79
+ worktrees
80
+ end
81
+ worktree = worktree_mgr.create(issue_number, setup_command: @config.setup_command)
70
82
  logger.info("Created worktree at #{worktree.path} (branch: #{worktree.branch})")
71
83
 
72
84
  complexity = @options[:fast] ? 'simple' : issue.fetch('complexity', 'full')
@@ -98,5 +110,23 @@ module Ocak
98
110
  { issue_number: issue_number, success: false, worktree: worktree }
99
111
  end
100
112
  end
113
+
114
+ def worktree_manager_for(worktree, shared:, logger:)
115
+ target = worktree.target_repo
116
+ return WorktreeManager.new(config: @config, repo_dir: target[:path], logger: logger) if target
117
+
118
+ shared || WorktreeManager.new(config: @config, logger: logger)
119
+ end
120
+
121
+ def resolve_targets(ready_issues, logger:)
122
+ ready_issues.filter_map do |issue|
123
+ target = TargetResolver.resolve(issue, config: @config)
124
+ issue['_target'] = target
125
+ issue
126
+ rescue TargetResolver::TargetResolutionError => e
127
+ logger.error("Skipping issue ##{issue['number']}: #{e.message}")
128
+ nil
129
+ end
130
+ end
101
131
  end
102
132
  end
@@ -4,8 +4,10 @@ require 'open3'
4
4
  require 'securerandom'
5
5
  require_relative '../config'
6
6
  require_relative '../claude_runner'
7
+ require_relative '../command_runner'
7
8
  require_relative '../git_utils'
8
9
  require_relative '../issue_backend'
10
+ require_relative '../issue_state_machine'
9
11
  require_relative '../pipeline_executor'
10
12
  require_relative '../step_comments'
11
13
  require_relative '../logger'
@@ -14,6 +16,7 @@ module Ocak
14
16
  module Commands
15
17
  class Hiz < Dry::CLI::Command
16
18
  include StepComments
19
+ include CommandRunner
17
20
 
18
21
  desc 'Fast-mode: implement an issue with Sonnet, create a PR (no merge)'
19
22
 
@@ -44,6 +47,7 @@ module Ocak
44
47
  watch_formatter = options[:watch] ? WatchFormatter.new : nil
45
48
  claude = ClaudeRunner.new(config: @config, logger: logger, watch: watch_formatter)
46
49
  issues = IssueBackend.build(config: @config, logger: logger)
50
+ @state_machine = IssueStateMachine.new(config: @config, issues: issues)
47
51
 
48
52
  logger.info("=== Hiz (fast mode) for issue ##{issue_number} ===")
49
53
 
@@ -68,7 +72,7 @@ module Ocak
68
72
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
69
73
  chdir = @config.project_dir
70
74
 
71
- issues.transition(issue_number, from: @config.label_ready, to: @config.label_in_progress)
75
+ @state_machine.mark_in_progress(issue_number)
72
76
  post_hiz_start_comment(issue_number, state: state)
73
77
  begin
74
78
  branch = create_branch(issue_number, chdir)
@@ -119,8 +123,8 @@ module Ocak
119
123
  branch = "hiz/issue-#{issue_number}-#{SecureRandom.hex(4)}"
120
124
  raise "Unsafe branch name: #{branch}" unless GitUtils.safe_branch_name?(branch)
121
125
 
122
- _, stderr, status = Open3.capture3('git', 'checkout', '-b', branch, chdir: chdir)
123
- raise "Failed to create branch #{branch}: #{stderr}" unless status.success?
126
+ result = run_git('checkout', '-b', branch, chdir: chdir)
127
+ raise "Failed to create branch #{branch}: #{result.error}" unless result.success?
124
128
 
125
129
  branch
126
130
  end
@@ -128,10 +132,10 @@ module Ocak
128
132
  def push_and_create_pr(issue_number, branch, logger:, chdir:, state:)
129
133
  commit_changes(issue_number, chdir, logger: logger)
130
134
 
131
- _, stderr, status = Open3.capture3('git', 'push', '-u', 'origin', branch, chdir: chdir)
132
- unless status.success?
133
- logger.error("Push failed: #{stderr}")
134
- handle_failure(issue_number, 'push', stderr, issues: state.issues, logger: logger, branch: branch)
135
+ push_result = run_git('push', '-u', 'origin', branch, chdir: chdir)
136
+ unless push_result.success?
137
+ logger.error("Push failed: #{push_result.error}")
138
+ handle_failure(issue_number, 'push', push_result.error, issues: state.issues, logger: logger, branch: branch)
135
139
  return
136
140
  end
137
141
 
@@ -140,21 +144,16 @@ module Ocak
140
144
  pr_title = issue_title ? "Fix ##{issue_number}: #{issue_title}" : "Fix ##{issue_number}"
141
145
  pr_body = build_pr_body(issue_number, state: state)
142
146
 
143
- stdout, stderr, status = Open3.capture3(
144
- 'gh', 'pr', 'create',
145
- '--title', pr_title,
146
- '--body', pr_body,
147
- '--head', branch,
148
- chdir: chdir
149
- )
147
+ pr_result = run_gh('pr', 'create', '--title', pr_title, '--body', pr_body, '--head', branch, chdir: chdir)
150
148
 
151
- if status.success?
152
- pr_url = stdout.strip
149
+ if pr_result.success?
150
+ pr_url = pr_result.output
153
151
  logger.info("PR created: #{pr_url}")
154
152
  puts "PR created: #{pr_url}"
155
153
  else
156
- logger.error("PR creation failed: #{stderr}")
157
- handle_failure(issue_number, 'pr-create', stderr, issues: state.issues, logger: logger, branch: branch)
154
+ logger.error("PR creation failed: #{pr_result.error}")
155
+ handle_failure(issue_number, 'pr-create', pr_result.error, issues: state.issues, logger: logger,
156
+ branch: branch)
158
157
  end
159
158
  end
160
159
 
@@ -185,7 +184,7 @@ module Ocak
185
184
 
186
185
  def handle_failure(issue_number, phase, output, issues:, logger:, branch: nil)
187
186
  logger.error("Issue ##{issue_number} failed at phase: #{phase}")
188
- issues.transition(issue_number, from: @config.label_in_progress, to: @config.label_failed)
187
+ @state_machine.mark_failed(issue_number)
189
188
  begin
190
189
  sanitized = output.to_s[0..1000].gsub('```', "'''")
191
190
  issues.comment(issue_number,
@@ -200,8 +199,8 @@ module Ocak
200
199
  end
201
200
 
202
201
  def delete_branch(branch, logger:)
203
- _, stderr, status = Open3.capture3('git', 'branch', '-D', branch, chdir: @config.project_dir)
204
- logger.warn("Failed to delete branch #{branch}: #{stderr}") unless status.success?
202
+ result = run_git('branch', '-D', branch, chdir: @config.project_dir)
203
+ logger.warn("Failed to delete branch #{branch}: #{result.error}") unless result.success?
205
204
  rescue StandardError => e
206
205
  logger.warn("Error deleting branch #{branch}: #{e.message}")
207
206
  end
@@ -35,6 +35,7 @@ module Ocak
35
35
  )
36
36
 
37
37
  generate_files(generator, project_dir, options)
38
+ scaffold_user_config
38
39
  update_settings(project_dir, stack)
39
40
  update_gitignore(project_dir)
40
41
  create_labels(project_dir)
@@ -83,6 +84,36 @@ module Ocak
83
84
  puts ''
84
85
  end
85
86
 
87
+ def scaffold_user_config
88
+ path = Config.user_config_path
89
+ if File.exist?(path)
90
+ puts " #{path} already exists — skipping"
91
+ return
92
+ end
93
+
94
+ FileUtils.mkdir_p(File.dirname(path))
95
+ File.write(path, user_config_template)
96
+ puts " Created #{path}"
97
+ end
98
+
99
+ def user_config_template
100
+ <<~YAML
101
+ # ~/.config/ocak/config.yml
102
+ # Machine-specific Ocak settings (not committed to any repo)
103
+
104
+ # Map repo names to local paths (used with multi_repo: true in project ocak.yml)
105
+ # repos:
106
+ # my-gem: ~/dev/my-gem
107
+ # other-gem: ~/dev/other-gem
108
+
109
+ # Pipeline tuning for this machine
110
+ # pipeline:
111
+ # max_parallel: 3 # default: 5
112
+ # poll_interval: 60 # seconds between polls, default: 60
113
+ # cost_budget: 10.00 # max $ per pipeline run
114
+ YAML
115
+ end
116
+
86
117
  def update_settings(project_dir, stack)
87
118
  settings_path = File.join(project_dir, '.claude', 'settings.json')
88
119
  existing = begin
@@ -206,6 +237,7 @@ module Ocak
206
237
  end
207
238
  puts ' .claude/hooks/ — lint + test hooks'
208
239
  puts ' .claude/settings.json — permissions & hooks config'
240
+ puts " #{Config.user_config_path} — machine-specific settings (not committed)"
209
241
  puts ''
210
242
  puts 'Next steps:'
211
243
  puts ' 1. Review ocak.yml and adjust settings'
@@ -3,6 +3,7 @@
3
3
  require_relative '../config'
4
4
  require_relative '../failure_reporting'
5
5
  require_relative '../git_utils'
6
+ require_relative '../issue_state_machine'
6
7
  require_relative '../pipeline_runner'
7
8
  require_relative '../pipeline_state'
8
9
  require_relative '../claude_runner'
@@ -79,8 +80,9 @@ module Ocak
79
80
  watch_formatter = options[:watch] ? WatchFormatter.new : nil
80
81
  claude = ClaudeRunner.new(config: config, logger: logger, watch: watch_formatter)
81
82
  issues = IssueBackend.build(config: config, logger: logger)
83
+ state_machine = IssueStateMachine.new(config: config, issues: issues)
82
84
 
83
- issues.transition(issue_number, from: config.label_failed, to: config.label_in_progress)
85
+ state_machine.mark_resuming(issue_number)
84
86
 
85
87
  runner = PipelineRunner.new(config: config, options: { watch: options[:watch] })
86
88
  result = runner.run_pipeline(issue_number,
@@ -88,7 +90,8 @@ module Ocak
88
90
  skip_steps: saved[:completed_steps])
89
91
 
90
92
  ctx = { config: config, issue_number: issue_number, saved: saved, chdir: chdir,
91
- issues: issues, claude: claude, logger: logger, watch: watch_formatter }
93
+ issues: issues, state_machine: state_machine, claude: claude, logger: logger,
94
+ watch: watch_formatter }
92
95
  handle_result(result, ctx)
93
96
  end
94
97
 
@@ -110,12 +113,10 @@ module Ocak
110
113
  )
111
114
 
112
115
  if merger.merge(ctx[:issue_number], worktree)
113
- ctx[:issues].transition(ctx[:issue_number], from: ctx[:config].label_in_progress,
114
- to: ctx[:config].label_completed)
116
+ ctx[:state_machine].mark_completed(ctx[:issue_number])
115
117
  puts "Issue ##{ctx[:issue_number]} resumed and merged successfully!"
116
118
  else
117
- ctx[:issues].transition(ctx[:issue_number], from: ctx[:config].label_in_progress,
118
- to: ctx[:config].label_failed)
119
+ ctx[:state_machine].mark_failed(ctx[:issue_number])
119
120
  warn "Issue ##{ctx[:issue_number]} merge failed after resume"
120
121
  end
121
122
  end
@@ -3,6 +3,7 @@
3
3
  require 'open3'
4
4
  require 'json'
5
5
  require_relative '../config'
6
+ require_relative '../command_runner'
6
7
  require_relative '../issue_backend'
7
8
  require_relative '../run_report'
8
9
  require_relative '../worktree_manager'
@@ -10,6 +11,8 @@ require_relative '../worktree_manager'
10
11
  module Ocak
11
12
  module Commands
12
13
  class Status < Dry::CLI::Command
14
+ include CommandRunner
15
+
13
16
  desc 'Show pipeline status'
14
17
 
15
18
  option :report, type: :boolean, default: false, desc: 'Show run reports'
@@ -207,18 +210,12 @@ module Ocak
207
210
  end
208
211
 
209
212
  def fetch_issue_count(label, config)
210
- stdout, _, status = Open3.capture3(
211
- 'gh', 'issue', 'list',
212
- '--label', label,
213
- '--state', 'open',
214
- '--json', 'number',
215
- '--limit', '100',
216
- chdir: config.project_dir
217
- )
218
- return 0 unless status.success?
219
-
220
- JSON.parse(stdout).size
221
- rescue JSON::ParserError, Errno::ENOENT
213
+ result = run_gh('issue', 'list', '--label', label, '--state', 'open',
214
+ '--json', 'number', '--limit', '100', chdir: config.project_dir)
215
+ return 0 unless result.success?
216
+
217
+ JSON.parse(result.stdout).size
218
+ rescue JSON::ParserError
222
219
  0
223
220
  end
224
221
 
data/lib/ocak/config.rb CHANGED
@@ -5,14 +5,28 @@ require 'yaml'
5
5
  module Ocak
6
6
  class Config
7
7
  CONFIG_FILE = 'ocak.yml'
8
+ USER_CONFIG_DIR = 'ocak'
9
+ USER_CONFIG_FILE = 'config.yml'
8
10
 
9
11
  attr_reader :project_dir
10
12
 
13
+ def self.user_config_path
14
+ base = ENV.fetch('XDG_CONFIG_HOME', File.expand_path('~/.config'))
15
+ File.join(base, USER_CONFIG_DIR, USER_CONFIG_FILE)
16
+ end
17
+
11
18
  def self.load(dir = Dir.pwd)
12
19
  path = File.join(dir, CONFIG_FILE)
13
20
  raise ConfigNotFound, "No ocak.yml found in #{dir}. Run `ocak init` first." unless File.exist?(path)
14
21
 
15
- new(YAML.safe_load_file(path, symbolize_names: true), dir)
22
+ project_data = YAML.safe_load_file(path, symbolize_names: true) || {}
23
+ user_data = load_user_config
24
+
25
+ merged = deep_merge(user_data, project_data)
26
+ # repos: is user-only — always take from user config, never project
27
+ merged[:repos] = user_data[:repos] if user_data[:repos]
28
+
29
+ new(merged, dir)
16
30
  end
17
31
 
18
32
  def initialize(data, project_dir = Dir.pwd)
@@ -51,6 +65,10 @@ module Ocak
51
65
  dig(:stack, :security_commands) || []
52
66
  end
53
67
 
68
+ def custom_commands?
69
+ !!(test_command || lint_command || format_command || setup_command || security_commands.any?)
70
+ end
71
+
54
72
  # Pipeline
55
73
  def max_parallel = @overrides[:max_parallel] || dig(:pipeline, :max_parallel) || 5
56
74
  def poll_interval = @overrides[:poll_interval] || dig(:pipeline, :poll_interval) || 60
@@ -72,6 +90,33 @@ module Ocak
72
90
  def require_comment = dig(:safety, :require_comment)
73
91
  def max_issues_per_run = dig(:safety, :max_issues_per_run) || 5
74
92
 
93
+ # Multi-repo
94
+ def repos
95
+ @data[:repos]
96
+ end
97
+
98
+ def multi_repo?
99
+ r = repos
100
+ r.is_a?(Hash) && !r.empty?
101
+ end
102
+
103
+ def target_field
104
+ dig(:target_field) || 'target_repo'
105
+ end
106
+
107
+ def resolve_repo(name)
108
+ raise ConfigError, 'No repos configured in ocak.yml' unless multi_repo?
109
+
110
+ key = name.to_sym
111
+ path_value = repos[key]
112
+ raise ConfigError, "Unknown repo '#{name}'. Known repos: #{repos.keys.join(', ')}" unless path_value
113
+
114
+ expanded = File.expand_path(path_value.to_s)
115
+ raise ConfigError, "Repo path does not exist: #{expanded}" unless Dir.exist?(expanded)
116
+
117
+ { name: name.to_s, path: expanded }
118
+ end
119
+
75
120
  # Issues
76
121
  def issue_backend = dig(:issues, :backend)
77
122
  def local_issues? = issue_backend == 'local'
@@ -136,5 +181,27 @@ module Ocak
136
181
 
137
182
  class ConfigNotFound < StandardError; end
138
183
  class ConfigError < StandardError; end
184
+
185
+ private_class_method def self.load_user_config
186
+ path = user_config_path
187
+ return {} unless File.exist?(path)
188
+
189
+ data = YAML.safe_load_file(path, symbolize_names: true)
190
+ raise ConfigError, "#{path} must be a YAML hash" unless data.is_a?(Hash) || data.nil?
191
+
192
+ data || {}
193
+ rescue Psych::SyntaxError => e
194
+ raise ConfigError, "Invalid YAML in #{path}: #{e.message}"
195
+ end
196
+
197
+ private_class_method def self.deep_merge(base, override)
198
+ base.merge(override) do |_key, old_val, new_val|
199
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
200
+ deep_merge(old_val, new_val)
201
+ else
202
+ new_val
203
+ end
204
+ end
205
+ end
139
206
  end
140
207
  end