@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,98 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseTask, serializeTask } from "../serializer";
3
+ import type { Task } from "../../types";
4
+
5
+ const SAMPLE_TASK: Task = {
6
+ id: "001",
7
+ title: "Setup project",
8
+ status: "Todo",
9
+ priority: 3,
10
+ dependencies: [],
11
+ createdAt: "2026-03-21T00:00:00.000Z",
12
+ updatedAt: "2026-03-21T00:00:00.000Z",
13
+ description: "Initialize the project structure.",
14
+ };
15
+
16
+ const SAMPLE_MD = `---
17
+ id: "001"
18
+ title: Setup project
19
+ status: Todo
20
+ priority: 3
21
+ createdAt: "2026-03-21T00:00:00.000Z"
22
+ updatedAt: "2026-03-21T00:00:00.000Z"
23
+ ---
24
+ Initialize the project structure.
25
+ `;
26
+
27
+ describe("serializeTask", () => {
28
+ it("should produce valid frontmatter markdown", () => {
29
+ const output = serializeTask(SAMPLE_TASK);
30
+
31
+ expect(output).toContain("id:");
32
+ expect(output).toContain("001");
33
+ expect(output).toContain("title: Setup project");
34
+ expect(output).toContain("status: Todo");
35
+ expect(output).toContain("priority: 3");
36
+ expect(output).toContain("Initialize the project structure.");
37
+ });
38
+
39
+ it("should include dependencies when present", () => {
40
+ const task = { ...SAMPLE_TASK, dependencies: ["002", "003"] };
41
+ const output = serializeTask(task);
42
+
43
+ expect(output).toContain("dependencies:");
44
+ expect(output).toContain("002");
45
+ expect(output).toContain("003");
46
+ });
47
+
48
+ it("should omit dependencies when empty", () => {
49
+ const output = serializeTask(SAMPLE_TASK);
50
+ expect(output).not.toContain("dependencies:");
51
+ });
52
+
53
+ it("should include parentId when present", () => {
54
+ const task = { ...SAMPLE_TASK, parentId: "000" };
55
+ const output = serializeTask(task);
56
+ expect(output).toContain("parentId:");
57
+ expect(output).toContain("000");
58
+ });
59
+ });
60
+
61
+ describe("parseTask", () => {
62
+ it("should parse frontmatter and body", () => {
63
+ const task = parseTask(SAMPLE_MD);
64
+
65
+ expect(task.id).toBe("001");
66
+ expect(task.title).toBe("Setup project");
67
+ expect(task.status).toBe("Todo");
68
+ expect(task.priority).toBe(3);
69
+ expect(task.description).toBe("Initialize the project structure.");
70
+ });
71
+
72
+ it("should default status to Todo for invalid values", () => {
73
+ const md = SAMPLE_MD.replace("status: Todo", "status: InvalidStatus");
74
+ const task = parseTask(md);
75
+ expect(task.status).toBe("Todo");
76
+ });
77
+
78
+ it("should default empty dependencies", () => {
79
+ const task = parseTask(SAMPLE_MD);
80
+ expect(task.dependencies).toEqual([]);
81
+ });
82
+
83
+ it("should throw for missing required fields", () => {
84
+ const md = `---\ntitle: Test\n---\nBody`;
85
+ expect(() => parseTask(md)).toThrow("missing required fields");
86
+ });
87
+
88
+ it("should roundtrip serialize/parse", () => {
89
+ const serialized = serializeTask(SAMPLE_TASK);
90
+ const parsed = parseTask(serialized);
91
+
92
+ expect(parsed.id).toBe(SAMPLE_TASK.id);
93
+ expect(parsed.title).toBe(SAMPLE_TASK.title);
94
+ expect(parsed.status).toBe(SAMPLE_TASK.status);
95
+ expect(parsed.priority).toBe(SAMPLE_TASK.priority);
96
+ expect(parsed.description).toBe(SAMPLE_TASK.description);
97
+ });
98
+ });
@@ -0,0 +1,98 @@
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 { AdvisorDb } from "../db.js";
6
+ import { buildLocalSummary, formatStatusOutput } from "../local-summary.js";
7
+ import { buildContext, classifyQuestion } from "../context-builder.js";
8
+ import { createTask, ensureRepo, listTasks } from "../../repository.js";
9
+
10
+ let tmpDir: string;
11
+
12
+ beforeEach(async () => {
13
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "advisor-integ-"));
14
+ await ensureRepo(tmpDir);
15
+ });
16
+
17
+ afterEach(async () => {
18
+ await fs.rm(tmpDir, { recursive: true, force: true });
19
+ });
20
+
21
+ describe("Advisor Integration", () => {
22
+ it("should build local summary from real tasks", async () => {
23
+ await createTask(tmpDir, { title: "Task 1", status: "Done" });
24
+ await createTask(tmpDir, { title: "Task 2", status: "Todo" });
25
+ await createTask(tmpDir, { title: "Task 3", status: "InProgress" });
26
+
27
+ const tasks = await listTasks(tmpDir);
28
+ const summary = buildLocalSummary(tasks);
29
+
30
+ expect(summary.total).toBe(3);
31
+ expect(summary.done).toBe(1);
32
+ expect(summary.progressPercent).toBe(33);
33
+ });
34
+
35
+ it("should build context with DB decisions", async () => {
36
+ await createTask(tmpDir, { title: "Test Task" });
37
+
38
+ const dbPath = path.join(tmpDir, ".taskflow", "advisor.db");
39
+ const db = await AdvisorDb.open(dbPath);
40
+ db.insertDecision("s1", "Test decision", "Test reason", ["1"]);
41
+
42
+ const context = await buildContext({
43
+ command: "status",
44
+ projectRoot: tmpDir,
45
+ db,
46
+ });
47
+
48
+ expect(context.tasks.length).toBe(1);
49
+ expect(context.decisions.length).toBe(1);
50
+ expect(context.decisions[0].decision).toBe("Test decision");
51
+
52
+ db.close();
53
+ });
54
+
55
+ it("should persist DB, reload, and retain data", async () => {
56
+ const dbPath = path.join(tmpDir, ".taskflow", "advisor.db");
57
+
58
+ const db1 = await AdvisorDb.open(dbPath);
59
+ db1.insertLog("ask", "s1", "user", "테스트 질문");
60
+ db1.insertDecision("s1", "테스트 결정", "이유", []);
61
+ await db1.persistToDisk();
62
+ db1.close();
63
+
64
+ const db2 = await AdvisorDb.open(dbPath);
65
+ expect(db2.getLogsBySession("s1")).toHaveLength(1);
66
+ expect(db2.getAllDecisions()).toHaveLength(1);
67
+ db2.close();
68
+ });
69
+
70
+ it("should format status output correctly", async () => {
71
+ await createTask(tmpDir, { title: "Done task", status: "Done" });
72
+ await createTask(tmpDir, { title: "Todo task", status: "Todo" });
73
+
74
+ const tasks = await listTasks(tmpDir);
75
+ const summary = buildLocalSummary(tasks);
76
+ const output = formatStatusOutput(summary);
77
+
78
+ expect(output).toContain("1/2");
79
+ expect(output).toContain("50%");
80
+ });
81
+
82
+ it("should classify questions correctly in context building", async () => {
83
+ await createTask(tmpDir, { title: "Test" });
84
+ const dbPath = path.join(tmpDir, ".taskflow", "advisor.db");
85
+ const db = await AdvisorDb.open(dbPath);
86
+
87
+ const ctx = await buildContext({
88
+ command: "ask",
89
+ projectRoot: tmpDir,
90
+ db,
91
+ question: "최근 코드 변경 뭐 있어?",
92
+ });
93
+
94
+ expect(ctx.tasks.length).toBe(1);
95
+
96
+ db.close();
97
+ });
98
+ });
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { getInsight, getRecommendation, getAnswer } from "./ai-advisor.js";
3
+ import type { AdvisorContext } from "../../types.js";
4
+ import type { LocalSummary } from "./local-summary.js";
5
+
6
+ const mockContext: AdvisorContext = {
7
+ tasks: [
8
+ { id: "1", title: "Task A", status: "Done", priority: 5, dependencies: [] },
9
+ { id: "2", title: "Task B", status: "Todo", priority: 3, dependencies: ["1"] },
10
+ ],
11
+ decisions: [],
12
+ };
13
+
14
+ const mockSummary: LocalSummary = {
15
+ total: 2,
16
+ done: 1,
17
+ inProgress: 0,
18
+ todo: 1,
19
+ blocked: 0,
20
+ progressPercent: 50,
21
+ groups: { Done: [], InProgress: [], Todo: [], Blocked: [] },
22
+ };
23
+
24
+ describe("getInsight", () => {
25
+ it("should throw when AI client is unavailable", async () => {
26
+ await expect(getInsight(mockContext, mockSummary)).rejects.toThrow();
27
+ });
28
+ });
29
+
30
+ describe("getRecommendation", () => {
31
+ it("should throw when AI client is unavailable", async () => {
32
+ await expect(getRecommendation(mockContext)).rejects.toThrow();
33
+ });
34
+ });
35
+
36
+ describe("getAnswer", () => {
37
+ it("should throw when AI client is unavailable", async () => {
38
+ await expect(getAnswer(mockContext, "다음 뭐 해?")).rejects.toThrow();
39
+ });
40
+ });
@@ -0,0 +1,20 @@
1
+ import type { AdvisorContext } from "../../types.js";
2
+ import type { LocalSummary } from "./local-summary.js";
3
+
4
+ export async function getInsight(
5
+ _context: AdvisorContext,
6
+ _summary: LocalSummary
7
+ ): Promise<string> {
8
+ throw new Error("AI 인사이트 기능은 Claude Code 스킬을 통해 제공됩니다.");
9
+ }
10
+
11
+ export async function getRecommendation(_context: AdvisorContext): Promise<string> {
12
+ throw new Error("AI 추천 기능은 Claude Code 스킬을 통해 제공됩니다.");
13
+ }
14
+
15
+ export async function getAnswer(
16
+ _context: AdvisorContext,
17
+ _question: string
18
+ ): Promise<string> {
19
+ throw new Error("AI 답변 기능은 Claude Code 스킬을 통해 제공됩니다.");
20
+ }
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { classifyQuestion, buildContext, estimateTokens } from "./context-builder.js";
6
+ import { AdvisorDb } from "./db.js";
7
+ import { createTask, ensureRepo } from "../repository.js";
8
+
9
+ let tmpDir: string;
10
+
11
+ beforeEach(async () => {
12
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ctx-builder-"));
13
+ await ensureRepo(tmpDir);
14
+ });
15
+
16
+ afterEach(async () => {
17
+ await fs.rm(tmpDir, { recursive: true, force: true });
18
+ });
19
+
20
+ describe("classifyQuestion", () => {
21
+ it("should detect code-related keywords", () => {
22
+ const result = classifyQuestion("최근 코드 변경사항 알려줘");
23
+ expect(result.needsGitDiff).toBe(true);
24
+ });
25
+
26
+ it("should detect decision-related keywords", () => {
27
+ const result = classifyQuestion("왜 인증을 빼기로 결정했어?");
28
+ expect(result.needsConversationLogs).toBe(true);
29
+ });
30
+
31
+ it("should detect planning keywords", () => {
32
+ const result = classifyQuestion("전체 목표가 뭐야?");
33
+ expect(result.needsTrdPrd).toBe(true);
34
+ });
35
+
36
+ it("should return defaults for generic questions", () => {
37
+ const result = classifyQuestion("다음 뭐 해?");
38
+ expect(result.needsGitDiff).toBe(false);
39
+ expect(result.needsConversationLogs).toBe(false);
40
+ expect(result.needsTrdPrd).toBe(false);
41
+ });
42
+ });
43
+
44
+ describe("buildContext", () => {
45
+ it("should always include tasks and decisions for status command", async () => {
46
+ await createTask(tmpDir, { title: "Test task" });
47
+ const db = await AdvisorDb.open(path.join(tmpDir, ".taskflow", "advisor.db"));
48
+ db.insertDecision("s1", "test decision", "reason", []);
49
+
50
+ const ctx = await buildContext({ command: "status", projectRoot: tmpDir, db });
51
+ expect(ctx.tasks.length).toBeGreaterThan(0);
52
+ expect(ctx.decisions.length).toBeGreaterThan(0);
53
+ expect(ctx.gitDiff).toBeUndefined();
54
+
55
+ db.close();
56
+ });
57
+
58
+ it("should include TRD/PRD for next command", async () => {
59
+ await createTask(tmpDir, { title: "Test task" });
60
+ const db = await AdvisorDb.open(path.join(tmpDir, ".taskflow", "advisor.db"));
61
+
62
+ const ctx = await buildContext({ command: "next", projectRoot: tmpDir, db });
63
+ expect(ctx.tasks.length).toBeGreaterThan(0);
64
+
65
+ db.close();
66
+ });
67
+ });
68
+
69
+ describe("estimateTokens", () => {
70
+ it("should approximate tokens from character count", () => {
71
+ expect(estimateTokens("hello world")).toBe(3); // 11 chars / 4 = 2.75 → 3
72
+ });
73
+ });
@@ -0,0 +1,151 @@
1
+ import { execSync } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import type { AdvisorContext, TaskSummary } from "../../types.js";
5
+ import { listTasks } from "../repository.js";
6
+ import type { AdvisorDb } from "./db.js";
7
+
8
+ const TOKEN_BUDGET = {
9
+ status: 4_000,
10
+ next: 8_000,
11
+ ask: 12_000,
12
+ } as const;
13
+
14
+ const CODE_KEYWORDS = ["코드", "git", "커밋", "변경", "diff", "코드변경", "수정"];
15
+ const DECISION_KEYWORDS = ["왜", "이유", "결정", "배경", "판단"];
16
+ const PLANNING_KEYWORDS = ["계획", "전체", "목표", "방향", "prd", "trd"];
17
+
18
+ export interface QuestionClassification {
19
+ needsGitDiff: boolean;
20
+ needsConversationLogs: boolean;
21
+ needsTrdPrd: boolean;
22
+ }
23
+
24
+ export function classifyQuestion(question: string): QuestionClassification {
25
+ const q = question.toLowerCase();
26
+ return {
27
+ needsGitDiff: CODE_KEYWORDS.some((kw) => q.includes(kw)),
28
+ needsConversationLogs: DECISION_KEYWORDS.some((kw) => q.includes(kw)),
29
+ needsTrdPrd: PLANNING_KEYWORDS.some((kw) => q.includes(kw)),
30
+ };
31
+ }
32
+
33
+ export function estimateTokens(text: string): number {
34
+ return Math.ceil(text.length / 4);
35
+ }
36
+
37
+ interface BuildContextOptions {
38
+ command: "status" | "next" | "ask";
39
+ projectRoot: string;
40
+ db: AdvisorDb;
41
+ question?: string;
42
+ }
43
+
44
+ export async function buildContext(opts: BuildContextOptions): Promise<AdvisorContext> {
45
+ const { command, projectRoot, db, question } = opts;
46
+ const budget = TOKEN_BUDGET[command];
47
+
48
+ const tasks = await listTasks(projectRoot);
49
+ const taskSummaries: TaskSummary[] = tasks.map((t) => ({
50
+ id: t.id,
51
+ title: t.title,
52
+ status: t.status,
53
+ priority: t.priority,
54
+ dependencies: t.dependencies,
55
+ }));
56
+
57
+ const decisionLimit = command === "status" ? 10 : 20;
58
+ const decisions = db.getRecentDecisions(decisionLimit);
59
+
60
+ const context: AdvisorContext = { tasks: taskSummaries, decisions };
61
+
62
+ if (command === "status") return context;
63
+
64
+ if (command === "next") {
65
+ context.trdContent = await safeReadTrdPrd(projectRoot, "trd");
66
+ context.prdContent = await safeReadTrdPrd(projectRoot, "prd");
67
+ return truncateContext(context, budget);
68
+ }
69
+
70
+ if (command === "ask" && question) {
71
+ const classification = classifyQuestion(question);
72
+
73
+ if (classification.needsTrdPrd) {
74
+ context.trdContent = await safeReadTrdPrd(projectRoot, "trd");
75
+ context.prdContent = await safeReadTrdPrd(projectRoot, "prd");
76
+ }
77
+
78
+ if (classification.needsGitDiff) {
79
+ context.gitDiff = safeGitDiff(projectRoot);
80
+ }
81
+
82
+ if (classification.needsConversationLogs) {
83
+ context.conversationLogs = db.getLogsByType("brainstorm")
84
+ .concat(db.getLogsByType("refine"))
85
+ .concat(db.getLogsByType("prd"))
86
+ .slice(-50);
87
+ }
88
+
89
+ return truncateContext(context, budget);
90
+ }
91
+
92
+ return context;
93
+ }
94
+
95
+ async function safeReadTrdPrd(projectRoot: string, type: "trd" | "prd"): Promise<string | undefined> {
96
+ const candidates = [
97
+ path.join(projectRoot, "vooster-docs", `${type}.md`),
98
+ path.join(projectRoot, ".taskflow", `${type}.md`),
99
+ ];
100
+
101
+ for (const candidate of candidates) {
102
+ try {
103
+ return await fs.readFile(candidate, "utf-8");
104
+ } catch {
105
+ continue;
106
+ }
107
+ }
108
+
109
+ return undefined;
110
+ }
111
+
112
+ function safeGitDiff(projectRoot: string): string | undefined {
113
+ try {
114
+ const diff = execSync("git diff --stat HEAD~3", {
115
+ cwd: projectRoot,
116
+ encoding: "utf-8",
117
+ timeout: 5_000,
118
+ });
119
+ return diff || undefined;
120
+ } catch {
121
+ return undefined;
122
+ }
123
+ }
124
+
125
+ function truncateContext(context: AdvisorContext, maxTokens: number): AdvisorContext {
126
+ let totalTokens = estimateTokens(JSON.stringify(context));
127
+
128
+ if (totalTokens <= maxTokens) return context;
129
+
130
+ if (context.conversationLogs && totalTokens > maxTokens) {
131
+ context.conversationLogs = context.conversationLogs.slice(-20);
132
+ totalTokens = estimateTokens(JSON.stringify(context));
133
+ }
134
+
135
+ if (context.gitDiff && totalTokens > maxTokens) {
136
+ const lines = context.gitDiff.split("\n");
137
+ context.gitDiff = lines.slice(-5).join("\n");
138
+ totalTokens = estimateTokens(JSON.stringify(context));
139
+ }
140
+
141
+ if (context.trdContent && totalTokens > maxTokens) {
142
+ context.trdContent = context.trdContent.slice(0, 2000) + "\n...(truncated)";
143
+ totalTokens = estimateTokens(JSON.stringify(context));
144
+ }
145
+
146
+ if (context.prdContent && totalTokens > maxTokens) {
147
+ context.prdContent = context.prdContent.slice(0, 2000) + "\n...(truncated)";
148
+ }
149
+
150
+ return context;
151
+ }
@@ -0,0 +1,106 @@
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 { AdvisorDb } from "./db.js";
6
+
7
+ let tmpDir: string;
8
+ let db: AdvisorDb;
9
+
10
+ beforeEach(async () => {
11
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "advisor-db-"));
12
+ db = await AdvisorDb.open(path.join(tmpDir, "advisor.db"));
13
+ });
14
+
15
+ afterEach(async () => {
16
+ db.close();
17
+ await fs.rm(tmpDir, { recursive: true, force: true });
18
+ });
19
+
20
+ describe("AdvisorDb", () => {
21
+ describe("conversation_logs", () => {
22
+ it("should insert and query conversation logs", async () => {
23
+ db.insertLog("brainstorm", "session-1", "user", "hello");
24
+ db.insertLog("brainstorm", "session-1", "assistant", "hi there");
25
+
26
+ const logs = db.getLogsBySession("session-1");
27
+ expect(logs).toHaveLength(2);
28
+ expect(logs[0].role).toBe("user");
29
+ expect(logs[0].content).toBe("hello");
30
+ expect(logs[1].role).toBe("assistant");
31
+ });
32
+
33
+ it("should filter logs by session type", () => {
34
+ db.insertLog("brainstorm", "s1", "user", "msg1");
35
+ db.insertLog("ask", "s2", "user", "msg2");
36
+
37
+ const brainstormLogs = db.getLogsByType("brainstorm");
38
+ expect(brainstormLogs).toHaveLength(1);
39
+ expect(brainstormLogs[0].sessionId).toBe("s1");
40
+ });
41
+
42
+ it("should delete logs older than N days", () => {
43
+ db.insertLog("ask", "old", "user", "old message");
44
+ db.exec(
45
+ "UPDATE conversation_logs SET created_at = datetime('now', '-10 days') WHERE session_id = 'old'"
46
+ );
47
+ db.insertLog("ask", "new", "user", "new message");
48
+
49
+ const deleted = db.deleteExpiredLogs(7);
50
+ expect(deleted).toBe(1);
51
+
52
+ const remaining = db.getLogsByType("ask");
53
+ expect(remaining).toHaveLength(1);
54
+ expect(remaining[0].sessionId).toBe("new");
55
+ });
56
+ });
57
+
58
+ describe("decisions", () => {
59
+ it("should insert and query decisions", () => {
60
+ db.insertDecision("session-1", "Use sql.js", "No native deps", ["1", "2"]);
61
+
62
+ const decisions = db.getAllDecisions();
63
+ expect(decisions).toHaveLength(1);
64
+ expect(decisions[0].decision).toBe("Use sql.js");
65
+ expect(decisions[0].relatedTasks).toEqual(["1", "2"]);
66
+ });
67
+
68
+ it("should get recent decisions with limit", () => {
69
+ db.insertDecision("s1", "Decision 1", "Reason 1", []);
70
+ db.insertDecision("s2", "Decision 2", "Reason 2", []);
71
+ db.insertDecision("s3", "Decision 3", "Reason 3", []);
72
+
73
+ const recent = db.getRecentDecisions(2);
74
+ expect(recent).toHaveLength(2);
75
+ expect(recent[0].decision).toBe("Decision 3");
76
+ });
77
+ });
78
+
79
+ describe("persistence", () => {
80
+ it("should persist to disk and reload", async () => {
81
+ const dbPath = path.join(tmpDir, "persist-test.db");
82
+ const db1 = await AdvisorDb.open(dbPath);
83
+ db1.insertDecision("s1", "Persisted decision", "reason", []);
84
+ await db1.persistToDisk();
85
+ db1.close();
86
+
87
+ const db2 = await AdvisorDb.open(dbPath);
88
+ const decisions = db2.getAllDecisions();
89
+ expect(decisions).toHaveLength(1);
90
+ expect(decisions[0].decision).toBe("Persisted decision");
91
+ db2.close();
92
+ });
93
+ });
94
+
95
+ describe("stats", () => {
96
+ it("should return correct stats", () => {
97
+ db.insertLog("ask", "s1", "user", "q1");
98
+ db.insertLog("ask", "s1", "assistant", "a1");
99
+ db.insertDecision("s1", "d1", "r1", []);
100
+
101
+ const stats = db.getStats();
102
+ expect(stats.logCount).toBe(2);
103
+ expect(stats.decisionCount).toBe(1);
104
+ });
105
+ });
106
+ });