312 lines
9.8 KiB
TypeScript
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;
|
|
},
|
|
};
|