@kmgeon/taskflow 0.1.3

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.
Files changed (158) hide show
  1. package/README.md +374 -0
  2. package/bin/task-mcp.mjs +19 -0
  3. package/bin/task.mjs +19 -0
  4. package/docs/clean-code.md +29 -0
  5. package/docs/git.md +36 -0
  6. package/docs/guideline.md +25 -0
  7. package/docs/security.md +32 -0
  8. package/docs/step-by-step.md +29 -0
  9. package/docs/superpowers/specs/2026-03-21-cli-advisor-design.md +383 -0
  10. package/docs/superpowers/specs/2026-03-21-init-redesign-design.md +429 -0
  11. package/docs/superpowers/specs/2026-03-21-skill-architecture-design.md +362 -0
  12. package/docs/superpowers/specs/2026-03-23-t-create-task-run-design.md +40 -0
  13. package/docs/superpowers/specs/2026-03-23-task-run-design.md +44 -0
  14. package/docs/tdd.md +41 -0
  15. package/package.json +114 -0
  16. package/src/app/(protected)/dashboard/page.tsx +7 -0
  17. package/src/app/(protected)/layout.tsx +10 -0
  18. package/src/app/api/[[...hono]]/route.ts +13 -0
  19. package/src/app/example/page.tsx +11 -0
  20. package/src/app/favicon.ico +0 -0
  21. package/src/app/globals.css +168 -0
  22. package/src/app/layout.tsx +35 -0
  23. package/src/app/page.tsx +5 -0
  24. package/src/app/providers.tsx +57 -0
  25. package/src/backend/config/index.ts +36 -0
  26. package/src/backend/hono/app.ts +32 -0
  27. package/src/backend/hono/context.ts +38 -0
  28. package/src/backend/http/response.ts +64 -0
  29. package/src/backend/middleware/context.ts +23 -0
  30. package/src/backend/middleware/error.ts +31 -0
  31. package/src/backend/middleware/supabase.ts +23 -0
  32. package/src/backend/supabase/client.ts +17 -0
  33. package/src/cli/commands/__tests__/task-commands.test.ts +170 -0
  34. package/src/cli/commands/advisor.ts +45 -0
  35. package/src/cli/commands/ask.ts +50 -0
  36. package/src/cli/commands/board.ts +72 -0
  37. package/src/cli/commands/init.ts +184 -0
  38. package/src/cli/commands/list.ts +138 -0
  39. package/src/cli/commands/run.ts +143 -0
  40. package/src/cli/commands/set-status.ts +50 -0
  41. package/src/cli/commands/show.ts +28 -0
  42. package/src/cli/commands/tree.ts +72 -0
  43. package/src/cli/index.ts +38 -0
  44. package/src/cli/lib/__tests__/formatter.test.ts +123 -0
  45. package/src/cli/lib/error-boundary.test.ts +135 -0
  46. package/src/cli/lib/error-boundary.ts +70 -0
  47. package/src/cli/lib/formatter.ts +764 -0
  48. package/src/cli/lib/trd.ts +33 -0
  49. package/src/cli/lib/validate.test.ts +89 -0
  50. package/src/cli/lib/validate.ts +43 -0
  51. package/src/cli/prompts/task-run.md +25 -0
  52. package/src/components/layout/AppLayout.tsx +15 -0
  53. package/src/components/layout/Sidebar.tsx +124 -0
  54. package/src/components/ui/accordion.tsx +58 -0
  55. package/src/components/ui/avatar.tsx +50 -0
  56. package/src/components/ui/badge.tsx +36 -0
  57. package/src/components/ui/button.tsx +56 -0
  58. package/src/components/ui/card.tsx +79 -0
  59. package/src/components/ui/checkbox.tsx +30 -0
  60. package/src/components/ui/dialog.tsx +122 -0
  61. package/src/components/ui/dropdown-menu.tsx +200 -0
  62. package/src/components/ui/file-upload.tsx +50 -0
  63. package/src/components/ui/form.tsx +179 -0
  64. package/src/components/ui/input.tsx +25 -0
  65. package/src/components/ui/label.tsx +26 -0
  66. package/src/components/ui/scroll-area.tsx +48 -0
  67. package/src/components/ui/select.tsx +160 -0
  68. package/src/components/ui/separator.tsx +31 -0
  69. package/src/components/ui/sheet.tsx +140 -0
  70. package/src/components/ui/textarea.tsx +22 -0
  71. package/src/components/ui/toast.tsx +129 -0
  72. package/src/components/ui/toaster.tsx +35 -0
  73. package/src/core/ai/claude-client.ts +79 -0
  74. package/src/core/claude-runner/flag-builder.ts +57 -0
  75. package/src/core/claude-runner/index.ts +2 -0
  76. package/src/core/claude-runner/spawner.ts +86 -0
  77. package/src/core/prd/__tests__/auto-analyzer.test.ts +35 -0
  78. package/src/core/prd/__tests__/generator.test.ts +26 -0
  79. package/src/core/prd/__tests__/scanner.test.ts +35 -0
  80. package/src/core/prd/auto-analyzer.ts +9 -0
  81. package/src/core/prd/generator.ts +8 -0
  82. package/src/core/prd/scanner.ts +117 -0
  83. package/src/core/project/__tests__/claude-setup.test.ts +133 -0
  84. package/src/core/project/__tests__/config.test.ts +30 -0
  85. package/src/core/project/__tests__/init.test.ts +37 -0
  86. package/src/core/project/__tests__/skill-setup.test.ts +62 -0
  87. package/src/core/project/claude-setup.ts +224 -0
  88. package/src/core/project/config.ts +34 -0
  89. package/src/core/project/docs-setup.ts +26 -0
  90. package/src/core/project/docs-templates.ts +205 -0
  91. package/src/core/project/init.ts +40 -0
  92. package/src/core/project/skill-setup.ts +32 -0
  93. package/src/core/project/skill-templates.ts +277 -0
  94. package/src/core/task/index.ts +16 -0
  95. package/src/core/types.ts +58 -0
  96. package/src/features/example/backend/error.ts +9 -0
  97. package/src/features/example/backend/route.ts +52 -0
  98. package/src/features/example/backend/schema.ts +25 -0
  99. package/src/features/example/backend/service.ts +73 -0
  100. package/src/features/example/components/example-status.test.tsx +97 -0
  101. package/src/features/example/components/example-status.tsx +160 -0
  102. package/src/features/example/hooks/useExampleQuery.ts +23 -0
  103. package/src/features/example/lib/dto.test.ts +57 -0
  104. package/src/features/example/lib/dto.ts +5 -0
  105. package/src/features/kanban/backend/__tests__/sse-broadcaster.test.ts +137 -0
  106. package/src/features/kanban/backend/__tests__/sse-event-format.test.ts +55 -0
  107. package/src/features/kanban/backend/route.ts +55 -0
  108. package/src/features/kanban/backend/sse-broadcaster.ts +142 -0
  109. package/src/features/kanban/backend/sse-route.ts +43 -0
  110. package/src/features/kanban/components/KanbanBoard.tsx +105 -0
  111. package/src/features/kanban/components/KanbanColumn.tsx +51 -0
  112. package/src/features/kanban/components/KanbanError.tsx +29 -0
  113. package/src/features/kanban/components/KanbanSkeleton.tsx +46 -0
  114. package/src/features/kanban/components/ProgressCard.tsx +42 -0
  115. package/src/features/kanban/components/TaskCard.tsx +76 -0
  116. package/src/features/kanban/components/__tests__/kanban-components.test.tsx +86 -0
  117. package/src/features/kanban/hooks/useTaskSse.ts +66 -0
  118. package/src/features/kanban/hooks/useTasksQuery.ts +52 -0
  119. package/src/features/kanban/lib/__tests__/kanban-utils.test.ts +97 -0
  120. package/src/features/kanban/lib/kanban-utils.ts +37 -0
  121. package/src/features/taskflow/constants.ts +54 -0
  122. package/src/features/taskflow/index.ts +27 -0
  123. package/src/features/taskflow/lib/__tests__/filter.test.ts +89 -0
  124. package/src/features/taskflow/lib/__tests__/graph.test.ts +247 -0
  125. package/src/features/taskflow/lib/__tests__/repository.test.ts +233 -0
  126. package/src/features/taskflow/lib/__tests__/serializer.test.ts +98 -0
  127. package/src/features/taskflow/lib/advisor/__tests__/advisor-integration.test.ts +98 -0
  128. package/src/features/taskflow/lib/advisor/ai-advisor.test.ts +40 -0
  129. package/src/features/taskflow/lib/advisor/ai-advisor.ts +20 -0
  130. package/src/features/taskflow/lib/advisor/context-builder.test.ts +73 -0
  131. package/src/features/taskflow/lib/advisor/context-builder.ts +151 -0
  132. package/src/features/taskflow/lib/advisor/db.test.ts +106 -0
  133. package/src/features/taskflow/lib/advisor/db.ts +185 -0
  134. package/src/features/taskflow/lib/advisor/local-summary.test.ts +53 -0
  135. package/src/features/taskflow/lib/advisor/local-summary.ts +72 -0
  136. package/src/features/taskflow/lib/advisor/prompts.ts +86 -0
  137. package/src/features/taskflow/lib/filter.ts +54 -0
  138. package/src/features/taskflow/lib/fs-utils.ts +50 -0
  139. package/src/features/taskflow/lib/graph.ts +148 -0
  140. package/src/features/taskflow/lib/index-builder.ts +42 -0
  141. package/src/features/taskflow/lib/repository.ts +168 -0
  142. package/src/features/taskflow/lib/serializer.ts +62 -0
  143. package/src/features/taskflow/lib/watcher.ts +40 -0
  144. package/src/features/taskflow/types.ts +71 -0
  145. package/src/hooks/use-toast.ts +194 -0
  146. package/src/lib/remote/api-client.ts +40 -0
  147. package/src/lib/supabase/client.ts +8 -0
  148. package/src/lib/supabase/server.ts +46 -0
  149. package/src/lib/supabase/types.ts +3 -0
  150. package/src/lib/utils.ts +6 -0
  151. package/src/mcp/index.ts +7 -0
  152. package/src/mcp/server.ts +21 -0
  153. package/src/mcp/tools/brainstorm.ts +48 -0
  154. package/src/mcp/tools/prd.ts +71 -0
  155. package/src/mcp/tools/project.ts +39 -0
  156. package/src/mcp/tools/task-status.ts +40 -0
  157. package/src/mcp/tools/task.ts +82 -0
  158. package/src/mcp/util.ts +6 -0
