@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.
- package/README.md +374 -0
- package/bin/task-mcp.mjs +19 -0
- package/bin/task.mjs +19 -0
- package/docs/clean-code.md +29 -0
- package/docs/git.md +36 -0
- package/docs/guideline.md +25 -0
- package/docs/security.md +32 -0
- package/docs/step-by-step.md +29 -0
- package/docs/superpowers/specs/2026-03-21-cli-advisor-design.md +383 -0
- package/docs/superpowers/specs/2026-03-21-init-redesign-design.md +429 -0
- package/docs/superpowers/specs/2026-03-21-skill-architecture-design.md +362 -0
- package/docs/superpowers/specs/2026-03-23-t-create-task-run-design.md +40 -0
- package/docs/superpowers/specs/2026-03-23-task-run-design.md +44 -0
- package/docs/tdd.md +41 -0
- package/package.json +114 -0
- package/src/app/(protected)/dashboard/page.tsx +7 -0
- package/src/app/(protected)/layout.tsx +10 -0
- package/src/app/api/[[...hono]]/route.ts +13 -0
- package/src/app/example/page.tsx +11 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +168 -0
- package/src/app/layout.tsx +35 -0
- package/src/app/page.tsx +5 -0
- package/src/app/providers.tsx +57 -0
- package/src/backend/config/index.ts +36 -0
- package/src/backend/hono/app.ts +32 -0
- package/src/backend/hono/context.ts +38 -0
- package/src/backend/http/response.ts +64 -0
- package/src/backend/middleware/context.ts +23 -0
- package/src/backend/middleware/error.ts +31 -0
- package/src/backend/middleware/supabase.ts +23 -0
- package/src/backend/supabase/client.ts +17 -0
- package/src/cli/commands/__tests__/task-commands.test.ts +170 -0
- package/src/cli/commands/advisor.ts +45 -0
- package/src/cli/commands/ask.ts +50 -0
- package/src/cli/commands/board.ts +72 -0
- package/src/cli/commands/init.ts +184 -0
- package/src/cli/commands/list.ts +138 -0
- package/src/cli/commands/run.ts +143 -0
- package/src/cli/commands/set-status.ts +50 -0
- package/src/cli/commands/show.ts +28 -0
- package/src/cli/commands/tree.ts +72 -0
- package/src/cli/index.ts +38 -0
- package/src/cli/lib/__tests__/formatter.test.ts +123 -0
- package/src/cli/lib/error-boundary.test.ts +135 -0
- package/src/cli/lib/error-boundary.ts +70 -0
- package/src/cli/lib/formatter.ts +764 -0
- package/src/cli/lib/trd.ts +33 -0
- package/src/cli/lib/validate.test.ts +89 -0
- package/src/cli/lib/validate.ts +43 -0
- package/src/cli/prompts/task-run.md +25 -0
- package/src/components/layout/AppLayout.tsx +15 -0
- package/src/components/layout/Sidebar.tsx +124 -0
- package/src/components/ui/accordion.tsx +58 -0
- package/src/components/ui/avatar.tsx +50 -0
- package/src/components/ui/badge.tsx +36 -0
- package/src/components/ui/button.tsx +56 -0
- package/src/components/ui/card.tsx +79 -0
- package/src/components/ui/checkbox.tsx +30 -0
- package/src/components/ui/dialog.tsx +122 -0
- package/src/components/ui/dropdown-menu.tsx +200 -0
- package/src/components/ui/file-upload.tsx +50 -0
- package/src/components/ui/form.tsx +179 -0
- package/src/components/ui/input.tsx +25 -0
- package/src/components/ui/label.tsx +26 -0
- package/src/components/ui/scroll-area.tsx +48 -0
- package/src/components/ui/select.tsx +160 -0
- package/src/components/ui/separator.tsx +31 -0
- package/src/components/ui/sheet.tsx +140 -0
- package/src/components/ui/textarea.tsx +22 -0
- package/src/components/ui/toast.tsx +129 -0
- package/src/components/ui/toaster.tsx +35 -0
- package/src/core/ai/claude-client.ts +79 -0
- package/src/core/claude-runner/flag-builder.ts +57 -0
- package/src/core/claude-runner/index.ts +2 -0
- package/src/core/claude-runner/spawner.ts +86 -0
- package/src/core/prd/__tests__/auto-analyzer.test.ts +35 -0
- package/src/core/prd/__tests__/generator.test.ts +26 -0
- package/src/core/prd/__tests__/scanner.test.ts +35 -0
- package/src/core/prd/auto-analyzer.ts +9 -0
- package/src/core/prd/generator.ts +8 -0
- package/src/core/prd/scanner.ts +117 -0
- package/src/core/project/__tests__/claude-setup.test.ts +133 -0
- package/src/core/project/__tests__/config.test.ts +30 -0
- package/src/core/project/__tests__/init.test.ts +37 -0
- package/src/core/project/__tests__/skill-setup.test.ts +62 -0
- package/src/core/project/claude-setup.ts +224 -0
- package/src/core/project/config.ts +34 -0
- package/src/core/project/docs-setup.ts +26 -0
- package/src/core/project/docs-templates.ts +205 -0
- package/src/core/project/init.ts +40 -0
- package/src/core/project/skill-setup.ts +32 -0
- package/src/core/project/skill-templates.ts +277 -0
- package/src/core/task/index.ts +16 -0
- package/src/core/types.ts +58 -0
- package/src/features/example/backend/error.ts +9 -0
- package/src/features/example/backend/route.ts +52 -0
- package/src/features/example/backend/schema.ts +25 -0
- package/src/features/example/backend/service.ts +73 -0
- package/src/features/example/components/example-status.test.tsx +97 -0
- package/src/features/example/components/example-status.tsx +160 -0
- package/src/features/example/hooks/useExampleQuery.ts +23 -0
- package/src/features/example/lib/dto.test.ts +57 -0
- package/src/features/example/lib/dto.ts +5 -0
- package/src/features/kanban/backend/__tests__/sse-broadcaster.test.ts +137 -0
- package/src/features/kanban/backend/__tests__/sse-event-format.test.ts +55 -0
- package/src/features/kanban/backend/route.ts +55 -0
- package/src/features/kanban/backend/sse-broadcaster.ts +142 -0
- package/src/features/kanban/backend/sse-route.ts +43 -0
- package/src/features/kanban/components/KanbanBoard.tsx +105 -0
- package/src/features/kanban/components/KanbanColumn.tsx +51 -0
- package/src/features/kanban/components/KanbanError.tsx +29 -0
- package/src/features/kanban/components/KanbanSkeleton.tsx +46 -0
- package/src/features/kanban/components/ProgressCard.tsx +42 -0
- package/src/features/kanban/components/TaskCard.tsx +76 -0
- package/src/features/kanban/components/__tests__/kanban-components.test.tsx +86 -0
- package/src/features/kanban/hooks/useTaskSse.ts +66 -0
- package/src/features/kanban/hooks/useTasksQuery.ts +52 -0
- package/src/features/kanban/lib/__tests__/kanban-utils.test.ts +97 -0
- package/src/features/kanban/lib/kanban-utils.ts +37 -0
- package/src/features/taskflow/constants.ts +54 -0
- package/src/features/taskflow/index.ts +27 -0
- package/src/features/taskflow/lib/__tests__/filter.test.ts +89 -0
- package/src/features/taskflow/lib/__tests__/graph.test.ts +247 -0
- package/src/features/taskflow/lib/__tests__/repository.test.ts +233 -0
- package/src/features/taskflow/lib/__tests__/serializer.test.ts +98 -0
- package/src/features/taskflow/lib/advisor/__tests__/advisor-integration.test.ts +98 -0
- package/src/features/taskflow/lib/advisor/ai-advisor.test.ts +40 -0
- package/src/features/taskflow/lib/advisor/ai-advisor.ts +20 -0
- package/src/features/taskflow/lib/advisor/context-builder.test.ts +73 -0
- package/src/features/taskflow/lib/advisor/context-builder.ts +151 -0
- package/src/features/taskflow/lib/advisor/db.test.ts +106 -0
- package/src/features/taskflow/lib/advisor/db.ts +185 -0
- package/src/features/taskflow/lib/advisor/local-summary.test.ts +53 -0
- package/src/features/taskflow/lib/advisor/local-summary.ts +72 -0
- package/src/features/taskflow/lib/advisor/prompts.ts +86 -0
- package/src/features/taskflow/lib/filter.ts +54 -0
- package/src/features/taskflow/lib/fs-utils.ts +50 -0
- package/src/features/taskflow/lib/graph.ts +148 -0
- package/src/features/taskflow/lib/index-builder.ts +42 -0
- package/src/features/taskflow/lib/repository.ts +168 -0
- package/src/features/taskflow/lib/serializer.ts +62 -0
- package/src/features/taskflow/lib/watcher.ts +40 -0
- package/src/features/taskflow/types.ts +71 -0
- package/src/hooks/use-toast.ts +194 -0
- package/src/lib/remote/api-client.ts +40 -0
- package/src/lib/supabase/client.ts +8 -0
- package/src/lib/supabase/server.ts +46 -0
- package/src/lib/supabase/types.ts +3 -0
- package/src/lib/utils.ts +6 -0
- package/src/mcp/index.ts +7 -0
- package/src/mcp/server.ts +21 -0
- package/src/mcp/tools/brainstorm.ts +48 -0
- package/src/mcp/tools/prd.ts +71 -0
- package/src/mcp/tools/project.ts +39 -0
- package/src/mcp/tools/task-status.ts +40 -0
- package/src/mcp/tools/task.ts +82 -0
- package/src/mcp/util.ts +6 -0
|
@@ -0,0 +1,170 @@
|
|
|
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
|
+
createTask,
|
|
7
|
+
readTask,
|
|
8
|
+
listTasks,
|
|
9
|
+
updateTask,
|
|
10
|
+
ensureRepo,
|
|
11
|
+
} from "@/features/taskflow/lib/repository";
|
|
12
|
+
import { filterTasks, sortTasks } from "@/features/taskflow/lib/filter";
|
|
13
|
+
import type { Task } from "@/features/taskflow/types";
|
|
14
|
+
|
|
15
|
+
let tmpDir: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(async () => {
|
|
18
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "task-cmd-test-"));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("set-status integration", () => {
|
|
26
|
+
it("should change task status and update file", async () => {
|
|
27
|
+
const task = await createTask(tmpDir, { title: "My task", status: "Todo" });
|
|
28
|
+
const updated = await updateTask(tmpDir, task.id, { status: "InProgress" });
|
|
29
|
+
|
|
30
|
+
expect(updated.status).toBe("InProgress");
|
|
31
|
+
expect(updated.updatedAt).not.toBe(task.updatedAt);
|
|
32
|
+
|
|
33
|
+
// Verify file was persisted
|
|
34
|
+
const reRead = await readTask(tmpDir, task.id);
|
|
35
|
+
expect(reRead!.status).toBe("InProgress");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should update TASKS.md index after status change", async () => {
|
|
39
|
+
const task = await createTask(tmpDir, { title: "Index task" });
|
|
40
|
+
await updateTask(tmpDir, task.id, { status: "Done" });
|
|
41
|
+
|
|
42
|
+
const indexPath = path.join(tmpDir, ".taskflow", "index", "TASKS.md");
|
|
43
|
+
const content = await fs.readFile(indexPath, "utf-8");
|
|
44
|
+
expect(content).toContain("Done");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("should reject invalid status", async () => {
|
|
48
|
+
const task = await createTask(tmpDir, { title: "Invalid" });
|
|
49
|
+
// updateTask accepts TaskUpdateInput which has TaskStatus type
|
|
50
|
+
// But we test that the CLI layer validates — here just test the type
|
|
51
|
+
const reRead = await readTask(tmpDir, task.id);
|
|
52
|
+
expect(reRead!.status).toBe("Todo");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("filter integration", () => {
|
|
57
|
+
let tasks: Task[];
|
|
58
|
+
|
|
59
|
+
beforeEach(async () => {
|
|
60
|
+
tasks = [];
|
|
61
|
+
tasks.push(await createTask(tmpDir, { title: "A", status: "Todo", priority: 3 }));
|
|
62
|
+
tasks.push(await createTask(tmpDir, { title: "B", status: "InProgress", priority: 7 }));
|
|
63
|
+
tasks.push(await createTask(tmpDir, { title: "C", status: "Done", priority: 1 }));
|
|
64
|
+
tasks.push(
|
|
65
|
+
await createTask(tmpDir, {
|
|
66
|
+
title: "D",
|
|
67
|
+
status: "Blocked",
|
|
68
|
+
priority: 5,
|
|
69
|
+
dependencies: [tasks[0].id],
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("should filter by status", async () => {
|
|
75
|
+
const result = await listTasks(tmpDir, { filter: { status: "Todo" } });
|
|
76
|
+
expect(result).toHaveLength(1);
|
|
77
|
+
expect(result[0].title).toBe("A");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("should filter by multiple statuses", async () => {
|
|
81
|
+
const result = await listTasks(tmpDir, {
|
|
82
|
+
filter: { status: ["Todo", "InProgress"] },
|
|
83
|
+
});
|
|
84
|
+
expect(result).toHaveLength(2);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("should filter by priority", async () => {
|
|
88
|
+
const result = await listTasks(tmpDir, { filter: { priority: 7 } });
|
|
89
|
+
expect(result).toHaveLength(1);
|
|
90
|
+
expect(result[0].title).toBe("B");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("should filter by hasDependency", async () => {
|
|
94
|
+
const result = await listTasks(tmpDir, {
|
|
95
|
+
filter: { hasDependency: tasks[0].id },
|
|
96
|
+
});
|
|
97
|
+
expect(result).toHaveLength(1);
|
|
98
|
+
expect(result[0].title).toBe("D");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("should filter by updatedSince", async () => {
|
|
102
|
+
// All tasks were just created, so they should all pass a past date
|
|
103
|
+
const yesterday = new Date(Date.now() - 86_400_000).toISOString();
|
|
104
|
+
const result = await listTasks(tmpDir, {
|
|
105
|
+
filter: { updatedSince: yesterday },
|
|
106
|
+
});
|
|
107
|
+
expect(result).toHaveLength(4);
|
|
108
|
+
|
|
109
|
+
// Future date should return none
|
|
110
|
+
const tomorrow = new Date(Date.now() + 86_400_000).toISOString();
|
|
111
|
+
const result2 = await listTasks(tmpDir, {
|
|
112
|
+
filter: { updatedSince: tomorrow },
|
|
113
|
+
});
|
|
114
|
+
expect(result2).toHaveLength(0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("should sort by priority descending", async () => {
|
|
118
|
+
const result = await listTasks(tmpDir, {
|
|
119
|
+
sortKey: "priority",
|
|
120
|
+
sortOrder: "desc",
|
|
121
|
+
});
|
|
122
|
+
expect(result.map((t) => t.priority)).toEqual([7, 5, 3, 1]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("should sort by status order", async () => {
|
|
126
|
+
const result = await listTasks(tmpDir, {
|
|
127
|
+
sortKey: "status",
|
|
128
|
+
sortOrder: "asc",
|
|
129
|
+
});
|
|
130
|
+
expect(result.map((t) => t.status)).toEqual([
|
|
131
|
+
"Todo",
|
|
132
|
+
"InProgress",
|
|
133
|
+
"Blocked",
|
|
134
|
+
"Done",
|
|
135
|
+
]);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("performance", () => {
|
|
140
|
+
it("should list 1000 tasks under 200ms", async () => {
|
|
141
|
+
await ensureRepo(tmpDir);
|
|
142
|
+
|
|
143
|
+
// Batch create 1000 task files directly for speed
|
|
144
|
+
const tasksDir = path.join(tmpDir, ".taskflow", "tasks");
|
|
145
|
+
const writes = Array.from({ length: 1000 }, (_, i) => {
|
|
146
|
+
const id = String(i + 1).padStart(4, "0");
|
|
147
|
+
const content = [
|
|
148
|
+
"---",
|
|
149
|
+
`id: '${id}'`,
|
|
150
|
+
`title: Task ${id}`,
|
|
151
|
+
"status: Todo",
|
|
152
|
+
`priority: ${(i % 10) + 1}`,
|
|
153
|
+
`createdAt: '2026-03-21T00:00:00.000Z'`,
|
|
154
|
+
`updatedAt: '2026-03-21T00:00:00.000Z'`,
|
|
155
|
+
"---",
|
|
156
|
+
`Description for task ${id}`,
|
|
157
|
+
"",
|
|
158
|
+
].join("\n");
|
|
159
|
+
return fs.writeFile(path.join(tasksDir, `task-${id}.md`), content, "utf-8");
|
|
160
|
+
});
|
|
161
|
+
await Promise.all(writes);
|
|
162
|
+
|
|
163
|
+
const start = performance.now();
|
|
164
|
+
const tasks = await listTasks(tmpDir);
|
|
165
|
+
const elapsed = performance.now() - start;
|
|
166
|
+
|
|
167
|
+
expect(tasks).toHaveLength(1000);
|
|
168
|
+
expect(elapsed).toBeLessThan(1000);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { withCliErrorBoundary } from "../lib/error-boundary.js";
|
|
4
|
+
import { ensureRepo } from "@/features/taskflow/lib/repository";
|
|
5
|
+
import { getAdvisorDbPath } from "@/features/taskflow/constants";
|
|
6
|
+
import { AdvisorDb } from "@/features/taskflow/lib/advisor/db";
|
|
7
|
+
|
|
8
|
+
export function registerAdvisorCommand(program: Command) {
|
|
9
|
+
program
|
|
10
|
+
.command("advisor")
|
|
11
|
+
.description("AI 비서 관리 유틸리티")
|
|
12
|
+
.option("--cleanup", "만료된 대화 로그를 삭제합니다 (기본: 7일)")
|
|
13
|
+
.option("--days <n>", "로그 보관 기간 (일)", parseInt, 7)
|
|
14
|
+
.option("--stats", "DB 통계를 표시합니다")
|
|
15
|
+
.action(
|
|
16
|
+
withCliErrorBoundary(async (opts: { cleanup?: boolean; days: number; stats?: boolean }) => {
|
|
17
|
+
const projectRoot = process.cwd();
|
|
18
|
+
await ensureRepo(projectRoot);
|
|
19
|
+
|
|
20
|
+
const db = await AdvisorDb.open(getAdvisorDbPath(projectRoot));
|
|
21
|
+
|
|
22
|
+
if (opts.cleanup) {
|
|
23
|
+
const deleted = db.deleteExpiredLogs(opts.days);
|
|
24
|
+
await db.persistToDisk();
|
|
25
|
+
console.log(chalk.green(`✔ ${deleted}개의 만료된 로그를 삭제했습니다.`));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (opts.stats) {
|
|
29
|
+
const stats = db.getStats();
|
|
30
|
+
console.log("");
|
|
31
|
+
console.log(chalk.bold("📊 Advisor DB 통계"));
|
|
32
|
+
console.log(` 대화 로그: ${stats.logCount}개`);
|
|
33
|
+
console.log(` 결정 기록: ${stats.decisionCount}개`);
|
|
34
|
+
console.log(` DB 크기: ${(stats.dbSizeBytes / 1024).toFixed(1)} KB`);
|
|
35
|
+
console.log("");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!opts.cleanup && !opts.stats) {
|
|
39
|
+
console.log(chalk.yellow("옵션을 지정해주세요. --help로 사용법을 확인하세요."));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
db.close();
|
|
43
|
+
})
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import { withCliErrorBoundary } from "../lib/error-boundary.js";
|
|
5
|
+
import { ensureRepo } from "@/features/taskflow/lib/repository";
|
|
6
|
+
import { getAdvisorDbPath } from "@/features/taskflow/constants";
|
|
7
|
+
import { AdvisorDb } from "@/features/taskflow/lib/advisor/db";
|
|
8
|
+
import { buildContext } from "@/features/taskflow/lib/advisor/context-builder";
|
|
9
|
+
import { getAnswer } from "@/features/taskflow/lib/advisor/ai-advisor";
|
|
10
|
+
|
|
11
|
+
export function registerAskCommand(program: Command) {
|
|
12
|
+
program
|
|
13
|
+
.command("ask")
|
|
14
|
+
.description("AI 비서에게 프로젝트에 대해 자유롭게 질문합니다")
|
|
15
|
+
.argument("<question>", "질문 내용")
|
|
16
|
+
.action(
|
|
17
|
+
withCliErrorBoundary(async (question: string) => {
|
|
18
|
+
const projectRoot = process.cwd();
|
|
19
|
+
await ensureRepo(projectRoot);
|
|
20
|
+
|
|
21
|
+
const spinner = ora("답변 생성 중...").start();
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const db = await AdvisorDb.open(getAdvisorDbPath(projectRoot));
|
|
25
|
+
const context = await buildContext({
|
|
26
|
+
command: "ask",
|
|
27
|
+
projectRoot,
|
|
28
|
+
db,
|
|
29
|
+
question,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const answer = await getAnswer(context, question);
|
|
33
|
+
|
|
34
|
+
const sessionId = `ask-${Date.now()}`;
|
|
35
|
+
db.insertLog("ask", sessionId, "user", question);
|
|
36
|
+
db.insertLog("ask", sessionId, "assistant", answer);
|
|
37
|
+
db.persistToDiskAsync();
|
|
38
|
+
db.close();
|
|
39
|
+
|
|
40
|
+
spinner.stop();
|
|
41
|
+
console.log("");
|
|
42
|
+
console.log(answer);
|
|
43
|
+
console.log("");
|
|
44
|
+
} catch {
|
|
45
|
+
spinner.stop();
|
|
46
|
+
console.log(chalk.red("AI 연결 실패. 잠시 후 다시 시도해주세요."));
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { listTasks } from "@/features/taskflow/lib/repository";
|
|
4
|
+
import { formatKanbanBoard } from "../lib/formatter.js";
|
|
5
|
+
import { withCliErrorBoundary } from "../lib/error-boundary.js";
|
|
6
|
+
import type { Task } from "@/features/taskflow/types";
|
|
7
|
+
import { getTrdGroupNames } from "../lib/trd.js";
|
|
8
|
+
|
|
9
|
+
function formatGroupBoard(tasks: Task[], trdGroupNames: string[]): void {
|
|
10
|
+
const groups = new Map<string, Task[]>();
|
|
11
|
+
|
|
12
|
+
// TRD 그룹을 먼저 빈 상태로 등록
|
|
13
|
+
for (const name of trdGroupNames) {
|
|
14
|
+
groups.set(name, []);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
for (const task of tasks) {
|
|
18
|
+
const name = task.group ?? "(그룹 없음)";
|
|
19
|
+
let list = groups.get(name);
|
|
20
|
+
if (!list) {
|
|
21
|
+
list = [];
|
|
22
|
+
groups.set(name, list);
|
|
23
|
+
}
|
|
24
|
+
list.push(task);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
for (const [name, groupTasks] of groups) {
|
|
28
|
+
const done = groupTasks.filter((t) => t.status === "Done").length;
|
|
29
|
+
const total = groupTasks.length;
|
|
30
|
+
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
31
|
+
|
|
32
|
+
console.log(chalk.bold(`\n── ${name} (${pct}% · ${done}/${total}) ──\n`));
|
|
33
|
+
console.log(formatKanbanBoard(groupTasks, { compact: true }));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function registerBoardCommand(program: Command) {
|
|
38
|
+
program
|
|
39
|
+
.command("board")
|
|
40
|
+
.alias("kb")
|
|
41
|
+
.description("칸반보드 형태로 태스크를 출력합니다 (그룹별)")
|
|
42
|
+
.option("--detail [group]", "특정 그룹의 상세 칸반보드")
|
|
43
|
+
.action(
|
|
44
|
+
withCliErrorBoundary(
|
|
45
|
+
async (opts: { detail?: string | boolean }) => {
|
|
46
|
+
const projectRoot = process.cwd();
|
|
47
|
+
const tasks = await listTasks(projectRoot);
|
|
48
|
+
const trdGroups = getTrdGroupNames(projectRoot);
|
|
49
|
+
|
|
50
|
+
if (tasks.length === 0 && trdGroups.length === 0) {
|
|
51
|
+
console.log(chalk.yellow("\n⚠ 태스크가 없습니다.\n"));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (opts.detail !== undefined) {
|
|
56
|
+
const groupFilter = typeof opts.detail === "string" ? opts.detail : undefined;
|
|
57
|
+
const filtered = groupFilter
|
|
58
|
+
? tasks.filter((t) => t.group?.includes(groupFilter))
|
|
59
|
+
: tasks;
|
|
60
|
+
|
|
61
|
+
console.log("");
|
|
62
|
+
console.log(formatKanbanBoard(filtered, { compact: false }));
|
|
63
|
+
console.log("");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
formatGroupBoard(tasks, trdGroups);
|
|
68
|
+
console.log(chalk.gray("\n 상세 보기: task board --detail [그룹명]\n"));
|
|
69
|
+
},
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join, basename } from "node:path";
|
|
3
|
+
import type { Command } from "commander";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import ora from "ora";
|
|
6
|
+
import inquirer from "inquirer";
|
|
7
|
+
import { withCliErrorBoundary } from "../lib/error-boundary.js";
|
|
8
|
+
import { initProject } from "../../core/project/init.js";
|
|
9
|
+
import { readConfig, writeConfig } from "../../core/project/config.js";
|
|
10
|
+
import { generateClaudeMd, generateMcpJson, appendClaudeImport, appendDocsReference, setupPlugins } from "../../core/project/claude-setup.js";
|
|
11
|
+
import { installSkills } from "../../core/project/skill-setup.js";
|
|
12
|
+
import { installDocs } from "../../core/project/docs-setup.js";
|
|
13
|
+
import { SKILL_TEMPLATES } from "../../core/project/skill-templates.js";
|
|
14
|
+
import { scanFiles, sampleFiles } from "../../core/prd/scanner.js";
|
|
15
|
+
import { savePrd } from "../../core/prd/generator.js";
|
|
16
|
+
import { runPrdBrainstorm } from "../../core/ai/claude-client.js";
|
|
17
|
+
|
|
18
|
+
export function registerInitCommand(program: Command) {
|
|
19
|
+
program
|
|
20
|
+
.command("init")
|
|
21
|
+
.description("Initialize a new TaskFlow project (setup + Claude Code integration)")
|
|
22
|
+
.action(
|
|
23
|
+
withCliErrorBoundary(async () => {
|
|
24
|
+
const cwd = process.cwd();
|
|
25
|
+
const taskflowDir = join(cwd, ".taskflow");
|
|
26
|
+
const alreadyExists = existsSync(taskflowDir);
|
|
27
|
+
|
|
28
|
+
if (alreadyExists) {
|
|
29
|
+
await handleReinit(cwd);
|
|
30
|
+
} else {
|
|
31
|
+
await handleNewInit(cwd);
|
|
32
|
+
}
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function handleNewInit(cwd: string): Promise<void> {
|
|
38
|
+
console.log(chalk.bold("\n🚀 TaskFlow 프로젝트를 초기화합니다.\n"));
|
|
39
|
+
|
|
40
|
+
// Step 1: Create directory + config
|
|
41
|
+
const spinner = ora("프로젝트 구조 생성 중...").start();
|
|
42
|
+
await initProject(cwd);
|
|
43
|
+
spinner.succeed("프로젝트 구조 생성 완료");
|
|
44
|
+
|
|
45
|
+
// Step 2: .mcp.json
|
|
46
|
+
const mcpSpinner = ora("Claude Code 연동 설정 중...").start();
|
|
47
|
+
await generateMcpJson(cwd);
|
|
48
|
+
mcpSpinner.succeed("Claude Code 연동 설정 완료 (.mcp.json)");
|
|
49
|
+
|
|
50
|
+
// Step 3: Generate CLAUDE.md
|
|
51
|
+
const config = await readConfig(cwd);
|
|
52
|
+
const claudeSpinner = ora("CLAUDE.md 생성 중...").start();
|
|
53
|
+
await generateClaudeMd(cwd, { projectName: config.project.name || basename(cwd) });
|
|
54
|
+
claudeSpinner.succeed("CLAUDE.md 생성 완료");
|
|
55
|
+
|
|
56
|
+
// Step 4: Append import to root CLAUDE.md
|
|
57
|
+
await appendClaudeImport(cwd);
|
|
58
|
+
console.log(chalk.green("✔ 루트 CLAUDE.md에 TaskFlow import 추가 완료"));
|
|
59
|
+
|
|
60
|
+
// Step 5: Install docs
|
|
61
|
+
const docsSpinner = ora("개발 가이드라인 문서 생성 중...").start();
|
|
62
|
+
await installDocs(cwd);
|
|
63
|
+
docsSpinner.succeed("개발 가이드라인 문서 생성 완료 (docs/)");
|
|
64
|
+
|
|
65
|
+
// Step 5.1: Append docs reference to root CLAUDE.md
|
|
66
|
+
await appendDocsReference(cwd);
|
|
67
|
+
console.log(chalk.green("✔ 루트 CLAUDE.md에 docs 참조 추가 완료"));
|
|
68
|
+
|
|
69
|
+
// Step 6: Install skills + symlinks
|
|
70
|
+
const skillSpinner = ora("Claude Code 스킬 설치 중...").start();
|
|
71
|
+
await installSkills(cwd);
|
|
72
|
+
skillSpinner.succeed("Claude Code 스킬 설치 완료");
|
|
73
|
+
|
|
74
|
+
// Step 7: Setup plugins (superpowers, ralph-loop)
|
|
75
|
+
const pluginSpinner = ora("Claude Code 플러그인 설정 중...").start();
|
|
76
|
+
await setupPlugins(cwd);
|
|
77
|
+
pluginSpinner.succeed("Claude Code 플러그인 설정 완료 (superpowers, ralph-loop)");
|
|
78
|
+
|
|
79
|
+
// Step 6: PRD generation
|
|
80
|
+
const { generatePrd } = await inquirer.prompt<{ generatePrd: boolean }>([
|
|
81
|
+
{
|
|
82
|
+
type: "confirm",
|
|
83
|
+
name: "generatePrd",
|
|
84
|
+
message: "PRD를 지금 생성하시겠습니까? (Claude와 대화형으로 진행)",
|
|
85
|
+
default: true,
|
|
86
|
+
},
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
if (generatePrd) {
|
|
90
|
+
console.log(chalk.cyan("\n💬 PRD 브레인스토밍을 시작합니다...\n"));
|
|
91
|
+
|
|
92
|
+
// Scan codebase for context
|
|
93
|
+
const scanSpinner = ora("코드베이스 분석 중...").start();
|
|
94
|
+
let projectContext = "";
|
|
95
|
+
try {
|
|
96
|
+
const files = await scanFiles(cwd);
|
|
97
|
+
if (files.length > 0) {
|
|
98
|
+
const samples = await sampleFiles(files, cwd);
|
|
99
|
+
projectContext = JSON.stringify({ files, samples: samples.slice(0, 20) }, null, 2);
|
|
100
|
+
}
|
|
101
|
+
scanSpinner.succeed(`코드베이스 분석 완료 (${files.length}개 파일)`);
|
|
102
|
+
} catch {
|
|
103
|
+
scanSpinner.warn("코드베이스 분석 스킵");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const result = await runPrdBrainstorm({
|
|
108
|
+
projectRoot: cwd,
|
|
109
|
+
systemPrompt: SKILL_TEMPLATES.prd,
|
|
110
|
+
projectContext,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (result) {
|
|
114
|
+
await savePrd(cwd, result.markdown);
|
|
115
|
+
console.log(chalk.green("\n✔ PRD 저장 완료: .taskflow/prd.md"));
|
|
116
|
+
|
|
117
|
+
// Update config with project name
|
|
118
|
+
const currentConfig = await readConfig(cwd);
|
|
119
|
+
currentConfig.project.name = result.projectName;
|
|
120
|
+
await writeConfig(cwd, currentConfig);
|
|
121
|
+
}
|
|
122
|
+
} catch (error) {
|
|
123
|
+
console.log(chalk.yellow(`\n⚠ PRD 생성 스킵: ${error instanceof Error ? error.message : "알 수 없는 오류"}`));
|
|
124
|
+
console.log(chalk.gray(" 나중에 Claude Code에서 /prd 명령어로 생성할 수 있습니다."));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
console.log(chalk.bold.green("\n✅ TaskFlow 초기화가 완료되었습니다!\n"));
|
|
129
|
+
console.log(chalk.gray(" 다음 단계 (Claude Code에서):"));
|
|
130
|
+
console.log(chalk.gray(" /prd PRD 대화형 생성"));
|
|
131
|
+
console.log(chalk.gray(" /parse-prd PRD → 태스크 분해"));
|
|
132
|
+
console.log(chalk.gray(" /next 다음 태스크 추천\n"));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function handleReinit(cwd: string): Promise<void> {
|
|
136
|
+
console.log(chalk.yellow("\n⚠ TaskFlow 프로젝트가 이미 초기화되어 있습니다.\n"));
|
|
137
|
+
|
|
138
|
+
// Re-install docs (create missing docs only)
|
|
139
|
+
const docsSpinner = ora("개발 가이드라인 문서 확인 중...").start();
|
|
140
|
+
await installDocs(cwd);
|
|
141
|
+
docsSpinner.succeed("개발 가이드라인 문서 확인 완료");
|
|
142
|
+
|
|
143
|
+
// Re-install skills (update to latest templates)
|
|
144
|
+
const skillSpinner = ora("스킬 업데이트 중...").start();
|
|
145
|
+
await installSkills(cwd);
|
|
146
|
+
skillSpinner.succeed("스킬 업데이트 완료");
|
|
147
|
+
|
|
148
|
+
const { regeneratePrd } = await inquirer.prompt<{ regeneratePrd: boolean }>([
|
|
149
|
+
{
|
|
150
|
+
type: "confirm",
|
|
151
|
+
name: "regeneratePrd",
|
|
152
|
+
message: "PRD를 다시 생성하시겠습니까?",
|
|
153
|
+
default: false,
|
|
154
|
+
},
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
if (regeneratePrd) {
|
|
158
|
+
console.log(chalk.cyan("\n💬 PRD 브레인스토밍을 시작합니다...\n"));
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const files = await scanFiles(cwd);
|
|
162
|
+
const samples = files.length > 0 ? await sampleFiles(files, cwd) : [];
|
|
163
|
+
const projectContext =
|
|
164
|
+
files.length > 0
|
|
165
|
+
? JSON.stringify({ files, samples: samples.slice(0, 20) }, null, 2)
|
|
166
|
+
: "";
|
|
167
|
+
|
|
168
|
+
const result = await runPrdBrainstorm({
|
|
169
|
+
projectRoot: cwd,
|
|
170
|
+
systemPrompt: SKILL_TEMPLATES.prd,
|
|
171
|
+
projectContext,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
if (result) {
|
|
175
|
+
await savePrd(cwd, result.markdown);
|
|
176
|
+
console.log(chalk.green("\n✔ PRD 저장 완료: .taskflow/prd.md"));
|
|
177
|
+
}
|
|
178
|
+
} catch (error) {
|
|
179
|
+
console.log(chalk.yellow(`\n⚠ PRD 생성 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
console.log(chalk.gray("\n Claude Code에서 /prd 명령어로도 PRD를 생성/재생성할 수 있습니다.\n"));
|
|
184
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { listTasks } from "@/features/taskflow/lib/repository";
|
|
4
|
+
import { formatTaskTable, formatDashboard } from "../lib/formatter.js";
|
|
5
|
+
import { withCliErrorBoundary } from "../lib/error-boundary.js";
|
|
6
|
+
import type { Task, TaskStatus, TaskSortKey, TaskSortOrder } from "@/features/taskflow/types";
|
|
7
|
+
import { TASK_STATUSES } from "@/features/taskflow/types";
|
|
8
|
+
import { getTrdGroupNames } from "../lib/trd.js";
|
|
9
|
+
|
|
10
|
+
function parseStatus(value: string): TaskStatus {
|
|
11
|
+
if (!(TASK_STATUSES as readonly string[]).includes(value)) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
`유효하지 않은 상태입니다: ${value}\n 허용: ${TASK_STATUSES.join(", ")}`,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
return value as TaskStatus;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function formatGroupList(tasks: Task[], trdGroupNames: string[]): void {
|
|
20
|
+
const groups = new Map<string, { total: number; done: number; inProgress: number; blocked: number }>();
|
|
21
|
+
|
|
22
|
+
// TRD 그룹을 먼저 빈 상태로 등록
|
|
23
|
+
for (const name of trdGroupNames) {
|
|
24
|
+
groups.set(name, { total: 0, done: 0, inProgress: 0, blocked: 0 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
for (const task of tasks) {
|
|
28
|
+
const name = task.group ?? "(그룹 없음)";
|
|
29
|
+
let g = groups.get(name);
|
|
30
|
+
if (!g) {
|
|
31
|
+
g = { total: 0, done: 0, inProgress: 0, blocked: 0 };
|
|
32
|
+
groups.set(name, g);
|
|
33
|
+
}
|
|
34
|
+
g.total++;
|
|
35
|
+
if (task.status === "Done") g.done++;
|
|
36
|
+
if (task.status === "InProgress") g.inProgress++;
|
|
37
|
+
if (task.status === "Blocked") g.blocked++;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
console.log(chalk.bold("\n📋 요구사항 그룹 목록:\n"));
|
|
41
|
+
|
|
42
|
+
for (const [name, g] of groups) {
|
|
43
|
+
const ratio = g.total > 0 ? g.done / g.total : 0;
|
|
44
|
+
const barLen = 10;
|
|
45
|
+
const filled = Math.round(ratio * barLen);
|
|
46
|
+
const bar = chalk.green("█".repeat(filled)) + chalk.gray("░".repeat(barLen - filled));
|
|
47
|
+
const pct = Math.round(ratio * 100);
|
|
48
|
+
|
|
49
|
+
let status = "";
|
|
50
|
+
if (g.total === 0) {
|
|
51
|
+
status = chalk.dim("태스크 없음 — task run으로 시작");
|
|
52
|
+
} else if (g.done === g.total) {
|
|
53
|
+
status = chalk.green("완료!");
|
|
54
|
+
} else {
|
|
55
|
+
const parts: string[] = [`${g.done}/${g.total}`];
|
|
56
|
+
if (g.inProgress > 0) parts.push(chalk.hex("#FFA500")(`${g.inProgress} 진행중`));
|
|
57
|
+
if (g.blocked > 0) parts.push(chalk.red(`${g.blocked} 블로커`));
|
|
58
|
+
status = parts.join(" · ");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.log(` ${bar} ${chalk.bold(name)} ${chalk.gray(`${pct}%`)} ${status}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
console.log(chalk.gray(`\n총 ${groups.size}개 그룹, ${tasks.length}개 태스크`));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function registerListCommand(program: Command) {
|
|
68
|
+
program
|
|
69
|
+
.command("list")
|
|
70
|
+
.alias("ls")
|
|
71
|
+
.description("태스크 목록을 출력합니다")
|
|
72
|
+
.option("--detail [group]", "개별 태스크 상세 보기 (그룹명 지정 가능)")
|
|
73
|
+
.option("--status <status>", "상태로 필터 (Todo, InProgress, Blocked, Done)")
|
|
74
|
+
.option("--priority <n>", "우선순위로 필터", parseInt)
|
|
75
|
+
.option("--dep <id>", "특정 태스크에 의존하는 태스크만 표시")
|
|
76
|
+
.option("--updated-since <date>", "지정 날짜 이후 업데이트된 태스크만 (YYYY-MM-DD)")
|
|
77
|
+
.option("--sort <key>", "정렬 기준 (priority, status, createdAt, updatedAt, title)", "priority")
|
|
78
|
+
.option("--order <dir>", "정렬 방향 (asc, desc)", "desc")
|
|
79
|
+
.action(
|
|
80
|
+
withCliErrorBoundary(
|
|
81
|
+
async (opts: {
|
|
82
|
+
detail?: string | boolean;
|
|
83
|
+
status?: string;
|
|
84
|
+
priority?: number;
|
|
85
|
+
dep?: string;
|
|
86
|
+
updatedSince?: string;
|
|
87
|
+
sort?: string;
|
|
88
|
+
order?: string;
|
|
89
|
+
}) => {
|
|
90
|
+
const projectRoot = process.cwd();
|
|
91
|
+
|
|
92
|
+
const tasks = await listTasks(projectRoot, {
|
|
93
|
+
filter: {
|
|
94
|
+
status: opts.status ? parseStatus(opts.status) : undefined,
|
|
95
|
+
priority: opts.priority,
|
|
96
|
+
hasDependency: opts.dep,
|
|
97
|
+
updatedSince: opts.updatedSince
|
|
98
|
+
? new Date(opts.updatedSince).toISOString()
|
|
99
|
+
: undefined,
|
|
100
|
+
},
|
|
101
|
+
sortKey: (opts.sort as TaskSortKey) ?? "priority",
|
|
102
|
+
sortOrder: (opts.order as TaskSortOrder) ?? "desc",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// --detail: 개별 태스크 보기
|
|
106
|
+
if (opts.detail !== undefined) {
|
|
107
|
+
const groupFilter = typeof opts.detail === "string" ? opts.detail : undefined;
|
|
108
|
+
const filtered = groupFilter
|
|
109
|
+
? tasks.filter((t) => t.group?.includes(groupFilter))
|
|
110
|
+
: tasks;
|
|
111
|
+
|
|
112
|
+
// Dashboard
|
|
113
|
+
const dashboard = formatDashboard(filtered);
|
|
114
|
+
if (dashboard) {
|
|
115
|
+
console.log("");
|
|
116
|
+
console.log(dashboard);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
console.log("");
|
|
120
|
+
console.log(formatTaskTable(filtered));
|
|
121
|
+
console.log("");
|
|
122
|
+
console.log(chalk.gray(`총 ${filtered.length}건`));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 기본: 그룹 단위 보기
|
|
127
|
+
const trdGroups = getTrdGroupNames(projectRoot);
|
|
128
|
+
if (tasks.length === 0 && trdGroups.length === 0) {
|
|
129
|
+
console.log(chalk.yellow("\n⚠ 태스크가 없습니다.\n"));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
formatGroupList(tasks, trdGroups);
|
|
134
|
+
console.log(chalk.gray("\n 개별 태스크 보기: task list --detail [그룹명]\n"));
|
|
135
|
+
},
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
}
|