@task-mcp/cli 1.0.15 → 1.0.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -2
- package/package.json +1 -1
- package/src/__tests__/dashboard.test.ts +228 -0
- package/src/__tests__/inbox.test.ts +307 -0
- package/src/__tests__/list.test.ts +352 -0
- package/src/commands/dashboard.ts +11 -6
- package/src/commands/inbox.ts +7 -20
- package/src/commands/list.ts +19 -26
- package/src/constants.ts +56 -0
- package/src/interactive.ts +9 -145
- package/src/storage.ts +37 -424
package/src/interactive.ts
CHANGED
|
@@ -7,6 +7,11 @@ import { c, banner } from "./ansi.js";
|
|
|
7
7
|
import { dashboard } from "./commands/dashboard.js";
|
|
8
8
|
import { listTasksCmd, listProjectsCmd } from "./commands/list.js";
|
|
9
9
|
import * as readline from "node:readline";
|
|
10
|
+
import {
|
|
11
|
+
MENU_SEPARATOR_WIDTH,
|
|
12
|
+
ERROR_MESSAGE_DELAY_MS,
|
|
13
|
+
WELCOME_SCREEN_DELAY_MS,
|
|
14
|
+
} from "./constants.js";
|
|
10
15
|
|
|
11
16
|
interface MenuOption {
|
|
12
17
|
key: string;
|
|
@@ -40,7 +45,7 @@ async function showMenu(title: string, options: MenuOption[]): Promise<void> {
|
|
|
40
45
|
console.log(banner("TASK MCP"));
|
|
41
46
|
console.log();
|
|
42
47
|
console.log(c.bold(c.cyan(title)));
|
|
43
|
-
console.log(c.dim("─".repeat(
|
|
48
|
+
console.log(c.dim("─".repeat(MENU_SEPARATOR_WIDTH)));
|
|
44
49
|
console.log();
|
|
45
50
|
|
|
46
51
|
for (const opt of options) {
|
|
@@ -56,7 +61,7 @@ async function showMenu(title: string, options: MenuOption[]): Promise<void> {
|
|
|
56
61
|
await selected.action();
|
|
57
62
|
} else if (choice !== "") {
|
|
58
63
|
console.log(c.error(`Invalid option: ${choice}`));
|
|
59
|
-
await sleep(
|
|
64
|
+
await sleep(ERROR_MESSAGE_DELAY_MS);
|
|
60
65
|
}
|
|
61
66
|
}
|
|
62
67
|
|
|
@@ -103,22 +108,6 @@ async function mainMenu(): Promise<boolean> {
|
|
|
103
108
|
await tasksMenu();
|
|
104
109
|
},
|
|
105
110
|
},
|
|
106
|
-
{
|
|
107
|
-
key: "a",
|
|
108
|
-
label: "Analysis",
|
|
109
|
-
description: "View analysis tools",
|
|
110
|
-
action: async () => {
|
|
111
|
-
await analysisMenu();
|
|
112
|
-
},
|
|
113
|
-
},
|
|
114
|
-
{
|
|
115
|
-
key: "q",
|
|
116
|
-
label: "Quick Actions",
|
|
117
|
-
description: "Common quick actions",
|
|
118
|
-
action: async () => {
|
|
119
|
-
await quickActionsMenu();
|
|
120
|
-
},
|
|
121
|
-
},
|
|
122
111
|
{
|
|
123
112
|
key: "x",
|
|
124
113
|
label: "Exit",
|
|
@@ -140,7 +129,7 @@ async function mainMenu(): Promise<boolean> {
|
|
|
140
129
|
async function dashboardMenu(): Promise<void> {
|
|
141
130
|
console.clear();
|
|
142
131
|
console.log(c.bold(c.cyan("Dashboard")));
|
|
143
|
-
console.log(c.dim("─".repeat(
|
|
132
|
+
console.log(c.dim("─".repeat(MENU_SEPARATOR_WIDTH)));
|
|
144
133
|
console.log();
|
|
145
134
|
|
|
146
135
|
const projectId = await prompt(c.cyan("Project ID (Enter for all): "));
|
|
@@ -247,131 +236,6 @@ async function tasksMenu(): Promise<void> {
|
|
|
247
236
|
await showMenu("Tasks", options);
|
|
248
237
|
}
|
|
249
238
|
|
|
250
|
-
/**
|
|
251
|
-
* Analysis Menu
|
|
252
|
-
*/
|
|
253
|
-
async function analysisMenu(): Promise<void> {
|
|
254
|
-
const options: MenuOption[] = [
|
|
255
|
-
{
|
|
256
|
-
key: "c",
|
|
257
|
-
label: "Complexity Analysis",
|
|
258
|
-
description: "View task complexity summary",
|
|
259
|
-
action: async () => {
|
|
260
|
-
console.clear();
|
|
261
|
-
console.log(c.yellow("Complexity Analysis"));
|
|
262
|
-
console.log(c.dim("Use MCP tools: get_complexity_summary, save_complexity_analysis"));
|
|
263
|
-
console.log();
|
|
264
|
-
console.log("Features:");
|
|
265
|
-
console.log(" - Score 1-10 complexity rating");
|
|
266
|
-
console.log(" - Factors: state_management, cross_cutting, etc.");
|
|
267
|
-
console.log(" - Suggested subtask count");
|
|
268
|
-
console.log();
|
|
269
|
-
await waitForKey();
|
|
270
|
-
},
|
|
271
|
-
},
|
|
272
|
-
{
|
|
273
|
-
key: "t",
|
|
274
|
-
label: "Tech Stack Analysis",
|
|
275
|
-
description: "View tech areas and risk",
|
|
276
|
-
action: async () => {
|
|
277
|
-
console.clear();
|
|
278
|
-
console.log(c.yellow("Tech Stack Analysis"));
|
|
279
|
-
console.log(c.dim("Use MCP tools: get_tech_stack_summary, save_tech_stack_analysis"));
|
|
280
|
-
console.log();
|
|
281
|
-
console.log("Features:");
|
|
282
|
-
console.log(" - Areas: schema, backend, frontend, infra, devops, test, docs");
|
|
283
|
-
console.log(" - Risk levels: low, medium, high, critical");
|
|
284
|
-
console.log(" - Breaking change detection");
|
|
285
|
-
console.log();
|
|
286
|
-
await waitForKey();
|
|
287
|
-
},
|
|
288
|
-
},
|
|
289
|
-
{
|
|
290
|
-
key: "r",
|
|
291
|
-
label: "Risk Analysis",
|
|
292
|
-
description: "Find high-risk tasks",
|
|
293
|
-
action: async () => {
|
|
294
|
-
console.clear();
|
|
295
|
-
console.log(c.yellow("Risk Analysis"));
|
|
296
|
-
console.log(c.dim("Use MCP tools: find_high_risk_tasks, suggest_safe_order"));
|
|
297
|
-
console.log();
|
|
298
|
-
console.log("Features:");
|
|
299
|
-
console.log(" - Identify high/critical risk tasks");
|
|
300
|
-
console.log(" - Suggest safe execution order");
|
|
301
|
-
console.log(" - Phase-based task grouping");
|
|
302
|
-
console.log();
|
|
303
|
-
await waitForKey();
|
|
304
|
-
},
|
|
305
|
-
},
|
|
306
|
-
{
|
|
307
|
-
key: "b",
|
|
308
|
-
label: "Back to Main Menu",
|
|
309
|
-
action: () => {},
|
|
310
|
-
},
|
|
311
|
-
];
|
|
312
|
-
|
|
313
|
-
await showMenu("Analysis Tools", options);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/**
|
|
317
|
-
* Quick Actions Menu
|
|
318
|
-
*/
|
|
319
|
-
async function quickActionsMenu(): Promise<void> {
|
|
320
|
-
const options: MenuOption[] = [
|
|
321
|
-
{
|
|
322
|
-
key: "t",
|
|
323
|
-
label: "Today's Tasks",
|
|
324
|
-
action: async () => {
|
|
325
|
-
console.clear();
|
|
326
|
-
console.log(c.bold(c.yellow("Today's Tasks")));
|
|
327
|
-
console.log(c.dim("Use MCP tool: view_today"));
|
|
328
|
-
console.log();
|
|
329
|
-
await waitForKey();
|
|
330
|
-
},
|
|
331
|
-
},
|
|
332
|
-
{
|
|
333
|
-
key: "w",
|
|
334
|
-
label: "This Week",
|
|
335
|
-
action: async () => {
|
|
336
|
-
console.clear();
|
|
337
|
-
console.log(c.bold(c.yellow("This Week's Tasks")));
|
|
338
|
-
console.log(c.dim("Use MCP tool: view_this_week"));
|
|
339
|
-
console.log();
|
|
340
|
-
await waitForKey();
|
|
341
|
-
},
|
|
342
|
-
},
|
|
343
|
-
{
|
|
344
|
-
key: "q",
|
|
345
|
-
label: "Quick Wins",
|
|
346
|
-
action: async () => {
|
|
347
|
-
console.clear();
|
|
348
|
-
console.log(c.bold(c.yellow("Quick Wins")));
|
|
349
|
-
console.log(c.dim("Use MCP tool: view_quick_wins"));
|
|
350
|
-
console.log();
|
|
351
|
-
await waitForKey();
|
|
352
|
-
},
|
|
353
|
-
},
|
|
354
|
-
{
|
|
355
|
-
key: "n",
|
|
356
|
-
label: "Next Task Suggestion",
|
|
357
|
-
action: async () => {
|
|
358
|
-
console.clear();
|
|
359
|
-
console.log(c.bold(c.yellow("Next Task Suggestion")));
|
|
360
|
-
console.log(c.dim("Use MCP tool: suggest_next_task"));
|
|
361
|
-
console.log();
|
|
362
|
-
await waitForKey();
|
|
363
|
-
},
|
|
364
|
-
},
|
|
365
|
-
{
|
|
366
|
-
key: "b",
|
|
367
|
-
label: "Back to Main Menu",
|
|
368
|
-
action: () => {},
|
|
369
|
-
},
|
|
370
|
-
];
|
|
371
|
-
|
|
372
|
-
await showMenu("Quick Actions", options);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
239
|
/**
|
|
376
240
|
* Start interactive mode
|
|
377
241
|
*/
|
|
@@ -382,7 +246,7 @@ export async function startInteractive(): Promise<void> {
|
|
|
382
246
|
console.log(c.bold("Welcome to Task MCP Interactive Mode"));
|
|
383
247
|
console.log(c.dim("Navigate using keyboard shortcuts"));
|
|
384
248
|
console.log();
|
|
385
|
-
await sleep(
|
|
249
|
+
await sleep(WELCOME_SCREEN_DELAY_MS);
|
|
386
250
|
|
|
387
251
|
while (true) {
|
|
388
252
|
try {
|
package/src/storage.ts
CHANGED
|
@@ -1,183 +1,68 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* CLI Storage utilities
|
|
3
|
+
* Wrapper functions for store access and pure functions for task statistics
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import type { Task, Project, InboxItem } from "@task-mcp/shared";
|
|
7
|
+
import {
|
|
8
|
+
TaskStore,
|
|
9
|
+
ProjectStore,
|
|
10
|
+
InboxStore,
|
|
11
|
+
StateStore,
|
|
12
|
+
} from "@task-mcp/mcp-server/storage";
|
|
8
13
|
|
|
9
|
-
export
|
|
10
|
-
|
|
11
|
-
factors?: string[];
|
|
12
|
-
rationale?: string;
|
|
13
|
-
}
|
|
14
|
+
// Re-export types from shared for consumers of this module
|
|
15
|
+
export type { Task, Project, InboxItem };
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
17
|
+
// Initialize stores
|
|
18
|
+
const taskStore = new TaskStore();
|
|
19
|
+
const projectStore = new ProjectStore();
|
|
20
|
+
const inboxStore = new InboxStore();
|
|
21
|
+
const stateStore = new StateStore();
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
description?: string;
|
|
26
|
-
status: "pending" | "in_progress" | "blocked" | "completed" | "cancelled";
|
|
27
|
-
priority: "critical" | "high" | "medium" | "low";
|
|
28
|
-
projectId: string;
|
|
29
|
-
parentId?: string;
|
|
30
|
-
dependencies?: { taskId: string; type: "blocks" | "blocked_by" | "related"; reason?: string }[];
|
|
31
|
-
dueDate?: string;
|
|
32
|
-
createdAt: string;
|
|
33
|
-
updatedAt: string;
|
|
34
|
-
completedAt?: string;
|
|
35
|
-
contexts?: string[];
|
|
36
|
-
tags?: string[];
|
|
37
|
-
complexity?: ComplexityAnalysis;
|
|
38
|
-
techStack?: TechStackAnalysis;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface Project {
|
|
42
|
-
id: string;
|
|
43
|
-
name: string;
|
|
44
|
-
description?: string;
|
|
45
|
-
status: "active" | "on_hold" | "completed" | "archived";
|
|
46
|
-
defaultPriority?: "critical" | "high" | "medium" | "low";
|
|
47
|
-
createdAt: string;
|
|
48
|
-
updatedAt: string;
|
|
49
|
-
targetDate?: string;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export interface TasksFile {
|
|
53
|
-
version: number;
|
|
54
|
-
tasks: Task[];
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export interface InboxItem {
|
|
58
|
-
id: string;
|
|
59
|
-
content: string;
|
|
60
|
-
status: "pending" | "promoted" | "discarded";
|
|
61
|
-
source?: string;
|
|
62
|
-
tags?: string[];
|
|
63
|
-
capturedAt: string;
|
|
64
|
-
promotedToTaskId?: string;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export interface InboxFile {
|
|
68
|
-
version: number;
|
|
69
|
-
items: InboxItem[];
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function getTasksDir(): string {
|
|
73
|
-
return join(process.cwd(), ".tasks");
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Validate a project ID format to prevent path traversal attacks.
|
|
78
|
-
* Valid format: proj_[alphanumeric only]
|
|
79
|
-
*/
|
|
80
|
-
function isValidProjectId(id: string): boolean {
|
|
81
|
-
return /^proj_[a-z0-9]+$/.test(id);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
async function readJson<T>(path: string): Promise<T | null> {
|
|
85
|
-
try {
|
|
86
|
-
const content = await readFile(path, "utf-8");
|
|
87
|
-
return JSON.parse(content) as T;
|
|
88
|
-
} catch {
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
async function listDirs(path: string): Promise<string[]> {
|
|
94
|
-
try {
|
|
95
|
-
const entries = await readdir(path, { withFileTypes: true });
|
|
96
|
-
return entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
97
|
-
} catch {
|
|
98
|
-
return [];
|
|
99
|
-
}
|
|
100
|
-
}
|
|
23
|
+
// =============================================================================
|
|
24
|
+
// Store Wrapper Functions
|
|
25
|
+
// =============================================================================
|
|
101
26
|
|
|
102
27
|
/**
|
|
103
28
|
* List all projects
|
|
104
|
-
* Uses Promise.allSettled for parallel I/O with graceful error handling
|
|
105
29
|
*/
|
|
106
30
|
export async function listProjects(includeArchived = false): Promise<Project[]> {
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
projectIds.map((id) => readJson<Project>(join(projectsDir, id, "project.json")))
|
|
113
|
-
);
|
|
114
|
-
|
|
115
|
-
const projects = results
|
|
116
|
-
.filter((r): r is PromiseFulfilledResult<Project | null> => r.status === "fulfilled")
|
|
117
|
-
.map((r) => r.value)
|
|
118
|
-
.filter(
|
|
119
|
-
(project): project is Project =>
|
|
120
|
-
project !== null && (includeArchived || project.status !== "archived")
|
|
121
|
-
);
|
|
122
|
-
|
|
123
|
-
return projects.sort(
|
|
124
|
-
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
|
125
|
-
);
|
|
31
|
+
const projects = await projectStore.list();
|
|
32
|
+
if (includeArchived) {
|
|
33
|
+
return projects;
|
|
34
|
+
}
|
|
35
|
+
return projects.filter((p) => p.status !== "archived");
|
|
126
36
|
}
|
|
127
37
|
|
|
128
|
-
|
|
129
38
|
/**
|
|
130
|
-
* List tasks for a project
|
|
39
|
+
* List tasks for a specific project
|
|
131
40
|
*/
|
|
132
41
|
export async function listTasks(projectId: string): Promise<Task[]> {
|
|
133
|
-
|
|
134
|
-
if (!isValidProjectId(projectId)) {
|
|
135
|
-
console.error(`Invalid project ID format: ${projectId}`);
|
|
136
|
-
return [];
|
|
137
|
-
}
|
|
138
|
-
const path = join(getTasksDir(), "projects", projectId, "tasks.json");
|
|
139
|
-
const data = await readJson<TasksFile>(path);
|
|
140
|
-
return data?.tasks ?? [];
|
|
42
|
+
return taskStore.list(projectId);
|
|
141
43
|
}
|
|
142
44
|
|
|
143
45
|
/**
|
|
144
|
-
* List
|
|
46
|
+
* List all tasks across all projects
|
|
145
47
|
*/
|
|
146
|
-
export async function
|
|
147
|
-
|
|
148
|
-
const data = await readJson<InboxFile>(path);
|
|
149
|
-
const items = data?.items ?? [];
|
|
150
|
-
|
|
151
|
-
if (status) {
|
|
152
|
-
return items.filter(i => i.status === status);
|
|
153
|
-
}
|
|
154
|
-
return items;
|
|
48
|
+
export async function listAllTasks(): Promise<Task[]> {
|
|
49
|
+
return taskStore.listAll();
|
|
155
50
|
}
|
|
156
51
|
|
|
157
52
|
/**
|
|
158
|
-
*
|
|
53
|
+
* List inbox items by status
|
|
159
54
|
*/
|
|
160
|
-
export async function
|
|
161
|
-
|
|
162
|
-
|
|
55
|
+
export async function listInboxItems(
|
|
56
|
+
status?: "pending" | "promoted" | "discarded"
|
|
57
|
+
): Promise<InboxItem[]> {
|
|
58
|
+
return inboxStore.list({ status });
|
|
163
59
|
}
|
|
164
60
|
|
|
165
61
|
/**
|
|
166
|
-
*
|
|
167
|
-
* Uses Promise.all for parallel I/O instead of sequential reads
|
|
62
|
+
* Get the active tag (git branch context)
|
|
168
63
|
*/
|
|
169
|
-
export async function
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
// Parallel reads instead of sequential for better performance
|
|
173
|
-
const taskArrays = await Promise.all(
|
|
174
|
-
projects.map((project) => listTasks(project.id))
|
|
175
|
-
);
|
|
176
|
-
const allTasks = taskArrays.flat();
|
|
177
|
-
|
|
178
|
-
return allTasks.sort(
|
|
179
|
-
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
|
180
|
-
);
|
|
64
|
+
export async function getActiveTag(): Promise<string> {
|
|
65
|
+
return stateStore.getActiveTag();
|
|
181
66
|
}
|
|
182
67
|
|
|
183
68
|
/**
|
|
@@ -320,275 +205,3 @@ export function suggestNextTask(tasks: Task[]): Task | null {
|
|
|
320
205
|
|
|
321
206
|
return actionable[0] ?? null;
|
|
322
207
|
}
|
|
323
|
-
|
|
324
|
-
/**
|
|
325
|
-
* Check if date is today
|
|
326
|
-
*/
|
|
327
|
-
function isToday(dateStr: string): boolean {
|
|
328
|
-
const date = new Date(dateStr);
|
|
329
|
-
const today = new Date();
|
|
330
|
-
return date.toDateString() === today.toDateString();
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/**
|
|
334
|
-
* Check if date is overdue
|
|
335
|
-
*/
|
|
336
|
-
function isOverdue(dateStr: string): boolean {
|
|
337
|
-
const date = new Date(dateStr);
|
|
338
|
-
const today = new Date();
|
|
339
|
-
today.setHours(0, 0, 0, 0);
|
|
340
|
-
return date < today;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
/**
|
|
344
|
-
* Check if date is within N days
|
|
345
|
-
*/
|
|
346
|
-
function isWithinDays(dateStr: string, days: number): boolean {
|
|
347
|
-
const date = new Date(dateStr);
|
|
348
|
-
const future = new Date();
|
|
349
|
-
future.setDate(future.getDate() + days);
|
|
350
|
-
return date <= future;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/**
|
|
354
|
-
* Get tasks due today or overdue
|
|
355
|
-
*/
|
|
356
|
-
export function getTodayTasks(tasks: Task[]): Task[] {
|
|
357
|
-
return tasks.filter(t =>
|
|
358
|
-
t.status !== "completed" &&
|
|
359
|
-
t.status !== "cancelled" &&
|
|
360
|
-
t.dueDate &&
|
|
361
|
-
(isToday(t.dueDate) || isOverdue(t.dueDate))
|
|
362
|
-
);
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* Get tasks due this week
|
|
367
|
-
*/
|
|
368
|
-
export function getThisWeekTasks(tasks: Task[]): Task[] {
|
|
369
|
-
return tasks.filter(t =>
|
|
370
|
-
t.status !== "completed" &&
|
|
371
|
-
t.status !== "cancelled" &&
|
|
372
|
-
t.dueDate &&
|
|
373
|
-
isWithinDays(t.dueDate, 7)
|
|
374
|
-
);
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
/**
|
|
378
|
-
* Get overdue tasks
|
|
379
|
-
*/
|
|
380
|
-
export function getOverdueTasks(tasks: Task[]): Task[] {
|
|
381
|
-
return tasks.filter(t =>
|
|
382
|
-
t.status !== "completed" &&
|
|
383
|
-
t.status !== "cancelled" &&
|
|
384
|
-
t.dueDate &&
|
|
385
|
-
isOverdue(t.dueDate)
|
|
386
|
-
);
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/**
|
|
390
|
-
* Task tree node for hierarchy
|
|
391
|
-
*/
|
|
392
|
-
export interface TaskTreeNode {
|
|
393
|
-
task: Task;
|
|
394
|
-
children: TaskTreeNode[];
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/**
|
|
398
|
-
* Build task hierarchy tree
|
|
399
|
-
*/
|
|
400
|
-
export function buildTaskTree(tasks: Task[]): TaskTreeNode[] {
|
|
401
|
-
const taskMap = new Map(tasks.map(t => [t.id, t]));
|
|
402
|
-
const childrenMap = new Map<string, Task[]>();
|
|
403
|
-
|
|
404
|
-
// Build parent -> children map
|
|
405
|
-
for (const task of tasks) {
|
|
406
|
-
if (task.parentId) {
|
|
407
|
-
const children = childrenMap.get(task.parentId) ?? [];
|
|
408
|
-
children.push(task);
|
|
409
|
-
childrenMap.set(task.parentId, children);
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
function buildNode(task: Task): TaskTreeNode {
|
|
414
|
-
const children = childrenMap.get(task.id) ?? [];
|
|
415
|
-
return {
|
|
416
|
-
task,
|
|
417
|
-
children: children.map(buildNode),
|
|
418
|
-
};
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
// Return all root tasks (no parent)
|
|
422
|
-
const rootTasks = tasks.filter(t => !t.parentId);
|
|
423
|
-
return rootTasks.map(buildNode);
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
/**
|
|
427
|
-
* Count total nodes in tree
|
|
428
|
-
*/
|
|
429
|
-
export function countTreeNodes(nodes: TaskTreeNode[]): number {
|
|
430
|
-
return nodes.reduce((sum, n) => sum + 1 + countTreeNodes(n.children), 0);
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
/**
|
|
434
|
-
* Simple Critical Path calculation
|
|
435
|
-
* Returns tasks that are on the critical path based on dependencies
|
|
436
|
-
*/
|
|
437
|
-
export interface CriticalPathResult {
|
|
438
|
-
criticalPath: Task[];
|
|
439
|
-
totalDuration: number;
|
|
440
|
-
bottlenecks: { task: Task; blocksCount: number }[];
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
/**
|
|
444
|
-
* Calculate complexity and tech stack statistics
|
|
445
|
-
*/
|
|
446
|
-
export interface AnalysisStats {
|
|
447
|
-
complexity: {
|
|
448
|
-
analyzed: number;
|
|
449
|
-
avgScore: number;
|
|
450
|
-
distribution: { low: number; medium: number; high: number };
|
|
451
|
-
topFactors: { factor: string; count: number }[];
|
|
452
|
-
};
|
|
453
|
-
techStack: {
|
|
454
|
-
analyzed: number;
|
|
455
|
-
byArea: Record<string, number>;
|
|
456
|
-
byRisk: { low: number; medium: number; high: number; critical: number };
|
|
457
|
-
breakingChanges: number;
|
|
458
|
-
};
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
export function calculateAnalysisStats(tasks: Task[]): AnalysisStats {
|
|
462
|
-
const activeTasks = tasks.filter(t =>
|
|
463
|
-
t.status !== "completed" && t.status !== "cancelled"
|
|
464
|
-
);
|
|
465
|
-
|
|
466
|
-
// Complexity stats
|
|
467
|
-
const tasksWithComplexity = activeTasks.filter(t => t.complexity?.score);
|
|
468
|
-
const scores = tasksWithComplexity.map(t => t.complexity!.score!);
|
|
469
|
-
const avgScore = scores.length > 0
|
|
470
|
-
? Math.round((scores.reduce((a, b) => a + b, 0) / scores.length) * 10) / 10
|
|
471
|
-
: 0;
|
|
472
|
-
|
|
473
|
-
const complexityDist = { low: 0, medium: 0, high: 0 };
|
|
474
|
-
for (const score of scores) {
|
|
475
|
-
if (score <= 3) complexityDist.low++;
|
|
476
|
-
else if (score <= 6) complexityDist.medium++;
|
|
477
|
-
else complexityDist.high++;
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
// Count factors
|
|
481
|
-
const factorCounts: Record<string, number> = {};
|
|
482
|
-
for (const t of tasksWithComplexity) {
|
|
483
|
-
for (const f of t.complexity?.factors ?? []) {
|
|
484
|
-
factorCounts[f] = (factorCounts[f] ?? 0) + 1;
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
const topFactors = Object.entries(factorCounts)
|
|
488
|
-
.map(([factor, count]) => ({ factor, count }))
|
|
489
|
-
.sort((a, b) => b.count - a.count)
|
|
490
|
-
.slice(0, 5);
|
|
491
|
-
|
|
492
|
-
// Tech stack stats
|
|
493
|
-
const tasksWithTech = activeTasks.filter(t => t.techStack?.areas?.length);
|
|
494
|
-
const byArea: Record<string, number> = {};
|
|
495
|
-
for (const t of tasksWithTech) {
|
|
496
|
-
for (const area of t.techStack?.areas ?? []) {
|
|
497
|
-
byArea[area] = (byArea[area] ?? 0) + 1;
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
const byRisk = { low: 0, medium: 0, high: 0, critical: 0 };
|
|
502
|
-
for (const t of tasksWithTech) {
|
|
503
|
-
const risk = t.techStack?.riskLevel ?? "medium";
|
|
504
|
-
byRisk[risk]++;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
const breakingChanges = tasksWithTech.filter(t => t.techStack?.hasBreakingChange).length;
|
|
508
|
-
|
|
509
|
-
return {
|
|
510
|
-
complexity: {
|
|
511
|
-
analyzed: tasksWithComplexity.length,
|
|
512
|
-
avgScore,
|
|
513
|
-
distribution: complexityDist,
|
|
514
|
-
topFactors,
|
|
515
|
-
},
|
|
516
|
-
techStack: {
|
|
517
|
-
analyzed: tasksWithTech.length,
|
|
518
|
-
byArea,
|
|
519
|
-
byRisk,
|
|
520
|
-
breakingChanges,
|
|
521
|
-
},
|
|
522
|
-
};
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
export function calculateCriticalPath(tasks: Task[]): CriticalPathResult {
|
|
526
|
-
const activeTasks = tasks.filter(t =>
|
|
527
|
-
t.status !== "completed" && t.status !== "cancelled"
|
|
528
|
-
);
|
|
529
|
-
|
|
530
|
-
if (activeTasks.length === 0) {
|
|
531
|
-
return { criticalPath: [], totalDuration: 0, bottlenecks: [] };
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
const taskMap = new Map(activeTasks.map(t => [t.id, t]));
|
|
535
|
-
|
|
536
|
-
// Count how many tasks each task blocks
|
|
537
|
-
const blocksCount: Record<string, number> = {};
|
|
538
|
-
for (const task of activeTasks) {
|
|
539
|
-
for (const dep of task.dependencies ?? []) {
|
|
540
|
-
if (dep.type === "blocked_by") {
|
|
541
|
-
blocksCount[dep.taskId] = (blocksCount[dep.taskId] ?? 0) + 1;
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
// Find bottlenecks (tasks that block the most other tasks)
|
|
547
|
-
const bottlenecks = Object.entries(blocksCount)
|
|
548
|
-
.map(([id, count]) => ({ task: taskMap.get(id)!, blocksCount: count }))
|
|
549
|
-
.filter(b => b.task)
|
|
550
|
-
.sort((a, b) => b.blocksCount - a.blocksCount)
|
|
551
|
-
.slice(0, 5);
|
|
552
|
-
|
|
553
|
-
// Simple critical path: find longest chain
|
|
554
|
-
const getDepth = (taskId: string, visited = new Set<string>()): number => {
|
|
555
|
-
if (visited.has(taskId)) return 0;
|
|
556
|
-
visited.add(taskId);
|
|
557
|
-
|
|
558
|
-
const task = taskMap.get(taskId);
|
|
559
|
-
if (!task) return 0;
|
|
560
|
-
|
|
561
|
-
const deps = task.dependencies?.filter(d => d.type === "blocked_by") ?? [];
|
|
562
|
-
if (deps.length === 0) return 1;
|
|
563
|
-
|
|
564
|
-
let maxDepth = 0;
|
|
565
|
-
for (const dep of deps) {
|
|
566
|
-
maxDepth = Math.max(maxDepth, getDepth(dep.taskId, new Set(visited)));
|
|
567
|
-
}
|
|
568
|
-
return maxDepth + 1;
|
|
569
|
-
};
|
|
570
|
-
|
|
571
|
-
// Calculate depth for each task and find critical path
|
|
572
|
-
const tasksWithDepth = activeTasks.map(t => ({
|
|
573
|
-
task: t,
|
|
574
|
-
depth: getDepth(t.id),
|
|
575
|
-
}));
|
|
576
|
-
|
|
577
|
-
const maxDepth = Math.max(...tasksWithDepth.map(t => t.depth), 0);
|
|
578
|
-
|
|
579
|
-
// Critical path = tasks with maximum depth
|
|
580
|
-
const criticalPath = tasksWithDepth
|
|
581
|
-
.filter(t => t.depth === maxDepth)
|
|
582
|
-
.map(t => t.task)
|
|
583
|
-
.sort((a, b) => {
|
|
584
|
-
const aBlocks = blocksCount[a.id] ?? 0;
|
|
585
|
-
const bBlocks = blocksCount[b.id] ?? 0;
|
|
586
|
-
return bBlocks - aBlocks;
|
|
587
|
-
});
|
|
588
|
-
|
|
589
|
-
return {
|
|
590
|
-
criticalPath,
|
|
591
|
-
totalDuration: maxDepth * 30, // Assume 30 min per task
|
|
592
|
-
bottlenecks,
|
|
593
|
-
};
|
|
594
|
-
}
|