@@ -0,0 +1,37 @@
1
+ import type { Task, TaskStatus } from "@/features/taskflow/types";
2
+ import { TASK_STATUSES } from "@/features/taskflow/types";
3
+
4
+ export type ColumnMap = Record<TaskStatus, Task[]>;
5
+
6
+ export function groupByStatus(tasks: Task[]): ColumnMap {
7
+ const columns: ColumnMap = {
8
+ Todo: [],
9
+ InProgress: [],
10
+ Blocked: [],
11
+ Done: [],
12
+ };
13
+
14
+ for (const task of tasks) {
15
+ columns[task.status].push(task);
16
+ }
17
+
18
+ return columns;
19
+ }
20
+
21
+ export function computeProgress(tasks: Task[]): number {
22
+ if (tasks.length === 0) return 0;
23
+ const done = tasks.filter((t) => t.status === "Done").length;
24
+ return Math.round((done / tasks.length) * 100);
25
+ }
26
+
27
+ export const STATUS_CONFIG: Record<
28
+ TaskStatus,
29
+ { label: string; dotColor: string; bgColor: string }
30
+ > = {
31
+ Todo: { label: "Todo", dotColor: "bg-slate-400", bgColor: "bg-slate-400/10" },
32
+ InProgress: { label: "In Progress", dotColor: "bg-blue-500", bgColor: "bg-blue-500/10" },
33
+ Blocked: { label: "Blocked", dotColor: "bg-red-500", bgColor: "bg-red-500/10" },
34
+ Done: { label: "Done", dotColor: "bg-green-500", bgColor: "bg-green-500/10" },
35
+ };
36
+
37
+ export const COLUMN_ORDER: TaskStatus[] = [...TASK_STATUSES];
@@ -0,0 +1,54 @@
1
+ import path from "node:path";
2
+
3
+ export const TASKFLOW_DIR = ".taskflow";
4
+ export const TASKS_DIR = "tasks";
5
+ export const INDEX_DIR = "index";
6
+ export const LOGS_DIR = "logs";
7
+ export const CACHE_DIR = "cache";
8
+ export const INDEX_FILE = "TASKS.md";
9
+ export const TASK_FILE_PREFIX = "task-";
10
+ export const TASK_FILE_EXT = ".md";
11
+
12
+ export function getTaskflowRoot(projectRoot: string): string {
13
+ return path.join(projectRoot, TASKFLOW_DIR);
14
+ }
15
+
16
+ export function getTasksDir(projectRoot: string): string {
17
+ return path.join(projectRoot, TASKFLOW_DIR, TASKS_DIR);
18
+ }
19
+
20
+ export function getIndexDir(projectRoot: string): string {
21
+ return path.join(projectRoot, TASKFLOW_DIR, INDEX_DIR);
22
+ }
23
+
24
+ export function getLogsDir(projectRoot: string): string {
25
+ return path.join(projectRoot, TASKFLOW_DIR, LOGS_DIR);
26
+ }
27
+
28
+ export function getCacheDir(projectRoot: string): string {
29
+ return path.join(projectRoot, TASKFLOW_DIR, CACHE_DIR);
30
+ }
31
+
32
+ export function getIndexFilePath(projectRoot: string): string {
33
+ return path.join(projectRoot, TASKFLOW_DIR, INDEX_DIR, INDEX_FILE);
34
+ }
35
+
36
+ export function getTaskFilePath(projectRoot: string, taskId: string): string {
37
+ return path.join(
38
+ projectRoot,
39
+ TASKFLOW_DIR,
40
+ TASKS_DIR,
41
+ `${TASK_FILE_PREFIX}${taskId}${TASK_FILE_EXT}`,
42
+ );
43
+ }
44
+
45
+ export function extractTaskId(filename: string): string | null {
46
+ const match = filename.match(/^task-(.+)\.md$/);
47
+ return match ? match[1] : null;
48
+ }
49
+
50
+ export const ADVISOR_DB_FILE = "advisor.db";
51
+
52
+ export function getAdvisorDbPath(projectRoot: string): string {
53
+ return path.join(getTaskflowRoot(projectRoot), ADVISOR_DB_FILE);
54
+ }
@@ -0,0 +1,27 @@
1
+ export type {
2
+ Task,
3
+ TaskStatus,
4
+ TaskCreateInput,
5
+ TaskUpdateInput,
6
+ TaskFilter,
7
+ TaskSortKey,
8
+ TaskSortOrder,
9
+ } from "./types";
10
+ export { TASK_STATUSES } from "./types";
11
+
12
+ export {
13
+ ensureRepo,
14
+ readTask,
15
+ listTasks,
16
+ createTask,
17
+ updateTask,
18
+ deleteTask,
19
+ searchTasks,
20
+ } from "./lib/repository";
21
+
22
+ export { rebuildIndex } from "./lib/index-builder";
23
+ export { filterTasks, sortTasks } from "./lib/filter";
24
+ export { createTaskWatcher } from "./lib/watcher";
25
+ export type { TaskFileEvent, WatchEvent } from "./lib/watcher";
26
+ export { detectCycles, computeReadySet, scoreTask, recommend } from "./lib/graph";
27
+ export type { ReadyResult, BlockedTask, Recommendation, RecommendOptions } from "./lib/graph";
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { filterTasks, sortTasks } from "../filter";
3
+ import type { Task } from "../../types";
4
+
5
+ const now = "2026-03-21T00:00:00.000Z";
6
+
7
+ function makeTask(overrides: Partial<Task>): Task {
8
+ return {
9
+ id: "001",
10
+ title: "Test",
11
+ status: "Todo",
12
+ priority: 0,
13
+ dependencies: [],
14
+ createdAt: now,
15
+ updatedAt: now,
16
+ description: "",
17
+ ...overrides,
18
+ };
19
+ }
20
+
21
+ const TASKS: Task[] = [
22
+ makeTask({ id: "001", title: "Alpha", status: "Todo", priority: 3 }),
23
+ makeTask({ id: "002", title: "Bravo", status: "InProgress", priority: 5 }),
24
+ makeTask({ id: "003", title: "Charlie", status: "Done", priority: 1 }),
25
+ makeTask({ id: "004", title: "Delta", status: "Blocked", priority: 2, parentId: "001" }),
26
+ ];
27
+
28
+ describe("filterTasks", () => {
29
+ it("should filter by single status", () => {
30
+ const result = filterTasks(TASKS, { status: "Todo" });
31
+ expect(result).toHaveLength(1);
32
+ expect(result[0].id).toBe("001");
33
+ });
34
+
35
+ it("should filter by multiple statuses", () => {
36
+ const result = filterTasks(TASKS, { status: ["Todo", "InProgress"] });
37
+ expect(result).toHaveLength(2);
38
+ });
39
+
40
+ it("should filter by priority", () => {
41
+ const result = filterTasks(TASKS, { priority: 5 });
42
+ expect(result).toHaveLength(1);
43
+ expect(result[0].id).toBe("002");
44
+ });
45
+
46
+ it("should filter by parentId", () => {
47
+ const result = filterTasks(TASKS, { parentId: "001" });
48
+ expect(result).toHaveLength(1);
49
+ expect(result[0].id).toBe("004");
50
+ });
51
+
52
+ it("should combine filters", () => {
53
+ const result = filterTasks(TASKS, { status: "Blocked", parentId: "001" });
54
+ expect(result).toHaveLength(1);
55
+ expect(result[0].id).toBe("004");
56
+ });
57
+
58
+ it("should return all tasks with empty filter", () => {
59
+ const result = filterTasks(TASKS, {});
60
+ expect(result).toHaveLength(4);
61
+ });
62
+ });
63
+
64
+ describe("sortTasks", () => {
65
+ it("should sort by priority ascending", () => {
66
+ const result = sortTasks(TASKS, "priority", "asc");
67
+ expect(result.map((t) => t.priority)).toEqual([1, 2, 3, 5]);
68
+ });
69
+
70
+ it("should sort by priority descending", () => {
71
+ const result = sortTasks(TASKS, "priority", "desc");
72
+ expect(result.map((t) => t.priority)).toEqual([5, 3, 2, 1]);
73
+ });
74
+
75
+ it("should sort by title ascending", () => {
76
+ const result = sortTasks(TASKS, "title", "asc");
77
+ expect(result.map((t) => t.title)).toEqual(["Alpha", "Bravo", "Charlie", "Delta"]);
78
+ });
79
+
80
+ it("should sort by status order", () => {
81
+ const result = sortTasks(TASKS, "status", "asc");
82
+ expect(result.map((t) => t.status)).toEqual(["Todo", "InProgress", "Blocked", "Done"]);
83
+ });
84
+
85
+ it("should default to priority desc", () => {
86
+ const result = sortTasks(TASKS);
87
+ expect(result[0].priority).toBe(5);
88
+ });
89
+ });
@@ -0,0 +1,247 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { detectCycles, computeReadySet, scoreTask, recommend } from "../graph";
3
+ import type { Task } from "../../types";
4
+
5
+ const now = new Date().toISOString();
6
+ const old = new Date(Date.now() - 7 * 86_400_000).toISOString();
7
+
8
+ function t(overrides: Partial<Task>): Task {
9
+ return {
10
+ id: "001",
11
+ title: "Task",
12
+ status: "Todo",
13
+ priority: 5,
14
+ dependencies: [],
15
+ createdAt: now,
16
+ updatedAt: now,
17
+ description: "",
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ // ── detectCycles ──
23
+
24
+ describe("detectCycles", () => {
25
+ it("should return empty for acyclic graph", () => {
26
+ const tasks = [
27
+ t({ id: "1", dependencies: [] }),
28
+ t({ id: "2", dependencies: ["1"] }),
29
+ t({ id: "3", dependencies: ["2"] }),
30
+ ];
31
+ expect(detectCycles(tasks)).toHaveLength(0);
32
+ });
33
+
34
+ it("should detect simple cycle", () => {
35
+ const tasks = [
36
+ t({ id: "1", dependencies: ["2"] }),
37
+ t({ id: "2", dependencies: ["1"] }),
38
+ ];
39
+ const cycles = detectCycles(tasks);
40
+ expect(cycles.length).toBeGreaterThan(0);
41
+ });
42
+
43
+ it("should detect longer cycle", () => {
44
+ const tasks = [
45
+ t({ id: "1", dependencies: ["3"] }),
46
+ t({ id: "2", dependencies: ["1"] }),
47
+ t({ id: "3", dependencies: ["2"] }),
48
+ ];
49
+ const cycles = detectCycles(tasks);
50
+ expect(cycles.length).toBeGreaterThan(0);
51
+ });
52
+
53
+ it("should handle self-cycle", () => {
54
+ const tasks = [t({ id: "1", dependencies: ["1"] })];
55
+ const cycles = detectCycles(tasks);
56
+ expect(cycles.length).toBeGreaterThan(0);
57
+ });
58
+
59
+ it("should handle no dependencies", () => {
60
+ const tasks = [t({ id: "1" }), t({ id: "2" }), t({ id: "3" })];
61
+ expect(detectCycles(tasks)).toHaveLength(0);
62
+ });
63
+
64
+ it("should ignore deps to non-existent tasks", () => {
65
+ const tasks = [t({ id: "1", dependencies: ["999"] })];
66
+ expect(detectCycles(tasks)).toHaveLength(0);
67
+ });
68
+ });
69
+
70
+ // ── computeReadySet ──
71
+
72
+ describe("computeReadySet", () => {
73
+ it("should mark tasks with no deps as ready", () => {
74
+ const tasks = [
75
+ t({ id: "1", status: "Todo", dependencies: [] }),
76
+ t({ id: "2", status: "InProgress", dependencies: [] }),
77
+ ];
78
+ const { ready, blocked } = computeReadySet(tasks);
79
+ expect(ready).toHaveLength(2);
80
+ expect(blocked).toHaveLength(0);
81
+ });
82
+
83
+ it("should mark tasks with all deps Done as ready", () => {
84
+ const tasks = [
85
+ t({ id: "1", status: "Done", dependencies: [] }),
86
+ t({ id: "2", status: "Todo", dependencies: ["1"] }),
87
+ ];
88
+ const { ready } = computeReadySet(tasks);
89
+ expect(ready).toHaveLength(1);
90
+ expect(ready[0].id).toBe("2");
91
+ });
92
+
93
+ it("should mark tasks with pending deps as blocked", () => {
94
+ const tasks = [
95
+ t({ id: "1", status: "Todo", dependencies: [] }),
96
+ t({ id: "2", status: "Todo", dependencies: ["1"] }),
97
+ ];
98
+ const { ready, blocked } = computeReadySet(tasks);
99
+ expect(ready).toHaveLength(1);
100
+ expect(ready[0].id).toBe("1");
101
+ expect(blocked).toHaveLength(1);
102
+ expect(blocked[0].task.id).toBe("2");
103
+ expect(blocked[0].pendingDeps).toEqual(["1"]);
104
+ });
105
+
106
+ it("should exclude Done tasks from results", () => {
107
+ const tasks = [
108
+ t({ id: "1", status: "Done" }),
109
+ t({ id: "2", status: "Done" }),
110
+ ];
111
+ const { ready, blocked } = computeReadySet(tasks);
112
+ expect(ready).toHaveLength(0);
113
+ expect(blocked).toHaveLength(0);
114
+ });
115
+
116
+ it("should handle chain dependencies", () => {
117
+ const tasks = [
118
+ t({ id: "1", status: "Todo" }),
119
+ t({ id: "2", status: "Todo", dependencies: ["1"] }),
120
+ t({ id: "3", status: "Todo", dependencies: ["2"] }),
121
+ ];
122
+ const { ready, blocked } = computeReadySet(tasks);
123
+ expect(ready).toHaveLength(1);
124
+ expect(ready[0].id).toBe("1");
125
+ expect(blocked).toHaveLength(2);
126
+ });
127
+ });
128
+
129
+ // ── scoreTask ──
130
+
131
+ describe("scoreTask", () => {
132
+ it("should give higher score to higher priority", () => {
133
+ const high = scoreTask(t({ priority: 9 }));
134
+ const low = scoreTask(t({ priority: 2 }));
135
+ expect(high).toBeGreaterThan(low);
136
+ });
137
+
138
+ it("should give bonus to InProgress tasks", () => {
139
+ const ip = scoreTask(t({ status: "InProgress", priority: 5 }));
140
+ const todo = scoreTask(t({ status: "Todo", priority: 5 }));
141
+ expect(ip).toBeGreaterThan(todo);
142
+ });
143
+
144
+ it("should penalize Blocked tasks", () => {
145
+ const blocked = scoreTask(t({ status: "Blocked", priority: 5 }));
146
+ const todo = scoreTask(t({ status: "Todo", priority: 5 }));
147
+ expect(blocked).toBeLessThan(todo);
148
+ });
149
+
150
+ it("should prefer recently updated tasks", () => {
151
+ const recent = scoreTask(t({ updatedAt: now }));
152
+ const stale = scoreTask(t({ updatedAt: old }));
153
+ expect(recent).toBeGreaterThan(stale);
154
+ });
155
+ });
156
+
157
+ // ── recommend ──
158
+
159
+ describe("recommend", () => {
160
+ const tasks = [
161
+ t({ id: "1", status: "Done", priority: 10 }),
162
+ t({ id: "2", status: "Todo", priority: 8, dependencies: [] }),
163
+ t({ id: "3", status: "Todo", priority: 3, dependencies: [] }),
164
+ t({ id: "4", status: "Todo", priority: 6, dependencies: ["1"] }), // ready (dep done)
165
+ t({ id: "5", status: "Todo", priority: 9, dependencies: ["2"] }), // blocked (dep not done)
166
+ t({ id: "6", status: "InProgress", priority: 7, dependencies: [] }),
167
+ ];
168
+
169
+ it("should return top N ready tasks by score", () => {
170
+ const recs = recommend(tasks, { limit: 3 });
171
+ expect(recs).toHaveLength(3);
172
+ // Should not include Done tasks or blocked tasks
173
+ const ids = recs.map((r) => r.task.id);
174
+ expect(ids).not.toContain("1"); // Done
175
+ expect(ids).not.toContain("5"); // blocked
176
+ });
177
+
178
+ it("should default to 5 results", () => {
179
+ const recs = recommend(tasks);
180
+ expect(recs.length).toBeLessThanOrEqual(5);
181
+ });
182
+
183
+ it("should return all with --all", () => {
184
+ const recs = recommend(tasks, { all: true });
185
+ // Ready: 2 (no deps), 3 (no deps), 4 (dep 1 is Done), 6 (no deps) = 4
186
+ expect(recs.length).toBe(4);
187
+ });
188
+
189
+ it("should include blocked with --include-blocked", () => {
190
+ const recs = recommend(tasks, { includeBlocked: true, all: true });
191
+ const ids = recs.map((r) => r.task.id);
192
+ expect(ids).toContain("5"); // blocked but included
193
+ const blockedRec = recs.find((r) => r.task.id === "5");
194
+ expect(blockedRec!.pendingDeps).toEqual(["2"]);
195
+ });
196
+
197
+ it("should rank by score descending", () => {
198
+ const recs = recommend(tasks, { all: true });
199
+ for (let i = 0; i < recs.length - 1; i++) {
200
+ expect(recs[i].score).toBeGreaterThanOrEqual(recs[i + 1].score);
201
+ }
202
+ });
203
+
204
+ it("should handle empty task list", () => {
205
+ const recs = recommend([], { limit: 5 });
206
+ expect(recs).toHaveLength(0);
207
+ });
208
+
209
+ it("should handle all-done task list", () => {
210
+ const done = [
211
+ t({ id: "1", status: "Done" }),
212
+ t({ id: "2", status: "Done" }),
213
+ ];
214
+ const recs = recommend(done);
215
+ expect(recs).toHaveLength(0);
216
+ });
217
+ });
218
+
219
+ // ── Performance ──
220
+
221
+ describe("performance", () => {
222
+ it("should handle 2000 tasks under 200ms", () => {
223
+ const tasks: Task[] = [];
224
+ for (let i = 0; i < 2000; i++) {
225
+ const id = String(i + 1).padStart(4, "0");
226
+ const deps = i > 0 && i % 10 === 0 ? [String(i).padStart(4, "0")] : [];
227
+ tasks.push(
228
+ t({
229
+ id,
230
+ title: `Task ${id}`,
231
+ priority: (i % 10) + 1,
232
+ status: i < 100 ? "Done" : "Todo",
233
+ dependencies: deps,
234
+ }),
235
+ );
236
+ }
237
+
238
+ const start = performance.now();
239
+ const cycles = detectCycles(tasks);
240
+ const recs = recommend(tasks, { limit: 10 });
241
+ const elapsed = performance.now() - start;
242
+
243
+ expect(cycles).toHaveLength(0);
244
+ expect(recs.length).toBeGreaterThan(0);
245
+ expect(elapsed).toBeLessThan(200);
246
+ });
247
+ });
@@ -0,0 +1,233 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import {
6
+ ensureRepo,
7
+ createTask,
8
+ readTask,
9
+ listTasks,
10
+ updateTask,
11
+ deleteTask,
12
+ searchTasks,
13
+ } from "../repository";
14
+
15
+ let tmpDir: string;
16
+
17
+ beforeEach(async () => {
18
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "taskflow-test-"));
19
+ });
20
+
21
+ afterEach(async () => {
22
+ await fs.rm(tmpDir, { recursive: true, force: true });
23
+ });
24
+
25
+ describe("ensureRepo", () => {
26
+ it("should create all required directories", async () => {
27
+ await ensureRepo(tmpDir);
28
+
29
+ const dirs = ["tasks", "index", "logs", "cache"];
30
+ for (const dir of dirs) {
31
+ const stat = await fs.stat(path.join(tmpDir, ".taskflow", dir));
32
+ expect(stat.isDirectory()).toBe(true);
33
+ }
34
+ });
35
+
36
+ it("should be idempotent", async () => {
37
+ await ensureRepo(tmpDir);
38
+ await ensureRepo(tmpDir);
39
+
40
+ const stat = await fs.stat(path.join(tmpDir, ".taskflow", "tasks"));
41
+ expect(stat.isDirectory()).toBe(true);
42
+ });
43
+ });
44
+
45
+ describe("createTask", () => {
46
+ it("should create a task file and return the task", async () => {
47
+ const task = await createTask(tmpDir, { title: "First task" });
48
+
49
+ expect(task.id).toBe("001");
50
+ expect(task.title).toBe("First task");
51
+ expect(task.status).toBe("Todo");
52
+ expect(task.priority).toBe(0);
53
+ });
54
+
55
+ it("should auto-increment IDs", async () => {
56
+ const t1 = await createTask(tmpDir, { title: "Task 1" });
57
+ const t2 = await createTask(tmpDir, { title: "Task 2" });
58
+
59
+ expect(t1.id).toBe("001");
60
+ expect(t2.id).toBe("002");
61
+ });
62
+
63
+ it("should rebuild TASKS.md index", async () => {
64
+ await createTask(tmpDir, { title: "Indexed task" });
65
+
66
+ const indexPath = path.join(tmpDir, ".taskflow", "index", "TASKS.md");
67
+ const content = await fs.readFile(indexPath, "utf-8");
68
+
69
+ expect(content).toContain("Indexed task");
70
+ expect(content).toContain("001");
71
+ });
72
+ });
73
+
74
+ describe("readTask", () => {
75
+ it("should read an existing task", async () => {
76
+ const created = await createTask(tmpDir, {
77
+ title: "Read me",
78
+ description: "Some description",
79
+ });
80
+ const task = await readTask(tmpDir, created.id);
81
+
82
+ expect(task).not.toBeNull();
83
+ expect(task!.title).toBe("Read me");
84
+ expect(task!.description).toBe("Some description");
85
+ });
86
+
87
+ it("should return null for non-existent task", async () => {
88
+ await ensureRepo(tmpDir);
89
+ const task = await readTask(tmpDir, "999");
90
+ expect(task).toBeNull();
91
+ });
92
+ });
93
+
94
+ describe("updateTask", () => {
95
+ it("should update task fields", async () => {
96
+ const created = await createTask(tmpDir, { title: "Original" });
97
+ const updated = await updateTask(tmpDir, created.id, {
98
+ title: "Updated",
99
+ status: "InProgress",
100
+ });
101
+
102
+ expect(updated.title).toBe("Updated");
103
+ expect(updated.status).toBe("InProgress");
104
+ expect(updated.createdAt).toBe(created.createdAt);
105
+ expect(updated.updatedAt).not.toBe(created.updatedAt);
106
+ });
107
+
108
+ it("should throw for non-existent task", async () => {
109
+ await ensureRepo(tmpDir);
110
+ await expect(
111
+ updateTask(tmpDir, "999", { title: "Nope" }),
112
+ ).rejects.toThrow("Task not found");
113
+ });
114
+
115
+ it("should preserve createdAt on update", async () => {
116
+ const created = await createTask(tmpDir, { title: "Keep date" });
117
+ const updated = await updateTask(tmpDir, created.id, { status: "Done" });
118
+
119
+ expect(updated.createdAt).toBe(created.createdAt);
120
+ });
121
+ });
122
+
123
+ describe("deleteTask", () => {
124
+ it("should remove the task file", async () => {
125
+ const created = await createTask(tmpDir, { title: "Delete me" });
126
+ const result = await deleteTask(tmpDir, created.id);
127
+
128
+ expect(result).toBe(true);
129
+ expect(await readTask(tmpDir, created.id)).toBeNull();
130
+ });
131
+
132
+ it("should return false for non-existent task", async () => {
133
+ await ensureRepo(tmpDir);
134
+ const result = await deleteTask(tmpDir, "999");
135
+ expect(result).toBe(false);
136
+ });
137
+
138
+ it("should update index after deletion", async () => {
139
+ await createTask(tmpDir, { title: "Task A" });
140
+ const taskB = await createTask(tmpDir, { title: "Task B" });
141
+ await deleteTask(tmpDir, taskB.id);
142
+
143
+ const indexPath = path.join(tmpDir, ".taskflow", "index", "TASKS.md");
144
+ const content = await fs.readFile(indexPath, "utf-8");
145
+
146
+ expect(content).toContain("Task A");
147
+ expect(content).not.toContain("Task B");
148
+ });
149
+ });
150
+
151
+ describe("listTasks", () => {
152
+ it("should list all tasks", async () => {
153
+ await createTask(tmpDir, { title: "A" });
154
+ await createTask(tmpDir, { title: "B" });
155
+ await createTask(tmpDir, { title: "C" });
156
+
157
+ const tasks = await listTasks(tmpDir);
158
+ expect(tasks).toHaveLength(3);
159
+ });
160
+
161
+ it("should filter by status", async () => {
162
+ await createTask(tmpDir, { title: "Todo task", status: "Todo" });
163
+ await createTask(tmpDir, { title: "Done task", status: "Done" });
164
+
165
+ const todoTasks = await listTasks(tmpDir, {
166
+ filter: { status: "Todo" },
167
+ });
168
+ expect(todoTasks).toHaveLength(1);
169
+ expect(todoTasks[0].title).toBe("Todo task");
170
+ });
171
+
172
+ it("should sort by priority descending", async () => {
173
+ await createTask(tmpDir, { title: "Low", priority: 1 });
174
+ await createTask(tmpDir, { title: "High", priority: 5 });
175
+ await createTask(tmpDir, { title: "Mid", priority: 3 });
176
+
177
+ const tasks = await listTasks(tmpDir, {
178
+ sortKey: "priority",
179
+ sortOrder: "desc",
180
+ });
181
+
182
+ expect(tasks[0].title).toBe("High");
183
+ expect(tasks[1].title).toBe("Mid");
184
+ expect(tasks[2].title).toBe("Low");
185
+ });
186
+ });
187
+
188
+ describe("searchTasks", () => {
189
+ it("should search by title", async () => {
190
+ await createTask(tmpDir, { title: "Setup database" });
191
+ await createTask(tmpDir, { title: "Write tests" });
192
+
193
+ const results = await searchTasks(tmpDir, "database");
194
+ expect(results).toHaveLength(1);
195
+ expect(results[0].title).toBe("Setup database");
196
+ });
197
+
198
+ it("should search by description", async () => {
199
+ await createTask(tmpDir, {
200
+ title: "Task",
201
+ description: "Configure PostgreSQL connection",
202
+ });
203
+
204
+ const results = await searchTasks(tmpDir, "postgresql");
205
+ expect(results).toHaveLength(1);
206
+ });
207
+
208
+ it("should be case-insensitive", async () => {
209
+ await createTask(tmpDir, { title: "UPPERCASE TASK" });
210
+
211
+ const results = await searchTasks(tmpDir, "uppercase");
212
+ expect(results).toHaveLength(1);
213
+ });
214
+ });
215
+
216
+ describe("atomic write integrity", () => {
217
+ it("should handle sequential writes without corruption", async () => {
218
+ await ensureRepo(tmpDir);
219
+
220
+ for (let i = 0; i < 10; i++) {
221
+ await createTask(tmpDir, { title: `Sequential task ${i}`, priority: i });
222
+ }
223
+
224
+ const allTasks = await listTasks(tmpDir);
225
+ expect(allTasks).toHaveLength(10);
226
+
227
+ for (const task of allTasks) {
228
+ const read = await readTask(tmpDir, task.id);
229
+ expect(read).not.toBeNull();
230
+ expect(read!.title).toBe(task.title);
231
+ }
232
+ });
233
+ });