@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,185 @@
1
+ import initSqlJs, { type Database } from "sql.js";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import type { ConvLog, Decision, SessionType } from "../../types.js";
5
+
6
+ const SCHEMA = `
7
+ CREATE TABLE IF NOT EXISTS conversation_logs (
8
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
9
+ session_type TEXT NOT NULL,
10
+ session_id TEXT NOT NULL,
11
+ role TEXT NOT NULL,
12
+ content TEXT NOT NULL,
13
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
14
+ );
15
+
16
+ CREATE TABLE IF NOT EXISTS decisions (
17
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
18
+ session_id TEXT NOT NULL,
19
+ decision TEXT NOT NULL,
20
+ reason TEXT NOT NULL,
21
+ related_tasks TEXT NOT NULL DEFAULT '[]',
22
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
23
+ );
24
+
25
+ CREATE INDEX IF NOT EXISTS idx_conv_session ON conversation_logs(session_type, session_id);
26
+ CREATE INDEX IF NOT EXISTS idx_conv_created ON conversation_logs(created_at);
27
+ CREATE INDEX IF NOT EXISTS idx_decisions_session ON decisions(session_id);
28
+ `;
29
+
30
+ export class AdvisorDb {
31
+ private db: Database;
32
+ private dbPath: string;
33
+
34
+ private constructor(db: Database, dbPath: string) {
35
+ this.db = db;
36
+ this.dbPath = dbPath;
37
+ }
38
+
39
+ static async open(dbPath: string): Promise<AdvisorDb> {
40
+ const SQL = await initSqlJs();
41
+
42
+ let db: Database;
43
+ try {
44
+ const buffer = await fs.readFile(dbPath);
45
+ db = new SQL.Database(buffer);
46
+ } catch {
47
+ db = new SQL.Database();
48
+ }
49
+
50
+ db.run(SCHEMA);
51
+ return new AdvisorDb(db, dbPath);
52
+ }
53
+
54
+ insertLog(
55
+ sessionType: SessionType,
56
+ sessionId: string,
57
+ role: "user" | "assistant",
58
+ content: string
59
+ ): void {
60
+ this.db.run(
61
+ "INSERT INTO conversation_logs (session_type, session_id, role, content) VALUES (?, ?, ?, ?)",
62
+ [sessionType, sessionId, role, content]
63
+ );
64
+ }
65
+
66
+ getLogsBySession(sessionId: string): ConvLog[] {
67
+ return this.queryLogs(
68
+ "SELECT * FROM conversation_logs WHERE session_id = ? ORDER BY id ASC",
69
+ [sessionId]
70
+ );
71
+ }
72
+
73
+ getLogsByType(sessionType: SessionType): ConvLog[] {
74
+ return this.queryLogs(
75
+ "SELECT * FROM conversation_logs WHERE session_type = ? ORDER BY id ASC",
76
+ [sessionType]
77
+ );
78
+ }
79
+
80
+ deleteExpiredLogs(days: number): number {
81
+ this.db.run(
82
+ "DELETE FROM conversation_logs WHERE created_at < datetime('now', ? || ' days')",
83
+ [`-${days}`]
84
+ );
85
+ return this.db.getRowsModified();
86
+ }
87
+
88
+ insertDecision(
89
+ sessionId: string,
90
+ decision: string,
91
+ reason: string,
92
+ relatedTasks: string[]
93
+ ): void {
94
+ this.db.run(
95
+ "INSERT INTO decisions (session_id, decision, reason, related_tasks) VALUES (?, ?, ?, ?)",
96
+ [sessionId, decision, reason, JSON.stringify(relatedTasks)]
97
+ );
98
+ }
99
+
100
+ getAllDecisions(): Decision[] {
101
+ return this.queryDecisions(
102
+ "SELECT * FROM decisions ORDER BY id DESC"
103
+ );
104
+ }
105
+
106
+ getRecentDecisions(limit: number): Decision[] {
107
+ return this.queryDecisions(
108
+ "SELECT * FROM decisions ORDER BY id DESC LIMIT ?",
109
+ [limit]
110
+ );
111
+ }
112
+
113
+ getStats(): { logCount: number; decisionCount: number; dbSizeBytes: number } {
114
+ const logRow = this.db.exec("SELECT COUNT(*) FROM conversation_logs");
115
+ const decisionRow = this.db.exec("SELECT COUNT(*) FROM decisions");
116
+ const data = this.db.export();
117
+
118
+ return {
119
+ logCount: Number(logRow[0]?.values[0]?.[0] ?? 0),
120
+ decisionCount: Number(decisionRow[0]?.values[0]?.[0] ?? 0),
121
+ dbSizeBytes: data.length,
122
+ };
123
+ }
124
+
125
+ async persistToDisk(): Promise<void> {
126
+ const data = this.db.export();
127
+ const buffer = Buffer.from(data);
128
+ await fs.mkdir(path.dirname(this.dbPath), { recursive: true });
129
+ await fs.writeFile(this.dbPath, buffer);
130
+ }
131
+
132
+ persistToDiskAsync(): void {
133
+ this.persistToDisk().catch((err) => {
134
+ console.error("⚠️ advisor.db 디스크 저장 실패:", err);
135
+ });
136
+ }
137
+
138
+ exec(sql: string): void {
139
+ this.db.run(sql);
140
+ }
141
+
142
+ close(): void {
143
+ this.db.close();
144
+ }
145
+
146
+ private queryLogs(sql: string, params: unknown[] = []): ConvLog[] {
147
+ const stmt = this.db.prepare(sql);
148
+ stmt.bind(params);
149
+
150
+ const results: ConvLog[] = [];
151
+ while (stmt.step()) {
152
+ const row = stmt.getAsObject();
153
+ results.push({
154
+ id: Number(row.id),
155
+ sessionType: row.session_type as SessionType,
156
+ sessionId: String(row.session_id),
157
+ role: row.role as "user" | "assistant",
158
+ content: String(row.content),
159
+ createdAt: String(row.created_at),
160
+ });
161
+ }
162
+ stmt.free();
163
+ return results;
164
+ }
165
+
166
+ private queryDecisions(sql: string, params: unknown[] = []): Decision[] {
167
+ const stmt = this.db.prepare(sql);
168
+ stmt.bind(params);
169
+
170
+ const results: Decision[] = [];
171
+ while (stmt.step()) {
172
+ const row = stmt.getAsObject();
173
+ results.push({
174
+ id: Number(row.id),
175
+ sessionId: String(row.session_id),
176
+ decision: String(row.decision),
177
+ reason: String(row.reason),
178
+ relatedTasks: JSON.parse(String(row.related_tasks)),
179
+ createdAt: String(row.created_at),
180
+ });
181
+ }
182
+ stmt.free();
183
+ return results;
184
+ }
185
+ }
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildLocalSummary, formatStatusOutput } from "./local-summary.js";
3
+ import type { Task } from "../../types.js";
4
+
5
+ const makeTasks = (): Task[] => [
6
+ { id: "1", title: "Task A", status: "Done", priority: 5, dependencies: [], createdAt: "", updatedAt: "", description: "" },
7
+ { id: "2", title: "Task B", status: "Done", priority: 4, dependencies: ["1"], createdAt: "", updatedAt: "", description: "" },
8
+ { id: "3", title: "Task C", status: "InProgress", priority: 3, dependencies: [], createdAt: "", updatedAt: "", description: "" },
9
+ { id: "4", title: "Task D", status: "Todo", priority: 2, dependencies: ["3"], createdAt: "", updatedAt: "", description: "" },
10
+ { id: "5", title: "Task E", status: "Blocked", priority: 1, dependencies: [], createdAt: "", updatedAt: "", description: "" },
11
+ ];
12
+
13
+ describe("buildLocalSummary", () => {
14
+ it("should calculate correct progress", () => {
15
+ const summary = buildLocalSummary(makeTasks());
16
+ expect(summary.total).toBe(5);
17
+ expect(summary.done).toBe(2);
18
+ expect(summary.progressPercent).toBe(40);
19
+ });
20
+
21
+ it("should group tasks by status", () => {
22
+ const summary = buildLocalSummary(makeTasks());
23
+ expect(summary.groups.Done).toHaveLength(2);
24
+ expect(summary.groups.InProgress).toHaveLength(1);
25
+ expect(summary.groups.Todo).toHaveLength(1);
26
+ expect(summary.groups.Blocked).toHaveLength(1);
27
+ });
28
+
29
+ it("should handle empty task list", () => {
30
+ const summary = buildLocalSummary([]);
31
+ expect(summary.total).toBe(0);
32
+ expect(summary.progressPercent).toBe(0);
33
+ });
34
+ });
35
+
36
+ describe("formatStatusOutput", () => {
37
+ it("should produce formatted output with all sections", () => {
38
+ const summary = buildLocalSummary(makeTasks());
39
+ const output = formatStatusOutput(summary);
40
+ expect(output).toContain("2/5");
41
+ expect(output).toContain("40%");
42
+ expect(output).toContain("Done");
43
+ expect(output).toContain("In Progress");
44
+ expect(output).toContain("Todo");
45
+ expect(output).toContain("Blocked");
46
+ });
47
+
48
+ it("should handle no tasks gracefully", () => {
49
+ const summary = buildLocalSummary([]);
50
+ const output = formatStatusOutput(summary);
51
+ expect(output).toContain("0/0");
52
+ });
53
+ });
@@ -0,0 +1,72 @@
1
+ import chalk from "chalk";
2
+ import type { Task, TaskStatus } from "../../types.js";
3
+
4
+ export interface LocalSummary {
5
+ total: number;
6
+ done: number;
7
+ inProgress: number;
8
+ todo: number;
9
+ blocked: number;
10
+ progressPercent: number;
11
+ groups: Record<TaskStatus, Task[]>;
12
+ }
13
+
14
+ export function buildLocalSummary(tasks: Task[]): LocalSummary {
15
+ const groups: Record<TaskStatus, Task[]> = {
16
+ Done: [],
17
+ InProgress: [],
18
+ Todo: [],
19
+ Blocked: [],
20
+ };
21
+
22
+ for (const task of tasks) {
23
+ groups[task.status].push(task);
24
+ }
25
+
26
+ const total = tasks.length;
27
+ const done = groups.Done.length;
28
+ const progressPercent = total === 0 ? 0 : Math.round((done / total) * 100);
29
+
30
+ return {
31
+ total,
32
+ done,
33
+ inProgress: groups.InProgress.length,
34
+ todo: groups.Todo.length,
35
+ blocked: groups.Blocked.length,
36
+ progressPercent,
37
+ groups,
38
+ };
39
+ }
40
+
41
+ const STATUS_DISPLAY: Record<TaskStatus, { icon: string; label: string; color: (s: string) => string }> = {
42
+ Done: { icon: "✅", label: "Done", color: chalk.green },
43
+ InProgress: { icon: "🔵", label: "In Progress", color: chalk.hex("#FFA500") },
44
+ Todo: { icon: "⬜", label: "Todo", color: chalk.yellow },
45
+ Blocked: { icon: "🔴", label: "Blocked", color: chalk.red },
46
+ };
47
+
48
+ const DISPLAY_ORDER: TaskStatus[] = ["Done", "InProgress", "Blocked", "Todo"];
49
+
50
+ export function formatStatusOutput(summary: LocalSummary): string {
51
+ const lines: string[] = [];
52
+
53
+ lines.push(
54
+ chalk.bold(`📊 프로젝트 진행률: ${summary.done}/${summary.total} (${summary.progressPercent}%)`)
55
+ );
56
+ lines.push("");
57
+
58
+ for (const status of DISPLAY_ORDER) {
59
+ const tasks = summary.groups[status];
60
+ if (tasks.length === 0) continue;
61
+
62
+ const { icon, label, color } = STATUS_DISPLAY[status];
63
+ lines.push(color(`${icon} ${label} (${tasks.length})`));
64
+
65
+ for (const task of tasks) {
66
+ lines.push(chalk.gray(` ${task.id}. ${task.title}`));
67
+ }
68
+ lines.push("");
69
+ }
70
+
71
+ return lines.join("\n");
72
+ }
@@ -0,0 +1,86 @@
1
+ import type { AdvisorContext } from "../../types.js";
2
+ import type { LocalSummary } from "./local-summary.js";
3
+
4
+ export function buildStatusPrompt(context: AdvisorContext, summary: LocalSummary): string {
5
+ return `너는 프로젝트 비서야. 현황을 보고 한 줄 인사이트를 줘.
6
+
7
+ ## 현재 진행률
8
+ - 전체: ${summary.total}개
9
+ - 완료: ${summary.done}개 (${summary.progressPercent}%)
10
+ - 진행중: ${summary.inProgress}개
11
+ - 대기: ${summary.todo}개
12
+ - 차단: ${summary.blocked}개
13
+
14
+ ## 태스크 목록
15
+ ${formatTaskList(context)}
16
+
17
+ ## 최근 결정 사항
18
+ ${formatDecisions(context)}
19
+
20
+ ## 요청
21
+ 한 줄로 현재 상황에 대한 인사이트를 줘. "💡 " 로 시작해. 한국어로 답변해.`;
22
+ }
23
+
24
+ export function buildNextPrompt(context: AdvisorContext): string {
25
+ return `너는 프로젝트 비서야. 다음에 할 태스크를 추천해.
26
+
27
+ ## 전체 태스크 목록
28
+ ${formatTaskList(context)}
29
+
30
+ ## 최근 결정 사항
31
+ ${formatDecisions(context)}
32
+
33
+ ${context.trdContent ? `## TRD (구현 계획)\n${context.trdContent}\n` : ""}
34
+ ${context.prdContent ? `## PRD (제품 요구사항)\n${context.prdContent}\n` : ""}
35
+
36
+ ## 요청
37
+ 다음에 할 태스크 1개를 추천해. 아래 형식으로 답변해:
38
+
39
+ 👉 추천: #ID 태스크 제목
40
+ 이유: (왜 이것을 다음에 해야 하는지)
41
+ 의존: (선행 태스크가 있다면)
42
+
43
+ 한국어로 답변해. 짧게.`;
44
+ }
45
+
46
+ export function buildAskPrompt(context: AdvisorContext, question: string): string {
47
+ return `너는 프로젝트 비서야. 질문에 맞게 답변해.
48
+ 짧은 질문이면 짧게, 현황 질문이면 브리핑 형태로.
49
+
50
+ ## 태스크 목록
51
+ ${formatTaskList(context)}
52
+
53
+ ## 최근 결정 사항
54
+ ${formatDecisions(context)}
55
+
56
+ ${context.trdContent ? `## TRD\n${context.trdContent}\n` : ""}
57
+ ${context.prdContent ? `## PRD\n${context.prdContent}\n` : ""}
58
+ ${context.gitDiff ? `## 최근 코드 변경\n${context.gitDiff}\n` : ""}
59
+ ${context.conversationLogs ? `## 관련 대화 로그\n${formatConvLogs(context)}\n` : ""}
60
+
61
+ ## 질문
62
+ ${question}
63
+
64
+ 한국어로 답변해.`;
65
+ }
66
+
67
+ function formatTaskList(context: AdvisorContext): string {
68
+ if (context.tasks.length === 0) return "(태스크 없음)";
69
+ return context.tasks
70
+ .map((t) => `- [${t.status}] #${t.id} ${t.title} (우선순위: ${t.priority}, 의존: ${t.dependencies.join(", ") || "없음"})`)
71
+ .join("\n");
72
+ }
73
+
74
+ function formatDecisions(context: AdvisorContext): string {
75
+ if (context.decisions.length === 0) return "(결정 기록 없음)";
76
+ return context.decisions
77
+ .map((d) => `- ${d.decision} (이유: ${d.reason})`)
78
+ .join("\n");
79
+ }
80
+
81
+ function formatConvLogs(context: AdvisorContext): string {
82
+ if (!context.conversationLogs || context.conversationLogs.length === 0) return "";
83
+ return context.conversationLogs
84
+ .map((l) => `[${l.role}] ${l.content}`)
85
+ .join("\n");
86
+ }
@@ -0,0 +1,54 @@
1
+ import type { Task, TaskFilter, TaskSortKey, TaskSortOrder, TaskStatus } from "../types";
2
+ import { TASK_STATUSES } from "../types";
3
+
4
+ export function filterTasks(tasks: Task[], filter: TaskFilter): Task[] {
5
+ return tasks.filter((task) => {
6
+ if (filter.status) {
7
+ const statuses: TaskStatus[] = Array.isArray(filter.status)
8
+ ? filter.status
9
+ : [filter.status];
10
+ if (!statuses.includes(task.status)) return false;
11
+ }
12
+
13
+ if (filter.priority !== undefined && task.priority !== filter.priority) {
14
+ return false;
15
+ }
16
+
17
+ if (filter.parentId !== undefined && task.parentId !== filter.parentId) {
18
+ return false;
19
+ }
20
+
21
+ if (filter.updatedSince && task.updatedAt < filter.updatedSince) {
22
+ return false;
23
+ }
24
+
25
+ if (filter.hasDependency && !task.dependencies.includes(filter.hasDependency)) {
26
+ return false;
27
+ }
28
+
29
+ return true;
30
+ });
31
+ }
32
+
33
+ export function sortTasks(
34
+ tasks: Task[],
35
+ key: TaskSortKey = "priority",
36
+ order: TaskSortOrder = "desc",
37
+ ): Task[] {
38
+ const sorted = [...tasks].sort((a, b) => {
39
+ if (key === "status") {
40
+ return TASK_STATUSES.indexOf(a.status) - TASK_STATUSES.indexOf(b.status);
41
+ }
42
+
43
+ const valA = a[key];
44
+ const valB = b[key];
45
+
46
+ if (typeof valA === "number" && typeof valB === "number") {
47
+ return valA - valB;
48
+ }
49
+
50
+ return String(valA).localeCompare(String(valB));
51
+ });
52
+
53
+ return order === "desc" ? sorted.reverse() : sorted;
54
+ }
@@ -0,0 +1,50 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+
5
+ /**
6
+ * write-temp-rename 전략으로 원자적 파일 쓰기
7
+ * 임시파일에 먼저 쓴 후 rename하여 손상 방지
8
+ */
9
+ export async function atomicWrite(filePath: string, content: string): Promise<void> {
10
+ const dir = path.dirname(filePath);
11
+ const tmpPath = path.join(dir, `.${path.basename(filePath)}.${crypto.randomUUID()}.tmp`);
12
+
13
+ await fs.writeFile(tmpPath, normalizeLineEndings(content), "utf-8");
14
+ await fs.rename(tmpPath, filePath);
15
+ }
16
+
17
+ export function normalizeLineEndings(content: string): string {
18
+ return content.replace(/\r\n/g, "\n");
19
+ }
20
+
21
+ export async function ensureDir(dirPath: string): Promise<void> {
22
+ await fs.mkdir(dirPath, { recursive: true });
23
+ }
24
+
25
+ export async function fileExists(filePath: string): Promise<boolean> {
26
+ try {
27
+ await fs.access(filePath);
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ export async function safeReadFile(filePath: string): Promise<string | null> {
35
+ try {
36
+ const content = await fs.readFile(filePath, "utf-8");
37
+ return normalizeLineEndings(content);
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ export async function safeRemove(filePath: string): Promise<boolean> {
44
+ try {
45
+ await fs.unlink(filePath);
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
@@ -0,0 +1,148 @@
1
+ import type { Task } from "../types";
2
+
3
+ // ── Cycle Detection (DFS) ──
4
+
5
+ export function detectCycles(tasks: Task[]): string[][] {
6
+ const taskMap = new Map(tasks.map((t) => [t.id, t]));
7
+ const visited = new Set<string>();
8
+ const inStack = new Set<string>();
9
+ const cycles: string[][] = [];
10
+
11
+ function dfs(id: string, path: string[]): void {
12
+ if (inStack.has(id)) {
13
+ const start = path.indexOf(id);
14
+ cycles.push(path.slice(start).concat(id));
15
+ return;
16
+ }
17
+ if (visited.has(id)) return;
18
+
19
+ visited.add(id);
20
+ inStack.add(id);
21
+
22
+ const task = taskMap.get(id);
23
+ if (task) {
24
+ for (const dep of task.dependencies) {
25
+ if (taskMap.has(dep)) {
26
+ dfs(dep, [...path, id]);
27
+ }
28
+ }
29
+ }
30
+
31
+ inStack.delete(id);
32
+ }
33
+
34
+ for (const task of tasks) {
35
+ if (!visited.has(task.id)) {
36
+ dfs(task.id, []);
37
+ }
38
+ }
39
+
40
+ return cycles;
41
+ }
42
+
43
+ // ── Ready Set (all deps Done) ──
44
+
45
+ export interface ReadyResult {
46
+ ready: Task[];
47
+ blocked: BlockedTask[];
48
+ }
49
+
50
+ export interface BlockedTask {
51
+ task: Task;
52
+ pendingDeps: string[];
53
+ }
54
+
55
+ export function computeReadySet(tasks: Task[]): ReadyResult {
56
+ const taskMap = new Map(tasks.map((t) => [t.id, t]));
57
+ const ready: Task[] = [];
58
+ const blocked: BlockedTask[] = [];
59
+
60
+ for (const task of tasks) {
61
+ if (task.status === "Done") continue;
62
+
63
+ const pendingDeps = task.dependencies.filter((depId) => {
64
+ const dep = taskMap.get(depId);
65
+ return dep && dep.status !== "Done";
66
+ });
67
+
68
+ if (pendingDeps.length === 0) {
69
+ ready.push(task);
70
+ } else {
71
+ blocked.push({ task, pendingDeps });
72
+ }
73
+ }
74
+
75
+ return { ready, blocked };
76
+ }
77
+
78
+ // ── Scoring ──
79
+
80
+ const WEIGHTS = {
81
+ priority: 10,
82
+ recency: 3,
83
+ inProgress: 5,
84
+ blockedPenalty: -20,
85
+ };
86
+
87
+ const DAY_MS = 86_400_000;
88
+
89
+ export function scoreTask(task: Task): number {
90
+ let score = 0;
91
+
92
+ // Priority contribution (0-10 → 0-100)
93
+ score += task.priority * WEIGHTS.priority;
94
+
95
+ // Recency bonus (more recent update = higher score)
96
+ const daysSinceUpdate = (Date.now() - new Date(task.updatedAt).getTime()) / DAY_MS;
97
+ score += Math.max(0, WEIGHTS.recency * (30 - daysSinceUpdate));
98
+
99
+ // In-progress bonus
100
+ if (task.status === "InProgress") {
101
+ score += WEIGHTS.inProgress;
102
+ }
103
+
104
+ // Blocked penalty
105
+ if (task.status === "Blocked") {
106
+ score += WEIGHTS.blockedPenalty;
107
+ }
108
+
109
+ return score;
110
+ }
111
+
112
+ // ── Recommendation ──
113
+
114
+ export interface RecommendOptions {
115
+ limit?: number;
116
+ includeBlocked?: boolean;
117
+ all?: boolean;
118
+ }
119
+
120
+ export interface Recommendation {
121
+ task: Task;
122
+ score: number;
123
+ pendingDeps?: string[];
124
+ }
125
+
126
+ export function recommend(tasks: Task[], options: RecommendOptions = {}): Recommendation[] {
127
+ const limit = options.all ? Infinity : (options.limit ?? 5);
128
+ const { ready, blocked } = computeReadySet(tasks);
129
+
130
+ const recommendations: Recommendation[] = ready.map((task) => ({
131
+ task,
132
+ score: scoreTask(task),
133
+ }));
134
+
135
+ if (options.includeBlocked) {
136
+ for (const { task, pendingDeps } of blocked) {
137
+ recommendations.push({
138
+ task,
139
+ score: scoreTask(task),
140
+ pendingDeps,
141
+ });
142
+ }
143
+ }
144
+
145
+ return recommendations
146
+ .sort((a, b) => b.score - a.score)
147
+ .slice(0, limit);
148
+ }
@@ -0,0 +1,42 @@
1
+ import { getIndexFilePath } from "../constants";
2
+ import type { Task } from "../types";
3
+ import { atomicWrite, ensureDir } from "./fs-utils";
4
+ import path from "node:path";
5
+
6
+ function formatDate(iso: string): string {
7
+ return iso.slice(0, 10);
8
+ }
9
+
10
+ function buildMarkdownTable(tasks: Task[]): string {
11
+ const lines: string[] = [
12
+ "# TaskFlow Index",
13
+ "",
14
+ `> Auto-generated at ${new Date().toISOString()}`,
15
+ "",
16
+ "| ID | Title | Status | Priority | Dependencies | Updated |",
17
+ "|----|-------|--------|----------|--------------|---------|",
18
+ ];
19
+
20
+ const sorted = [...tasks].sort((a, b) => b.priority - a.priority);
21
+
22
+ for (const task of sorted) {
23
+ const deps = task.dependencies.length > 0 ? task.dependencies.join(", ") : "-";
24
+ lines.push(
25
+ `| ${task.id} | ${task.title} | ${task.status} | ${task.priority} | ${deps} | ${formatDate(task.updatedAt)} |`,
26
+ );
27
+ }
28
+
29
+ lines.push("");
30
+ lines.push(`Total: ${tasks.length} tasks`);
31
+ lines.push("");
32
+
33
+ return lines.join("\n");
34
+ }
35
+
36
+ export async function rebuildIndex(projectRoot: string, tasks: Task[]): Promise<void> {
37
+ const indexPath = getIndexFilePath(projectRoot);
38
+ await ensureDir(path.dirname(indexPath));
39
+
40
+ const content = buildMarkdownTable(tasks);
41
+ await atomicWrite(indexPath, content);
42
+ }