diff --git a/src/db.ts b/src/db.ts index a450dfd..e9f512b 100644 --- a/src/db.ts +++ b/src/db.ts @@ -176,6 +176,13 @@ export async function testConnection(): Promise { } } +/** + * Get a client from the pool for transactions + */ +export async function getClient() { + return await pool.connect(); +} + /** * Close the connection pool */ diff --git a/src/index.ts b/src/index.ts index 2d9647c..7046afe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,7 +37,7 @@ import { import { testConnection, close } from './db.js'; import { toolDefinitions } from './tools/index.js'; -import { taskAdd, taskList, taskShow, taskClose, taskUpdate, taskInvestigate } from './tools/crud.js'; +import { taskAdd, taskList, taskShow, taskClose, taskUpdate, taskInvestigate, taskMoveProject } from './tools/crud.js'; import { taskSimilar, taskContext } from './tools/search.js'; import { taskLink, checklistAdd, checklistToggle, taskResolveDuplicate } from './tools/relations.js'; import { epicAdd, epicList, epicShow, epicAssign, epicClose } from './tools/epics.js'; @@ -147,6 +147,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { description: a.description, }); break; + case 'task_move_project': + result = await taskMoveProject({ + id: a.id, + target_project: a.target_project, + reason: a.reason, + }); + break; // Search case 'task_similar': diff --git a/src/tools/crud.ts b/src/tools/crud.ts index 0af16b1..d554e7f 100644 --- a/src/tools/crud.ts +++ b/src/tools/crud.ts @@ -1,6 +1,6 @@ // CRUD operations for tasks -import { query, queryOne, execute, getNextTaskId, getProjectKey, detectProjectFromCwd } from '../db.js'; +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'; @@ -551,3 +551,109 @@ export async function taskInvestigate(args: TaskAddArgs): Promise { 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(); + } +} diff --git a/src/tools/index.ts b/src/tools/index.ts index dcff30f..bb3a311 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -82,6 +82,19 @@ export const toolDefinitions = [ required: ['title'], }, }, + { + name: 'task_move_project', + description: 'Move task to different project while preserving history', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Task ID to move (e.g., CF-295)' }, + target_project: { type: 'string', description: 'Target project key (e.g., VPN, ST, GB)' }, + reason: { type: 'string', description: 'Optional reason for move' }, + }, + required: ['id', 'target_project'], + }, + }, // Semantic Search Tools {