MagicCode/packages/ext-claudecode/src/tools/task.ts
2026-10-12 00:41:17 +02:00

312 lines
9.8 KiB
TypeScript

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;
},
};