import { readFile, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import { mkdir } from "node:fs/promises"; import type { Tool } from "@magiccode/core"; import { resolvePath, optStr, str } from "./helpers"; interface TaskNode { id: string; title: string; status: "todo" | "done" | "blocked" | "in-progress"; description?: string; priority?: "low" | "normal" | "high" | "critical"; subtasks?: TaskNode[]; parent?: string; createdAt: string; updatedAt: string; dueAt?: string; assignee?: string; tags?: string[]; } interface TaskList { version: 1; tasks: TaskNode[]; lastModified: string; } /** * Calcule le statut d'une tâche parent en fonction de ses sous-tâches. * - Toutes complètes : done * - Aucune commencée : todo * - Toutes bloquées : blocked * - Sinon : in-progress */ function computeParentStatus(task: TaskNode): "todo" | "done" | "blocked" | "in-progress" { if (!task.subtasks || task.subtasks.length === 0) { return task.status; } const statuses = new Set(task.subtasks.map((t) => t.status)); if (statuses.size === 1 && statuses.has("done")) { return "done"; } if (statuses.has("done") && statuses.size > 1) { return "in-progress"; } if (statuses.size === 1 && statuses.has("blocked")) { return "blocked"; } if (statuses.size === 1 && statuses.has("todo")) { return "todo"; } return "in-progress"; } /** * Trouve une tâche récursivement. */ function findTask(tasks: TaskNode[], id: string): TaskNode | null { for (const task of tasks) { if (task.id === id) return task; if (task.subtasks) { const found = findTask(task.subtasks, id); if (found) return found; } } return null; } /** * Ajoute une sous-tâche à une tâche parent. */ function addSubtask(tasks: TaskNode[], parentId: string, subtask: TaskNode): boolean { const parent = findTask(tasks, parentId); if (!parent) return false; if (!parent.subtasks) parent.subtasks = []; parent.subtasks.push(subtask); return true; } /** * Supprime une tâche récursivement. */ function removeTask(tasks: TaskNode[], id: string): boolean { const idx = tasks.findIndex((t) => t.id === id); if (idx >= 0) { tasks.splice(idx, 1); return true; } for (const task of tasks) { if (task.subtasks && removeTask(task.subtasks, id)) { return true; } } return false; } /** * Formate l'affichage arborescent des tâches. */ function formatTaskTree(tasks: TaskNode[], indent = 0): string { const lines: string[] = []; for (const task of tasks) { const prefix = indent === 0 ? "" : " ".repeat(indent - 1) + "├─ "; const statusIcon = task.status === "done" ? "✓" : task.status === "in-progress" ? "→" : task.status === "blocked" ? "⚠" : "○"; const priority = task.priority ? ` [${task.priority.toUpperCase()}]` : ""; const assignee = task.assignee ? ` (@${task.assignee})` : ""; lines.push(`${prefix}${statusIcon} [${task.id}] ${task.title}${priority}${assignee}`); if (task.description) { lines.push(`${" ".repeat(indent)} > ${task.description}`); } if (task.subtasks && task.subtasks.length > 0) { lines.push(...formatTaskTree(task.subtasks, indent + 1).split("\n")); } } return lines.join("\n"); } export const taskTool: Tool = { spec: { name: "Task", description: "Gère une arborescence de tâches hiérarchiques (tâches + sous-tâches) avec statuts, priorités et assignations.", inputSchema: { type: "object", properties: { file: { type: "string", description: "Fichier des tâches (ex: tasks.json, relatif au dossier de lancement)", }, action: { type: "string", enum: ["add", "list", "subtask", "update", "remove", "status", "assign", "tree"], description: "Action à effectuer", }, title: { type: "string", description: "Titre de la tâche" }, id: { type: "string", description: "ID de la tâche" }, parent: { type: "string", description: "ID du parent (pour créer une sous-tâche)" }, status: { type: "string", enum: ["todo", "in-progress", "done", "blocked"], description: "Nouveau statut", }, priority: { type: "string", enum: ["low", "normal", "high", "critical"], description: "Priorité", }, description: { type: "string", description: "Description" }, assignee: { type: "string", description: "Responsable" }, dueAt: { type: "string", description: "Date limite (ISO 8601)" }, tags: { type: "array", items: { type: "string" }, description: "Tags" }, }, required: ["file", "action"], }, }, permission: "auto", describe: (i) => `Task ${String(i.action)} ${String(i.file)}`, async execute(input, ctx) { const filePath = resolvePath(ctx.cwd, str(input, "file")); const action = str(input, "action"); // Charger la liste existante let list: TaskList; try { const content = await readFile(filePath, "utf8"); list = JSON.parse(content); } catch { list = { version: 1, tasks: [], lastModified: new Date().toISOString(), }; } let result: string; switch (action) { case "add": { const title = str(input, "title"); const newId = String(Math.max(0, ...list.tasks.map((t) => parseInt(t.id, 10))) + 1); const newTask: TaskNode = { id: newId, title, status: "todo", description: optStr(input, "description"), priority: (input.priority as any) || "normal", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), dueAt: optStr(input, "dueAt"), assignee: optStr(input, "assignee"), tags: (input.tags as string[]) || undefined, }; list.tasks.push(newTask); result = `Tâche créée : [${newId}] ${title}`; break; } case "subtask": { const parentId = str(input, "parent"); const title = str(input, "title"); const subtaskId = String(Date.now()); // ID unique basé sur le timestamp const subtask: TaskNode = { id: subtaskId, title, status: "todo", parent: parentId, description: optStr(input, "description"), priority: (input.priority as any) || "normal", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), dueAt: optStr(input, "dueAt"), }; if (addSubtask(list.tasks, parentId, subtask)) { result = `Sous-tâche créée : [${subtaskId}] ${title} (parent: ${parentId})`; } else { throw new Error(`Tâche parent ${parentId} non trouvée`); } break; } case "list": { const tasks = list.tasks; if (tasks.length === 0) { result = "Aucune tâche"; } else { const lines: string[] = []; lines.push("Tâches principales :"); for (const task of tasks) { const statusIcon = task.status === "done" ? "✓" : task.status === "in-progress" ? "→" : task.status === "blocked" ? "⚠" : "○"; const subtaskCount = task.subtasks ? ` (${task.subtasks.length} sous-tâches)` : ""; const priority = task.priority ? ` [${task.priority.toUpperCase()}]` : ""; lines.push( ` ${statusIcon} [${task.id}] ${task.title}${priority}${subtaskCount}` + (task.assignee ? ` (@${task.assignee})` : "") ); } result = lines.join("\n"); } break; } case "tree": { result = formatTaskTree(list.tasks); if (!result) result = "Aucune tâche"; break; } case "status": { const id = str(input, "id"); const newStatus = str(input, "status") as any; const task = findTask(list.tasks, id); if (!task) throw new Error(`Tâche ${id} non trouvée`); task.status = newStatus; task.updatedAt = new Date().toISOString(); result = `Statut mis à jour : [${id}] → ${newStatus}`; break; } case "assign": { const id = str(input, "id"); const assignee = optStr(input, "assignee"); const task = findTask(list.tasks, id); if (!task) throw new Error(`Tâche ${id} non trouvée`); task.assignee = assignee; task.updatedAt = new Date().toISOString(); result = assignee ? `Assignée à ${assignee} : [${id}]` : `Désassignée : [${id}]`; break; } case "update": { const id = str(input, "id"); const task = findTask(list.tasks, id); if (!task) throw new Error(`Tâche ${id} non trouvée`); if (input.title) task.title = String(input.title); if (input.description !== undefined) task.description = optStr(input, "description"); if (input.priority) task.priority = input.priority as any; if (input.dueAt !== undefined) task.dueAt = optStr(input, "dueAt"); task.updatedAt = new Date().toISOString(); result = `Tâche mise à jour : [${id}] ${task.title}`; break; } case "remove": { const id = str(input, "id"); if (removeTask(list.tasks, id)) { result = `Tâche supprimée : [${id}]`; } else { throw new Error(`Tâche ${id} non trouvée`); } break; } default: throw new Error(`Action inconnue : ${action}`); } // Sauvegarder if (!["list", "tree"].includes(action)) { list.lastModified = new Date().toISOString(); await mkdir(dirname(filePath), { recursive: true }); await writeFile(filePath, JSON.stringify(list, null, 2), "utf8"); } return result; }, };