// CRUD operations for tasks import { query, queryOne, execute, getNextTaskId, getProjectKey, detectProjectFromCwd, getClient } from '../db.js'; import { getEmbedding, formatEmbedding } from '../embeddings.js'; import type { Task, ChecklistItem, TaskLink } from '../types.js'; import { getRecentDelegations } from './delegations.js'; import { getTaskCommits } from './commits.js'; import { taskLink } from './relations.js'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; /** * Get current session ID from environment or cache file */ function getSessionId(): string { // Check environment first if (process.env.CLAUDE_SESSION_ID) { return process.env.CLAUDE_SESSION_ID; } // Try to read from cache file (session-memory format) const cacheFile = path.join(os.homedir(), '.cache', 'session-memory', 'current_session'); try { const sessionId = fs.readFileSync(cacheFile, 'utf-8').trim(); if (sessionId) return sessionId; } catch { // File doesn't exist or can't be read } // Generate a new session ID const now = new Date(); const timestamp = now.toISOString().replace(/[-:T]/g, '').slice(0, 15); return `session_${timestamp}`; } /** * Record task activity for session tracking */ async function recordActivity( task_id: string, activity_type: 'created' | 'updated' | 'status_change' | 'closed', old_value?: string, new_value?: string ): Promise { const session_id = getSessionId(); try { await execute( `INSERT INTO task_activity (task_id, session_id, activity_type, old_value, new_value) VALUES ($1, $2, $3, $4, $5)`, [task_id, session_id, activity_type, old_value || null, new_value || null] ); } catch { // Don't fail the main operation if activity tracking fails console.error('Failed to record task activity'); } } interface TaskAddArgs { title: string; project?: string; type?: string; priority?: string; description?: string; } interface TaskListArgs { project?: string; status?: string; type?: string; priority?: string; limit?: number; } interface TaskUpdateArgs { id: string; status?: string; priority?: string; type?: string; title?: string; } /** * Create a new task */ export async function taskAdd(args: TaskAddArgs): Promise { const { title, project = 'Unknown', type = 'task', priority = 'P2', description = '' } = args; // Get project key const projectKey = await getProjectKey(project); // Generate embedding for duplicate detection const embedText = description ? `${title}. ${description}` : title; const embedding = await getEmbedding(embedText); const embeddingValue = embedding ? formatEmbedding(embedding) : null; // Check for similar/duplicate tasks (only if embedding succeeded) // CF-450: Check both open AND completed tasks to avoid circular work let duplicateWarning = ''; if (embeddingValue) { const similarTasks = await query<{ id: string; title: string; status: string; description: string; similarity: number }>( `SELECT id, title, status, description, 1 - (embedding <=> $1) as similarity FROM tasks WHERE project = $2 AND embedding IS NOT NULL ORDER BY embedding <=> $1 LIMIT 5`, [embeddingValue, projectKey] ); // Warn if highly similar tasks exist (>70% similarity) const highSimilarity = similarTasks.filter(t => t.similarity > 0.70); if (highSimilarity.length > 0) { duplicateWarning = '\n\nāš ļø Similar tasks found:\n'; const openTasks = highSimilarity.filter(t => t.status !== 'completed'); const completedTasks = highSimilarity.filter(t => t.status === 'completed'); if (openTasks.length > 0) { duplicateWarning += '\n**Open/In Progress:**\n'; for (const t of openTasks) { const pct = Math.round(t.similarity * 100); duplicateWarning += ` - ${t.id}: ${t.title} (${pct}% match, ${t.status})\n`; } } if (completedTasks.length > 0) { duplicateWarning += '\n**Previously Completed:**\n'; for (const t of completedTasks) { const pct = Math.round(t.similarity * 100); duplicateWarning += ` - ${t.id}: ${t.title} (${pct}% match)\n`; // Show snippet of solution/outcome from description if (t.description) { const snippet = t.description.substring(0, 150).replace(/\n/g, ' ').replace(/"/g, '\\"'); const ellipsis = t.description.length > 150 ? '...' : ''; duplicateWarning += ` Context: "${snippet}${ellipsis}"\n`; } } duplicateWarning += '\n šŸ’” Use "task show " to see full solution before recreating work\n'; } duplicateWarning += '\nConsider linking with: task link relates_to'; } } // Get next task ID const taskId = await getNextTaskId(projectKey); // Get current session ID for linking const session_id = getSessionId(); // Insert task with session_id if (embeddingValue) { await execute( `INSERT INTO tasks (id, project, title, description, type, status, priority, session_id, embedding) VALUES ($1, $2, $3, $4, $5, 'open', $6, $7, $8)`, [taskId, projectKey, title, description, type, priority, session_id, embeddingValue] ); } else { await execute( `INSERT INTO tasks (id, project, title, description, type, status, priority, session_id) VALUES ($1, $2, $3, $4, $5, 'open', $6, $7)`, [taskId, projectKey, title, description, type, priority, session_id] ); } // Record activity for session tracking await recordActivity(taskId, 'created', undefined, 'open'); // Enhanced auto-linking logic (CF-166) let autoLinkMessage = ''; try { const sessionContext = await queryOne<{ current_task_id: string | null; investigation_parent_id: string | null; auto_link_enabled: boolean; }>( `SELECT current_task_id, investigation_parent_id, auto_link_enabled FROM session_context WHERE session_id = $1`, [session_id] ); if (sessionContext?.auto_link_enabled !== false) { const linkedTasks: string[] = []; // 1. Auto-link to investigation parent if this is created during an investigation if (sessionContext?.investigation_parent_id) { await execute( `INSERT INTO task_links (from_task_id, to_task_id, link_type, auto_linked) VALUES ($1, $2, 'relates_to', true) ON CONFLICT DO NOTHING`, [taskId, sessionContext.investigation_parent_id] ); linkedTasks.push(`${sessionContext.investigation_parent_id} (investigation)`); } // 2. Auto-link to current working task if different from investigation parent if (sessionContext?.current_task_id && sessionContext.current_task_id !== sessionContext?.investigation_parent_id) { await execute( `INSERT INTO task_links (from_task_id, to_task_id, link_type, auto_linked) VALUES ($1, $2, 'relates_to', true) ON CONFLICT DO NOTHING`, [taskId, sessionContext.current_task_id] ); linkedTasks.push(`${sessionContext.current_task_id} (current task)`); } // 3. Time-based auto-linking: find tasks created within 1 hour in same session if (!sessionContext?.investigation_parent_id && !sessionContext?.current_task_id) { const recentTasks = await query<{ id: string; title: string }>( `SELECT id, title FROM tasks WHERE session_id = $1 AND id != $2 AND created_at > NOW() - INTERVAL '1 hour' AND status != 'completed' ORDER BY created_at DESC LIMIT 3`, [session_id, taskId] ); for (const task of recentTasks) { await execute( `INSERT INTO task_links (from_task_id, to_task_id, link_type, auto_linked) VALUES ($1, $2, 'relates_to', true) ON CONFLICT DO NOTHING`, [taskId, task.id] ); linkedTasks.push(`${task.id} (recent)`); } } if (linkedTasks.length > 0) { autoLinkMessage = `\n\nšŸ”— Auto-linked to: ${linkedTasks.join(', ')}`; } } } catch (error) { // Log but don't fail if auto-linking fails console.error('Auto-linking failed:', error); } return `Created: ${taskId}\n Title: ${title}\n Type: ${type}\n Priority: ${priority}\n Project: ${projectKey}${embedding ? '\n (embedded for semantic search)' : ''}${duplicateWarning}${autoLinkMessage}`; } /** * List tasks with filters * Auto-detects project from CWD if not explicitly provided */ export async function taskList(args: TaskListArgs): Promise { const { project, status, type, priority, limit = 20 } = args; let whereClause = 'WHERE 1=1'; const params: unknown[] = []; let paramIndex = 1; // Auto-detect project from CWD if not explicitly provided const effectiveProject = project || detectProjectFromCwd(); if (effectiveProject) { const projectKey = await getProjectKey(effectiveProject); whereClause += ` AND project = $${paramIndex++}`; params.push(projectKey); } if (status) { whereClause += ` AND status = $${paramIndex++}`; params.push(status); } if (type) { whereClause += ` AND type = $${paramIndex++}`; params.push(type); } if (priority) { whereClause += ` AND priority = $${paramIndex++}`; params.push(priority); } params.push(limit); const tasks = await query( `SELECT id, title, type, status, priority, project FROM tasks ${whereClause} ORDER BY CASE priority WHEN 'P0' THEN 0 WHEN 'P1' THEN 1 WHEN 'P2' THEN 2 ELSE 3 END, created_at DESC LIMIT $${paramIndex}`, params ); if (tasks.length === 0) { return `No tasks found${effectiveProject ? ` for project ${effectiveProject}` : ''}`; } const lines = tasks.map(t => { const statusIcon = t.status === 'completed' ? '[x]' : t.status === 'in_progress' ? '[>]' : t.status === 'blocked' ? '[!]' : '[ ]'; const typeLabel = t.type !== 'task' ? ` [${t.type}]` : ''; return `${statusIcon} ${t.priority} ${t.id}: ${t.title}${typeLabel}`; }); return `Tasks${effectiveProject ? ` (${effectiveProject})` : ''}:\n\n${lines.join('\n')}`; } /** * Show task details */ export async function taskShow(id: string): Promise { const task = await queryOne( `SELECT id, project, title, description, type, status, priority, session_id, to_char(created_at, 'YYYY-MM-DD HH24:MI') as created, to_char(updated_at, 'YYYY-MM-DD HH24:MI') as updated, to_char(completed_at, 'YYYY-MM-DD HH24:MI') as completed FROM tasks WHERE id = $1`, [id] ); if (!task) { return `Task not found: ${id}`; } let output = `# ${task.id}\n\n`; output += `**Title:** ${task.title}\n`; output += `**Project:** ${task.project}\n`; output += `**Type:** ${task.type}\n`; output += `**Status:** ${task.status}\n`; output += `**Priority:** ${task.priority}\n`; output += `**Created:** ${(task as unknown as { created: string }).created}\n`; output += `**Updated:** ${(task as unknown as { updated: string }).updated}\n`; if ((task as unknown as { completed: string }).completed) { output += `**Completed:** ${(task as unknown as { completed: string }).completed}\n`; } if (task.session_id) { output += `**Created in session:** ${task.session_id}\n`; } if (task.description) { output += `\n**Description:**\n${task.description}\n`; } // Get checklist const checklist = await query( `SELECT id, item, checked FROM task_checklist WHERE task_id = $1 ORDER BY position, id`, [id] ); if (checklist.length > 0) { const done = checklist.filter(c => c.checked).length; output += `\n**Checklist:** (${done}/${checklist.length})\n`; for (const item of checklist) { output += ` ${item.checked ? '[x]' : '[ ]'} ${item.item} (#${item.id})\n`; } } // Get dependencies const blockedBy = await query<{ id: string; title: string }>( `SELECT t.id, t.title FROM task_links l JOIN tasks t ON t.id = l.from_task_id WHERE l.to_task_id = $1 AND l.link_type = 'blocks'`, [id] ); const blocks = await query<{ id: string; title: string }>( `SELECT t.id, t.title FROM task_links l JOIN tasks t ON t.id = l.to_task_id WHERE l.from_task_id = $1 AND l.link_type = 'blocks'`, [id] ); if (blockedBy.length > 0) { output += `\n**Blocked by:**\n`; for (const t of blockedBy) { output += ` - ${t.id}: ${t.title}\n`; } } if (blocks.length > 0) { output += `\n**Blocks:**\n`; for (const t of blocks) { output += ` - ${t.id}: ${t.title}\n`; } } // Get related tasks (bidirectional - only need to query one direction since links are symmetric) const relatesTo = await query<{ id: string; title: string }>( `SELECT t.id, t.title FROM task_links l JOIN tasks t ON t.id = l.to_task_id WHERE l.from_task_id = $1 AND l.link_type = 'relates_to'`, [id] ); if (relatesTo.length > 0) { output += `\n**Related:**\n`; for (const t of relatesTo) { output += ` - ${t.id}: ${t.title}\n`; } } // Get duplicates (bidirectional) const duplicates = await query<{ id: string; title: string }>( `SELECT t.id, t.title FROM task_links l JOIN tasks t ON t.id = l.to_task_id WHERE l.from_task_id = $1 AND l.link_type = 'duplicates'`, [id] ); if (duplicates.length > 0) { output += `\n**Duplicates:**\n`; for (const t of duplicates) { output += ` - ${t.id}: ${t.title}\n`; } } // Get commits const commitHistory = await getTaskCommits(id); if (commitHistory) { output += commitHistory; } // Get recent delegations const delegationHistory = await getRecentDelegations(id); if (delegationHistory) { output += delegationHistory; } return output; } /** * Close a task */ export async function taskClose(id: string): Promise { // Get current status for activity tracking const task = await queryOne<{ status: string }>(`SELECT status FROM tasks WHERE id = $1`, [id]); const result = await execute( `UPDATE tasks SET status = 'completed', completed_at = NOW(), updated_at = NOW() WHERE id = $1`, [id] ); if (result === 0) { return `Task not found: ${id}`; } // Record activity await recordActivity(id, 'closed', task?.status, 'completed'); return `Closed: ${id}`; } /** * Update a task */ export async function taskUpdate(args: TaskUpdateArgs): Promise { const { id, status, priority, type, title } = args; // Get current values for activity tracking const task = await queryOne<{ status: string }>(`SELECT status FROM tasks WHERE id = $1`, [id]); if (!task) { return `Task not found: ${id}`; } const updates: string[] = []; const params: unknown[] = []; let paramIndex = 1; if (status) { updates.push(`status = $${paramIndex++}`); params.push(status); if (status === 'completed') { updates.push(`completed_at = NOW()`); } } if (priority) { updates.push(`priority = $${paramIndex++}`); params.push(priority); } if (type) { updates.push(`type = $${paramIndex++}`); params.push(type); } if (title) { updates.push(`title = $${paramIndex++}`); params.push(title); } if (updates.length === 0) { return 'No updates specified'; } updates.push('updated_at = NOW()'); params.push(id); const result = await execute( `UPDATE tasks SET ${updates.join(', ')} WHERE id = $${paramIndex}`, params ); if (result === 0) { return `Task not found: ${id}`; } // Record activity if (status && status !== task.status) { await recordActivity(id, 'status_change', task.status, status); } else { await recordActivity(id, 'updated'); } // Manage session context based on status changes if (status) { const session_id = getSessionId(); try { if (status === 'in_progress') { // Set as current working task await execute( `INSERT INTO session_context (session_id, current_task_id) VALUES ($1, $2) ON CONFLICT (session_id) DO UPDATE SET current_task_id = $2, updated_at = NOW()`, [session_id, id] ); } else if (status === 'completed') { // Clear if this is the current working task await execute( `DELETE FROM session_context WHERE session_id = $1 AND current_task_id = $2`, [session_id, id] ); } } catch { // Silently fail if session context unavailable } } return `Updated: ${id}`; } /** * Start an investigation workflow (CF-166) * Creates an investigation task and sets it as the session context parent * All subsequent tasks will auto-link to this investigation */ export async function taskInvestigate(args: TaskAddArgs): Promise { const { title, project, priority = 'P1', description = '' } = args; // Create investigation task const taskResult = await taskAdd({ title, project, type: 'investigation', priority, description: description || 'Investigation task to coordinate related subtasks', }); // Extract task ID from result (format: "Created: XX-123\n...") const taskIdMatch = taskResult.match(/Created: ([\w-]+)/); if (!taskIdMatch) { return taskResult; // Return original message if format unexpected } const taskId = taskIdMatch[1]; // Set as investigation parent in session context const session_id = getSessionId(); try { await execute( `INSERT INTO session_context (session_id, current_task_id, investigation_parent_id) VALUES ($1, $2, $2) ON CONFLICT (session_id) DO UPDATE SET investigation_parent_id = $2, current_task_id = $2, updated_at = NOW()`, [session_id, taskId] ); } catch (error) { console.error('Failed to set investigation context:', error); } return taskResult + '\n\nšŸ” Investigation started! All new tasks will auto-link to this investigation.'; } interface TaskMoveProjectArgs { id: string; target_project: string; reason?: string; } /** * Move task to different project while preserving history (CF-301) * Creates new task with next ID in target project and transfers all related data */ export async function taskMoveProject(args: TaskMoveProjectArgs): Promise { const { id, target_project, reason } = args; // Validate source task exists const task = await queryOne<{ project: string; status: string }>( `SELECT project, status FROM tasks WHERE id = $1`, [id] ); if (!task) { return `Task not found: ${id}`; } if (task.project === target_project) { return `Task ${id} is already in project ${target_project}`; } // Validate target project exists const targetProj = await queryOne<{ key: string }>( `SELECT key FROM projects WHERE key = $1`, [target_project] ); if (!targetProj) { return `Target project not found: ${target_project}`; } // Generate new ID using getNextTaskId const newId = await getNextTaskId(target_project); // Execute move in transaction const client = await getClient(); try { await client.query('BEGIN'); // Insert new task (copy of old) await client.query(` INSERT INTO tasks (id, project, title, description, type, status, priority, version_id, epic_id, embedding, created_at, updated_at, completed_at, session_id) SELECT $1, $2, title, description, type, status, priority, version_id, epic_id, embedding, created_at, NOW(), completed_at, session_id FROM tasks WHERE id = $3 `, [newId, target_project, id]); // Transfer all related records const transfers = [ `UPDATE task_checklist SET task_id = $1 WHERE task_id = $2`, `UPDATE task_commits SET task_id = $1 WHERE task_id = $2`, `UPDATE task_delegations SET task_id = $1 WHERE task_id = $2`, `UPDATE task_activity SET task_id = $1 WHERE task_id = $2`, `UPDATE task_links SET from_task_id = $1 WHERE from_task_id = $2`, `UPDATE task_links SET to_task_id = $1 WHERE to_task_id = $2`, `UPDATE deployments SET task_id = $1 WHERE task_id = $2`, `UPDATE memories SET task_id = $1 WHERE task_id = $2`, `UPDATE session_context SET current_task_id = $1 WHERE current_task_id = $2`, `UPDATE session_context SET investigation_parent_id = $1 WHERE investigation_parent_id = $2`, `UPDATE task_learning_effectiveness SET task_id = $1 WHERE task_id = $2`, ]; for (const sql of transfers) { await client.query(sql, [newId, id]); } // Record activity await client.query(` INSERT INTO task_activity (task_id, activity_type, old_value, new_value, note, created_at) VALUES ($1, 'project_moved', $2, $3, $4, NOW()) `, [newId, task.project, target_project, reason || 'Moved via task_move_project']); // Update old task await client.query(` UPDATE tasks SET status = 'completed', completed_at = NOW(), updated_at = NOW(), description = COALESCE(description, '') || $1 WHERE id = $2 `, [`\n\n---\n**Moved to ${newId}**${reason ? ` (Reason: ${reason})` : ''}`, id]); // Add duplicate link await client.query(` INSERT INTO task_links (from_task_id, to_task_id, link_type, created_at) VALUES ($1, $2, 'duplicates', NOW()) `, [id, newId]); await client.query('COMMIT'); return `Moved ${id} → ${newId} (project: ${task.project} → ${target_project})`; } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } }