feat(CF-301): Implement task_move_project function

Added task_move_project tool to move tasks between projects while
preserving all relationships and history:

Changes:
- db.ts: Added getClient() for transaction support
- crud.ts: Implemented taskMoveProject() with full transaction
- index.ts: Wired up tool handler and import
- tools/index.ts: Registered tool definition

Preserves:
- Task metadata (title, description, type, status, priority)
- Checklist items
- Linked commits
- Delegations
- Activity history
- Task links (relates_to, blocks, duplicates)
- Epic and version assignments
- Embeddings

Process:
1. Validates source task and target project exist
2. Generates new ID in target project
3. Transfers all related data in transaction
4. Marks old task as completed with reference
5. Creates duplicate link between tasks

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-01-20 08:05:34 +02:00
parent 213a84f464
commit 18a016f82f
4 changed files with 135 additions and 2 deletions

View File

@@ -176,6 +176,13 @@ export async function testConnection(): Promise<boolean> {
} }
} }
/**
* Get a client from the pool for transactions
*/
export async function getClient() {
return await pool.connect();
}
/** /**
* Close the connection pool * Close the connection pool
*/ */

View File

@@ -37,7 +37,7 @@ import {
import { testConnection, close } from './db.js'; import { testConnection, close } from './db.js';
import { toolDefinitions } from './tools/index.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 { taskSimilar, taskContext } from './tools/search.js';
import { taskLink, checklistAdd, checklistToggle, taskResolveDuplicate } from './tools/relations.js'; import { taskLink, checklistAdd, checklistToggle, taskResolveDuplicate } from './tools/relations.js';
import { epicAdd, epicList, epicShow, epicAssign, epicClose } from './tools/epics.js'; import { epicAdd, epicList, epicShow, epicAssign, epicClose } from './tools/epics.js';
@@ -147,6 +147,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
description: a.description, description: a.description,
}); });
break; break;
case 'task_move_project':
result = await taskMoveProject({
id: a.id,
target_project: a.target_project,
reason: a.reason,
});
break;
// Search // Search
case 'task_similar': case 'task_similar':

View File

@@ -1,6 +1,6 @@
// CRUD operations for tasks // 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 { getEmbedding, formatEmbedding } from '../embeddings.js';
import type { Task, ChecklistItem, TaskLink } from '../types.js'; import type { Task, ChecklistItem, TaskLink } from '../types.js';
import { getRecentDelegations } from './delegations.js'; import { getRecentDelegations } from './delegations.js';
@@ -551,3 +551,109 @@ export async function taskInvestigate(args: TaskAddArgs): Promise<string> {
return taskResult + '\n\n🔍 Investigation started! All new tasks will auto-link to this investigation.'; 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<string> {
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();
}
}

View File

@@ -82,6 +82,19 @@ export const toolDefinitions = [
required: ['title'], 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 // Semantic Search Tools
{ {