diff --git a/.claude/commands/issue/plan.md b/.claude/commands/issue/plan.md index f994034a..d8e08650 100644 --- a/.claude/commands/issue/plan.md +++ b/.claude/commands/issue/plan.md @@ -1,7 +1,7 @@ --- name: plan description: Batch plan issue resolution using issue-plan-agent (explore + plan closed-loop) -argument-hint: "[,,...] [--batch-size 3]" +argument-hint: "[,,...] [--batch-size 3] --all-pending" allowed-tools: TodoWrite(*), Task(*), SlashCommand(*), AskUserQuestion(*), Bash(*), Read(*), Write(*) --- @@ -78,24 +78,49 @@ Phase 4: Summary ### Phase 1: Issue Loading ```javascript -// Parse input -const issueIds = userInput.includes(',') - ? userInput.split(',').map(s => s.trim()) - : [userInput.trim()]; - -// Read issues.jsonl +// Parse input and flags const issuesPath = '.workflow/issues/issues.jsonl'; -const allIssues = Bash(`cat "${issuesPath}" 2>/dev/null || echo ''`) - .split('\n') - .filter(line => line.trim()) - .map(line => JSON.parse(line)); +const batchSize = flags.batchSize || 3; -// Load and validate issues +// Key fields for planning (avoid loading full issue data) +const PLAN_FIELDS = 'id,title,status,context,affected_components,lifecycle_requirements,priority,bound_solution_id'; + +let issueIds = []; + +if (flags.allPending) { + // Use jq to filter pending/registered issues - extract only IDs + const pendingIds = Bash(` + cat "${issuesPath}" 2>/dev/null | \\ + jq -r 'select(.status == "pending" or .status == "registered") | .id' 2>/dev/null || echo '' + `).trim(); + + issueIds = pendingIds ? pendingIds.split('\n').filter(Boolean) : []; + + if (issueIds.length === 0) { + console.log('No pending issues found.'); + return; + } + console.log(`Found ${issueIds.length} pending issues`); +} else { + // Parse comma-separated issue IDs + issueIds = userInput.includes(',') + ? userInput.split(',').map(s => s.trim()) + : [userInput.trim()]; +} + +// Load issues using jq to extract only key fields const issues = []; for (const id of issueIds) { - let issue = allIssues.find(i => i.id === id); + // Use jq to find issue by ID and extract only needed fields + const issueJson = Bash(` + cat "${issuesPath}" 2>/dev/null | \\ + jq -c 'select(.id == "${id}") | {${PLAN_FIELDS}}' 2>/dev/null | head -1 + `).trim(); - if (!issue) { + let issue; + if (issueJson) { + issue = JSON.parse(issueJson); + } else { console.log(`Issue ${id} not found. Creating...`); issue = { id, @@ -114,12 +139,13 @@ for (const id of issueIds) { } // Group into batches -const batchSize = flags.batchSize || 3; const batches = []; for (let i = 0; i < issues.length; i += batchSize) { batches.push(issues.slice(i, i + batchSize)); } +console.log(`Processing ${issues.length} issues in ${batches.length} batch(es)`); + TodoWrite({ todos: batches.flatMap((batch, i) => [ { content: `Plan batch ${i+1}`, status: 'pending', activeForm: `Planning batch ${i+1}` } @@ -130,10 +156,13 @@ TodoWrite({ ### Phase 2: Unified Explore + Plan (issue-plan-agent) ```javascript +// Ensure solutions directory exists +Bash(`mkdir -p .workflow/issues/solutions`); + for (const [batchIndex, batch] of batches.entries()) { updateTodo(`Plan batch ${batchIndex + 1}`, 'in_progress'); - // Build issue prompt for agent with lifecycle requirements + // Build issue prompt for agent - agent writes solutions directly const issuePrompt = ` ## Issues to Plan (Closed-Loop Tasks Required) @@ -152,7 +181,29 @@ ${batch.map((issue, i) => ` ## Project Root ${process.cwd()} -## Requirements - CLOSED-LOOP TASKS +## Output Requirements + +**IMPORTANT**: Write solutions DIRECTLY to files, do NOT return full solution content. + +### 1. Write Solution Files +For each issue, write solution to: \`.workflow/issues/solutions/{issue-id}.jsonl\` +- Append one JSON line per solution +- Solution must include all closed-loop task fields (see Solution Format below) + +### 2. Return Summary Only +After writing solutions, return ONLY a brief JSON summary: +\`\`\`json +{ + "planned": [ + { "issue_id": "XXX", "solution_id": "SOL-xxx", "task_count": 3, "description": "Brief description" } + ], + "conflicts": [ + { "file": "path/to/file", "issues": ["ID1", "ID2"], "suggested_order": ["ID1", "ID2"] } + ] +} +\`\`\` + +## Closed-Loop Task Requirements Each task MUST include ALL lifecycle phases: @@ -168,12 +219,10 @@ Each task MUST include ALL lifecycle phases: ### 3. Regression - regression: string[] (commands to run for regression check) -- Based on issue's regression_scope setting ### 4. Acceptance - acceptance.criteria: string[] (testable acceptance criteria) - acceptance.verification: string[] (how to verify each criterion) -- acceptance.manual_checks: string[] (manual checks if needed) ### 5. Commit - commit.type: feat|fix|refactor|test|docs|chore @@ -188,7 +237,7 @@ Each task MUST include ALL lifecycle phases: 4. Infer commit scope from affected files `; - // Launch issue-plan-agent (combines explore + plan) + // Launch issue-plan-agent - agent writes solutions directly const result = Task( subagent_type="issue-plan-agent", run_in_background=false, @@ -196,24 +245,18 @@ Each task MUST include ALL lifecycle phases: prompt=issuePrompt ); - // Parse agent output - const agentOutput = JSON.parse(result); + // Parse brief summary from agent + const summary = JSON.parse(result); - // Register solutions for each issue (append to solutions/{issue-id}.jsonl) - for (const item of agentOutput.solutions) { - const solutionPath = `.workflow/issues/solutions/${item.issue_id}.jsonl`; - - // Ensure solutions directory exists - Bash(`mkdir -p .workflow/issues/solutions`); - - // Append solution as new line - Bash(`echo '${JSON.stringify(item.solution)}' >> "${solutionPath}"`); + // Display planning results + for (const item of summary.planned || []) { + console.log(`✓ ${item.issue_id}: ${item.solution_id} (${item.task_count} tasks) - ${item.description}`); } // Handle conflicts if any - if (agentOutput.conflicts?.length > 0) { + if (summary.conflicts?.length > 0) { console.log(`\n⚠ File conflicts detected:`); - agentOutput.conflicts.forEach(c => { + summary.conflicts.forEach(c => { console.log(` ${c.file}: ${c.issues.join(', ')} → suggested: ${c.suggested_order.join(' → ')}`); }); } @@ -225,90 +268,86 @@ Each task MUST include ALL lifecycle phases: ### Phase 3: Solution Binding ```javascript -// Re-read issues.jsonl -let allIssuesUpdated = Bash(`cat "${issuesPath}"`) - .split('\n') - .filter(line => line.trim()) - .map(line => JSON.parse(line)); +// Collect issues needing user selection (multiple solutions) +const needSelection = []; for (const issue of issues) { const solPath = `.workflow/issues/solutions/${issue.id}.jsonl`; - const solutions = Bash(`cat "${solPath}" 2>/dev/null || echo ''`) - .split('\n') - .filter(line => line.trim()) - .map(line => JSON.parse(line)); - if (solutions.length === 0) { - console.log(`⚠ No solutions for ${issue.id}`); - continue; - } + // Use jq to count solutions + const count = parseInt(Bash(`cat "${solPath}" 2>/dev/null | jq -s 'length' 2>/dev/null || echo '0'`).trim()) || 0; - let selectedSolId; + if (count === 0) continue; // No solutions - skip silently (agent already reported) - if (solutions.length === 1) { + if (count === 1) { // Auto-bind single solution - selectedSolId = solutions[0].id; - console.log(`✓ Auto-bound ${selectedSolId} to ${issue.id} (${solutions[0].tasks?.length || 0} tasks)`); + const solId = Bash(`cat "${solPath}" | jq -r '.id' | head -1`).trim(); + bindSolution(issue.id, solId); } else { - // Multiple solutions - ask user - const answer = AskUserQuestion({ - questions: [{ - question: `Select solution for ${issue.id}:`, - header: issue.id, - multiSelect: false, - options: solutions.map(s => ({ - label: `${s.id}: ${s.description || 'Solution'}`, - description: `${s.tasks?.length || 0} tasks` - })) - }] - }); - - selectedSolId = extractSelectedSolutionId(answer); - console.log(`✓ Bound ${selectedSolId} to ${issue.id}`); + // Multiple solutions - collect for batch selection + const options = Bash(`cat "${solPath}" | jq -c '{id, description, task_count: (.tasks | length)}'`).trim(); + needSelection.push({ issue, options: options.split('\n').map(s => JSON.parse(s)) }); } - - // Update issue in allIssuesUpdated - const issueIndex = allIssuesUpdated.findIndex(i => i.id === issue.id); - if (issueIndex !== -1) { - allIssuesUpdated[issueIndex].bound_solution_id = selectedSolId; - allIssuesUpdated[issueIndex].status = 'planned'; - allIssuesUpdated[issueIndex].planned_at = new Date().toISOString(); - allIssuesUpdated[issueIndex].updated_at = new Date().toISOString(); - } - - // Mark solution as bound in solutions file - const updatedSolutions = solutions.map(s => ({ - ...s, - is_bound: s.id === selectedSolId, - bound_at: s.id === selectedSolId ? new Date().toISOString() : s.bound_at - })); - Write(solPath, updatedSolutions.map(s => JSON.stringify(s)).join('\n')); } -// Write updated issues.jsonl -Write(issuesPath, allIssuesUpdated.map(i => JSON.stringify(i)).join('\n')); +// Batch ask user for multiple-solution issues +if (needSelection.length > 0) { + const answer = AskUserQuestion({ + questions: needSelection.map(({ issue, options }) => ({ + question: `Select solution for ${issue.id}:`, + header: issue.id, + multiSelect: false, + options: options.map(s => ({ + label: `${s.id} (${s.task_count} tasks)`, + description: s.description || 'Solution' + })) + })) + }); + + // Bind selected solutions + for (const { issue } of needSelection) { + const selectedSolId = extractSelectedSolutionId(answer, issue.id); + if (selectedSolId) bindSolution(issue.id, selectedSolId); + } +} + +// Helper: bind solution to issue +function bindSolution(issueId, solutionId) { + const now = new Date().toISOString(); + const solPath = `.workflow/issues/solutions/${issueId}.jsonl`; + + // Update issue status + Bash(` + tmpfile=$(mktemp) && \\ + cat "${issuesPath}" | jq -c 'if .id == "${issueId}" then . + { + bound_solution_id: "${solutionId}", status: "planned", + planned_at: "${now}", updated_at: "${now}" + } else . end' > "$tmpfile" && mv "$tmpfile" "${issuesPath}" + `); + + // Mark solution as bound + Bash(` + tmpfile=$(mktemp) && \\ + cat "${solPath}" | jq -c 'if .id == "${solutionId}" then . + { + is_bound: true, bound_at: "${now}" + } else . + {is_bound: false} end' > "$tmpfile" && mv "$tmpfile" "${solPath}" + `); +} ``` ### Phase 4: Summary ```javascript +// Brief summary using jq +const stats = Bash(` + cat "${issuesPath}" 2>/dev/null | \\ + jq -s '[.[] | select(.status == "planned")] | length' 2>/dev/null || echo '0' +`).trim(); + console.log(` -## Planning Complete +## Done: ${issues.length} issues → ${stats} planned -**Issues Planned**: ${issues.length} - -### Bound Solutions -${issues.map(i => { - const issue = allIssuesUpdated.find(a => a.id === i.id); - return issue?.bound_solution_id - ? `✓ ${i.id}: ${issue.bound_solution_id}` - : `○ ${i.id}: No solution bound`; -}).join('\n')} - -### Next Steps -1. Review: \`ccw issue status \` -2. Form queue: \`/issue:queue\` -3. Execute: \`/issue:execute\` +Next: \`/issue:queue\` → \`/issue:execute\` `); ``` diff --git a/ccw/src/commands/issue.ts b/ccw/src/commands/issue.ts index 2bfc9de6..5e2a11f9 100644 --- a/ccw/src/commands/issue.ts +++ b/ccw/src/commands/issue.ts @@ -936,8 +936,11 @@ async function queueAction(subAction: string | undefined, issueId: string | unde async function nextAction(options: IssueOptions): Promise { const queue = readActiveQueue(); - // Find ready tasks - const readyTasks = queue.queue.filter(item => { + // Priority 1: Resume executing tasks (interrupted/crashed) + const executingTasks = queue.queue.filter(item => item.status === 'executing'); + + // Priority 2: Find pending tasks with satisfied dependencies + const pendingTasks = queue.queue.filter(item => { if (item.status !== 'pending') return false; return item.depends_on.every(depId => { const dep = queue.queue.find(q => q.queue_id === depId); @@ -945,6 +948,9 @@ async function nextAction(options: IssueOptions): Promise { }); }); + // Combine: executing first, then pending + const readyTasks = [...executingTasks, ...pendingTasks]; + if (readyTasks.length === 0) { console.log(JSON.stringify({ status: 'empty', @@ -957,6 +963,7 @@ async function nextAction(options: IssueOptions): Promise { // Sort by execution order readyTasks.sort((a, b) => a.execution_order - b.execution_order); const nextItem = readyTasks[0]; + const isResume = nextItem.status === 'executing'; // Load task definition const solution = findSolution(nextItem.issue_id, nextItem.solution_id); @@ -967,14 +974,24 @@ async function nextAction(options: IssueOptions): Promise { process.exit(1); } - // Mark as executing - const idx = queue.queue.findIndex(q => q.queue_id === nextItem.queue_id); - queue.queue[idx].status = 'executing'; - queue.queue[idx].started_at = new Date().toISOString(); - writeQueue(queue); + // Only update status if not already executing (new task) + if (!isResume) { + const idx = queue.queue.findIndex(q => q.queue_id === nextItem.queue_id); + queue.queue[idx].status = 'executing'; + queue.queue[idx].started_at = new Date().toISOString(); + writeQueue(queue); + updateIssue(nextItem.issue_id, { status: 'executing' }); + } - // Update issue status - updateIssue(nextItem.issue_id, { status: 'executing' }); + // Calculate queue stats for context + const stats = { + total: queue.queue.length, + completed: queue.queue.filter(q => q.status === 'completed').length, + failed: queue.queue.filter(q => q.status === 'failed').length, + executing: executingTasks.length, + pending: pendingTasks.length + }; + const remaining = stats.pending + stats.executing; console.log(JSON.stringify({ queue_id: nextItem.queue_id, @@ -982,9 +999,17 @@ async function nextAction(options: IssueOptions): Promise { solution_id: nextItem.solution_id, task: taskDef, context: solution?.exploration_context || {}, + resumed: isResume, + resume_note: isResume ? `Resuming interrupted task (started: ${nextItem.started_at})` : undefined, execution_hints: { executor: nextItem.assigned_executor, estimated_minutes: taskDef.estimated_minutes || 30 + }, + queue_progress: { + completed: stats.completed, + remaining: remaining, + total: stats.total, + progress: `${stats.completed}/${stats.total}` } }, null, 2)); } @@ -1054,7 +1079,7 @@ async function doneAction(queueId: string | undefined, options: IssueOptions): P } /** - * retry - Retry failed tasks + * retry - Retry failed tasks, or reset stuck executing tasks (--force) */ async function retryAction(issueId: string | undefined, options: IssueOptions): Promise { const queue = readActiveQueue(); @@ -1066,7 +1091,12 @@ async function retryAction(issueId: string | undefined, options: IssueOptions): let updated = 0; + // Check for stuck executing tasks (started > 30 min ago with no completion) + const stuckThreshold = 30 * 60 * 1000; // 30 minutes + const now = Date.now(); + for (const item of queue.queue) { + // Retry failed tasks if (item.status === 'failed') { if (!issueId || item.issue_id === issueId) { item.status = 'pending'; @@ -1076,10 +1106,23 @@ async function retryAction(issueId: string | undefined, options: IssueOptions): updated++; } } + // Reset stuck executing tasks (optional: use --force or --reset-stuck) + else if (item.status === 'executing' && options.force) { + const startedAt = item.started_at ? new Date(item.started_at).getTime() : 0; + if (now - startedAt > stuckThreshold) { + if (!issueId || item.issue_id === issueId) { + console.log(chalk.yellow(`Resetting stuck task: ${item.queue_id} (started ${Math.round((now - startedAt) / 60000)} min ago)`)); + item.status = 'pending'; + item.started_at = undefined; + updated++; + } + } + } } if (updated === 0) { - console.log(chalk.yellow('No failed tasks to retry')); + console.log(chalk.yellow('No failed/stuck tasks to retry')); + console.log(chalk.gray('Use --force to reset stuck executing tasks (>30 min)')); return; } @@ -1160,7 +1203,7 @@ export async function issueCommand( console.log(chalk.gray(' queue add Add issue to active queue (or create new)')); console.log(chalk.gray(' queue switch Switch active queue')); console.log(chalk.gray(' queue archive Archive current queue')); - console.log(chalk.gray(' retry [issue-id] Retry failed tasks')); + console.log(chalk.gray(' retry [issue-id] [--force] Retry failed/stuck tasks')); console.log(); console.log(chalk.bold('Execution Endpoints:')); console.log(chalk.gray(' next Get next ready task (JSON)'));