Enhanced task_add to search both open AND completed tasks when checking for duplicates. Previously only checked non-completed tasks. **Changes:** - Removed `status != 'completed'` filter from similarity search - Increased search limit from 3 to 5 tasks - Separated display: open tasks vs previously completed - Show 150-char context snippet from completed task descriptions - Suggest using "task show <id>" to see full solution **Benefits:** - Prevents recreating already-solved tasks - Surfaces solutions from previous attempts - Reduces circular work on recurring issues - Maintains context from closed tasks **Example output:** ``` ⚠️ Similar tasks found: **Open/In Progress:** - CF-442: Next.js cache issue (92% match, open) **Previously Completed:** - CF-378: Pre-test validation (85% match) Context: "Created comprehensive pre-test quality validation workflow..." 💡 Use "task show <id>" to see full solution before recreating work ``` Fixes: CF-450 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
685 lines
21 KiB
TypeScript
685 lines
21 KiB
TypeScript
// 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<void> {
|
||
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<string> {
|
||
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 <id>" to see full solution before recreating work\n';
|
||
}
|
||
|
||
duplicateWarning += '\nConsider linking with: task link <new-id> <related-id> 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<string> {
|
||
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<Task>(
|
||
`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<string> {
|
||
const task = await queryOne<Task & { session_id?: string }>(
|
||
`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<ChecklistItem>(
|
||
`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<string> {
|
||
// 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<string> {
|
||
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<string> {
|
||
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<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();
|
||
}
|
||
}
|