Files
session-mcp/src/tools/crud.ts
Christian Gick 49284d2911 feat(CF-450): Check completed tasks on task creation to avoid circular work
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>
2026-01-25 13:26:23 +02:00

685 lines
21 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
}
}