/** * Issue Command - Unified JSONL storage with CLI & API compatibility * Storage: issues.jsonl + solutions/{issue-id}.jsonl + queue.json * Commands: init, list, status, task, bind, queue, next, done, retry */ import chalk from 'chalk'; import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs'; import { join, resolve } from 'path'; // Handle EPIPE errors gracefully process.stdout.on('error', (err: NodeJS.ErrnoException) => { if (err.code === 'EPIPE') { process.exit(0); } throw err; }); // ============ Interfaces ============ interface Issue { id: string; title: string; status: 'registered' | 'planning' | 'planned' | 'queued' | 'executing' | 'completed' | 'failed' | 'paused'; priority: number; context: string; bound_solution_id: string | null; solution_count: number; // Timestamps created_at: string; updated_at: string; planned_at?: string; queued_at?: string; completed_at?: string; } interface TaskTest { unit?: string[]; // Unit test requirements integration?: string[]; // Integration test requirements commands?: string[]; // Test commands to run coverage_target?: number; // Minimum coverage % (optional) } interface TaskAcceptance { criteria: string[]; // Acceptance criteria (testable) verification: string[]; // How to verify each criterion manual_checks?: string[]; // Manual verification steps if needed } interface TaskCommit { type: 'feat' | 'fix' | 'refactor' | 'test' | 'docs' | 'chore'; scope: string; // Commit scope (e.g., "auth", "api") message_template: string; // Commit message template breaking?: boolean; // Breaking change flag } interface SolutionTask { id: string; title: string; scope: string; action: string; description?: string; modification_points?: { file: string; target: string; change: string }[]; // Lifecycle phases (closed-loop) implementation: string[]; // Implementation steps test: TaskTest; // Test requirements regression: string[]; // Regression check points acceptance: TaskAcceptance; // Acceptance criteria & verification commit: TaskCommit; // Commit specification depends_on: string[]; estimated_minutes?: number; status?: string; priority?: number; } interface Solution { id: string; description?: string; approach?: string; // Solution approach description tasks: SolutionTask[]; exploration_context?: Record; analysis?: { risk?: string; impact?: string; complexity?: string }; score?: number; is_bound: boolean; created_at: string; bound_at?: string; } interface QueueItem { item_id: string; // Item ID in queue: T-1, T-2, ... (task-level) or S-1, S-2, ... (solution-level) issue_id: string; solution_id: string; task_id?: string; // Only for task-level queues status: 'pending' | 'ready' | 'executing' | 'completed' | 'failed' | 'blocked'; execution_order: number; execution_group: string; depends_on: string[]; semantic_priority: number; assigned_executor: 'codex' | 'gemini' | 'agent'; task_count?: number; // For solution-level queues files_touched?: string[]; // For solution-level queues queued_at?: string; started_at?: string; completed_at?: string; result?: Record; failure_reason?: string; } interface QueueConflict { type: 'file_conflict' | 'dependency_conflict' | 'resource_conflict'; tasks?: string[]; // Task IDs involved (task-level queues) solutions?: string[]; // Solution IDs involved (solution-level queues) file?: string; // Conflicting file path resolution: 'sequential' | 'merge' | 'manual'; resolution_order?: string[]; rationale?: string; resolved: boolean; } interface ExecutionGroup { id: string; // Group ID: P1, S1, etc. type: 'parallel' | 'sequential'; task_count?: number; // For task-level queues solution_count?: number; // For solution-level queues tasks?: string[]; // Task IDs in this group (task-level) solutions?: string[]; // Solution IDs in this group (solution-level) } interface Queue { id: string; // Queue unique ID: QUE-YYYYMMDD-HHMMSS (derived from filename) name?: string; // Optional queue name status: 'active' | 'completed' | 'archived' | 'failed'; issue_ids: string[]; // Issues in this queue tasks: QueueItem[]; // Task items (task-level queue) solutions?: QueueItem[]; // Solution items (solution-level queue) conflicts: QueueConflict[]; execution_groups?: ExecutionGroup[]; _metadata: { version: string; total_tasks: number; pending_count: number; executing_count: number; completed_count: number; failed_count: number; updated_at: string; }; } interface QueueIndex { active_queue_id: string | null; queues: { id: string; status: string; issue_ids: string[]; total_tasks?: number; // For task-level queues total_solutions?: number; // For solution-level queues completed_tasks?: number; // For task-level queues completed_solutions?: number; // For solution-level queues created_at: string; completed_at?: string; }[]; } interface IssueOptions { status?: string; title?: string; description?: string; executor?: string; priority?: string; solution?: string; result?: string; reason?: string; json?: boolean; force?: boolean; fail?: boolean; ids?: boolean; // List only IDs (one per line) } const ISSUES_DIR = '.workflow/issues'; // ============ Storage Layer (JSONL) ============ function getProjectRoot(): string { let dir = process.cwd(); while (dir !== resolve(dir, '..')) { if (existsSync(join(dir, '.workflow')) || existsSync(join(dir, '.git'))) { return dir; } dir = resolve(dir, '..'); } return process.cwd(); } function getIssuesDir(): string { return join(getProjectRoot(), ISSUES_DIR); } function ensureIssuesDir(): void { const dir = getIssuesDir(); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } } // ============ Issues JSONL ============ function readIssues(): Issue[] { const path = join(getIssuesDir(), 'issues.jsonl'); if (!existsSync(path)) return []; try { return readFileSync(path, 'utf-8') .split('\n') .filter(line => line.trim()) .map(line => JSON.parse(line)); } catch { return []; } } function writeIssues(issues: Issue[]): void { ensureIssuesDir(); const path = join(getIssuesDir(), 'issues.jsonl'); writeFileSync(path, issues.map(i => JSON.stringify(i)).join('\n'), 'utf-8'); } function findIssue(issueId: string): Issue | undefined { return readIssues().find(i => i.id === issueId); } function updateIssue(issueId: string, updates: Partial): boolean { const issues = readIssues(); const idx = issues.findIndex(i => i.id === issueId); if (idx === -1) return false; issues[idx] = { ...issues[idx], ...updates, updated_at: new Date().toISOString() }; writeIssues(issues); return true; } // ============ Solutions JSONL ============ function getSolutionsPath(issueId: string): string { return join(getIssuesDir(), 'solutions', `${issueId}.jsonl`); } function readSolutions(issueId: string): Solution[] { const path = getSolutionsPath(issueId); if (!existsSync(path)) return []; try { return readFileSync(path, 'utf-8') .split('\n') .filter(line => line.trim()) .map(line => JSON.parse(line)); } catch { return []; } } function writeSolutions(issueId: string, solutions: Solution[]): void { const dir = join(getIssuesDir(), 'solutions'); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); writeFileSync(getSolutionsPath(issueId), solutions.map(s => JSON.stringify(s)).join('\n'), 'utf-8'); } function findSolution(issueId: string, solutionId: string): Solution | undefined { return readSolutions(issueId).find(s => s.id === solutionId); } function getBoundSolution(issueId: string): Solution | undefined { return readSolutions(issueId).find(s => s.is_bound); } function generateSolutionId(): string { const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14); return `SOL-${ts}`; } // ============ Queue Management (Multi-Queue) ============ function getQueuesDir(): string { return join(getIssuesDir(), 'queues'); } function ensureQueuesDir(): void { const dir = getQueuesDir(); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } } function readQueueIndex(): QueueIndex { const path = join(getQueuesDir(), 'index.json'); if (!existsSync(path)) { return { active_queue_id: null, queues: [] }; } return JSON.parse(readFileSync(path, 'utf-8')); } function writeQueueIndex(index: QueueIndex): void { ensureQueuesDir(); writeFileSync(join(getQueuesDir(), 'index.json'), JSON.stringify(index, null, 2), 'utf-8'); } function generateQueueFileId(): string { const now = new Date(); const ts = now.toISOString().replace(/[-:T]/g, '').slice(0, 14); return `QUE-${ts}`; } function readQueue(queueId?: string): Queue | null { const index = readQueueIndex(); const targetId = queueId || index.active_queue_id; if (!targetId) return null; const path = join(getQueuesDir(), `${targetId}.json`); if (!existsSync(path)) return null; return JSON.parse(readFileSync(path, 'utf-8')); } function readActiveQueue(): Queue { const queue = readQueue(); if (queue) return queue; // Return empty queue structure if no active queue return createEmptyQueue(); } function createEmptyQueue(): Queue { return { id: generateQueueFileId(), status: 'active', issue_ids: [], tasks: [], conflicts: [], _metadata: { version: '2.1', total_tasks: 0, pending_count: 0, executing_count: 0, completed_count: 0, failed_count: 0, updated_at: new Date().toISOString() } }; } function writeQueue(queue: Queue): void { ensureQueuesDir(); // Update metadata counts queue._metadata.total_tasks = queue.tasks.length; queue._metadata.pending_count = queue.tasks.filter(q => q.status === 'pending').length; queue._metadata.executing_count = queue.tasks.filter(q => q.status === 'executing').length; queue._metadata.completed_count = queue.tasks.filter(q => q.status === 'completed').length; queue._metadata.failed_count = queue.tasks.filter(q => q.status === 'failed').length; queue._metadata.updated_at = new Date().toISOString(); // Write queue file const path = join(getQueuesDir(), `${queue.id}.json`); writeFileSync(path, JSON.stringify(queue, null, 2), 'utf-8'); // Update index const index = readQueueIndex(); const existingIdx = index.queues.findIndex(q => q.id === queue.id); const indexEntry = { id: queue.id, status: queue.status, issue_ids: queue.issue_ids, total_tasks: queue._metadata.total_tasks, completed_tasks: queue._metadata.completed_count, created_at: queue.id.replace('QUE-', '').replace(/(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/, '$1-$2-$3T$4:$5:$6Z'), // Derive from ID completed_at: queue.status === 'completed' ? new Date().toISOString() : undefined }; if (existingIdx >= 0) { index.queues[existingIdx] = indexEntry; } else { index.queues.unshift(indexEntry); } if (queue.status === 'active') { index.active_queue_id = queue.id; } writeQueueIndex(index); } function generateQueueItemId(queue: Queue): string { const maxNum = queue.tasks.reduce((max, q) => { const match = q.item_id.match(/^T-(\d+)$/); return match ? Math.max(max, parseInt(match[1])) : max; }, 0); return `T-${maxNum + 1}`; } // ============ Commands ============ /** * init - Initialize a new issue */ async function initAction(issueId: string | undefined, options: IssueOptions): Promise { if (!issueId) { console.error(chalk.red('Issue ID is required')); console.error(chalk.gray('Usage: ccw issue init [--title "..."]')); process.exit(1); } const existing = findIssue(issueId); if (existing && !options.force) { console.error(chalk.red(`Issue "${issueId}" already exists`)); console.error(chalk.gray('Use --force to reinitialize')); process.exit(1); } const issues = readIssues().filter(i => i.id !== issueId); const newIssue: Issue = { id: issueId, title: options.title || issueId, status: 'registered', priority: options.priority ? parseInt(options.priority) : 3, context: options.description || '', bound_solution_id: null, solution_count: 0, created_at: new Date().toISOString(), updated_at: new Date().toISOString() }; issues.push(newIssue); writeIssues(issues); console.log(chalk.green(`✓ Issue "${issueId}" initialized`)); console.log(chalk.gray(` Next: ccw issue task ${issueId} --title "Task title"`)); } /** * list - List issues or tasks */ async function listAction(issueId: string | undefined, options: IssueOptions): Promise { if (!issueId) { // List all issues let issues = readIssues(); // Filter by status if specified if (options.status) { const statuses = options.status.split(',').map(s => s.trim()); issues = issues.filter(i => statuses.includes(i.status)); } // IDs only mode (one per line, for scripting) if (options.ids) { issues.forEach(i => console.log(i.id)); return; } if (options.json) { console.log(JSON.stringify(issues, null, 2)); return; } if (issues.length === 0) { console.log(chalk.yellow('No issues found')); console.log(chalk.gray('Create one with: ccw issue init ')); return; } console.log(chalk.bold.cyan('\nIssues\n')); console.log(chalk.gray('ID'.padEnd(20) + 'Status'.padEnd(15) + 'Solutions'.padEnd(12) + 'Title')); console.log(chalk.gray('-'.repeat(70))); for (const issue of issues) { const statusColor = { 'registered': chalk.gray, 'planning': chalk.blue, 'planned': chalk.cyan, 'queued': chalk.yellow, 'executing': chalk.yellow, 'completed': chalk.green, 'failed': chalk.red, 'paused': chalk.magenta }[issue.status] || chalk.white; const bound = issue.bound_solution_id ? `[${issue.bound_solution_id}]` : `${issue.solution_count}`; console.log( issue.id.padEnd(20) + statusColor(issue.status.padEnd(15)) + bound.padEnd(12) + (issue.title || '').substring(0, 30) ); } return; } // List tasks in bound solution const issue = findIssue(issueId); if (!issue) { console.error(chalk.red(`Issue "${issueId}" not found`)); process.exit(1); } const solution = getBoundSolution(issueId); const tasks = solution?.tasks || []; if (options.json) { console.log(JSON.stringify({ issue, solution, tasks }, null, 2)); return; } console.log(chalk.bold.cyan(`\nIssue: ${issueId}\n`)); console.log(`Title: ${issue.title}`); console.log(`Status: ${issue.status}`); console.log(`Bound: ${issue.bound_solution_id || 'none'}`); console.log(); if (tasks.length === 0) { console.log(chalk.yellow('No tasks (bind a solution first)')); return; } console.log(chalk.gray('ID'.padEnd(8) + 'Action'.padEnd(12) + 'Scope'.padEnd(20) + 'Title')); console.log(chalk.gray('-'.repeat(70))); for (const task of tasks) { console.log( task.id.padEnd(8) + task.action.padEnd(12) + task.scope.substring(0, 18).padEnd(20) + task.title.substring(0, 30) ); } } /** * status - Show detailed status */ async function statusAction(issueId: string | undefined, options: IssueOptions): Promise { if (!issueId) { // Show queue status const queue = readActiveQueue(); const issues = readIssues(); const index = readQueueIndex(); if (options.json) { // Return full queue for programmatic access console.log(JSON.stringify(queue, null, 2)); return; } console.log(chalk.bold.cyan('\nSystem Status\n')); console.log(`Issues: ${issues.length}`); console.log(`Queues: ${index.queues.length} (Active: ${index.active_queue_id || 'none'})`); console.log(`Active Queue: ${queue._metadata.total_tasks} tasks`); console.log(` Pending: ${queue._metadata.pending_count}`); console.log(` Executing: ${queue._metadata.executing_count}`); console.log(` Completed: ${queue._metadata.completed_count}`); console.log(` Failed: ${queue._metadata.failed_count}`); return; } const issue = findIssue(issueId); if (!issue) { console.error(chalk.red(`Issue "${issueId}" not found`)); process.exit(1); } const solutions = readSolutions(issueId); const boundSol = solutions.find(s => s.is_bound); if (options.json) { console.log(JSON.stringify({ issue, solutions, bound: boundSol }, null, 2)); return; } console.log(chalk.bold.cyan(`\nIssue: ${issueId}\n`)); console.log(`Title: ${issue.title}`); console.log(`Status: ${issue.status}`); console.log(`Priority: ${issue.priority}`); console.log(`Created: ${issue.created_at}`); console.log(`Updated: ${issue.updated_at}`); if (issue.context) { console.log(); console.log(chalk.bold('Context:')); console.log(issue.context.substring(0, 200)); } console.log(); console.log(chalk.bold(`Solutions (${solutions.length}):`)); for (const sol of solutions) { const marker = sol.is_bound ? chalk.green('◉') : chalk.gray('○'); console.log(` ${marker} ${sol.id}: ${sol.tasks.length} tasks`); } } /** * task - Add or update task (simplified - mainly for manual task management) */ async function taskAction(issueId: string | undefined, taskId: string | undefined, options: IssueOptions): Promise { if (!issueId) { console.error(chalk.red('Issue ID is required')); console.error(chalk.gray('Usage: ccw issue task [task-id] --title "..."')); process.exit(1); } const issue = findIssue(issueId); if (!issue) { console.error(chalk.red(`Issue "${issueId}" not found`)); process.exit(1); } const solutions = readSolutions(issueId); let boundIdx = solutions.findIndex(s => s.is_bound); // Create default solution if none bound if (boundIdx === -1) { const newSol: Solution = { id: generateSolutionId(), description: 'Manual tasks', tasks: [], is_bound: true, created_at: new Date().toISOString(), bound_at: new Date().toISOString() }; solutions.push(newSol); boundIdx = solutions.length - 1; updateIssue(issueId, { bound_solution_id: newSol.id, status: 'planned' }); } const solution = solutions[boundIdx]; if (taskId) { // Update existing task const taskIdx = solution.tasks.findIndex(t => t.id === taskId); if (taskIdx === -1) { console.error(chalk.red(`Task "${taskId}" not found`)); process.exit(1); } if (options.title) solution.tasks[taskIdx].title = options.title; if (options.status) solution.tasks[taskIdx].status = options.status; writeSolutions(issueId, solutions); console.log(chalk.green(`✓ Task ${taskId} updated`)); } else { // Add new task if (!options.title) { console.error(chalk.red('Task title is required (--title)')); process.exit(1); } const newTaskId = `T${solution.tasks.length + 1}`; const newTask: SolutionTask = { id: newTaskId, title: options.title, scope: '', action: 'Implement', description: options.description || options.title, implementation: [], test: { unit: [], commands: ['npm test'] }, regression: ['npm test'], acceptance: { criteria: ['Task completed successfully'], verification: ['Manual verification'] }, commit: { type: 'feat', scope: 'core', message_template: `feat(core): ${options.title}` }, depends_on: [] }; solution.tasks.push(newTask); writeSolutions(issueId, solutions); console.log(chalk.green(`✓ Task ${newTaskId} added to ${issueId}`)); } } /** * bind - Register and/or bind a solution */ async function bindAction(issueId: string | undefined, solutionId: string | undefined, options: IssueOptions): Promise { if (!issueId) { console.error(chalk.red('Issue ID is required')); console.error(chalk.gray('Usage: ccw issue bind [solution-id] [--solution ]')); process.exit(1); } const issue = findIssue(issueId); if (!issue) { console.error(chalk.red(`Issue "${issueId}" not found`)); process.exit(1); } let solutions = readSolutions(issueId); // Register new solution from file if provided if (options.solution) { try { const content = readFileSync(options.solution, 'utf-8'); const data = JSON.parse(content); const newSol: Solution = { id: solutionId || generateSolutionId(), description: data.description || data.approach_name || 'Imported solution', tasks: data.tasks || [], exploration_context: data.exploration_context, analysis: data.analysis, score: data.score, is_bound: false, created_at: new Date().toISOString() }; solutions.push(newSol); solutionId = newSol.id; console.log(chalk.green(`✓ Solution ${solutionId} registered (${newSol.tasks.length} tasks)`)); } catch (e) { console.error(chalk.red(`Failed to read solution file: ${options.solution}`)); process.exit(1); } } if (!solutionId) { // List available solutions if (solutions.length === 0) { console.log(chalk.yellow('No solutions available')); console.log(chalk.gray('Register one: ccw issue bind --solution ')); return; } console.log(chalk.bold.cyan(`\nSolutions for ${issueId}:\n`)); for (const sol of solutions) { const marker = sol.is_bound ? chalk.green('◉') : chalk.gray('○'); console.log(` ${marker} ${sol.id}: ${sol.tasks.length} tasks - ${sol.description || ''}`); } return; } // Bind the specified solution const solIdx = solutions.findIndex(s => s.id === solutionId); if (solIdx === -1) { console.error(chalk.red(`Solution "${solutionId}" not found`)); process.exit(1); } // Unbind all, bind selected solutions = solutions.map(s => ({ ...s, is_bound: false })); solutions[solIdx].is_bound = true; solutions[solIdx].bound_at = new Date().toISOString(); writeSolutions(issueId, solutions); updateIssue(issueId, { bound_solution_id: solutionId, solution_count: solutions.length, status: 'planned', planned_at: new Date().toISOString() }); console.log(chalk.green(`✓ Solution ${solutionId} bound to ${issueId}`)); } /** * queue - Queue management (list / add / history) */ async function queueAction(subAction: string | undefined, issueId: string | undefined, options: IssueOptions): Promise { // List all queues (history) if (subAction === 'list' || subAction === 'history') { const index = readQueueIndex(); if (options.json) { console.log(JSON.stringify(index, null, 2)); return; } console.log(chalk.bold.cyan('\nQueue History\n')); console.log(chalk.gray(`Active: ${index.active_queue_id || 'none'}`)); console.log(); if (index.queues.length === 0) { console.log(chalk.yellow('No queues found')); console.log(chalk.gray('Create one: ccw issue queue add ')); return; } console.log(chalk.gray('ID'.padEnd(22) + 'Status'.padEnd(12) + 'Tasks'.padEnd(10) + 'Issues')); console.log(chalk.gray('-'.repeat(70))); for (const q of index.queues) { const statusColor = { 'active': chalk.green, 'completed': chalk.cyan, 'archived': chalk.gray, 'failed': chalk.red }[q.status] || chalk.white; const marker = q.id === index.active_queue_id ? '→ ' : ' '; console.log( marker + q.id.padEnd(20) + statusColor(q.status.padEnd(12)) + `${q.completed_tasks}/${q.total_tasks}`.padEnd(10) + q.issue_ids.join(', ') ); } return; } // Switch active queue if (subAction === 'switch' && issueId) { const queueId = issueId; // issueId is actually queue ID here const targetQueue = readQueue(queueId); if (!targetQueue) { console.error(chalk.red(`Queue "${queueId}" not found`)); process.exit(1); } const index = readQueueIndex(); index.active_queue_id = queueId; writeQueueIndex(index); console.log(chalk.green(`✓ Switched to queue ${queueId}`)); return; } // DAG - Return dependency graph for parallel execution planning (solution-level) if (subAction === 'dag') { const queue = readActiveQueue(); // Support both old (tasks) and new (solutions) queue format const items = queue.solutions || queue.tasks || []; if (!queue.id || items.length === 0) { console.log(JSON.stringify({ error: 'No active queue', nodes: [], edges: [], groups: [] })); return; } // Build DAG nodes (solution-level) const completedIds = new Set(items.filter(t => t.status === 'completed').map(t => t.item_id)); const failedIds = new Set(items.filter(t => t.status === 'failed').map(t => t.item_id)); const nodes = items.map(item => ({ id: item.item_id, issue_id: item.issue_id, solution_id: item.solution_id, status: item.status, executor: item.assigned_executor, priority: item.semantic_priority, depends_on: item.depends_on || [], task_count: item.task_count || 1, files_touched: item.files_touched || [], // Calculate if ready (dependencies satisfied) ready: item.status === 'pending' && (item.depends_on || []).every(d => completedIds.has(d)), blocked_by: (item.depends_on || []).filter(d => !completedIds.has(d) && !failedIds.has(d)) })); // Build edges for visualization const edges = items.flatMap(item => (item.depends_on || []).map(dep => ({ from: dep, to: item.item_id })) ); // Group ready items by execution_group const readyItems = nodes.filter(n => n.ready || n.status === 'executing'); const groups: Record = {}; for (const item of items) { if (readyItems.some(r => r.id === item.item_id)) { const group = item.execution_group || 'P1'; if (!groups[group]) groups[group] = []; groups[group].push(item.item_id); } } // Calculate parallel batches - prefer execution_groups from queue if available const parallelBatches: string[][] = []; const readyItemIds = new Set(readyItems.map(t => t.id)); // Check if queue has pre-assigned execution_groups if (queue.execution_groups && queue.execution_groups.length > 0) { // Use agent-assigned execution groups for (const group of queue.execution_groups) { const groupItems = (group.solutions || group.tasks || []) .filter((id: string) => readyItemIds.has(id)); if (groupItems.length > 0) { if (group.type === 'parallel') { // All items in parallel group can run together parallelBatches.push(groupItems); } else { // Sequential group: each item is its own batch for (const itemId of groupItems) { parallelBatches.push([itemId]); } } } } } else { // Fallback: calculate parallel batches from file conflicts const remainingReady = new Set(readyItemIds); while (remainingReady.size > 0) { const batch: string[] = []; const batchFiles = new Set(); for (const itemId of Array.from(remainingReady)) { const item = items.find(t => t.item_id === itemId); if (!item) continue; // Get all files touched by this solution let solutionFiles: string[] = item.files_touched || []; // If not in queue item, fetch from solution definition if (solutionFiles.length === 0) { const solution = findSolution(item.issue_id, item.solution_id); if (solution?.tasks) { for (const task of solution.tasks) { for (const mp of task.modification_points || []) { solutionFiles.push(mp.file); } } } } const hasConflict = solutionFiles.some(f => batchFiles.has(f)); if (!hasConflict) { batch.push(itemId); solutionFiles.forEach(f => batchFiles.add(f)); } } if (batch.length === 0) { // Fallback: take one at a time if all conflict const first = Array.from(remainingReady)[0]; batch.push(first); } parallelBatches.push(batch); batch.forEach(id => remainingReady.delete(id)); } } console.log(JSON.stringify({ queue_id: queue.id, total: nodes.length, ready_count: readyItems.length, completed_count: completedIds.size, nodes, edges, groups: Object.entries(groups).map(([id, solutions]) => ({ id, solutions })), parallel_batches: parallelBatches, _summary: { can_parallel: parallelBatches[0]?.length || 0, batches_needed: parallelBatches.length } }, null, 2)); return; } // Archive current queue if (subAction === 'archive') { const queue = readActiveQueue(); if (!queue.id || queue.tasks.length === 0) { console.log(chalk.yellow('No active queue to archive')); return; } queue.status = 'archived'; writeQueue(queue); const index = readQueueIndex(); index.active_queue_id = null; writeQueueIndex(index); console.log(chalk.green(`✓ Archived queue ${queue.id}`)); return; } // Delete queue from history if ((subAction === 'clear' || subAction === 'delete') && issueId) { const queueId = issueId; // issueId is actually queue ID here const queuePath = join(getQueuesDir(), `${queueId}.json`); if (!existsSync(queuePath)) { console.error(chalk.red(`Queue "${queueId}" not found`)); process.exit(1); } // Remove from index const index = readQueueIndex(); index.queues = index.queues.filter(q => q.id !== queueId); if (index.active_queue_id === queueId) { index.active_queue_id = null; } writeQueueIndex(index); // Delete queue file unlinkSync(queuePath); console.log(chalk.green(`✓ Deleted queue ${queueId}`)); return; } // Add issue tasks to queue if (subAction === 'add' && issueId) { const issue = findIssue(issueId); if (!issue) { console.error(chalk.red(`Issue "${issueId}" not found`)); process.exit(1); } const solution = getBoundSolution(issueId); if (!solution) { console.error(chalk.red(`No bound solution for "${issueId}"`)); console.error(chalk.gray('First bind a solution: ccw issue bind ')); process.exit(1); } // Get or create active queue (create new if current is completed/archived) let queue = readActiveQueue(); const isNewQueue = queue.tasks.length === 0 || queue.status !== 'active'; if (queue.status !== 'active') { // Create new queue if current is not active queue = createEmptyQueue(); } // Add issue to queue's issue list if (!queue.issue_ids.includes(issueId)) { queue.issue_ids.push(issueId); } let added = 0; for (const task of solution.tasks) { const exists = queue.tasks.some(q => q.issue_id === issueId && q.task_id === task.id); if (exists) continue; queue.tasks.push({ item_id: generateQueueItemId(queue), issue_id: issueId, solution_id: solution.id, task_id: task.id, status: 'pending', execution_order: queue.tasks.length + 1, execution_group: 'P1', depends_on: task.depends_on.map(dep => { const depItem = queue.tasks.find(q => q.task_id === dep && q.issue_id === issueId); return depItem?.item_id || dep; }), semantic_priority: 0.5, assigned_executor: 'codex' }); added++; } writeQueue(queue); updateIssue(issueId, { status: 'queued', queued_at: new Date().toISOString() }); if (isNewQueue) { console.log(chalk.green(`✓ Created queue ${queue.id}`)); } console.log(chalk.green(`✓ Added ${added} tasks from ${solution.id}`)); return; } // Show current queue const queue = readActiveQueue(); if (options.json) { console.log(JSON.stringify(queue, null, 2)); return; } console.log(chalk.bold.cyan('\nActive Queue\n')); if (!queue.id || queue.tasks.length === 0) { console.log(chalk.yellow('No active queue')); console.log(chalk.gray('Create one: ccw issue queue add ')); console.log(chalk.gray('Or list history: ccw issue queue list')); return; } console.log(chalk.gray(`Queue: ${queue.id}`)); console.log(chalk.gray(`Issues: ${queue.issue_ids.join(', ')}`)); console.log(chalk.gray(`Total: ${queue._metadata.total_tasks} | Pending: ${queue._metadata.pending_count} | Executing: ${queue._metadata.executing_count} | Completed: ${queue._metadata.completed_count}`)); console.log(); console.log(chalk.gray('QueueID'.padEnd(10) + 'Issue'.padEnd(15) + 'Task'.padEnd(8) + 'Status'.padEnd(12) + 'Executor')); console.log(chalk.gray('-'.repeat(60))); for (const item of queue.tasks) { const statusColor = { 'pending': chalk.gray, 'ready': chalk.cyan, 'executing': chalk.yellow, 'completed': chalk.green, 'failed': chalk.red, 'blocked': chalk.magenta }[item.status] || chalk.white; console.log( item.item_id.padEnd(10) + item.issue_id.substring(0, 13).padEnd(15) + (item.task_id || '-').padEnd(8) + statusColor(item.status.padEnd(12)) + item.assigned_executor ); } } /** * next - Get next ready task for execution (JSON output) * Accepts optional item_id to fetch a specific task directly */ async function nextAction(itemId: string | undefined, options: IssueOptions): Promise { const queue = readActiveQueue(); // Support both old (tasks) and new (solutions) queue format const items = queue.solutions || queue.tasks || []; let nextItem: typeof items[0] | undefined; let isResume = false; // If specific item_id provided, fetch that item directly if (itemId) { nextItem = items.find(t => t.item_id === itemId); if (!nextItem) { console.log(JSON.stringify({ status: 'error', message: `Item ${itemId} not found` })); return; } if (nextItem.status === 'completed') { console.log(JSON.stringify({ status: 'completed', message: `Item ${itemId} already completed` })); return; } if (nextItem.status === 'failed') { console.log(JSON.stringify({ status: 'failed', message: `Item ${itemId} failed, use retry to reset` })); return; } isResume = nextItem.status === 'executing'; } else { // Auto-select: Priority 1 - executing, Priority 2 - ready pending const executingItems = items.filter(item => item.status === 'executing'); const pendingItems = items.filter(item => { if (item.status !== 'pending') return false; return (item.depends_on || []).every(depId => { const dep = items.find(q => q.item_id === depId); return !dep || dep.status === 'completed'; }); }); const readyItems = [...executingItems, ...pendingItems]; if (readyItems.length === 0) { console.log(JSON.stringify({ status: 'empty', message: 'No ready items', queue_status: queue._metadata }, null, 2)); return; } readyItems.sort((a, b) => a.execution_order - b.execution_order); nextItem = readyItems[0]; isResume = nextItem.status === 'executing'; } // Load FULL solution with all tasks const solution = findSolution(nextItem.issue_id, nextItem.solution_id); if (!solution) { console.log(JSON.stringify({ status: 'error', message: 'Solution not found' })); process.exit(1); } // Only update status if not already executing if (!isResume) { const idx = items.findIndex(q => q.item_id === nextItem.item_id); items[idx].status = 'executing'; items[idx].started_at = new Date().toISOString(); // Write back to correct array if (queue.solutions) { queue.solutions = items; } else { queue.tasks = items; } writeQueue(queue); updateIssue(nextItem.issue_id, { status: 'executing' }); } // Calculate queue stats const stats = { total: items.length, completed: items.filter(q => q.status === 'completed').length, failed: items.filter(q => q.status === 'failed').length, executing: items.filter(q => q.status === 'executing').length, pending: items.filter(q => q.status === 'pending').length }; const remaining = stats.pending + stats.executing; // Calculate total estimated time for all tasks const totalMinutes = solution.tasks?.reduce((sum, t) => sum + (t.estimated_minutes || 30), 0) || 30; console.log(JSON.stringify({ item_id: nextItem.item_id, issue_id: nextItem.issue_id, solution_id: nextItem.solution_id, // Return full solution object with all tasks solution: { id: solution.id, approach: solution.approach, tasks: solution.tasks || [], exploration_context: solution.exploration_context || {} }, resumed: isResume, resume_note: isResume ? `Resuming interrupted item (started: ${nextItem.started_at})` : undefined, execution_hints: { executor: nextItem.assigned_executor, task_count: solution.tasks?.length || 0, estimated_minutes: totalMinutes }, queue_progress: { completed: stats.completed, remaining: remaining, total: stats.total, progress: `${stats.completed}/${stats.total}` } }, null, 2)); } /** * detail - Get task details by item_id (READ-ONLY, does NOT change status) * Used for parallel execution: orchestrator gets dag, then dispatches with detail */ async function detailAction(itemId: string | undefined, options: IssueOptions): Promise { if (!itemId) { console.log(JSON.stringify({ status: 'error', message: 'item_id is required' })); return; } const queue = readActiveQueue(); // Support both old (tasks) and new (solutions) queue format const items = queue.solutions || queue.tasks || []; const queueItem = items.find(t => t.item_id === itemId); if (!queueItem) { console.log(JSON.stringify({ status: 'error', message: `Item ${itemId} not found` })); return; } // Load FULL solution with all tasks const solution = findSolution(queueItem.issue_id, queueItem.solution_id); if (!solution) { console.log(JSON.stringify({ status: 'error', message: 'Solution not found' })); return; } // Calculate total estimated time for all tasks const totalMinutes = solution.tasks?.reduce((sum, t) => sum + (t.estimated_minutes || 30), 0) || 30; // Return FULL SOLUTION with all tasks (READ-ONLY - no status update) console.log(JSON.stringify({ item_id: queueItem.item_id, issue_id: queueItem.issue_id, solution_id: queueItem.solution_id, status: queueItem.status, // Return full solution object with all tasks solution: { id: solution.id, approach: solution.approach, tasks: solution.tasks || [], exploration_context: solution.exploration_context || {} }, execution_hints: { executor: queueItem.assigned_executor, task_count: solution.tasks?.length || 0, estimated_minutes: totalMinutes } }, null, 2)); } /** * done - Mark task completed or failed */ async function doneAction(queueId: string | undefined, options: IssueOptions): Promise { if (!queueId) { console.error(chalk.red('Item ID is required')); console.error(chalk.gray('Usage: ccw issue done [--fail] [--reason "..."]')); process.exit(1); } const queue = readActiveQueue(); // Support both old (tasks) and new (solutions) queue format const items = queue.solutions || queue.tasks || []; const idx = items.findIndex(q => q.item_id === queueId); if (idx === -1) { console.error(chalk.red(`Queue item "${queueId}" not found`)); process.exit(1); } const isFail = options.fail; items[idx].status = isFail ? 'failed' : 'completed'; items[idx].completed_at = new Date().toISOString(); if (isFail) { items[idx].failure_reason = options.reason || 'Unknown failure'; } else if (options.result) { try { items[idx].result = JSON.parse(options.result); } catch { console.warn(chalk.yellow('Warning: Could not parse result JSON')); } } // Update issue status (solution = issue in new model) const issueId = items[idx].issue_id; if (isFail) { updateIssue(issueId, { status: 'failed' }); console.log(chalk.red(`✗ ${queueId} failed`)); } else { updateIssue(issueId, { status: 'completed', completed_at: new Date().toISOString() }); console.log(chalk.green(`✓ ${queueId} completed`)); console.log(chalk.green(`✓ Issue ${issueId} completed`)); } // Check if entire queue is complete const allQueueComplete = items.every(q => q.status === 'completed'); const anyQueueFailed = items.some(q => q.status === 'failed'); if (allQueueComplete) { queue.status = 'completed'; console.log(chalk.green(`\n✓ Queue ${queue.id} completed (all solutions done)`)); } else if (anyQueueFailed && items.every(q => q.status === 'completed' || q.status === 'failed')) { queue.status = 'failed'; console.log(chalk.yellow(`\n⚠ Queue ${queue.id} has failed solutions`)); } // Write back to queue (update the correct array) if (queue.solutions) { queue.solutions = items; } else { queue.tasks = items; } writeQueue(queue); } /** * retry - Reset failed items to pending for re-execution */ async function retryAction(issueId: string | undefined, options: IssueOptions): Promise { const queue = readActiveQueue(); // Support both old (tasks) and new (solutions) queue format const items = queue.solutions || queue.tasks || []; if (!queue.id || items.length === 0) { console.log(chalk.yellow('No active queue')); return; } let updated = 0; for (const item of items) { // Retry failed items only if (item.status === 'failed') { if (!issueId || item.issue_id === issueId) { item.status = 'pending'; item.failure_reason = undefined; item.started_at = undefined; item.completed_at = undefined; updated++; } } } if (updated === 0) { console.log(chalk.yellow('No failed items to retry')); return; } // Reset queue status if it was failed if (queue.status === 'failed') { queue.status = 'active'; } // Write back to queue if (queue.solutions) { queue.solutions = items; } else { queue.tasks = items; } writeQueue(queue); if (issueId) { updateIssue(issueId, { status: 'queued' }); } console.log(chalk.green(`✓ Reset ${updated} item(s) to pending`)); } // ============ Main Entry ============ export async function issueCommand( subcommand: string, args: string | string[], options: IssueOptions ): Promise { const argsArray = Array.isArray(args) ? args : (args ? [args] : []); switch (subcommand) { case 'init': await initAction(argsArray[0], options); break; case 'list': await listAction(argsArray[0], options); break; case 'status': await statusAction(argsArray[0], options); break; case 'task': await taskAction(argsArray[0], argsArray[1], options); break; case 'bind': await bindAction(argsArray[0], argsArray[1], options); break; case 'queue': await queueAction(argsArray[0], argsArray[1], options); break; case 'next': await nextAction(argsArray[0], options); break; case 'detail': await detailAction(argsArray[0], options); break; case 'done': await doneAction(argsArray[0], options); break; case 'retry': await retryAction(argsArray[0], options); break; // Legacy aliases case 'register': console.log(chalk.yellow('Deprecated: use "ccw issue bind --solution "')); await bindAction(argsArray[0], undefined, options); break; case 'complete': await doneAction(argsArray[0], options); break; case 'fail': await doneAction(argsArray[0], { ...options, fail: true }); break; default: console.log(chalk.bold.cyan('\nCCW Issue Management (v3.0 - Multi-Queue + Lifecycle)\n')); console.log(chalk.bold('Core Commands:')); console.log(chalk.gray(' init Initialize new issue')); console.log(chalk.gray(' list [issue-id] List issues or tasks')); console.log(chalk.gray(' status [issue-id] Show detailed status')); console.log(chalk.gray(' task [task-id] Add or update task')); console.log(chalk.gray(' bind [sol-id] Bind solution (--solution to register)')); console.log(); console.log(chalk.bold('Queue Commands:')); console.log(chalk.gray(' queue Show active queue')); console.log(chalk.gray(' queue list List all queues (history)')); 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 dag Get dependency graph (JSON) for parallel execution')); console.log(chalk.gray(' queue archive Archive current queue')); console.log(chalk.gray(' queue delete Delete queue from history')); console.log(chalk.gray(' retry [issue-id] Retry failed tasks')); console.log(); console.log(chalk.bold('Execution Endpoints:')); console.log(chalk.gray(' next [item-id] Get & mark task executing (JSON)')); console.log(chalk.gray(' detail Get task details (READ-ONLY, for parallel)')); console.log(chalk.gray(' done Mark task completed')); console.log(chalk.gray(' done --fail Mark task failed')); console.log(); console.log(chalk.bold('Options:')); console.log(chalk.gray(' --title Issue/task title')); console.log(chalk.gray(' --status <status> Filter by status (comma-separated)')); console.log(chalk.gray(' --ids List only IDs (one per line)')); console.log(chalk.gray(' --solution <path> Solution JSON file')); console.log(chalk.gray(' --result <json> Execution result')); console.log(chalk.gray(' --reason <text> Failure reason')); console.log(chalk.gray(' --json JSON output')); console.log(chalk.gray(' --force Force operation')); console.log(); console.log(chalk.bold('Storage:')); console.log(chalk.gray(' .workflow/issues/issues.jsonl All issues')); console.log(chalk.gray(' .workflow/issues/solutions/*.jsonl Solutions per issue')); console.log(chalk.gray(' .workflow/issues/queues/ Queue files (multi-queue)')); console.log(chalk.gray(' .workflow/issues/queues/index.json Queue index')); } }