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.
- checksums.yaml +7 -0
- data/CLAUDE.md +192 -0
- data/README.md +231 -0
- data/bin/claude-task-master +6 -0
- data/lib/claude_task_master/claude.rb +305 -0
- data/lib/claude_task_master/cli.rb +537 -0
- data/lib/claude_task_master/github.rb +345 -0
- data/lib/claude_task_master/loop.rb +250 -0
- data/lib/claude_task_master/pr_comment.rb +170 -0
- data/lib/claude_task_master/state.rb +183 -0
- data/lib/claude_task_master/version.rb +5 -0
- data/lib/claude_task_master.rb +17 -0
- metadata +141 -0
|
@@ -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
|