@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/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
- 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
- }
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
- for (const project of projects) {
119
- const tasks = await listTasks(project.id);
120
- allTasks.push(...tasks);
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
+ }