/** * 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 { execSync } from 'child_process'; import inquirer from 'inquirer'; import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, statSync } from 'fs'; import { join, resolve } from 'path'; import { EXEC_TIMEOUTS } from '../utils/exec-constants.js'; function isExecTimeoutError(error: unknown): boolean { const err = error as { code?: unknown; errno?: unknown; message?: unknown } | null; const code = err?.code ?? err?.errno; if (code === 'ETIMEDOUT') return true; const message = typeof err?.message === 'string' ? err.message : ''; return message.includes('ETIMEDOUT'); } // Handle EPIPE errors gracefully process.stdout.on('error', (err: NodeJS.ErrnoException) => { if (err.code === 'EPIPE') { process.exit(0); } throw err; }); // ============ Interfaces ============ interface IssueFeedback { type: 'failure' | 'clarification' | 'rejection'; stage: string; // new/plan/execute content: string; created_at: string; } interface Issue { id: string; title: string; status: 'registered' | 'planning' | 'planned' | 'queued' | 'executing' | 'completed' | 'failed' | 'paused'; priority: number; context: string; // Problem description (single source of truth) source?: 'github' | 'text' | 'discovery'; source_url?: string; tags?: string[]; // Optional structured fields expected_behavior?: string; actual_behavior?: string; affected_components?: string[]; // Feedback history (failures + human clarifications) feedback?: IssueFeedback[]; // Solution binding bound_solution_id: string | null; // 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; } // Structured failure detail for debugging interface FailureDetail { task_id?: string; // Which task failed within the solution error_type: string; // e.g., "compilation", "test_failure", "timeout" message: string; // Human-readable error message stack_trace?: string; // Optional stack trace timestamp: string; // ISO timestamp } 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; 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; // Simple string (backward compat) failure_details?: FailureDetail; // Structured failure info failure_history?: FailureDetail[]; // Preserved on retry for debugging } 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' | 'merged'; 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; merged_into?: string; // Queue ID this was merged into merged_at?: string; // Timestamp of merge }; } interface QueueIndex { active_queue_id: string | null; // Single active queue (backward compat) active_queue_ids?: string[]; // Multiple active queues, ordered by priority queues: { id: string; status: string; priority?: number; // Queue execution priority (lower = higher priority) 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; solutionId?: string; // --solution-id for filtering solutions result?: string; reason?: string; json?: boolean; force?: boolean; fail?: boolean; brief?: boolean; // List brief info only (id, title, status, priority, tags) - JSON format data?: string; // JSON data for create fromQueue?: boolean | string; // Sync statuses from queue (true=active, string=specific queue ID) queue?: string; // Target queue ID for multi-queue operations // GitHub pull options state?: string; // Issue state: open, closed, all limit?: number; // Maximum number of issues to pull labels?: string; // Filter by labels (comma-separated) } const ISSUES_DIR = '.workflow/issues'; // ============ Status Constants ============ const VALID_QUEUE_STATUSES = ['active', 'completed', 'archived', 'failed', 'merged'] as const; const VALID_ITEM_STATUSES = ['pending', 'ready', 'executing', 'completed', 'failed', 'blocked'] as const; const VALID_ISSUE_STATUSES = ['registered', 'planning', 'planned', 'queued', 'executing', 'completed', 'failed', 'paused'] as const; type QueueStatus = typeof VALID_QUEUE_STATUSES[number]; type QueueItemStatus = typeof VALID_ITEM_STATUSES[number]; type IssueStatus = typeof VALID_ISSUE_STATUSES[number]; /** * Validate queue status */ function validateQueueStatus(status: string): status is QueueStatus { return VALID_QUEUE_STATUSES.includes(status as QueueStatus); } /** * Validate queue item status */ function validateItemStatus(status: string): status is QueueItemStatus { return VALID_ITEM_STATUSES.includes(status as QueueItemStatus); } /** * Validate issue status */ function validateIssueStatus(status: string): status is IssueStatus { return VALID_ISSUE_STATUSES.includes(status as IssueStatus); } // ============ Storage Layer (JSONL) ============ /** * Cached project root to avoid repeated git command execution */ let cachedProjectRoot: string | null = null; /** * Clear cached project root (for testing) */ export function clearProjectRootCache(): void { cachedProjectRoot = null; } /** * Debug logging helper (enabled via CCW_DEBUG=true) */ const DEBUG = process.env.CCW_DEBUG === 'true'; function debugLog(msg: string): void { if (DEBUG) { console.log(`[ccw:worktree] ${msg}`); } } /** * Normalize path for comparison (handles Windows case sensitivity) */ function normalizePath(p: string): string { const normalized = resolve(p); // Windows: normalize to lowercase for comparison return process.platform === 'win32' ? normalized.toLowerCase() : normalized; } /** * Try to resolve main repo from .git file (worktree link file) * .git file format: "gitdir: /path/to/main/.git/worktrees/name" */ function resolveMainRepoFromGitFile(gitFilePath: string): string | null { try { const content = readFileSync(gitFilePath, 'utf-8').trim(); // Parse "gitdir: /path/to/.git/worktrees/name" const match = content.match(/^gitdir:\s*(.+)$/); if (match) { const gitDir = match[1]; // Navigate from .git/worktrees/name to .git to repo root // Pattern: /main/.git/worktrees/wt-name -> /main/.git -> /main const worktreesMatch = gitDir.match(/^(.+)[/\\]\.git[/\\]worktrees[/\\]/); if (worktreesMatch) { return worktreesMatch[1]; } } } catch { // Failed to read or parse .git file } return null; } /** * Get the main repository root, even when running from a worktree. * This ensures .workflow/issues/ is always accessed from the main repo. */ function getProjectRoot(): string { // Return cached result if available if (cachedProjectRoot) { debugLog(`Using cached project root: ${cachedProjectRoot}`); return cachedProjectRoot; } debugLog(`Detecting project root from cwd: ${process.cwd()}`); // Priority 1: Check CCW_MAIN_REPO environment variable const envMainRepo = process.env.CCW_MAIN_REPO; if (envMainRepo) { debugLog(`Found CCW_MAIN_REPO env: ${envMainRepo}`); const hasWorkflow = existsSync(join(envMainRepo, '.workflow')); const hasGit = existsSync(join(envMainRepo, '.git')); if (hasWorkflow || hasGit) { debugLog(`CCW_MAIN_REPO validated (workflow=${hasWorkflow}, git=${hasGit})`); cachedProjectRoot = envMainRepo; return envMainRepo; } else { console.warn('[ccw] CCW_MAIN_REPO is set but path is invalid (no .workflow or .git)'); console.warn(`[ccw] Path: ${envMainRepo}`); } } // Priority 2: Try to detect if we're in a git worktree using git commands try { // Get the common git directory (points to main repo's .git) const gitCommonDir = execSync('git rev-parse --git-common-dir', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: EXEC_TIMEOUTS.GIT_QUICK, }).trim(); // Get the current git directory const gitDir = execSync('git rev-parse --git-dir', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: EXEC_TIMEOUTS.GIT_QUICK, }).trim(); debugLog(`Git common dir: ${gitCommonDir}`); debugLog(`Git dir: ${gitDir}`); // Normalize paths for comparison (Windows case insensitive) const normalizedCommon = normalizePath(gitCommonDir); const normalizedGit = normalizePath(gitDir); // If gitDir != gitCommonDir, we're in a worktree if (normalizedGit !== normalizedCommon && gitDir !== '.git') { // We're in a worktree - resolve to main repo const absoluteCommonDir = resolve(process.cwd(), gitCommonDir); // .git directory's parent is the repo root const mainRepoRoot = resolve(absoluteCommonDir, '..'); debugLog(`Detected worktree, main repo: ${mainRepoRoot}`); // Verify .workflow or .git exists in main repo if (existsSync(join(mainRepoRoot, '.workflow')) || existsSync(join(mainRepoRoot, '.git'))) { debugLog(`Main repo validated, returning: ${mainRepoRoot}`); cachedProjectRoot = mainRepoRoot; return mainRepoRoot; } } } catch (err: unknown) { if (isExecTimeoutError(err)) { console.warn(`[issue] git rev-parse timed out after ${EXEC_TIMEOUTS.GIT_QUICK}ms; falling back to filesystem detection`); } debugLog(`Git command failed, falling back to filesystem detection`); // Git command failed - fall through to manual detection } // Priority 3: Standard detection with worktree file support: walk up to find .workflow or .git let dir = process.cwd(); while (dir !== resolve(dir, '..')) { const gitPath = join(dir, '.git'); // Check if .git is a file (worktree link) rather than directory if (existsSync(gitPath)) { try { const gitStat = statSync(gitPath); if (gitStat.isFile()) { // .git is a file - this is a worktree, try to resolve main repo const mainRepo = resolveMainRepoFromGitFile(gitPath); debugLog(`Parsed .git file, main repo: ${mainRepo}`); if (mainRepo) { // Verify main repo has .git directory (always true for main repo) // Don't require .workflow - it may not exist yet in a new repo const hasGit = existsSync(join(mainRepo, '.git')); const hasWorkflow = existsSync(join(mainRepo, '.workflow')); if (hasGit || hasWorkflow) { if (!hasWorkflow) { console.warn('[ccw] Worktree detected but main repo has no .workflow directory'); console.warn(`[ccw] Main repo: ${mainRepo}`); console.warn('[ccw] Issue commands may fail until .workflow is created'); console.warn('[ccw] Set CCW_MAIN_REPO environment variable to override detection'); } debugLog(`Main repo validated via .git file (git=${hasGit}, workflow=${hasWorkflow})`); cachedProjectRoot = mainRepo; return mainRepo; } } } } catch { // stat failed, continue with normal logic debugLog(`Failed to stat ${gitPath}, continuing`); } } if (existsSync(join(dir, '.workflow')) || existsSync(gitPath)) { debugLog(`Found project root at: ${dir}`); cachedProjectRoot = dir; return dir; } dir = resolve(dir, '..'); } debugLog(`No project root found, using cwd: ${process.cwd()}`); const fallback = process.cwd(); cachedProjectRoot = fallback; return fallback; } function getIssuesDir(): string { return join(getProjectRoot(), ISSUES_DIR); } function ensureIssuesDir(): void { const dir = getIssuesDir(); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } } // ============ Issues JSONL ============ export 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 []; } } export function writeIssues(issues: Issue[]): void { ensureIssuesDir(); const path = join(getIssuesDir(), 'issues.jsonl'); // Always add trailing newline for proper JSONL format const content = issues.map(i => JSON.stringify(i)).join('\n'); writeFileSync(path, content ? content + '\n' : '', 'utf-8'); } function findIssue(issueId: string): Issue | undefined { return readIssues().find(i => i.id === issueId); } // ============ Issue History JSONL ============ function readIssueHistory(): Issue[] { const path = join(getIssuesDir(), 'issue-history.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 appendIssueHistory(issue: Issue): void { ensureIssuesDir(); const path = join(getIssuesDir(), 'issue-history.jsonl'); const line = JSON.stringify(issue) + '\n'; // Append to history file if (existsSync(path)) { const content = readFileSync(path, 'utf-8'); // Ensure proper newline before appending const needsNewline = content.length > 0 && !content.endsWith('\n'); writeFileSync(path, (needsNewline ? '\n' : '') + line, { flag: 'a' }); } else { writeFileSync(path, line, 'utf-8'); } } /** * Move completed issue from issues.jsonl to issue-history.jsonl */ function moveIssueToHistory(issueId: string): boolean { const issues = readIssues(); const idx = issues.findIndex(i => i.id === issueId); if (idx === -1) return false; const issue = issues[idx]; if (issue.status !== 'completed') return false; // Append to history appendIssueHistory(issue); // Remove from active issues issues.splice(idx, 1); writeIssues(issues); return true; } 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); // Auto-move to history when completed if (updates.status === 'completed') { moveIssueToHistory(issueId); } return true; } /** * Generate auto-increment issue ID: ISS-YYYYMMDD-NNN */ function generateIssueId(existingIssues: Issue[] = []): string { const today = new Date(); const dateStr = today.toISOString().slice(0, 10).replace(/-/g, ''); const prefix = `ISS-${dateStr}-`; const todayPattern = new RegExp(`^ISS-${dateStr}-(\\d{3})$`); let maxSeq = 0; for (const issue of existingIssues) { const match = issue.id.match(todayPattern); if (match) maxSeq = Math.max(maxSeq, parseInt(match[1], 10)); } return `${prefix}${String(maxSeq + 1).padStart(3, '0')}`; } /** * Create a new issue with proper JSONL handling * Auto-generates ID if not provided */ function createIssue(data: Partial): Issue { const issues = readIssues(); const issueId = data.id || generateIssueId(issues); if (issues.some(i => i.id === issueId)) { throw new Error(`Issue "${issueId}" already exists`); } const newIssue: Issue = { id: issueId, title: data.title || issueId, status: data.status || 'registered', priority: data.priority || 3, context: data.context || '', source: data.source, source_url: data.source_url, tags: data.tags, expected_behavior: data.expected_behavior, actual_behavior: data.actual_behavior, affected_components: data.affected_components, feedback: data.feedback, bound_solution_id: data.bound_solution_id || null, created_at: new Date().toISOString(), updated_at: new Date().toISOString() }; issues.push(newIssue); writeIssues(issues); return newIssue; } // ============ Solutions JSONL ============ function getSolutionsPath(issueId: string): string { return join(getIssuesDir(), 'solutions', `${issueId}.jsonl`); } export 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 []; } } export function writeSolutions(issueId: string, solutions: Solution[]): void { const dir = join(getIssuesDir(), 'solutions'); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); // Always add trailing newline for proper JSONL format const content = solutions.map(s => JSON.stringify(s)).join('\n'); writeFileSync(getSolutionsPath(issueId), content ? content + '\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); } /** * Generate solution ID in format: SOL-{issue-id}-{seq} * @param issueId - The issue ID to include in the solution ID * @param existingSolutions - Existing solutions to calculate next sequence number * @returns Solution ID like "SOL-GH-123-1" or "SOL-ISS-20251229-001-2" */ function generateSolutionId(issueId: string, existingSolutions: Solution[] = []): string { // Find the highest existing sequence number for this issue const pattern = new RegExp(`^SOL-${issueId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-(\\d+)$`); let maxSeq = 0; for (const sol of existingSolutions) { const match = sol.id.match(pattern); if (match) { maxSeq = Math.max(maxSeq, parseInt(match[1], 10)); } } return `SOL-${issueId}-${maxSeq + 1}`; } /** * Create a new solution with proper JSONL handling * Auto-generates ID if not provided */ function createSolution(issueId: string, data: Partial): Solution { const issue = findIssue(issueId); if (!issue) { throw new Error(`Issue "${issueId}" not found`); } const solutions = readSolutions(issueId); const solutionId = data.id || generateSolutionId(issueId, solutions); if (solutions.some(s => s.id === solutionId)) { throw new Error(`Solution "${solutionId}" already exists`); } const newSolution: Solution = { id: solutionId, description: data.description || '', approach: data.approach || '', tasks: data.tasks || [], exploration_context: data.exploration_context, analysis: data.analysis, score: data.score, is_bound: false, created_at: new Date().toISOString() }; solutions.push(newSolution); writeSolutions(issueId, solutions); return newSolution; } // ============ 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}`; } export 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() } }; } interface MergeResult { success: boolean; itemsMerged: number; totalItems: number; skippedDuplicates: number; reason?: string; } /** * Merge items from source queue into target queue * - Skips duplicate items (same issue_id + solution_id) * - Re-generates item IDs for merged items * - Marks source queue as 'merged' with metadata (or deletes if deleteSource=true) * - Updates queue index */ function mergeQueues(target: Queue, source: Queue, options?: { deleteSource?: boolean }): MergeResult { const sourceItems = source.solutions || source.tasks || []; const targetItems = target.solutions || target.tasks || []; if (sourceItems.length === 0) { return { success: false, itemsMerged: 0, totalItems: targetItems.length, skippedDuplicates: 0, reason: 'Source queue is empty' }; } // Ensure target has solutions array if (!target.solutions) { target.solutions = []; } let itemsMerged = 0; let skippedDuplicates = 0; for (const sourceItem of sourceItems) { // Skip if already exists in target (same issue_id + solution_id) const exists = target.solutions.some( t => t.issue_id === sourceItem.issue_id && t.solution_id === sourceItem.solution_id ); if (exists) { skippedDuplicates++; continue; } // Add issue to target's issue_ids if not present if (!target.issue_ids.includes(sourceItem.issue_id)) { target.issue_ids.push(sourceItem.issue_id); } // Clone and add item with new item_id const newItem: QueueItem = { ...sourceItem, item_id: generateQueueItemId(target, 'solution'), execution_order: target.solutions.length + 1 }; target.solutions.push(newItem); itemsMerged++; } // Merge conflicts if any if (source.conflicts && source.conflicts.length > 0) { if (!target.conflicts) target.conflicts = []; target.conflicts.push(...source.conflicts); } // Write updated target queue writeQueue(target); // Handle source queue: delete or mark as merged const index = readQueueIndex(); if (options?.deleteSource) { // Delete source queue file and remove from index const queuePath = join(getQueuesDir(), `${source.id}.json`); if (existsSync(queuePath)) { unlinkSync(queuePath); } index.queues = index.queues.filter(q => q.id !== source.id); } else { // Mark source queue as merged source.status = 'merged'; if (!source._metadata) { source._metadata = { version: '2.1', total_tasks: 0, pending_count: 0, executing_count: 0, completed_count: 0, failed_count: 0, updated_at: new Date().toISOString() }; } source._metadata.merged_into = target.id; source._metadata.merged_at = new Date().toISOString(); writeQueue(source); const sourceEntry = index.queues.find(q => q.id === source.id); if (sourceEntry) { sourceEntry.status = 'merged'; } } // Update target entry in index const targetEntry = index.queues.find(q => q.id === target.id); if (targetEntry) { targetEntry.total_solutions = target.solutions.length; targetEntry.completed_solutions = target.solutions.filter(s => s.status === 'completed').length; targetEntry.issue_ids = target.issue_ids; } writeQueueIndex(index); return { success: itemsMerged > 0, itemsMerged, totalItems: target.solutions.length, skippedDuplicates, reason: itemsMerged === 0 ? 'All items already exist in target queue' : undefined }; } // ============ Multi-Queue Helper Functions ============ /** * Find which queue contains a given item ID * Supports both simple (S-1) and qualified (QUE-xxx:S-1) formats */ function findItemQueue(itemId: string): { queue: Queue; item: QueueItem; itemIndex: number } | null { // Check if qualified format (QUE-xxx:S-1) const qualifiedMatch = itemId.match(/^(QUE-[^:]+):(.+)$/); if (qualifiedMatch) { const [, queueId, actualItemId] = qualifiedMatch; const queue = readQueue(queueId); if (!queue) return null; const items = queue.solutions || queue.tasks || []; const itemIndex = items.findIndex(i => i.item_id === actualItemId); if (itemIndex === -1) return null; return { queue, item: items[itemIndex], itemIndex }; } // Search all queues for unqualified item ID const index = readQueueIndex(); const activeQueueIds = index.active_queue_ids || (index.active_queue_id ? [index.active_queue_id] : []); // Search active queues first for (const queueId of activeQueueIds) { const queue = readQueue(queueId); if (!queue) continue; const items = queue.solutions || queue.tasks || []; const itemIndex = items.findIndex(i => i.item_id === itemId); if (itemIndex >= 0) { return { queue, item: items[itemIndex], itemIndex }; } } // Search all other queues for (const queueEntry of index.queues) { if (activeQueueIds.includes(queueEntry.id)) continue; const queue = readQueue(queueEntry.id); if (!queue) continue; const items = queue.solutions || queue.tasks || []; const itemIndex = items.findIndex(i => i.item_id === itemId); if (itemIndex >= 0) { return { queue, item: items[itemIndex], itemIndex }; } } return null; } /** * Get all active queues ordered by priority (lower = higher priority) * Falls back to creation date order */ function getActiveQueues(): Queue[] { const index = readQueueIndex(); const activeIds = index.active_queue_ids || (index.active_queue_id ? [index.active_queue_id] : []); const queues: Queue[] = []; for (const queueId of activeIds) { const queue = readQueue(queueId); if (queue && queue.status === 'active') { queues.push(queue); } } // Sort by priority field in index (lower = higher priority) const priorityMap = new Map(); for (const entry of index.queues) { priorityMap.set(entry.id, entry.priority ?? Number.MAX_SAFE_INTEGER); } queues.sort((a, b) => { const pa = priorityMap.get(a.id) ?? Number.MAX_SAFE_INTEGER; const pb = priorityMap.get(b.id) ?? Number.MAX_SAFE_INTEGER; if (pa !== pb) return pa - pb; // Fall back to creation date (from queue ID) return a.id.localeCompare(b.id); }); return queues; } /** * Parse failure reason into structured FailureDetail * Detects JSON format vs plain string */ function parseFailureReason(reason: string): FailureDetail { const timestamp = new Date().toISOString(); // Try to parse as JSON first if (reason.trim().startsWith('{')) { try { const parsed = JSON.parse(reason); return { task_id: parsed.task_id, error_type: parsed.error_type || 'unknown', message: parsed.message || reason, stack_trace: parsed.stack_trace, timestamp }; } catch { // Not valid JSON, treat as plain message } } // Plain string message return { error_type: 'execution_error', message: reason, timestamp }; } export function writeQueue(queue: Queue): void { ensureQueuesDir(); // Support both old (tasks) and new (solutions) queue format const items = queue.solutions || queue.tasks || []; const isSolutionQueue = !!queue.solutions; // Ensure _metadata exists (support queues with 'metadata' field from external sources) if (!queue._metadata) { const extMeta = (queue as any).metadata; queue._metadata = { version: '2.0', total_tasks: extMeta?.total_tasks || items.length, pending_count: items.filter(q => q.status === 'pending').length, executing_count: items.filter(q => q.status === 'executing').length, completed_count: items.filter(q => q.status === 'completed').length, failed_count: items.filter(q => q.status === 'failed').length, updated_at: new Date().toISOString() }; } // Update metadata counts queue._metadata.total_tasks = items.length; queue._metadata.pending_count = items.filter(q => q.status === 'pending').length; queue._metadata.executing_count = items.filter(q => q.status === 'executing').length; queue._metadata.completed_count = items.filter(q => q.status === 'completed').length; queue._metadata.failed_count = items.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); // Derive issue_ids from solutions if not present const issueIds = queue.issue_ids || (isSolutionQueue ? [...new Set(items.map(item => item.issue_id))] : []); const indexEntry: QueueIndex['queues'][0] = { id: queue.id, status: queue.status, issue_ids: issueIds, 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 }; // Add format-specific counts if (isSolutionQueue) { indexEntry.total_solutions = items.length; indexEntry.completed_solutions = queue._metadata.completed_count; } else { indexEntry.total_tasks = items.length; indexEntry.completed_tasks = queue._metadata.completed_count; } 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, level: 'solution' | 'task' = 'solution'): string { const prefix = level === 'solution' ? 'S' : 'T'; const items = level === 'solution' ? (queue.solutions || []) : (queue.tasks || []); const pattern = new RegExp(`^${prefix}-(\\d+)$`); const maxNum = items.reduce((max, q) => { const match = q.item_id.match(pattern); return match ? Math.max(max, parseInt(match[1])) : max; }, 0); return `${prefix}-${maxNum + 1}`; } // ============ Commands ============ /** * create - Create issue from JSON data * Usage: ccw issue create --data '{"title":"...", "context":"..."}' * echo '{"title":"..."}' | ccw issue create * Output: JSON with created issue (includes auto-generated ID) */ async function createAction(options: IssueOptions): Promise { let jsonData: string | undefined = options.data; // Support stdin pipe input (avoids shell escaping issues) if (!jsonData && !process.stdin.isTTY) { try { jsonData = readFileSync(0, 'utf-8').trim(); } catch { // stdin not available or empty } } if (!jsonData) { console.error(chalk.red('JSON data required')); console.error(chalk.gray('Usage: ccw issue create --data \'{"title":"...", "context":"..."}\'')); console.error(chalk.gray(' echo \'{"title":"..."}\' | ccw issue create')); process.exit(1); } try { const data = JSON.parse(jsonData); const issue = createIssue(data); console.log(JSON.stringify(issue, null, 2)); } catch (err) { console.error(chalk.red((err as Error).message)); process.exit(1); } } /** * pull - Pull issues from GitHub * Usage: ccw issue pull [--state open|closed|all] [--limit N] [--labels label1,label2] */ async function pullAction(options: IssueOptions): Promise { try { // Check if gh CLI is available try { execSync('gh --version', { stdio: 'ignore', timeout: EXEC_TIMEOUTS.GIT_QUICK }); } catch { console.error(chalk.red('GitHub CLI (gh) is not installed or not in PATH')); console.error(chalk.gray('Install from: https://cli.github.com/')); process.exit(1); } // Build gh command with options const state = options.state || 'open'; const limit = options.limit || 100; let ghCommand = `gh issue list --state ${state} --limit ${limit} --json number,title,body,labels,url,state`; if (options.labels) { ghCommand += ` --label "${options.labels}"`; } console.log(chalk.cyan(`Fetching issues from GitHub (state: ${state}, limit: ${limit})...`)); // Fetch issues from GitHub const ghOutput = execSync(ghCommand, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: EXEC_TIMEOUTS.PROCESS_SPAWN, }).trim(); if (!ghOutput) { console.log(chalk.yellow('No issues found on GitHub')); return; } const ghIssues = JSON.parse(ghOutput); const existingIssues = readIssues(); let imported = 0; let skipped = 0; let updated = 0; for (const ghIssue of ghIssues) { const issueId = `GH-${ghIssue.number}`; const existingIssue = existingIssues.find(i => i.id === issueId); // Prepare issue data const issueData: Partial = { id: issueId, title: ghIssue.title, status: ghIssue.state === 'OPEN' ? 'registered' : 'completed', priority: 3, // Default priority context: ghIssue.body?.substring(0, 500) || ghIssue.title, source: 'github', source_url: ghIssue.url, tags: ghIssue.labels?.map((l: any) => l.name) || [], }; if (existingIssue) { // Update existing issue if state changed if (existingIssue.source_url === ghIssue.url) { // Check if status needs updating const newStatus = ghIssue.state === 'OPEN' ? 'registered' : 'completed'; if (existingIssue.status !== newStatus || existingIssue.title !== ghIssue.title) { existingIssue.title = ghIssue.title; existingIssue.status = newStatus; existingIssue.updated_at = new Date().toISOString(); updated++; } else { skipped++; } } else { skipped++; } } else { // Create new issue try { createIssue(issueData); imported++; } catch (err) { console.error(chalk.red(`Failed to import issue #${ghIssue.number}: ${(err as Error).message}`)); } } } // Save updates if any if (updated > 0) { writeIssues(existingIssues); } console.log(chalk.green(`\n✓ GitHub sync complete:`)); console.log(chalk.gray(` - Imported: ${imported} new issues`)); console.log(chalk.gray(` - Updated: ${updated} existing issues`)); console.log(chalk.gray(` - Skipped: ${skipped} unchanged issues`)); if (options.json) { console.log(JSON.stringify({ imported, updated, skipped, total: ghIssues.length })); } } catch (err) { console.error(chalk.red(`Failed to pull issues from GitHub: ${(err as Error).message}`)); process.exit(1); } } /** * solution - Create or read solutions * Create: ccw issue solution --data '{"tasks":[...]}' * Read: ccw issue solution [--brief] [--solution-id ] * Brief: Returns { solution_id, files_touched[], task_count } for each solution */ async function solutionAction(issueId: string | undefined, options: IssueOptions): Promise { if (!issueId) { console.error(chalk.red('Issue ID required')); console.error(chalk.gray('Usage: ccw issue solution [--brief] [--solution-id ]')); console.error(chalk.gray(' ccw issue solution --data \'{"tasks":[...]}\'')); process.exit(1); } let jsonData: string | undefined = options.data; // Support stdin pipe input (avoids shell escaping issues) if (!jsonData && !process.stdin.isTTY) { try { jsonData = readFileSync(0, 'utf-8').trim(); } catch { // stdin not available or empty } } // CREATE mode: if --data provided if (jsonData) { try { const data = JSON.parse(jsonData); const solution = createSolution(issueId, data); console.log(JSON.stringify(solution, null, 2)); } catch (err) { console.error(chalk.red((err as Error).message)); process.exit(1); } return; } // READ mode: list solutions for issue const issue = findIssue(issueId); if (!issue) { console.error(chalk.red(`Issue "${issueId}" not found`)); process.exit(1); } const solutions = readSolutions(issueId); if (solutions.length === 0) { if (options.json || options.brief) { console.log('[]'); } else { console.log(chalk.yellow(`No solutions found for ${issueId}`)); } return; } // Filter by solution-id if specified let targetSolutions = solutions; if (options.solutionId) { targetSolutions = solutions.filter(s => s.id === options.solutionId); if (targetSolutions.length === 0) { console.error(chalk.red(`Solution "${options.solutionId}" not found`)); process.exit(1); } } // Brief mode: extract files_touched from modification_points if (options.brief) { const briefSolutions = targetSolutions.map(sol => { const filesTouched = new Set(); for (const task of sol.tasks) { if (task.modification_points) { for (const mp of task.modification_points) { if (mp.file) filesTouched.add(mp.file); } } } return { solution_id: sol.id, is_bound: sol.is_bound, task_count: sol.tasks.length, files_touched: Array.from(filesTouched) }; }); console.log(JSON.stringify(briefSolutions, null, 2)); return; } // JSON mode: full solutions if (options.json) { console.log(JSON.stringify(targetSolutions, null, 2)); return; } // Human-readable output console.log(chalk.bold.cyan(`\nSolutions for ${issueId}:\n`)); for (const sol of targetSolutions) { const marker = sol.is_bound ? chalk.green('◉ BOUND') : chalk.gray('○'); console.log(`${marker} ${sol.id}`); console.log(chalk.gray(` Tasks: ${sol.tasks.length}`)); if (sol.description) { console.log(chalk.gray(` ${sol.description.substring(0, 80)}...`)); } console.log(); } } /** * solutions - Batch query solutions for multiple issues * Usage: ccw issue solutions --status planned --brief */ async function solutionsAction(options: IssueOptions): Promise { // Get issues filtered by status const issues = readIssues(); let targetIssues = issues; if (options.status) { const statuses = options.status.split(',').map((s: string) => s.trim()); targetIssues = issues.filter((i: Issue) => statuses.includes(i.status)); } // Filter to only issues with bound_solution_id const boundIssues = targetIssues.filter((i: Issue) => i.bound_solution_id); if (boundIssues.length === 0) { if (options.json || options.brief) { console.log('[]'); } else { console.log(chalk.yellow('No bound solutions found')); } return; } // Collect solutions for all bound issues const allSolutions: Array<{ issue_id: string; solution_id: string; is_bound: boolean; task_count: number; files_touched: string[]; priority?: number; }> = []; for (const issue of boundIssues) { const solutions = readSolutions(issue.id); const boundSolution = solutions.find(s => s.id === issue.bound_solution_id); if (boundSolution) { const filesTouched = new Set(); for (const task of boundSolution.tasks) { if (task.modification_points) { for (const mp of task.modification_points) { if (mp.file) filesTouched.add(mp.file); } } } allSolutions.push({ issue_id: issue.id, solution_id: boundSolution.id, is_bound: true, task_count: boundSolution.tasks.length, files_touched: Array.from(filesTouched), priority: issue.priority }); } } // Brief mode: already minimal if (options.brief || options.json) { console.log(JSON.stringify(allSolutions, null, 2)); return; } // Human-readable output console.log(chalk.bold.cyan(`\nBound Solutions (${allSolutions.length}):\n`)); for (const sol of allSolutions) { console.log(`${chalk.green('◉')} ${sol.issue_id} → ${sol.solution_id}`); console.log(chalk.gray(` Tasks: ${sol.task_count}, Files: ${sol.files_touched.length}`)); } } /** * init - Initialize a new issue (manual ID) */ 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, 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)); } // Brief mode: minimal fields only (id, title, status, priority, tags, bound_solution_id) if (options.brief) { const briefIssues = issues.map(i => ({ id: i.id, title: i.title, status: i.status, priority: i.priority, tags: i.tags || [], bound_solution_id: i.bound_solution_id })); console.log(JSON.stringify(briefIssues, null, 2)); 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 solutionCount = readSolutions(issue.id).length; const bound = issue.bound_solution_id ? `[${issue.bound_solution_id}]` : `${solutionCount}`; 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) ); } } /** * history - List completed issues from history */ async function historyAction(options: IssueOptions): Promise { const history = readIssueHistory(); // Brief mode: minimal fields only if (options.brief) { const briefHistory = history.map(i => ({ id: i.id, title: i.title, status: i.status, completed_at: i.completed_at })); console.log(JSON.stringify(briefHistory, null, 2)); return; } if (options.json) { console.log(JSON.stringify(history, null, 2)); return; } if (history.length === 0) { console.log(chalk.yellow('No completed issues in history')); return; } console.log(chalk.bold.cyan('\nIssue History (Completed)\n')); console.log(chalk.gray('ID'.padEnd(25) + 'Completed At'.padEnd(22) + 'Title')); console.log(chalk.gray('-'.repeat(80))); for (const issue of history) { const completedAt = issue.completed_at ? new Date(issue.completed_at).toLocaleString() : 'N/A'; console.log( chalk.green(issue.id.padEnd(25)) + completedAt.padEnd(22) + (issue.title || '').substring(0, 35) ); } console.log(chalk.gray(`\nTotal: ${history.length} completed issues`)); } /** * 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(issueId, solutions), 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}`)); } } /** * update - Update issue fields (status, priority, title, etc.) * --from-queue: Sync statuses from active queue (auto-update queued issues) */ async function updateAction(issueId: string | undefined, options: IssueOptions): Promise { // Handle --from-queue: Sync statuses from queue if (options.fromQueue) { // Determine queue ID: string value = specific queue, true = active queue const queueId = typeof options.fromQueue === 'string' ? options.fromQueue : undefined; const queue = queueId ? readQueue(queueId) : readActiveQueue(); if (!queue) { if (options.json) { console.log(JSON.stringify({ success: false, message: `Queue not found: ${queueId}`, queued: [], unplanned: [] })); } else { console.log(chalk.red(`Queue not found: ${queueId}`)); } return; } const items = queue.solutions || queue.tasks || []; const allIssues = readIssues(); if (!queue.id || items.length === 0) { if (options.json) { console.log(JSON.stringify({ success: false, message: 'No active queue', queued: [], unplanned: [] })); } else { console.log(chalk.yellow('No active queue to sync from')); } return; } // Get issue IDs from queue const queuedIssueIds = new Set(items.map(item => item.issue_id)); const now = new Date().toISOString(); // Track updates const updated: string[] = []; const unplanned: string[] = []; // Update queued issues for (const issueId of queuedIssueIds) { const issue = allIssues.find(i => i.id === issueId); if (issue && issue.status !== 'queued' && issue.status !== 'executing' && issue.status !== 'completed') { updateIssue(issueId, { status: 'queued', queued_at: now }); updated.push(issueId); } } // Find planned issues NOT in queue for (const issue of allIssues) { if (issue.status === 'planned' && issue.bound_solution_id && !queuedIssueIds.has(issue.id)) { unplanned.push(issue.id); } } if (options.json) { console.log(JSON.stringify({ success: true, queue_id: queue.id, queued: updated, queued_count: updated.length, unplanned: unplanned, unplanned_count: unplanned.length }, null, 2)); } else { console.log(chalk.green(`✓ Synced from queue ${queue.id}`)); console.log(chalk.gray(` Updated to 'queued': ${updated.length} issues`)); if (updated.length > 0) { updated.forEach(id => console.log(chalk.gray(` - ${id}`))); } if (unplanned.length > 0) { console.log(chalk.yellow(` Planned but NOT in queue: ${unplanned.length} issues`)); unplanned.forEach(id => console.log(chalk.yellow(` - ${id}`))); } } return; } // Standard single-issue update if (!issueId) { console.error(chalk.red('Issue ID is required')); console.error(chalk.gray('Usage: ccw issue update --status ')); console.error(chalk.gray(' ccw issue update --from-queue [queue-id] (sync from queue)')); process.exit(1); } const issue = findIssue(issueId); if (!issue) { console.error(chalk.red(`Issue "${issueId}" not found`)); process.exit(1); } const updates: Partial = {}; if (options.status) { if (!validateIssueStatus(options.status)) { console.error(chalk.red(`Invalid status: ${options.status}`)); console.error(chalk.gray(`Valid: ${VALID_ISSUE_STATUSES.join(', ')}`)); process.exit(1); } updates.status = options.status; // Auto-set timestamps based on status if (options.status === 'planned') updates.planned_at = new Date().toISOString(); if (options.status === 'queued') updates.queued_at = new Date().toISOString(); if (options.status === 'completed') updates.completed_at = new Date().toISOString(); } if (options.priority) { updates.priority = parseInt(options.priority); } if (options.title) { updates.title = options.title; } if (options.description) { updates.context = options.description; } if (Object.keys(updates).length === 0) { console.error(chalk.yellow('No updates specified')); console.error(chalk.gray('Use --status, --priority, --title, or --description')); return; } updateIssue(issueId, updates); if (options.json) { console.log(JSON.stringify({ success: true, issue_id: issueId, updates })); } else { console.log(chalk.green(`✓ Issue "${issueId}" updated`)); Object.entries(updates).forEach(([k, v]) => { console.log(chalk.gray(` ${k}: ${v}`)); }); } } /** * 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); // Priority: CLI arg > file content ID > generate new (SOL-{issue-id}-{seq}) const newSol: Solution = { id: solutionId || data.id || generateSolutionId(issueId, solutions), 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, 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(); // Brief mode: minimal queue index info if (options.brief) { const briefIndex = { active_queue_id: index.active_queue_id, queues: index.queues.map(q => ({ id: q.id, status: q.status, issue_ids: q.issue_ids, total_solutions: q.total_solutions, completed_solutions: q.completed_solutions })) }; console.log(JSON.stringify(briefIndex, null, 2)); return; } 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; // Also update active_queue_ids for multi-queue support index.active_queue_ids = [queueId]; writeQueueIndex(index); console.log(chalk.green(`✓ Switched to queue ${queueId}`)); return; } // Set queue priority (lower = higher priority) if (subAction === 'priority' && issueId) { const queueId = issueId; const priority = parseInt(options.priority || '0'); if (isNaN(priority)) { console.error(chalk.red('Invalid priority value (must be a number)')); process.exit(1); } const index = readQueueIndex(); const queueEntry = index.queues.find(q => q.id === queueId); if (!queueEntry) { console.error(chalk.red(`Queue "${queueId}" not found`)); process.exit(1); } queueEntry.priority = priority; writeQueueIndex(index); console.log(chalk.green(`✓ Queue ${queueId} priority set to ${priority}`)); return; } // Activate multiple queues at once if (subAction === 'activate' && issueId) { const queueIds = issueId.split(',').map(id => id.trim()); const index = readQueueIndex(); // Validate all queue IDs for (const queueId of queueIds) { if (!index.queues.some(q => q.id === queueId)) { console.error(chalk.red(`Queue "${queueId}" not found`)); process.exit(1); } } index.active_queue_ids = queueIds; index.active_queue_id = queueIds[0] || null; // Backward compat writeQueueIndex(index); console.log(chalk.green(`✓ Activated ${queueIds.length} queue(s): ${queueIds.join(', ')}`)); return; } // DAG - Return dependency graph for parallel execution planning (solution-level) if (subAction === 'dag') { let queue: Queue; // Use explicit queue if provided via --queue or issueId, otherwise use active queue if (options.queue) { const targetQueue = readQueue(options.queue); if (!targetQueue) { console.log(JSON.stringify({ error: `Queue ${options.queue} not found`, nodes: [], edges: [], groups: [] })); return; } queue = targetQueue; } else if (issueId && issueId.startsWith('QUE-')) { const targetQueue = readQueue(issueId); if (!targetQueue) { console.log(JSON.stringify({ error: `Queue ${issueId} not found`, nodes: [], edges: [], groups: [] })); return; } queue = targetQueue; } else { 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, 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; } // Merge queues: ccw issue queue merge --queue if (subAction === 'merge' && issueId) { const sourceQueueId = issueId; // issueId is actually source queue ID here const targetQueueId = options.queue; // --queue option if (!targetQueueId) { console.error(chalk.red('Target queue ID required')); console.error(chalk.gray('Usage: ccw issue queue merge --queue ')); process.exit(1); } const sourceQueue = readQueue(sourceQueueId); const targetQueue = readQueue(targetQueueId); if (!sourceQueue) { console.error(chalk.red(`Source queue "${sourceQueueId}" not found`)); process.exit(1); } if (!targetQueue) { console.error(chalk.red(`Target queue "${targetQueueId}" not found`)); process.exit(1); } // mergeQueues marks source as 'merged' and updates index const result = mergeQueues(targetQueue, sourceQueue); if (options.json) { console.log(JSON.stringify({ success: result.success, sourceQueueId, targetQueueId, itemsMerged: result.itemsMerged, skippedDuplicates: result.skippedDuplicates, totalItems: result.totalItems, reason: result.reason }, null, 2)); } else { if (result.success) { console.log(chalk.green(`✓ Merged ${result.itemsMerged} items from ${sourceQueueId} into ${targetQueueId}`)); if (result.skippedDuplicates > 0) { console.log(chalk.gray(` Skipped ${result.skippedDuplicates} duplicate items`)); } console.log(chalk.gray(` Total items in target: ${result.totalItems}`)); console.log(chalk.gray(` Source queue ${sourceQueueId} marked as 'merged'`)); } else { console.log(chalk.yellow(`⚠ Merge skipped: ${result.reason}`)); } } return; } // Archive current queue if (subAction === 'archive') { const queue = readActiveQueue(); const items = queue.solutions || queue.tasks || []; if (!queue.id || items.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); } if (!options.force) { const { proceed } = await inquirer.prompt([{ type: 'confirm', name: 'proceed', message: `Delete queue ${queueId}? This action cannot be undone.`, default: false }]); if (!proceed) { console.log(chalk.yellow('Queue deletion cancelled')); return; } } // 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 solution to queue (solution-level granularity) 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); } // Step 1: Create new queue (temporary, not active yet) const newQueue = createEmptyQueue(); newQueue.solutions = []; // Add issue to queue's issue list newQueue.issue_ids.push(issueId); // Collect all files touched by this solution const filesTouched = new Set(); for (const task of solution.tasks || []) { for (const mp of task.modification_points || []) { filesTouched.add(mp.file); } } // Create solution-level queue item (S-N) newQueue.solutions.push({ item_id: generateQueueItemId(newQueue, 'solution'), issue_id: issueId, solution_id: solution.id, status: 'pending', execution_order: 1, execution_group: 'P1', depends_on: [], semantic_priority: 0.5, task_count: solution.tasks?.length || 0, files_touched: Array.from(filesTouched) }); // Step 2: Write temporary queue file writeQueue(newQueue); updateIssue(issueId, { status: 'queued', queued_at: new Date().toISOString() }); console.log(chalk.green(`✓ Created temporary queue ${newQueue.id}`)); console.log(chalk.gray(` Solution ${solution.id} (${solution.tasks?.length || 0} tasks)`)); // Step 3: Check for existing active queue const existingQueue = readQueue(); const hasActiveQueue = existingQueue && existingQueue.status === 'active' && (existingQueue.solutions?.length || existingQueue.tasks?.length || 0) > 0; if (!hasActiveQueue || options.force) { // No active queue or force flag - set new queue as active const index = readQueueIndex(); index.active_queue_id = newQueue.id; writeQueueIndex(index); console.log(chalk.green(`✓ Queue ${newQueue.id} activated`)); return; } // Step 4: Active queue exists - prompt user const existingItems = existingQueue!.solutions || existingQueue!.tasks || []; console.log(); console.log(chalk.cyan(`Active queue exists: ${existingQueue!.id}`)); console.log(chalk.gray(` Issues: ${existingQueue!.issue_ids.join(', ')}`)); console.log(chalk.gray(` Items: ${existingItems.length} (${existingItems.filter(i => i.status === 'completed').length} completed)`)); console.log(); const { action } = await inquirer.prompt([{ type: 'list', name: 'action', message: 'How would you like to proceed?', choices: [ { name: 'Merge into existing queue', value: 'merge_to_existing' }, { name: 'Use new queue', value: 'use_new' }, { name: 'Cancel', value: 'cancel' } ] }]); // Step 5: Execute user choice if (action === 'cancel') { // Delete temporary queue const queuePath = join(getQueuesDir(), `${newQueue.id}.json`); unlinkSync(queuePath); console.log(chalk.yellow(`✓ New queue deleted, keeping ${existingQueue!.id} active`)); return; } if (action === 'use_new') { // Switch to new queue const index = readQueueIndex(); index.active_queue_id = newQueue.id; writeQueueIndex(index); console.log(chalk.green(`✓ Switched to new queue ${newQueue.id}`)); console.log(chalk.gray(` Previous queue ${existingQueue!.id} remains in history`)); return; } if (action === 'merge_to_existing') { // Merge new → existing, delete temporary queue const mergeResult = mergeQueues(existingQueue!, newQueue, { deleteSource: true }); console.log(chalk.green(`✓ Merged ${mergeResult.itemsMerged} items into ${existingQueue!.id}`)); if (mergeResult.skippedDuplicates > 0) { console.log(chalk.gray(` Skipped ${mergeResult.skippedDuplicates} duplicate items`)); } console.log(chalk.gray(` Temporary queue ${newQueue.id} deleted`)); return; } return; } // Show current queue - use readQueue() to detect if queue actually exists const queue = readQueue(); // Handle no active queue case for all output modes if (!queue) { if (options.brief || options.json) { console.log(JSON.stringify({ status: 'empty', message: 'No active queue', id: null, issue_ids: [], total: 0, items: [] }, null, 2)); return; } // Human-readable output handled below } // Brief mode: minimal queue info (id, issue_ids, item summaries) if (options.brief) { const items = queue!.solutions || queue!.tasks || []; const briefQueue = { id: queue!.id, issue_ids: queue!.issue_ids || [], total: items.length, pending: items.filter(i => i.status === 'pending').length, executing: items.filter(i => i.status === 'executing').length, completed: items.filter(i => i.status === 'completed').length, items: items.map(i => ({ item_id: i.item_id, issue_id: i.issue_id, solution_id: i.solution_id, status: i.status, task_count: i.task_count })) }; console.log(JSON.stringify(briefQueue, null, 2)); return; } if (options.json) { console.log(JSON.stringify(queue, null, 2)); return; } console.log(chalk.bold.cyan('\nActive Queue\n')); // Handle no queue case (human-readable) if (!queue) { 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; } // Support both solution-level and task-level queues const items = queue.solutions || queue.tasks || []; const isSolutionLevel = !!(queue.solutions && queue.solutions.length > 0); if (items.length === 0) { console.log(chalk.yellow(`Queue ${queue.id} is empty`)); console.log(chalk.gray('Add issues: ccw issue queue add ')); return; } console.log(chalk.gray(`Queue: ${queue.id}`)); console.log(chalk.gray(`Issues: ${queue.issue_ids.join(', ')}`)); console.log(chalk.gray(`Total: ${items.length} | Pending: ${items.filter(i => i.status === 'pending').length} | Executing: ${items.filter(i => i.status === 'executing').length} | Completed: ${items.filter(i => i.status === 'completed').length}`)); console.log(); if (isSolutionLevel) { console.log(chalk.gray('ItemID'.padEnd(10) + 'Issue'.padEnd(15) + 'Tasks'.padEnd(8) + 'Status')); } else { console.log(chalk.gray('ItemID'.padEnd(10) + 'Issue'.padEnd(15) + 'Task'.padEnd(8) + 'Status')); } console.log(chalk.gray('-'.repeat(48))); for (const item of items) { const statusColor = { 'pending': chalk.gray, 'ready': chalk.cyan, 'executing': chalk.yellow, 'completed': chalk.green, 'failed': chalk.red, 'blocked': chalk.magenta }[item.status] || chalk.white; const thirdCol = isSolutionLevel ? String(item.task_count || 0).padEnd(8) : (item.task_id || '-').padEnd(8); let line = item.item_id.padEnd(10) + item.issue_id.substring(0, 13).padEnd(15) + thirdCol + statusColor(item.status); // Show failure reason for failed items if (item.status === 'failed') { const reason = item.failure_details?.message || item.failure_reason; if (reason) { // Truncate to 40 chars for display const shortReason = reason.length > 40 ? reason.substring(0, 37) + '...' : reason; line += chalk.gray(` [${shortReason}]`); } // Show retry count if there's failure history if (item.failure_history && item.failure_history.length > 0) { line += chalk.gray(` (${item.failure_history.length} retry)`); } } console.log(line); } } /** * 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 { let queue: Queue; let items: QueueItem[]; // Determine which queue(s) to use if (options.queue) { // Explicit queue specified const targetQueue = readQueue(options.queue); if (!targetQueue) { console.log(JSON.stringify({ status: 'error', message: `Queue ${options.queue} not found` })); return; } queue = targetQueue; items = queue.solutions || queue.tasks || []; } else { // Multi-queue: iterate active queues in priority order (serialized execution) const activeQueues = getActiveQueues(); if (activeQueues.length === 0) { console.log(JSON.stringify({ status: 'empty', message: 'No active queues' })); return; } // Find first queue with incomplete items (serialized: complete Q1 before Q2) let foundQueue: Queue | null = null; for (const q of activeQueues) { const queueItems = q.solutions || q.tasks || []; const hasIncomplete = queueItems.some(i => i.status === 'pending' || i.status === 'executing' ); if (hasIncomplete) { foundQueue = q; break; } } if (!foundQueue) { console.log(JSON.stringify({ status: 'empty', message: 'All queues completed' })); return; } queue = foundQueue; items = queue.solutions || queue.tasks || []; } let nextItem: QueueItem | 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 in queue ${queue.id}` })); 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_id: queue.id, 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({ queue_id: queue.id, 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: { 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; } let queue: Queue; let queueItem: QueueItem | undefined; // Use explicit queue if provided, otherwise auto-detect if (options.queue) { const targetQueue = readQueue(options.queue); if (!targetQueue) { console.log(JSON.stringify({ status: 'error', message: `Queue ${options.queue} not found` })); return; } queue = targetQueue; const items = queue.solutions || queue.tasks || []; queueItem = items.find(t => t.item_id === itemId); } else { // Auto-detect queue from item ID const found = findItemQueue(itemId); if (found) { queue = found.queue; queueItem = found.item; } else { console.log(JSON.stringify({ status: 'error', message: `Item ${itemId} not found in any queue` })); return; } } 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({ queue_id: queue.id, 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: { task_count: solution.tasks?.length || 0, estimated_minutes: totalMinutes } }, null, 2)); } /** * done - Mark task completed or failed */ async function doneAction(queueItemId: string | undefined, options: IssueOptions): Promise { if (!queueItemId) { console.error(chalk.red('Item ID is required')); console.error(chalk.gray('Usage: ccw issue done [--fail] [--reason "..."] [--queue ]')); process.exit(1); } let queue: Queue; let items: QueueItem[]; let idx: number; // Use explicit queue if provided, otherwise auto-detect if (options.queue) { const targetQueue = readQueue(options.queue); if (!targetQueue) { console.error(chalk.red(`Queue "${options.queue}" not found`)); process.exit(1); } queue = targetQueue; items = queue.solutions || queue.tasks || []; idx = items.findIndex(q => q.item_id === queueItemId); if (idx === -1) { console.error(chalk.red(`Queue item "${queueItemId}" not found in queue ${options.queue}`)); process.exit(1); } } else { // Auto-detect queue from item ID const found = findItemQueue(queueItemId); if (!found) { console.error(chalk.red(`Queue item "${queueItemId}" not found in any queue`)); process.exit(1); } queue = found.queue; items = queue.solutions || queue.tasks || []; idx = found.itemIndex; } const isFail = options.fail; items[idx].status = isFail ? 'failed' : 'completed'; items[idx].completed_at = new Date().toISOString(); if (isFail) { const reason = options.reason || 'Unknown failure'; items[idx].failure_reason = reason; // Backward compat items[idx].failure_details = parseFailureReason(reason); // Structured 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(`✗ ${queueItemId} failed`)); } else { updateIssue(issueId, { status: 'completed', completed_at: new Date().toISOString() }); console.log(chalk.green(`✓ ${queueItemId} 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 * Syncs failure details to Issue.feedback for planning phase */ async function retryAction(issueId: string | undefined, options: IssueOptions): Promise { let queues: Queue[]; // Use explicit queue if provided, otherwise use all active queues if (options.queue) { const targetQueue = readQueue(options.queue); if (!targetQueue) { console.log(chalk.red(`Queue "${options.queue}" not found`)); return; } queues = [targetQueue]; } else { queues = getActiveQueues(); } if (queues.length === 0) { console.log(chalk.yellow('No active queues')); return; } let totalUpdated = 0; const updatedIssues = new Set(); for (const queue of queues) { const items = queue.solutions || queue.tasks || []; let queueUpdated = 0; for (const item of items) { // Retry failed items only if (item.status === 'failed') { if (!issueId || item.issue_id === issueId) { // Sync failure details to Issue.feedback (persistent for planning phase) if (item.failure_details && item.issue_id) { const issue = findIssue(item.issue_id); if (issue) { if (!issue.feedback) { issue.feedback = []; } // Add failure to feedback history issue.feedback.push({ type: 'failure', stage: 'execute', content: JSON.stringify({ solution_id: item.solution_id, task_id: item.failure_details.task_id, error_type: item.failure_details.error_type, message: item.failure_details.message, stack_trace: item.failure_details.stack_trace, queue_id: queue.id, item_id: item.item_id }), created_at: item.failure_details.timestamp }); // Reset issue status to 'queued' for re-execution // Failure details preserved in feedback for debugging updateIssue(item.issue_id, { status: 'queued', updated_at: new Date().toISOString() }); updatedIssues.add(item.issue_id); } } // Preserve failure history before resetting if (item.failure_details) { if (!item.failure_history) { item.failure_history = []; } item.failure_history.push(item.failure_details); } // Reset QueueItem for retry (Issue status also reset to 'queued') item.status = 'pending'; item.failure_reason = undefined; item.failure_details = undefined; item.started_at = undefined; item.completed_at = undefined; queueUpdated++; } } } if (queueUpdated > 0) { // 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); totalUpdated += queueUpdated; } } if (totalUpdated === 0) { console.log(chalk.yellow('No failed items to retry')); return; } console.log(chalk.green(`✓ Reset ${totalUpdated} item(s) to pending (failure history preserved)`)); if (updatedIssues.size > 0) { console.log(chalk.cyan(`✓ Synced failure details to ${updatedIssues.size} issue(s) for planning phase`)); } } // ============ 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 'create': await createAction(options); break; case 'pull': await pullAction(options); break; case 'solution': await solutionAction(argsArray[0], options); break; case 'solutions': await solutionsAction(options); break; case 'init': await initAction(argsArray[0], options); break; case 'list': await listAction(argsArray[0], options); break; case 'history': await historyAction(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 'update': await updateAction(argsArray[0], 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(' create --data \'{"title":"..."}\' Create issue (auto-generates ID)')); console.log(chalk.gray(' pull [--state open|closed|all] Pull issues from GitHub')); console.log(chalk.gray(' [--limit N] [--labels label1,label2]')); console.log(chalk.gray(' init Initialize new issue (manual ID)')); console.log(chalk.gray(' list [issue-id] List issues or tasks')); console.log(chalk.gray(' history List completed issues (from history)')); console.log(chalk.gray(' status [issue-id] Show detailed status')); console.log(chalk.gray(' solution List solutions for issue')); console.log(chalk.gray(' solution --brief Brief: solution_id, files_touched, task_count')); console.log(chalk.gray(' solution --data \'{...}\' Create solution (auto-generates ID)')); console.log(chalk.gray(' bind [sol-id] Bind solution')); console.log(chalk.gray(' update --status Update issue status')); console.log(chalk.gray(' update --from-queue [queue-id] Sync statuses from queue (default: active)')); 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 activate Activate multiple queues (comma-separated)')); console.log(chalk.gray(' queue priority Set queue priority (--priority N, lower=higher)')); console.log(chalk.gray(' queue dag [--queue ] 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] [--queue ] Retry failed tasks')); console.log(); console.log(chalk.bold('Execution Endpoints:')); console.log(chalk.gray(' next [item-id] [--queue ] Get & mark task executing (JSON)')); console.log(chalk.gray(' detail [--queue ] Get task details (READ-ONLY, for parallel)')); console.log(chalk.gray(' done [--queue ] Mark task completed')); console.log(chalk.gray(' done --fail --reason "." Mark task failed with reason (supports JSON)')); 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(' --brief Brief JSON output (minimal fields)')); 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 (string or JSON)')); console.log(chalk.gray(' --queue <queue-id> Target queue for multi-queue operations')); console.log(chalk.gray(' --priority <n> Queue priority (lower = higher)')); console.log(chalk.gray(' --json JSON output')); console.log(chalk.gray(' --force Force operation')); console.log(chalk.gray(' --state <state> GitHub issue state (open/closed/all)')); console.log(chalk.gray(' --limit <n> Max issues to pull from GitHub')); console.log(chalk.gray(' --labels <labels> Filter by GitHub labels (comma-separated)')); console.log(); console.log(chalk.bold('Storage:')); console.log(chalk.gray(' .workflow/issues/issues.jsonl Active issues')); console.log(chalk.gray(' .workflow/issues/issue-history.jsonl Completed 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')); } }