claude-task-master 0.2.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.
@@ -0,0 +1,345 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'json'
5
+ require 'octokit'
6
+
7
+ module ClaudeTaskMaster
8
+ # GitHub operations via gh CLI and Octokit
9
+ # Handles PR creation, CI status, comments, and merging
10
+ class GitHub
11
+ class << self
12
+ # Check if gh CLI is available and authenticated
13
+ def available?
14
+ _, status = Open3.capture2('gh auth status')
15
+ status.success?
16
+ end
17
+
18
+ # Get Octokit client using gh token
19
+ def client
20
+ @client ||= begin
21
+ token = gh_token
22
+ raise ConfigError, 'GitHub token not found. Run: gh auth login' unless token
23
+
24
+ Octokit::Client.new(access_token: token, auto_paginate: true)
25
+ end
26
+ end
27
+
28
+ # Reset client (useful for testing)
29
+ def reset_client!
30
+ @client = nil
31
+ end
32
+
33
+ # Get current repository (owner/repo format)
34
+ def current_repo
35
+ @current_repo ||= begin
36
+ stdout, status = Open3.capture2('gh repo view --json nameWithOwner -q .nameWithOwner')
37
+ return nil unless status.success?
38
+
39
+ stdout.strip
40
+ end
41
+ end
42
+
43
+ # Create a PR
44
+ # Returns [success, pr_number_or_error]
45
+ def create_pr(title:, body:, base: 'main', head: nil)
46
+ head ||= current_branch
47
+ repo = current_repo
48
+ return [false, 'Not in a git repository'] unless repo
49
+
50
+ pr = client.create_pull_request(repo, base, head, title, body)
51
+ [true, pr.number]
52
+ rescue Octokit::Error => e
53
+ [false, e.message]
54
+ end
55
+
56
+ # Get PR status (CI checks)
57
+ # Returns hash with :status (:pending, :passing, :failing) and :checks array
58
+ def pr_status(pr_number)
59
+ repo = current_repo
60
+ return { status: :unknown, checks: [] } unless repo
61
+
62
+ # Get check runs for the PR's head SHA
63
+ pr = client.pull_request(repo, pr_number)
64
+ checks = client.check_runs_for_ref(repo, pr.head.sha)
65
+
66
+ check_results = checks.check_runs.map do |run|
67
+ {
68
+ name: run.name,
69
+ status: run.status,
70
+ conclusion: run.conclusion
71
+ }
72
+ end
73
+
74
+ overall = determine_ci_status(check_results)
75
+ { status: overall, checks: check_results }
76
+ rescue Octokit::Error
77
+ # Fallback to gh CLI
78
+ gh_pr_status(pr_number)
79
+ end
80
+
81
+ # Get all PR review comments
82
+ def pr_comments(pr_number)
83
+ repo = current_repo
84
+ return [] unless repo
85
+
86
+ comments = client.pull_request_comments(repo, pr_number)
87
+ PRComment.from_api_response(comments.map(&:to_h))
88
+ rescue Octokit::Error => e
89
+ warn "Failed to fetch comments: #{e.message}"
90
+ []
91
+ end
92
+
93
+ # Get unresolved review threads via GraphQL
94
+ def unresolved_threads(pr_number)
95
+ repo = current_repo
96
+ return [] unless repo
97
+
98
+ owner, name = repo.split('/')
99
+
100
+ query = <<~GRAPHQL
101
+ query {
102
+ repository(owner: "#{owner}", name: "#{name}") {
103
+ pullRequest(number: #{pr_number}) {
104
+ reviewThreads(first: 100) {
105
+ nodes {
106
+ id
107
+ isResolved
108
+ comments(first: 10) {
109
+ nodes {
110
+ body
111
+ author { login }
112
+ path
113
+ line
114
+ }
115
+ }
116
+ }
117
+ }
118
+ }
119
+ }
120
+ }
121
+ GRAPHQL
122
+
123
+ response = client.post('/graphql', { query: query }.to_json)
124
+ threads = response.dig(:data, :repository, :pullRequest, :reviewThreads, :nodes) || []
125
+
126
+ threads.reject { |t| t[:isResolved] }.map do |thread|
127
+ first_comment = thread.dig(:comments, :nodes)&.first
128
+ {
129
+ id: thread[:id],
130
+ author: first_comment&.dig(:author, :login),
131
+ body: first_comment&.dig(:body),
132
+ file_path: first_comment&.dig(:path),
133
+ line: first_comment&.dig(:line)
134
+ }
135
+ end
136
+ rescue Octokit::Error, StandardError => e
137
+ # Fallback to gh CLI
138
+ gh_unresolved_threads(pr_number)
139
+ end
140
+
141
+ # Get actionable comments (unresolved + actionable severity)
142
+ def actionable_comments(pr_number)
143
+ comments = pr_comments(pr_number)
144
+ unresolved_ids = unresolved_threads(pr_number).map { |t| t[:id] }
145
+
146
+ comments.select do |comment|
147
+ comment.actionable? || unresolved_ids.include?(comment.id)
148
+ end
149
+ end
150
+
151
+ # Resolve a review thread
152
+ def resolve_thread(thread_id)
153
+ mutation = <<~GRAPHQL
154
+ mutation {
155
+ resolveReviewThread(input: {threadId: "#{thread_id}"}) {
156
+ thread {
157
+ id
158
+ isResolved
159
+ }
160
+ }
161
+ }
162
+ GRAPHQL
163
+
164
+ response = client.post('/graphql', { query: mutation }.to_json)
165
+ errors = response[:errors]
166
+
167
+ return true unless errors
168
+
169
+ raise GitHubError, errors.map { |e| e[:message] }.join(', ')
170
+ end
171
+
172
+ # Reply to a PR comment
173
+ def reply_to_comment(pr_number, comment_id, body)
174
+ repo = current_repo
175
+ return false unless repo
176
+
177
+ client.create_pull_request_comment_reply(repo, pr_number, body, comment_id)
178
+ true
179
+ rescue Octokit::Error => e
180
+ warn "Failed to reply: #{e.message}"
181
+ false
182
+ end
183
+
184
+ # Wait for CI to complete (blocking)
185
+ def wait_for_ci(pr_number, timeout: 600)
186
+ cmd = ['gh', 'pr', 'checks', pr_number.to_s, '--watch', '--fail-fast']
187
+
188
+ Timeout.timeout(timeout) do
189
+ _, status = Open3.capture2(*cmd)
190
+ status.success? ? :passing : :failing
191
+ end
192
+ rescue Timeout::Error
193
+ :timeout
194
+ end
195
+
196
+ # Merge PR
197
+ def merge_pr(pr_number, method: :squash, delete_branch: true)
198
+ repo = current_repo
199
+ return false unless repo
200
+
201
+ client.merge_pull_request(repo, pr_number, '', merge_method: method)
202
+ client.delete_branch(repo, pr_branch_name(pr_number)) if delete_branch
203
+ true
204
+ rescue Octokit::Error => e
205
+ warn "Failed to merge: #{e.message}"
206
+ false
207
+ end
208
+
209
+ # Get PR info
210
+ def pr_info(pr_number)
211
+ repo = current_repo
212
+ return nil unless repo
213
+
214
+ pr = client.pull_request(repo, pr_number)
215
+ {
216
+ number: pr.number,
217
+ title: pr.title,
218
+ state: pr.state,
219
+ url: pr.html_url,
220
+ head_ref: pr.head.ref,
221
+ base_ref: pr.base.ref,
222
+ mergeable: pr.mergeable,
223
+ merged: pr.merged
224
+ }
225
+ rescue Octokit::Error
226
+ nil
227
+ end
228
+
229
+ # List open PRs
230
+ def open_prs
231
+ repo = current_repo
232
+ return [] unless repo
233
+
234
+ client.pull_requests(repo, state: 'open').map do |pr|
235
+ {
236
+ number: pr.number,
237
+ title: pr.title,
238
+ head_ref: pr.head.ref
239
+ }
240
+ end
241
+ rescue Octokit::Error
242
+ []
243
+ end
244
+
245
+ private
246
+
247
+ # Get GitHub token from gh CLI
248
+ def gh_token
249
+ stdout, status = Open3.capture2('gh auth token')
250
+ return nil unless status.success?
251
+
252
+ stdout.strip
253
+ end
254
+
255
+ # Get current git branch
256
+ def current_branch
257
+ stdout, status = Open3.capture2('git rev-parse --abbrev-ref HEAD')
258
+ return nil unless status.success?
259
+
260
+ stdout.strip
261
+ end
262
+
263
+ # Get PR branch name
264
+ def pr_branch_name(pr_number)
265
+ pr = client.pull_request(current_repo, pr_number)
266
+ pr.head.ref
267
+ rescue Octokit::Error
268
+ nil
269
+ end
270
+
271
+ # Determine overall CI status from check results
272
+ def determine_ci_status(checks)
273
+ return :unknown if checks.empty?
274
+
275
+ if checks.any? { |c| c[:conclusion] == 'failure' }
276
+ :failing
277
+ elsif checks.any? { |c| c[:status] != 'completed' }
278
+ :pending
279
+ else
280
+ :passing
281
+ end
282
+ end
283
+
284
+ # Fallback: Get PR status via gh CLI
285
+ def gh_pr_status(pr_number)
286
+ cmd = ['gh', 'pr', 'checks', pr_number.to_s, '--json', 'name,state,bucket']
287
+ stdout, status = Open3.capture2(*cmd)
288
+
289
+ return { status: :unknown, checks: [] } unless status.success?
290
+
291
+ checks = JSON.parse(stdout, symbolize_names: true)
292
+
293
+ overall = if checks.any? { |c| c[:bucket] == 'fail' }
294
+ :failing
295
+ elsif checks.any? { |c| c[:bucket] == 'pending' }
296
+ :pending
297
+ else
298
+ :passing
299
+ end
300
+
301
+ { status: overall, checks: checks }
302
+ end
303
+
304
+ # Fallback: Get unresolved threads via gh CLI
305
+ def gh_unresolved_threads(pr_number)
306
+ repo = current_repo
307
+ return [] unless repo
308
+
309
+ owner, name = repo.split('/')
310
+
311
+ query = <<~GRAPHQL
312
+ query {
313
+ repository(owner: "#{owner}", name: "#{name}") {
314
+ pullRequest(number: #{pr_number}) {
315
+ reviewThreads(first: 100) {
316
+ nodes {
317
+ id
318
+ isResolved
319
+ comments(first: 10) {
320
+ nodes {
321
+ body
322
+ author { login }
323
+ }
324
+ }
325
+ }
326
+ }
327
+ }
328
+ }
329
+ }
330
+ GRAPHQL
331
+
332
+ stdout, status = Open3.capture2('gh', 'api', 'graphql', '-f', "query=#{query}")
333
+ return [] unless status.success?
334
+
335
+ response = JSON.parse(stdout, symbolize_names: true)
336
+ threads = response.dig(:data, :repository, :pullRequest, :reviewThreads, :nodes) || []
337
+
338
+ threads.reject { |t| t[:isResolved] }
339
+ end
340
+ end
341
+ end
342
+
343
+ # Custom error class
344
+ class GitHubError < StandardError; end
345
+ end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pastel'
4
+
5
+ module ClaudeTaskMaster
6
+ # The main work loop
7
+ # Keeps calling Claude until success criteria met
8
+ class Loop
9
+ attr_reader :state, :claude, :pastel, :options
10
+
11
+ # Options:
12
+ # no_merge: Don't auto-merge PRs, require manual merge
13
+ # max_sessions: Stop after N sessions
14
+ # pause_on_pr: Pause after creating each PR for review
15
+ # verbose: Show verbose output
16
+ def initialize(state:, model: 'sonnet', **opts)
17
+ @state = state
18
+ @claude = Claude.new(model:)
19
+ @pastel = Pastel.new
20
+ @options = {
21
+ no_merge: opts[:no_merge] || false,
22
+ max_sessions: opts[:max_sessions],
23
+ pause_on_pr: opts[:pause_on_pr] || false,
24
+ verbose: opts[:verbose] || false
25
+ }
26
+ @session_count = 0
27
+ end
28
+
29
+ # Run the full loop from start
30
+ def run(goal:, criteria:)
31
+ puts pastel.cyan("Starting claude-task-master...")
32
+ puts pastel.dim("Goal: #{goal[0..100]}#{'...' if goal.length > 100}")
33
+ show_options
34
+ puts
35
+
36
+ # Initialize state
37
+ state.init(goal:, criteria:)
38
+
39
+ # Phase 1: Planning
40
+ plan_phase
41
+
42
+ # Phase 2: Work loop
43
+ work_loop
44
+ end
45
+
46
+ # Resume from existing state
47
+ def resume
48
+ unless state.exists?
49
+ raise ConfigError, 'No existing state found. Start fresh with a goal.'
50
+ end
51
+
52
+ current_state = state.load_state
53
+ puts pastel.cyan("Resuming claude-task-master...")
54
+ puts pastel.dim("Goal: #{state.goal[0..100]}#{'...' if state.goal.length > 100}")
55
+ puts pastel.dim("Status: #{current_state[:status]}")
56
+ puts pastel.dim("Session: #{current_state[:session_count]}")
57
+ show_options
58
+ puts
59
+
60
+ if current_state[:status] == 'planning'
61
+ plan_phase
62
+ end
63
+
64
+ work_loop
65
+ end
66
+
67
+ private
68
+
69
+ # Display active options
70
+ def show_options
71
+ active = []
72
+ active << 'no-merge' if options[:no_merge]
73
+ active << "max-sessions=#{options[:max_sessions]}" if options[:max_sessions]
74
+ active << 'pause-on-pr' if options[:pause_on_pr]
75
+ active << 'verbose' if options[:verbose]
76
+ return if active.empty?
77
+
78
+ puts pastel.dim("Options: #{active.join(', ')}")
79
+ end
80
+
81
+ # Phase 1: Generate plan
82
+ def plan_phase
83
+ puts pastel.yellow("Phase 1: Planning...")
84
+
85
+ # Check for existing CLAUDE.md
86
+ claude_md_path = File.join(Dir.pwd, 'CLAUDE.md')
87
+ existing_claude_md = File.exist?(claude_md_path) ? File.read(claude_md_path) : nil
88
+
89
+ prompt = Claude.planning_prompt(state.goal, existing_claude_md:, no_merge: options[:no_merge])
90
+
91
+ session_num = state.next_session_number
92
+ state.update_state(session_count: session_num)
93
+
94
+ success, output, _exit_code = claude.invoke(prompt)
95
+
96
+ # Log the session
97
+ state.log_session(session_num, "# Planning Session\n\n#{output}")
98
+
99
+ unless success
100
+ state.update_state(status: 'blocked')
101
+ state.append_progress("\n## Blocked in Planning\n\n#{output[-500..]}")
102
+ raise ClaudeError, 'Planning failed. Check logs.'
103
+ end
104
+
105
+ current = state.load_state
106
+ if current[:status] != 'ready'
107
+ # Claude didn't update state, do it ourselves
108
+ state.update_state(status: 'ready')
109
+ end
110
+
111
+ puts pastel.green("Plan created. Check .claude-task-master/plan.md")
112
+ puts
113
+ end
114
+
115
+ # Show current PR status (CI, comments)
116
+ def show_pr_status(pr_number)
117
+ ci_status = GitHub.pr_status(pr_number)
118
+ unresolved = GitHub.unresolved_threads(pr_number)
119
+
120
+ status_icon = case ci_status[:status]
121
+ when :passing then pastel.green('CI passing')
122
+ when :failing then pastel.red('CI failing')
123
+ when :pending then pastel.yellow('CI pending')
124
+ else pastel.dim('CI unknown')
125
+ end
126
+
127
+ comments_text = if unresolved.empty?
128
+ pastel.green('0 unresolved')
129
+ else
130
+ pastel.yellow("#{unresolved.size} unresolved comments")
131
+ end
132
+
133
+ puts pastel.dim(" PR ##{pr_number}: #{status_icon} | #{comments_text}")
134
+ rescue StandardError => e
135
+ # Don't fail if we can't get PR status
136
+ puts pastel.dim(" PR ##{pr_number}: (couldn't fetch status)")
137
+ end
138
+
139
+ # Phase 2: Work until done
140
+ def work_loop
141
+ puts pastel.yellow("Phase 2: Working...")
142
+ puts pastel.dim("Press Ctrl+C to pause (can resume later)")
143
+ puts
144
+
145
+ loop do
146
+ # Check if done
147
+ if state.success?
148
+ puts
149
+ puts pastel.green.bold("SUCCESS!")
150
+ puts pastel.green("All tasks completed. Check .claude-task-master/progress.md")
151
+ break
152
+ end
153
+
154
+ if state.blocked?
155
+ puts
156
+ puts pastel.red.bold("BLOCKED")
157
+ puts pastel.red("Claude got stuck. Check .claude-task-master/progress.md for details.")
158
+ break
159
+ end
160
+
161
+ # Check max sessions limit
162
+ if options[:max_sessions] && @session_count >= options[:max_sessions]
163
+ puts
164
+ puts pastel.yellow.bold("MAX SESSIONS REACHED")
165
+ puts pastel.yellow("Stopped after #{@session_count} sessions. Run 'claude-task-master resume' to continue.")
166
+ state.append_progress("\n_Stopped at max sessions (#{@session_count}) at #{Time.now.iso8601}_\n")
167
+ break
168
+ end
169
+
170
+ # Run one work iteration
171
+ pr_before = state.load_state[:pr_number]
172
+ work_iteration
173
+ @session_count += 1
174
+
175
+ # Check if new PR was created
176
+ pr_after = state.load_state[:pr_number]
177
+ if pr_after && pr_after != pr_before && options[:pause_on_pr]
178
+ puts
179
+ puts pastel.yellow.bold("NEW PR CREATED")
180
+ puts pastel.yellow("Paused for review. PR ##{pr_after}")
181
+ puts pastel.dim("Run 'claude-task-master resume' to continue after review.")
182
+ state.append_progress("\n_Paused for PR review (##{pr_after}) at #{Time.now.iso8601}_\n")
183
+ break
184
+ end
185
+
186
+ # Brief pause between iterations
187
+ sleep 2
188
+ end
189
+ rescue Interrupt
190
+ puts
191
+ puts pastel.yellow("Paused. Run 'claude-task-master' to resume.")
192
+ state.append_progress("\n_Paused at #{Time.now.iso8601}_\n")
193
+ end
194
+
195
+ # Single work iteration
196
+ def work_iteration
197
+ current_state = state.load_state
198
+ session_num = state.next_session_number
199
+
200
+ # Show PR/CI status if we have a PR
201
+ if current_state[:pr_number]
202
+ show_pr_status(current_state[:pr_number])
203
+ end
204
+
205
+ puts pastel.cyan("[Session #{session_num}] Working on: #{current_state[:current_task] || 'next task'}")
206
+
207
+ # Build context and prompt
208
+ context = state.build_context
209
+ prompt = Claude.work_prompt(context, no_merge: options[:no_merge])
210
+
211
+ # Update session count
212
+ state.update_state(session_count: session_num)
213
+
214
+ # Invoke Claude
215
+ start_time = Time.now
216
+ success, output, _exit_code = claude.invoke(prompt)
217
+ duration = (Time.now - start_time).round(1)
218
+
219
+ # Log the session
220
+ log_content = <<~LOG
221
+ # Work Session #{session_num}
222
+
223
+ _Duration: #{duration}s_
224
+ _Started: #{start_time.iso8601}_
225
+
226
+ ## Output
227
+
228
+ #{output}
229
+ LOG
230
+ state.log_session(session_num, log_content)
231
+
232
+ # Report
233
+ if success
234
+ puts pastel.green(" Completed in #{duration}s")
235
+ else
236
+ puts pastel.red(" Session ended with error (#{duration}s)")
237
+ state.append_progress("\n## Session #{session_num} Error\n\n#{output[-500..]}")
238
+ end
239
+
240
+ # Check new state
241
+ new_state = state.load_state
242
+ if new_state[:current_task] != current_state[:current_task]
243
+ puts pastel.dim(" Task changed: #{new_state[:current_task]}")
244
+ end
245
+ if new_state[:pr_number] && new_state[:pr_number] != current_state[:pr_number]
246
+ puts pastel.dim(" PR created: ##{new_state[:pr_number]}")
247
+ end
248
+ end
249
+ end
250
+ end