@task-mcp/cli 1.0.4 → 1.0.5
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 +1 -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 -1
- package/src/commands/dashboard.ts +371 -40
- package/src/commands/inbox.ts +269 -0
- package/src/commands/list.ts +1 -1
- package/src/index.ts +124 -4
- package/src/interactive.ts +400 -0
- package/src/storage.ts +367 -14
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,10 +54,41 @@ 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
|
|
|
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
|
+
/**
|
|
85
|
+
* Validate a task ID format.
|
|
86
|
+
* Valid format: task_[alphanumeric only]
|
|
87
|
+
*/
|
|
88
|
+
function isValidTaskId(id: string): boolean {
|
|
89
|
+
return /^task_[a-z0-9]+$/.test(id);
|
|
90
|
+
}
|
|
91
|
+
|
|
45
92
|
async function fileExists(path: string): Promise<boolean> {
|
|
46
93
|
try {
|
|
47
94
|
await readFile(path);
|
|
@@ -71,20 +118,21 @@ async function listDirs(path: string): Promise<string[]> {
|
|
|
71
118
|
|
|
72
119
|
/**
|
|
73
120
|
* List all projects
|
|
121
|
+
* Uses Promise.all for parallel I/O instead of sequential reads
|
|
74
122
|
*/
|
|
75
123
|
export async function listProjects(includeArchived = false): Promise<Project[]> {
|
|
76
124
|
const projectsDir = join(getTasksDir(), "projects");
|
|
77
125
|
const projectIds = await listDirs(projectsDir);
|
|
78
126
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
127
|
+
// Parallel reads instead of sequential for better performance
|
|
128
|
+
const projectResults = await Promise.all(
|
|
129
|
+
projectIds.map((id) => readJson<Project>(join(projectsDir, id, "project.json")))
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const projects = projectResults.filter(
|
|
133
|
+
(project): project is Project =>
|
|
134
|
+
project !== null && (includeArchived || project.status !== "archived")
|
|
135
|
+
);
|
|
88
136
|
|
|
89
137
|
return projects.sort(
|
|
90
138
|
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
|
@@ -95,6 +143,11 @@ export async function listProjects(includeArchived = false): Promise<Project[]>
|
|
|
95
143
|
* Get a project by ID
|
|
96
144
|
*/
|
|
97
145
|
export async function getProject(projectId: string): Promise<Project | null> {
|
|
146
|
+
// Validate project ID to prevent path traversal
|
|
147
|
+
if (!isValidProjectId(projectId)) {
|
|
148
|
+
console.error(`Invalid project ID format: ${projectId}`);
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
98
151
|
const path = join(getTasksDir(), "projects", projectId, "project.json");
|
|
99
152
|
return readJson<Project>(path);
|
|
100
153
|
}
|
|
@@ -103,22 +156,50 @@ export async function getProject(projectId: string): Promise<Project | null> {
|
|
|
103
156
|
* List tasks for a project
|
|
104
157
|
*/
|
|
105
158
|
export async function listTasks(projectId: string): Promise<Task[]> {
|
|
159
|
+
// Validate project ID to prevent path traversal
|
|
160
|
+
if (!isValidProjectId(projectId)) {
|
|
161
|
+
console.error(`Invalid project ID format: ${projectId}`);
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
106
164
|
const path = join(getTasksDir(), "projects", projectId, "tasks.json");
|
|
107
165
|
const data = await readJson<TasksFile>(path);
|
|
108
166
|
return data?.tasks ?? [];
|
|
109
167
|
}
|
|
110
168
|
|
|
169
|
+
/**
|
|
170
|
+
* List inbox items
|
|
171
|
+
*/
|
|
172
|
+
export async function listInboxItems(status?: "pending" | "promoted" | "discarded"): Promise<InboxItem[]> {
|
|
173
|
+
const path = join(getTasksDir(), "inbox.json");
|
|
174
|
+
const data = await readJson<InboxFile>(path);
|
|
175
|
+
const items = data?.items ?? [];
|
|
176
|
+
|
|
177
|
+
if (status) {
|
|
178
|
+
return items.filter(i => i.status === status);
|
|
179
|
+
}
|
|
180
|
+
return items;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Get pending inbox count
|
|
185
|
+
*/
|
|
186
|
+
export async function getInboxCount(): Promise<number> {
|
|
187
|
+
const items = await listInboxItems("pending");
|
|
188
|
+
return items.length;
|
|
189
|
+
}
|
|
190
|
+
|
|
111
191
|
/**
|
|
112
192
|
* List all tasks across all projects
|
|
193
|
+
* Uses Promise.all for parallel I/O instead of sequential reads
|
|
113
194
|
*/
|
|
114
195
|
export async function listAllTasks(): Promise<Task[]> {
|
|
115
196
|
const projects = await listProjects(true);
|
|
116
|
-
const allTasks: Task[] = [];
|
|
117
197
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
198
|
+
// Parallel reads instead of sequential for better performance
|
|
199
|
+
const taskArrays = await Promise.all(
|
|
200
|
+
projects.map((project) => listTasks(project.id))
|
|
201
|
+
);
|
|
202
|
+
const allTasks = taskArrays.flat();
|
|
122
203
|
|
|
123
204
|
return allTasks.sort(
|
|
124
205
|
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
|
@@ -265,3 +346,275 @@ export function suggestNextTask(tasks: Task[]): Task | null {
|
|
|
265
346
|
|
|
266
347
|
return actionable[0] ?? null;
|
|
267
348
|
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Check if date is today
|
|
352
|
+
*/
|
|
353
|
+
function isToday(dateStr: string): boolean {
|
|
354
|
+
const date = new Date(dateStr);
|
|
355
|
+
const today = new Date();
|
|
356
|
+
return date.toDateString() === today.toDateString();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Check if date is overdue
|
|
361
|
+
*/
|
|
362
|
+
function isOverdue(dateStr: string): boolean {
|
|
363
|
+
const date = new Date(dateStr);
|
|
364
|
+
const today = new Date();
|
|
365
|
+
today.setHours(0, 0, 0, 0);
|
|
366
|
+
return date < today;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Check if date is within N days
|
|
371
|
+
*/
|
|
372
|
+
function isWithinDays(dateStr: string, days: number): boolean {
|
|
373
|
+
const date = new Date(dateStr);
|
|
374
|
+
const future = new Date();
|
|
375
|
+
future.setDate(future.getDate() + days);
|
|
376
|
+
return date <= future;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Get tasks due today or overdue
|
|
381
|
+
*/
|
|
382
|
+
export function getTodayTasks(tasks: Task[]): Task[] {
|
|
383
|
+
return tasks.filter(t =>
|
|
384
|
+
t.status !== "completed" &&
|
|
385
|
+
t.status !== "cancelled" &&
|
|
386
|
+
t.dueDate &&
|
|
387
|
+
(isToday(t.dueDate) || isOverdue(t.dueDate))
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Get tasks due this week
|
|
393
|
+
*/
|
|
394
|
+
export function getThisWeekTasks(tasks: Task[]): Task[] {
|
|
395
|
+
return tasks.filter(t =>
|
|
396
|
+
t.status !== "completed" &&
|
|
397
|
+
t.status !== "cancelled" &&
|
|
398
|
+
t.dueDate &&
|
|
399
|
+
isWithinDays(t.dueDate, 7)
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Get overdue tasks
|
|
405
|
+
*/
|
|
406
|
+
export function getOverdueTasks(tasks: Task[]): Task[] {
|
|
407
|
+
return tasks.filter(t =>
|
|
408
|
+
t.status !== "completed" &&
|
|
409
|
+
t.status !== "cancelled" &&
|
|
410
|
+
t.dueDate &&
|
|
411
|
+
isOverdue(t.dueDate)
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Task tree node for hierarchy
|
|
417
|
+
*/
|
|
418
|
+
export interface TaskTreeNode {
|
|
419
|
+
task: Task;
|
|
420
|
+
children: TaskTreeNode[];
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Build task hierarchy tree
|
|
425
|
+
*/
|
|
426
|
+
export function buildTaskTree(tasks: Task[]): TaskTreeNode[] {
|
|
427
|
+
const taskMap = new Map(tasks.map(t => [t.id, t]));
|
|
428
|
+
const childrenMap = new Map<string, Task[]>();
|
|
429
|
+
|
|
430
|
+
// Build parent -> children map
|
|
431
|
+
for (const task of tasks) {
|
|
432
|
+
if (task.parentId) {
|
|
433
|
+
const children = childrenMap.get(task.parentId) ?? [];
|
|
434
|
+
children.push(task);
|
|
435
|
+
childrenMap.set(task.parentId, children);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function buildNode(task: Task): TaskTreeNode {
|
|
440
|
+
const children = childrenMap.get(task.id) ?? [];
|
|
441
|
+
return {
|
|
442
|
+
task,
|
|
443
|
+
children: children.map(buildNode),
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Return all root tasks (no parent)
|
|
448
|
+
const rootTasks = tasks.filter(t => !t.parentId);
|
|
449
|
+
return rootTasks.map(buildNode);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Count total nodes in tree
|
|
454
|
+
*/
|
|
455
|
+
export function countTreeNodes(nodes: TaskTreeNode[]): number {
|
|
456
|
+
return nodes.reduce((sum, n) => sum + 1 + countTreeNodes(n.children), 0);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Simple Critical Path calculation
|
|
461
|
+
* Returns tasks that are on the critical path based on dependencies
|
|
462
|
+
*/
|
|
463
|
+
export interface CriticalPathResult {
|
|
464
|
+
criticalPath: Task[];
|
|
465
|
+
totalDuration: number;
|
|
466
|
+
bottlenecks: { task: Task; blocksCount: number }[];
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Calculate complexity and tech stack statistics
|
|
471
|
+
*/
|
|
472
|
+
export interface AnalysisStats {
|
|
473
|
+
complexity: {
|
|
474
|
+
analyzed: number;
|
|
475
|
+
avgScore: number;
|
|
476
|
+
distribution: { low: number; medium: number; high: number };
|
|
477
|
+
topFactors: { factor: string; count: number }[];
|
|
478
|
+
};
|
|
479
|
+
techStack: {
|
|
480
|
+
analyzed: number;
|
|
481
|
+
byArea: Record<string, number>;
|
|
482
|
+
byRisk: { low: number; medium: number; high: number; critical: number };
|
|
483
|
+
breakingChanges: number;
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export function calculateAnalysisStats(tasks: Task[]): AnalysisStats {
|
|
488
|
+
const activeTasks = tasks.filter(t =>
|
|
489
|
+
t.status !== "completed" && t.status !== "cancelled"
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
// Complexity stats
|
|
493
|
+
const tasksWithComplexity = activeTasks.filter(t => t.complexity?.score);
|
|
494
|
+
const scores = tasksWithComplexity.map(t => t.complexity!.score!);
|
|
495
|
+
const avgScore = scores.length > 0
|
|
496
|
+
? Math.round((scores.reduce((a, b) => a + b, 0) / scores.length) * 10) / 10
|
|
497
|
+
: 0;
|
|
498
|
+
|
|
499
|
+
const complexityDist = { low: 0, medium: 0, high: 0 };
|
|
500
|
+
for (const score of scores) {
|
|
501
|
+
if (score <= 3) complexityDist.low++;
|
|
502
|
+
else if (score <= 6) complexityDist.medium++;
|
|
503
|
+
else complexityDist.high++;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Count factors
|
|
507
|
+
const factorCounts: Record<string, number> = {};
|
|
508
|
+
for (const t of tasksWithComplexity) {
|
|
509
|
+
for (const f of t.complexity?.factors ?? []) {
|
|
510
|
+
factorCounts[f] = (factorCounts[f] ?? 0) + 1;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const topFactors = Object.entries(factorCounts)
|
|
514
|
+
.map(([factor, count]) => ({ factor, count }))
|
|
515
|
+
.sort((a, b) => b.count - a.count)
|
|
516
|
+
.slice(0, 5);
|
|
517
|
+
|
|
518
|
+
// Tech stack stats
|
|
519
|
+
const tasksWithTech = activeTasks.filter(t => t.techStack?.areas?.length);
|
|
520
|
+
const byArea: Record<string, number> = {};
|
|
521
|
+
for (const t of tasksWithTech) {
|
|
522
|
+
for (const area of t.techStack?.areas ?? []) {
|
|
523
|
+
byArea[area] = (byArea[area] ?? 0) + 1;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const byRisk = { low: 0, medium: 0, high: 0, critical: 0 };
|
|
528
|
+
for (const t of tasksWithTech) {
|
|
529
|
+
const risk = t.techStack?.riskLevel ?? "medium";
|
|
530
|
+
byRisk[risk]++;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const breakingChanges = tasksWithTech.filter(t => t.techStack?.hasBreakingChange).length;
|
|
534
|
+
|
|
535
|
+
return {
|
|
536
|
+
complexity: {
|
|
537
|
+
analyzed: tasksWithComplexity.length,
|
|
538
|
+
avgScore,
|
|
539
|
+
distribution: complexityDist,
|
|
540
|
+
topFactors,
|
|
541
|
+
},
|
|
542
|
+
techStack: {
|
|
543
|
+
analyzed: tasksWithTech.length,
|
|
544
|
+
byArea,
|
|
545
|
+
byRisk,
|
|
546
|
+
breakingChanges,
|
|
547
|
+
},
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export function calculateCriticalPath(tasks: Task[]): CriticalPathResult {
|
|
552
|
+
const activeTasks = tasks.filter(t =>
|
|
553
|
+
t.status !== "completed" && t.status !== "cancelled"
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
if (activeTasks.length === 0) {
|
|
557
|
+
return { criticalPath: [], totalDuration: 0, bottlenecks: [] };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const taskMap = new Map(activeTasks.map(t => [t.id, t]));
|
|
561
|
+
|
|
562
|
+
// Count how many tasks each task blocks
|
|
563
|
+
const blocksCount: Record<string, number> = {};
|
|
564
|
+
for (const task of activeTasks) {
|
|
565
|
+
for (const dep of task.dependencies ?? []) {
|
|
566
|
+
if (dep.type === "blocked_by") {
|
|
567
|
+
blocksCount[dep.taskId] = (blocksCount[dep.taskId] ?? 0) + 1;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Find bottlenecks (tasks that block the most other tasks)
|
|
573
|
+
const bottlenecks = Object.entries(blocksCount)
|
|
574
|
+
.map(([id, count]) => ({ task: taskMap.get(id)!, blocksCount: count }))
|
|
575
|
+
.filter(b => b.task)
|
|
576
|
+
.sort((a, b) => b.blocksCount - a.blocksCount)
|
|
577
|
+
.slice(0, 5);
|
|
578
|
+
|
|
579
|
+
// Simple critical path: find longest chain
|
|
580
|
+
const getDepth = (taskId: string, visited = new Set<string>()): number => {
|
|
581
|
+
if (visited.has(taskId)) return 0;
|
|
582
|
+
visited.add(taskId);
|
|
583
|
+
|
|
584
|
+
const task = taskMap.get(taskId);
|
|
585
|
+
if (!task) return 0;
|
|
586
|
+
|
|
587
|
+
const deps = task.dependencies?.filter(d => d.type === "blocked_by") ?? [];
|
|
588
|
+
if (deps.length === 0) return 1;
|
|
589
|
+
|
|
590
|
+
let maxDepth = 0;
|
|
591
|
+
for (const dep of deps) {
|
|
592
|
+
maxDepth = Math.max(maxDepth, getDepth(dep.taskId, new Set(visited)));
|
|
593
|
+
}
|
|
594
|
+
return maxDepth + 1;
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
// Calculate depth for each task and find critical path
|
|
598
|
+
const tasksWithDepth = activeTasks.map(t => ({
|
|
599
|
+
task: t,
|
|
600
|
+
depth: getDepth(t.id),
|
|
601
|
+
}));
|
|
602
|
+
|
|
603
|
+
const maxDepth = Math.max(...tasksWithDepth.map(t => t.depth), 0);
|
|
604
|
+
|
|
605
|
+
// Critical path = tasks with maximum depth
|
|
606
|
+
const criticalPath = tasksWithDepth
|
|
607
|
+
.filter(t => t.depth === maxDepth)
|
|
608
|
+
.map(t => t.task)
|
|
609
|
+
.sort((a, b) => {
|
|
610
|
+
const aBlocks = blocksCount[a.id] ?? 0;
|
|
611
|
+
const bBlocks = blocksCount[b.id] ?? 0;
|
|
612
|
+
return bBlocks - aBlocks;
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
return {
|
|
616
|
+
criticalPath,
|
|
617
|
+
totalDuration: maxDepth * 30, // Assume 30 min per task
|
|
618
|
+
bottlenecks,
|
|
619
|
+
};
|
|
620
|
+
}
|