@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/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
- async function fileExists(path: string): Promise<boolean> {
46
- try {
47
- await readFile(path);
48
- return true;
49
- } catch {
50
- return false;
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
- const projects: Project[] = [];
80
- for (const id of projectIds) {
81
- const project = await readJson<Project>(join(projectsDir, id, "project.json"));
82
- if (project) {
83
- if (includeArchived || project.status !== "archived") {
84
- projects.push(project);
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
- for (const project of projects) {
119
- const tasks = await listTasks(project.id);
120
- allTasks.push(...tasks);
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
+ }