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,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../config'
4
+ require_relative '../worktree_manager'
5
+
6
+ module Ocak
7
+ module Commands
8
+ class Clean < Dry::CLI::Command
9
+ desc 'Remove stale worktrees and prune git worktree list'
10
+
11
+ def call(**)
12
+ config = Config.load
13
+ manager = WorktreeManager.new(config: config)
14
+
15
+ puts 'Cleaning stale worktrees...'
16
+ removed = manager.clean_stale
17
+
18
+ if removed.empty?
19
+ puts 'No stale worktrees found.'
20
+ else
21
+ removed.each { |path| puts " Removed: #{path}" }
22
+ puts "Cleaned #{removed.size} worktree(s)."
23
+ end
24
+ rescue Config::ConfigNotFound => e
25
+ warn "Error: #{e.message}"
26
+ exit 1
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ocak
4
+ module Commands
5
+ class Debt < Dry::CLI::Command
6
+ desc 'Track technical debt'
7
+
8
+ def call(**)
9
+ skill_path = File.join(Dir.pwd, '.claude', 'skills', 'debt', 'SKILL.md')
10
+
11
+ unless File.exist?(skill_path)
12
+ warn 'No debt skill found. Run `ocak init` first.'
13
+ exit 1
14
+ end
15
+
16
+ puts 'Run this inside Claude Code:'
17
+ puts ' /debt'
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ocak
4
+ module Commands
5
+ class Design < Dry::CLI::Command
6
+ desc 'Launch interactive issue design session'
7
+
8
+ argument :description, type: :string, required: false, desc: 'Rough description of what to build'
9
+
10
+ def call(description: nil, **)
11
+ skill_path = File.join(Dir.pwd, '.claude', 'skills', 'design', 'SKILL.md')
12
+
13
+ unless File.exist?(skill_path)
14
+ warn 'No design skill found. Run `ocak init` first.'
15
+ exit 1
16
+ end
17
+
18
+ puts 'Starting interactive design session...'
19
+ puts 'This will open Claude Code with the /design skill.'
20
+ puts ''
21
+
22
+ if description
23
+ exec('claude', '--skill', skill_path, '--', description)
24
+ else
25
+ puts 'Run this inside Claude Code:'
26
+ puts ' /design <description of what you want to build>'
27
+ puts ''
28
+ puts 'Or provide a description directly:'
29
+ puts ' ocak design "add user authentication with OAuth"'
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require_relative '../stack_detector'
6
+ require_relative '../agent_generator'
7
+ require_relative '../config'
8
+
9
+ module Ocak
10
+ module Commands
11
+ class Init < Dry::CLI::Command
12
+ desc 'Initialize Ocak pipeline in the current project'
13
+
14
+ option :force, type: :boolean, default: false, desc: 'Overwrite existing configuration'
15
+ option :no_ai, type: :boolean, default: false, desc: 'Skip AI-powered agent customization'
16
+ option :config_only, type: :boolean, default: false, desc: 'Only generate config, hooks, and settings'
17
+ option :skip_agents, type: :boolean, default: false, desc: 'Skip agent generation'
18
+ option :skip_skills, type: :boolean, default: false, desc: 'Skip skill generation'
19
+
20
+ def call(**options)
21
+ project_dir = Dir.pwd
22
+
23
+ if File.exist?(File.join(project_dir, 'ocak.yml')) && !options[:force]
24
+ puts 'ocak.yml already exists. Use --force to overwrite.'
25
+ return
26
+ end
27
+
28
+ puts 'Detecting project stack...'
29
+ stack = StackDetector.new(project_dir).detect
30
+ print_stack(stack)
31
+
32
+ generator = AgentGenerator.new(
33
+ stack: stack, project_dir: project_dir, use_ai: !options[:no_ai], logger: init_logger
34
+ )
35
+
36
+ generate_files(generator, project_dir, options)
37
+ update_settings(project_dir, stack)
38
+ update_gitignore(project_dir)
39
+
40
+ puts ''
41
+ print_summary(project_dir, stack, options)
42
+ end
43
+
44
+ private
45
+
46
+ def generate_files(generator, project_dir, options)
47
+ generator.generate_config(File.join(project_dir, 'ocak.yml'))
48
+ generate_agents(generator, project_dir, options)
49
+ generate_skills(generator, project_dir, options)
50
+ generator.generate_hooks(File.join(project_dir, '.claude', 'hooks'))
51
+ end
52
+
53
+ def generate_agents(generator, project_dir, options)
54
+ return if options[:config_only] || options[:skip_agents]
55
+
56
+ agents_dir = File.join(project_dir, '.claude', 'agents')
57
+ if agents_exist?(agents_dir) && !options[:force]
58
+ puts ' Existing agents found in .claude/agents/ — skipping (use --force to overwrite)'
59
+ else
60
+ generator.generate_agents(agents_dir)
61
+ end
62
+ end
63
+
64
+ def generate_skills(generator, project_dir, options)
65
+ return if options[:config_only] || options[:skip_skills]
66
+
67
+ generator.generate_skills(File.join(project_dir, '.claude', 'skills'))
68
+ end
69
+
70
+ def agents_exist?(agents_dir)
71
+ Dir.exist?(agents_dir) && Dir.glob(File.join(agents_dir, '*.md')).any?
72
+ end
73
+
74
+ def print_stack(stack)
75
+ puts " Language: #{stack.language}"
76
+ puts " Framework: #{stack.framework || 'none detected'}"
77
+ puts " Tests: #{stack.test_command || 'none detected'}"
78
+ puts " Lint: #{stack.lint_command || 'none detected'}"
79
+ puts " Security: #{stack.security_commands.empty? ? 'none detected' : stack.security_commands.join(', ')}"
80
+ puts " Monorepo: yes (#{stack.packages.size} packages)" if stack.respond_to?(:monorepo) && stack.monorepo
81
+ puts ''
82
+ end
83
+
84
+ def update_settings(project_dir, stack)
85
+ settings_path = File.join(project_dir, '.claude', 'settings.json')
86
+ existing = File.exist?(settings_path) ? JSON.parse(File.read(settings_path)) : {}
87
+
88
+ merge_permissions(existing, stack)
89
+ merge_hooks(existing)
90
+
91
+ FileUtils.mkdir_p(File.dirname(settings_path))
92
+ File.write(settings_path, JSON.pretty_generate(existing))
93
+ puts ' Updated .claude/settings.json'
94
+ end
95
+
96
+ def merge_permissions(settings, stack)
97
+ settings['permissions'] ||= {}
98
+ settings['permissions']['allow'] ||= []
99
+ allowed = settings['permissions']['allow']
100
+
101
+ build_permissions(stack).each do |perm|
102
+ allowed << perm unless allowed.include?(perm)
103
+ end
104
+ end
105
+
106
+ def merge_hooks(settings)
107
+ settings['hooks'] ||= {}
108
+ settings['hooks']['PostToolUse'] ||= []
109
+ settings['hooks']['TaskCompleted'] ||= []
110
+
111
+ add_hook_unless_exists(settings['hooks']['PostToolUse'], 'post-edit-lint',
112
+ 'matcher' => 'Edit|Write',
113
+ 'hooks' => [{ 'type' => 'command', 'command' => '.claude/hooks/post-edit-lint.sh' }])
114
+
115
+ add_hook_unless_exists(settings['hooks']['TaskCompleted'], 'task-completed-test',
116
+ 'hooks' => [{ 'type' => 'command',
117
+ 'command' => '.claude/hooks/task-completed-test.sh' }])
118
+ end
119
+
120
+ def add_hook_unless_exists(hooks_list, hook_name, hook_entry)
121
+ return if hooks_list.any? { |h| h.dig('hooks', 0, 'command')&.include?(hook_name) }
122
+
123
+ hooks_list << hook_entry
124
+ end
125
+
126
+ def build_permissions(stack)
127
+ perms = language_permissions(stack)
128
+ # Always allow gh CLI for pipeline operations
129
+ perms.push('Bash(gh issue*)', 'Bash(gh pr*)', 'Bash(gh label*)')
130
+ end
131
+
132
+ def language_permissions(stack)
133
+ case stack.language
134
+ when 'ruby'
135
+ ruby_permissions(stack)
136
+ when 'typescript', 'javascript'
137
+ ['Bash(npm test*)', 'Bash(npx biome*)', 'Bash(npx eslint*)', 'Bash(npm audit*)', 'Bash(npm run typecheck*)']
138
+ when 'python'
139
+ ['Bash(pytest*)', 'Bash(python -m pytest*)', 'Bash(ruff*)', 'Bash(flake8*)']
140
+ when 'rust'
141
+ ['Bash(cargo test*)', 'Bash(cargo clippy*)', 'Bash(cargo fmt*)']
142
+ when 'go'
143
+ ['Bash(go test*)', 'Bash(golangci-lint*)']
144
+ else
145
+ []
146
+ end
147
+ end
148
+
149
+ def ruby_permissions(stack)
150
+ perms = ['Bash(bundle exec rubocop*)', 'Bash(bundle exec rspec*)', 'Bash(bundle exec rake test*)']
151
+ perms << 'Bash(bundle exec brakeman*)' if stack.security_commands.any? { |c| c.include?('brakeman') }
152
+ perms << 'Bash(bundle exec bundler-audit*)' if stack.security_commands.any? { |c| c.include?('bundler-audit') }
153
+ perms
154
+ end
155
+
156
+ def update_gitignore(project_dir)
157
+ gitignore_path = File.join(project_dir, '.gitignore')
158
+ additions_path = File.join(Ocak.templates_dir, 'gitignore_additions.txt')
159
+ additions = File.read(additions_path)
160
+
161
+ existing = File.exist?(gitignore_path) ? File.read(gitignore_path) : ''
162
+
163
+ new_lines = additions.lines.reject do |line|
164
+ line.strip.empty? || line.start_with?('#') || existing.include?(line.strip)
165
+ end
166
+
167
+ return if new_lines.empty?
168
+
169
+ File.open(gitignore_path, 'a') do |f|
170
+ f.puts '' unless existing.end_with?("\n\n")
171
+ f.puts '# Ocak / Claude Code'
172
+ new_lines.each { |line| f.puts line }
173
+ end
174
+ puts ' Updated .gitignore'
175
+ end
176
+
177
+ def print_summary(_project_dir, _stack, options)
178
+ puts 'Ocak initialized successfully!'
179
+ puts ''
180
+ puts 'Created:'
181
+ puts ' ocak.yml — pipeline configuration'
182
+ unless options[:config_only]
183
+ puts ' .claude/agents/ — 8 pipeline agents' unless options[:skip_agents]
184
+ puts ' .claude/skills/ — 4 interactive skills' unless options[:skip_skills]
185
+ end
186
+ puts ' .claude/hooks/ — lint + test hooks'
187
+ puts ' .claude/settings.json — permissions & hooks config'
188
+ puts ''
189
+ puts 'Next steps:'
190
+ puts ' 1. Review ocak.yml and adjust settings'
191
+ puts ' 2. Review .claude/agents/ and customize if needed'
192
+ puts ' 3. Create issues with: claude then /design'
193
+ puts " 4. Label issues 'auto-ready'"
194
+ puts ' 5. Run the pipeline: ocak run --once'
195
+ puts ''
196
+ puts 'Quick commands:'
197
+ puts ' ocak run --single 42 Run one issue'
198
+ puts ' ocak run --watch Run with live output'
199
+ puts ' ocak status Check pipeline state'
200
+ puts ' ocak audit Run codebase audit'
201
+ end
202
+
203
+ def init_logger
204
+ @init_logger ||= Object.new.tap do |l|
205
+ def l.info(msg)
206
+ puts " #{msg}"
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../config'
4
+ require_relative '../pipeline_runner'
5
+ require_relative '../pipeline_state'
6
+ require_relative '../claude_runner'
7
+ require_relative '../issue_fetcher'
8
+ require_relative '../worktree_manager'
9
+ require_relative '../merge_manager'
10
+ require_relative '../logger'
11
+
12
+ module Ocak
13
+ module Commands
14
+ class Resume < Dry::CLI::Command
15
+ desc 'Resume a failed pipeline from the last successful step'
16
+
17
+ argument :issue, type: :integer, required: true, desc: 'Issue number to resume'
18
+ option :watch, type: :boolean, default: false, desc: 'Stream agent activity to terminal'
19
+
20
+ def call(issue:, **options)
21
+ config = Config.load
22
+ issue_number = issue.to_i
23
+ saved = load_state(config, issue_number)
24
+ chdir = resolve_worktree(config, saved)
25
+
26
+ print_resume_info(issue_number, saved, config)
27
+ run_resumed_pipeline(config, issue_number, saved, chdir, options)
28
+ rescue Config::ConfigNotFound => e
29
+ warn "Error: #{e.message}"
30
+ exit 1
31
+ end
32
+
33
+ private
34
+
35
+ def load_state(config, issue_number)
36
+ log_dir = File.join(config.project_dir, config.log_dir)
37
+ state = PipelineState.new(log_dir: log_dir)
38
+ saved = state.load(issue_number)
39
+ unless saved
40
+ warn "No saved state for issue ##{issue_number}. Nothing to resume."
41
+ exit 1
42
+ end
43
+ saved
44
+ end
45
+
46
+ def print_resume_info(issue_number, saved, config)
47
+ puts "Resuming issue ##{issue_number}"
48
+ puts " Completed steps: #{saved[:completed_steps].size}/#{config.steps.size}"
49
+ puts " Worktree: #{saved[:worktree_path]}"
50
+ puts " Branch: #{saved[:branch]}"
51
+ puts ''
52
+ end
53
+
54
+ def run_resumed_pipeline(config, issue_number, saved, chdir, options)
55
+ log_dir = File.join(config.project_dir, config.log_dir)
56
+ logger = PipelineLogger.new(log_dir: log_dir, issue_number: issue_number)
57
+ watch_formatter = options[:watch] ? WatchFormatter.new : nil
58
+ claude = ClaudeRunner.new(config: config, logger: logger, watch: watch_formatter)
59
+ issues = IssueFetcher.new(config: config, logger: logger)
60
+
61
+ issues.transition(issue_number, from: config.label_failed, to: config.label_in_progress)
62
+
63
+ runner = PipelineRunner.new(config: config, options: { watch: options[:watch] })
64
+ result = runner.send(:run_pipeline, issue_number,
65
+ logger: logger, claude: claude, chdir: chdir,
66
+ skip_steps: saved[:completed_steps])
67
+
68
+ ctx = { config: config, issue_number: issue_number, saved: saved, chdir: chdir,
69
+ issues: issues, claude: claude, logger: logger, watch: watch_formatter }
70
+ handle_result(result, ctx)
71
+ end
72
+
73
+ def handle_result(result, ctx)
74
+ if result[:success]
75
+ attempt_merge(ctx)
76
+ else
77
+ ctx[:issues].transition(ctx[:issue_number], from: ctx[:config].label_in_progress,
78
+ to: ctx[:config].label_failed)
79
+ ctx[:issues].comment(ctx[:issue_number],
80
+ "Pipeline failed at phase: #{result[:phase]}\n\n```\n#{result[:output][0..1000]}\n```")
81
+ warn "Issue ##{ctx[:issue_number]} failed again at phase: #{result[:phase]}"
82
+ end
83
+ end
84
+
85
+ def attempt_merge(ctx)
86
+ merger = MergeManager.new(config: ctx[:config], claude: ctx[:claude],
87
+ logger: ctx[:logger], watch: ctx[:watch])
88
+ worktree = WorktreeManager::Worktree.new(
89
+ path: ctx[:chdir], branch: ctx[:saved][:branch], issue_number: ctx[:issue_number]
90
+ )
91
+
92
+ if merger.merge(ctx[:issue_number], worktree)
93
+ ctx[:issues].transition(ctx[:issue_number], from: ctx[:config].label_in_progress,
94
+ to: ctx[:config].label_completed)
95
+ puts "Issue ##{ctx[:issue_number]} resumed and merged successfully!"
96
+ else
97
+ ctx[:issues].transition(ctx[:issue_number], from: ctx[:config].label_in_progress,
98
+ to: ctx[:config].label_failed)
99
+ warn "Issue ##{ctx[:issue_number]} merge failed after resume"
100
+ end
101
+ end
102
+
103
+ def resolve_worktree(config, saved)
104
+ return saved[:worktree_path] if saved[:worktree_path] && Dir.exist?(saved[:worktree_path])
105
+
106
+ recreate_from_branch(config, saved)
107
+ end
108
+
109
+ def recreate_from_branch(config, saved)
110
+ unless saved[:branch]
111
+ warn 'Worktree no longer exists and no branch saved. Cannot resume.'
112
+ exit 1
113
+ end
114
+
115
+ _, _, status = Open3.capture3('git', 'rev-parse', '--verify', saved[:branch], chdir: config.project_dir)
116
+ unless status.success?
117
+ warn "Worktree no longer exists and branch '#{saved[:branch]}' not found. Cannot resume."
118
+ exit 1
119
+ end
120
+
121
+ worktrees = WorktreeManager.new(config: config)
122
+ wt = worktrees.create(saved[:issue_number], setup_command: config.setup_command)
123
+ Open3.capture3('git', 'checkout', saved[:branch], chdir: wt.path)
124
+ wt.path
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../config'
4
+ require_relative '../pipeline_runner'
5
+ require_relative '../claude_runner'
6
+ require_relative '../issue_fetcher'
7
+ require_relative '../worktree_manager'
8
+ require_relative '../merge_manager'
9
+ require_relative '../logger'
10
+
11
+ module Ocak
12
+ module Commands
13
+ class Run < Dry::CLI::Command
14
+ desc 'Run the issue processing pipeline'
15
+
16
+ option :watch, type: :boolean, default: false, desc: 'Stream agent activity to terminal'
17
+ option :single, type: :integer, desc: 'Run a single issue without worktrees'
18
+ option :dry_run, type: :boolean, default: false, desc: 'Show what would happen'
19
+ option :once, type: :boolean, default: false, desc: 'Process current batch and exit'
20
+ option :max_parallel, type: :integer, desc: 'Max concurrent pipelines'
21
+ option :poll_interval, type: :integer, desc: 'Seconds between polls'
22
+
23
+ def call(**options)
24
+ config = Config.load
25
+
26
+ # CLI options override config
27
+ config.override(:max_parallel, options[:max_parallel]) if options[:max_parallel]
28
+ config.override(:poll_interval, options[:poll_interval]) if options[:poll_interval]
29
+
30
+ runner = PipelineRunner.new(
31
+ config: config,
32
+ options: {
33
+ watch: options[:watch],
34
+ single: options[:single],
35
+ dry_run: options[:dry_run],
36
+ once: options[:once]
37
+ }
38
+ )
39
+
40
+ setup_signal_handlers(runner)
41
+ runner.run
42
+ rescue Config::ConfigNotFound => e
43
+ warn "Error: #{e.message}"
44
+ exit 1
45
+ end
46
+
47
+ private
48
+
49
+ def setup_signal_handlers(runner)
50
+ %w[INT TERM].each do |signal|
51
+ trap(signal) do
52
+ warn "\nReceived #{signal}, shutting down gracefully..."
53
+ runner.shutdown!
54
+ exit 0
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'json'
5
+ require_relative '../config'
6
+ require_relative '../worktree_manager'
7
+
8
+ module Ocak
9
+ module Commands
10
+ class Status < Dry::CLI::Command
11
+ desc 'Show pipeline status'
12
+
13
+ def call(**)
14
+ config = Config.load
15
+
16
+ puts 'Pipeline Status'
17
+ puts '=' * 40
18
+ puts ''
19
+
20
+ show_issues(config)
21
+ puts ''
22
+ show_worktrees(config)
23
+ puts ''
24
+ show_recent_logs(config)
25
+ rescue Config::ConfigNotFound => e
26
+ warn "Error: #{e.message}"
27
+ exit 1
28
+ end
29
+
30
+ private
31
+
32
+ def show_issues(config)
33
+ puts 'Issues:'
34
+
35
+ %w[ready in_progress completed failed].each do |state|
36
+ label = config.send(:"label_#{state}")
37
+ count = fetch_issue_count(label, config)
38
+ icon = { 'ready' => ' ', 'in_progress' => ' ', 'completed' => ' ', 'failed' => ' ' }[state]
39
+ puts " #{icon} #{state.tr('_', ' ')}: #{count} (label: #{label})"
40
+ end
41
+ end
42
+
43
+ def show_worktrees(config)
44
+ puts 'Worktrees:'
45
+ manager = WorktreeManager.new(config: config)
46
+ worktrees = manager.list
47
+
48
+ pipeline_wts = worktrees.select { |wt| wt[:branch]&.start_with?('auto/') }
49
+ if pipeline_wts.empty?
50
+ puts ' No active pipeline worktrees'
51
+ else
52
+ pipeline_wts.each do |wt|
53
+ puts " #{wt[:branch]} -> #{wt[:path]}"
54
+ end
55
+ end
56
+ end
57
+
58
+ def show_recent_logs(config)
59
+ log_dir = File.join(config.project_dir, config.log_dir)
60
+ return unless Dir.exist?(log_dir)
61
+
62
+ puts 'Recent logs:'
63
+ logs = Dir.glob(File.join(log_dir, '*.log')).last(5)
64
+ if logs.empty?
65
+ puts ' No logs yet'
66
+ else
67
+ logs.reverse_each do |log|
68
+ name = File.basename(log)
69
+ size = File.size(log)
70
+ puts " #{name} (#{format_size(size)})"
71
+ end
72
+ end
73
+ end
74
+
75
+ def fetch_issue_count(label, config)
76
+ stdout, _, status = Open3.capture3(
77
+ 'gh', 'issue', 'list',
78
+ '--label', label,
79
+ '--state', 'open',
80
+ '--json', 'number',
81
+ '--limit', '100',
82
+ chdir: config.project_dir
83
+ )
84
+ return 0 unless status.success?
85
+
86
+ JSON.parse(stdout).size
87
+ rescue JSON::ParserError, Errno::ENOENT
88
+ 0
89
+ end
90
+
91
+ def format_size(bytes)
92
+ if bytes < 1024
93
+ "#{bytes}B"
94
+ elsif bytes < 1024 * 1024
95
+ "#{(bytes / 1024.0).round(1)}KB"
96
+ else
97
+ "#{(bytes / (1024.0 * 1024)).round(1)}MB"
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end