@task-mcp/cli 1.0.4 → 1.0.6
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 +127 -35
- package/package.json +5 -1
- package/src/__tests__/ansi.test.ts +221 -0
- package/src/__tests__/index.test.ts +140 -0
- package/src/__tests__/storage.test.ts +271 -0
- package/src/ansi.ts +1 -14
- package/src/commands/dashboard.ts +371 -40
- package/src/commands/inbox.ts +267 -0
- package/src/commands/list.ts +1 -1
- package/src/index.ts +125 -4
- package/src/interactive.ts +400 -0
- package/src/storage.ts +355 -28
package/src/storage.ts
CHANGED
|
@@ -6,6 +6,19 @@
|
|
|
6
6
|
import { readdir, readFile } from "node:fs/promises";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
|
|
9
|
+
export interface ComplexityAnalysis {
|
|
10
|
+
score?: number;
|
|
11
|
+
factors?: string[];
|
|
12
|
+
rationale?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TechStackAnalysis {
|
|
16
|
+
areas?: string[];
|
|
17
|
+
riskLevel?: "low" | "medium" | "high" | "critical";
|
|
18
|
+
hasBreakingChange?: boolean;
|
|
19
|
+
affectedComponents?: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
9
22
|
export interface Task {
|
|
10
23
|
id: string;
|
|
11
24
|
title: string;
|
|
@@ -13,6 +26,7 @@ export interface Task {
|
|
|
13
26
|
status: "pending" | "in_progress" | "blocked" | "completed" | "cancelled";
|
|
14
27
|
priority: "critical" | "high" | "medium" | "low";
|
|
15
28
|
projectId: string;
|
|
29
|
+
parentId?: string;
|
|
16
30
|
dependencies?: { taskId: string; type: string; reason?: string }[];
|
|
17
31
|
dueDate?: string;
|
|
18
32
|
createdAt: string;
|
|
@@ -20,6 +34,8 @@ export interface Task {
|
|
|
20
34
|
completedAt?: string;
|
|
21
35
|
contexts?: string[];
|
|
22
36
|
tags?: string[];
|
|
37
|
+
complexity?: ComplexityAnalysis;
|
|
38
|
+
techStack?: TechStackAnalysis;
|
|
23
39
|
}
|
|
24
40
|
|
|
25
41
|
export interface Project {
|
|
@@ -38,17 +54,31 @@ export interface TasksFile {
|
|
|
38
54
|
tasks: Task[];
|
|
39
55
|
}
|
|
40
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
|
+
|
|
41
72
|
function getTasksDir(): string {
|
|
42
73
|
return join(process.cwd(), ".tasks");
|
|
43
74
|
}
|
|
44
75
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
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);
|
|
52
82
|
}
|
|
53
83
|
|
|
54
84
|
async function readJson<T>(path: string): Promise<T | null> {
|
|
@@ -71,54 +101,79 @@ async function listDirs(path: string): Promise<string[]> {
|
|
|
71
101
|
|
|
72
102
|
/**
|
|
73
103
|
* List all projects
|
|
104
|
+
* Uses Promise.allSettled for parallel I/O with graceful error handling
|
|
74
105
|
*/
|
|
75
106
|
export async function listProjects(includeArchived = false): Promise<Project[]> {
|
|
76
107
|
const projectsDir = join(getTasksDir(), "projects");
|
|
77
108
|
const projectIds = await listDirs(projectsDir);
|
|
78
109
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
110
|
+
// Use allSettled to handle individual project read failures gracefully
|
|
111
|
+
const results = await Promise.allSettled(
|
|
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
|
+
);
|
|
88
122
|
|
|
89
123
|
return projects.sort(
|
|
90
124
|
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
|
91
125
|
);
|
|
92
126
|
}
|
|
93
127
|
|
|
94
|
-
/**
|
|
95
|
-
* Get a project by ID
|
|
96
|
-
*/
|
|
97
|
-
export async function getProject(projectId: string): Promise<Project | null> {
|
|
98
|
-
const path = join(getTasksDir(), "projects", projectId, "project.json");
|
|
99
|
-
return readJson<Project>(path);
|
|
100
|
-
}
|
|
101
128
|
|
|
102
129
|
/**
|
|
103
130
|
* List tasks for a project
|
|
104
131
|
*/
|
|
105
132
|
export async function listTasks(projectId: string): Promise<Task[]> {
|
|
133
|
+
// Validate project ID to prevent path traversal
|
|
134
|
+
if (!isValidProjectId(projectId)) {
|
|
135
|
+
console.error(`Invalid project ID format: ${projectId}`);
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
106
138
|
const path = join(getTasksDir(), "projects", projectId, "tasks.json");
|
|
107
139
|
const data = await readJson<TasksFile>(path);
|
|
108
140
|
return data?.tasks ?? [];
|
|
109
141
|
}
|
|
110
142
|
|
|
143
|
+
/**
|
|
144
|
+
* List inbox items
|
|
145
|
+
*/
|
|
146
|
+
export async function listInboxItems(status?: "pending" | "promoted" | "discarded"): Promise<InboxItem[]> {
|
|
147
|
+
const path = join(getTasksDir(), "inbox.json");
|
|
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;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Get pending inbox count
|
|
159
|
+
*/
|
|
160
|
+
export async function getInboxCount(): Promise<number> {
|
|
161
|
+
const items = await listInboxItems("pending");
|
|
162
|
+
return items.length;
|
|
163
|
+
}
|
|
164
|
+
|
|
111
165
|
/**
|
|
112
166
|
* List all tasks across all projects
|
|
167
|
+
* Uses Promise.all for parallel I/O instead of sequential reads
|
|
113
168
|
*/
|
|
114
169
|
export async function listAllTasks(): Promise<Task[]> {
|
|
115
170
|
const projects = await listProjects(true);
|
|
116
|
-
const allTasks: Task[] = [];
|
|
117
171
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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();
|
|
122
177
|
|
|
123
178
|
return allTasks.sort(
|
|
124
179
|
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
|
@@ -265,3 +320,275 @@ export function suggestNextTask(tasks: Task[]): Task | null {
|
|
|
265
320
|
|
|
266
321
|
return actionable[0] ?? null;
|
|
267
322
|
}
|
|
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
|
+
}
|