@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,764 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import Table from "cli-table3"; // eslint-disable-line
|
|
3
|
+
import boxen from "boxen";
|
|
4
|
+
import type { Task, TaskStatus } from "@/features/taskflow/types";
|
|
5
|
+
|
|
6
|
+
// ── Status Config ──────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
const STATUS_CONFIG: Record<TaskStatus, { color: (t: string) => string; icon: string }> = {
|
|
9
|
+
Todo: { color: chalk.yellow, icon: "○" },
|
|
10
|
+
InProgress: { color: chalk.hex("#FFA500"), icon: "▶" },
|
|
11
|
+
Blocked: { color: chalk.red, icon: "!" },
|
|
12
|
+
Done: { color: chalk.green, icon: "✓" },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function getStatusWithColor(status: TaskStatus): string {
|
|
16
|
+
const cfg = STATUS_CONFIG[status];
|
|
17
|
+
return cfg.color(`${cfg.icon} ${status}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ── Priority Format ────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
function getPriorityWithColor(priority: number): string {
|
|
23
|
+
if (priority >= 9) return chalk.red.bold(String(priority));
|
|
24
|
+
if (priority >= 7) return chalk.red(String(priority));
|
|
25
|
+
if (priority >= 4) return chalk.yellow(String(priority));
|
|
26
|
+
return chalk.gray(String(priority));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Helpers ────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export function truncate(text: string, maxLen: number): string {
|
|
32
|
+
if (visibleLength(text) <= maxLen) return text;
|
|
33
|
+
if (maxLen <= 3) return sliceByWidth(text, maxLen).taken;
|
|
34
|
+
return sliceByWidth(text, maxLen - 3).taken + "...";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getBoxWidth(percentage = 0.9, minWidth = 40): number {
|
|
38
|
+
const terminalWidth = process.stdout.columns || 80;
|
|
39
|
+
return Math.max(Math.floor(terminalWidth * percentage), minWidth);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function formatRelativeDate(iso: string): string {
|
|
43
|
+
const diff = Date.now() - new Date(iso).getTime();
|
|
44
|
+
const minutes = Math.floor(diff / 60_000);
|
|
45
|
+
if (minutes < 1) return "방금 전";
|
|
46
|
+
if (minutes < 60) return `${minutes}분 전`;
|
|
47
|
+
const hours = Math.floor(minutes / 60);
|
|
48
|
+
if (hours < 24) return `${hours}시간 전`;
|
|
49
|
+
const days = Math.floor(hours / 24);
|
|
50
|
+
if (days < 30) return `${days}일 전`;
|
|
51
|
+
return iso.slice(0, 10);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Progress Bar ───────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
function createProgressBar(tasks: Task[], width = 30): string {
|
|
57
|
+
const total = tasks.length;
|
|
58
|
+
if (total === 0) return chalk.gray("태스크 없음");
|
|
59
|
+
|
|
60
|
+
const counts: Record<TaskStatus, number> = { Todo: 0, InProgress: 0, Blocked: 0, Done: 0 };
|
|
61
|
+
for (const t of tasks) counts[t.status]++;
|
|
62
|
+
|
|
63
|
+
let bar = "";
|
|
64
|
+
let used = 0;
|
|
65
|
+
|
|
66
|
+
// Done → green filled
|
|
67
|
+
const doneChars = Math.round((counts.Done / total) * width);
|
|
68
|
+
if (doneChars > 0) { bar += chalk.green("█").repeat(doneChars); used += doneChars; }
|
|
69
|
+
|
|
70
|
+
// InProgress → blue filled
|
|
71
|
+
const ipChars = Math.min(Math.round((counts.InProgress / total) * width), width - used);
|
|
72
|
+
if (ipChars > 0) { bar += chalk.blue("█").repeat(ipChars); used += ipChars; }
|
|
73
|
+
|
|
74
|
+
// Todo → yellow empty
|
|
75
|
+
const todoChars = Math.min(Math.round((counts.Todo / total) * width), width - used);
|
|
76
|
+
if (todoChars > 0) { bar += chalk.yellow("░").repeat(todoChars); used += todoChars; }
|
|
77
|
+
|
|
78
|
+
// Blocked → red empty
|
|
79
|
+
const blockedChars = Math.min(Math.round((counts.Blocked / total) * width), width - used);
|
|
80
|
+
if (blockedChars > 0) { bar += chalk.red("░").repeat(blockedChars); used += blockedChars; }
|
|
81
|
+
|
|
82
|
+
// Fill remaining
|
|
83
|
+
if (used < width) bar += chalk.yellow("░").repeat(width - used);
|
|
84
|
+
|
|
85
|
+
const pct = Math.round((counts.Done / total) * 100);
|
|
86
|
+
return `${bar} ${chalk.cyan(`${pct}%`)} (${counts.Done}/${total})`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Dashboard ──────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
function buildProjectDashboard(tasks: Task[]): string {
|
|
92
|
+
const total = tasks.length;
|
|
93
|
+
const counts: Record<TaskStatus, number> = { Todo: 0, InProgress: 0, Blocked: 0, Done: 0 };
|
|
94
|
+
for (const t of tasks) counts[t.status]++;
|
|
95
|
+
|
|
96
|
+
const progressBar = createProgressBar(tasks);
|
|
97
|
+
|
|
98
|
+
// Priority breakdown
|
|
99
|
+
let critical = 0, high = 0, medium = 0, low = 0;
|
|
100
|
+
for (const t of tasks) {
|
|
101
|
+
if (t.priority >= 9) critical++;
|
|
102
|
+
else if (t.priority >= 7) high++;
|
|
103
|
+
else if (t.priority >= 4) medium++;
|
|
104
|
+
else low++;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const content =
|
|
108
|
+
chalk.white.bold("프로젝트 대시보드") + "\n" +
|
|
109
|
+
`진행률: ${progressBar}\n` +
|
|
110
|
+
`완료: ${chalk.green(counts.Done)} 진행중: ${chalk.hex("#FFA500")(String(counts.InProgress))} 대기: ${chalk.yellow(counts.Todo)} 차단: ${chalk.red(counts.Blocked)}\n\n` +
|
|
111
|
+
chalk.cyan.bold("우선순위 분포:") + "\n" +
|
|
112
|
+
`${chalk.red("•")} ${chalk.white("긴급 (9-10):")} ${critical}\n` +
|
|
113
|
+
`${chalk.red("•")} ${chalk.white("높음 (7-8):")} ${high}\n` +
|
|
114
|
+
`${chalk.yellow("•")} ${chalk.white("보통 (4-6):")} ${medium}\n` +
|
|
115
|
+
`${chalk.green("•")} ${chalk.white("낮음 (0-3):")} ${low}`;
|
|
116
|
+
|
|
117
|
+
return content;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function buildDependencyDashboard(tasks: Task[]): string {
|
|
121
|
+
const doneIds = new Set(tasks.filter((t) => t.status === "Done").map((t) => t.id));
|
|
122
|
+
const activeTasks = tasks.filter((t) => t.status !== "Done");
|
|
123
|
+
|
|
124
|
+
const noDeps = activeTasks.filter((t) => t.dependencies.length === 0).length;
|
|
125
|
+
const depsSatisfied = activeTasks.filter(
|
|
126
|
+
(t) => t.dependencies.length > 0 && t.dependencies.every((d) => doneIds.has(d)),
|
|
127
|
+
).length;
|
|
128
|
+
const blockedByDeps = activeTasks.filter(
|
|
129
|
+
(t) => t.dependencies.length > 0 && !t.dependencies.every((d) => doneIds.has(d)),
|
|
130
|
+
).length;
|
|
131
|
+
|
|
132
|
+
// Most depended-on task
|
|
133
|
+
const depCount: Record<string, number> = {};
|
|
134
|
+
for (const t of tasks) {
|
|
135
|
+
for (const d of t.dependencies) {
|
|
136
|
+
depCount[d] = (depCount[d] || 0) + 1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
let mostDepId: string | undefined;
|
|
140
|
+
let mostDepCount = 0;
|
|
141
|
+
for (const [id, count] of Object.entries(depCount)) {
|
|
142
|
+
if (count > mostDepCount) { mostDepId = id; mostDepCount = count; }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const totalDeps = tasks.reduce((s, t) => s + t.dependencies.length, 0);
|
|
146
|
+
const avgDeps = tasks.length > 0 ? (totalDeps / tasks.length).toFixed(1) : "0.0";
|
|
147
|
+
|
|
148
|
+
const content =
|
|
149
|
+
chalk.white.bold("의존성 현황") + "\n" +
|
|
150
|
+
chalk.cyan.bold("의존성 지표:") + "\n" +
|
|
151
|
+
`${chalk.green("•")} ${chalk.white("의존성 없음:")} ${noDeps}\n` +
|
|
152
|
+
`${chalk.green("•")} ${chalk.white("작업 가능:")} ${noDeps + depsSatisfied}\n` +
|
|
153
|
+
`${chalk.yellow("•")} ${chalk.white("의존성 대기:")} ${blockedByDeps}\n` +
|
|
154
|
+
`${chalk.magenta("•")} ${chalk.white("최다 의존 태스크:")} ${
|
|
155
|
+
mostDepId ? chalk.cyan(`#${mostDepId} (${mostDepCount}개 태스크가 의존)`) : chalk.gray("없음")
|
|
156
|
+
}\n` +
|
|
157
|
+
`${chalk.blue("•")} ${chalk.white("평균 의존성 수:")} ${avgDeps}`;
|
|
158
|
+
|
|
159
|
+
return content;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function formatDashboard(tasks: Task[]): string {
|
|
163
|
+
if (tasks.length === 0) return "";
|
|
164
|
+
|
|
165
|
+
const projectContent = buildProjectDashboard(tasks);
|
|
166
|
+
const depContent = buildDependencyDashboard(tasks);
|
|
167
|
+
|
|
168
|
+
const terminalWidth = process.stdout.columns || 80;
|
|
169
|
+
const minSideWidth = 50;
|
|
170
|
+
|
|
171
|
+
if (terminalWidth >= minSideWidth * 2 + 4) {
|
|
172
|
+
// Side by side
|
|
173
|
+
const halfWidth = Math.floor(terminalWidth / 2) - 1;
|
|
174
|
+
const boxWidth = halfWidth - 2;
|
|
175
|
+
|
|
176
|
+
const leftBox = boxen(projectContent, {
|
|
177
|
+
padding: 1,
|
|
178
|
+
borderColor: "blue",
|
|
179
|
+
borderStyle: "round",
|
|
180
|
+
width: boxWidth,
|
|
181
|
+
dimBorder: false,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const rightBox = boxen(depContent, {
|
|
185
|
+
padding: 1,
|
|
186
|
+
borderColor: "magenta",
|
|
187
|
+
borderStyle: "round",
|
|
188
|
+
width: boxWidth,
|
|
189
|
+
dimBorder: false,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const leftLines = leftBox.split("\n");
|
|
193
|
+
const rightLines = rightBox.split("\n");
|
|
194
|
+
const maxH = Math.max(leftLines.length, rightLines.length);
|
|
195
|
+
|
|
196
|
+
const combined: string[] = [];
|
|
197
|
+
for (let i = 0; i < maxH; i++) {
|
|
198
|
+
const l = i < leftLines.length ? leftLines[i] : "";
|
|
199
|
+
const r = i < rightLines.length ? rightLines[i] : "";
|
|
200
|
+
combined.push(l.padEnd(halfWidth) + r);
|
|
201
|
+
}
|
|
202
|
+
return combined.join("\n");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Stacked
|
|
206
|
+
const leftBox = boxen(projectContent, {
|
|
207
|
+
padding: 1,
|
|
208
|
+
borderColor: "blue",
|
|
209
|
+
borderStyle: "round",
|
|
210
|
+
margin: { top: 0, bottom: 1, left: 0, right: 0 },
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const rightBox = boxen(depContent, {
|
|
214
|
+
padding: 1,
|
|
215
|
+
borderColor: "magenta",
|
|
216
|
+
borderStyle: "round",
|
|
217
|
+
margin: { top: 0, bottom: 1, left: 0, right: 0 },
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
return leftBox + "\n" + rightBox;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── Task Table (cli-table3) ────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
export function formatTaskTable(tasks: Task[]): string {
|
|
226
|
+
if (tasks.length === 0) {
|
|
227
|
+
return chalk.gray("태스크가 없습니다.");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const tableWidth = getBoxWidth(0.9, 80);
|
|
231
|
+
|
|
232
|
+
// Column widths as ratios: ID, Title, Status, Priority, Dependencies, Updated
|
|
233
|
+
const ratios = [0.07, 0.30, 0.18, 0.1, 0.15, 0.14];
|
|
234
|
+
const colWidths = ratios.map((r) => Math.max(Math.floor(tableWidth * r), 6));
|
|
235
|
+
|
|
236
|
+
const table = new Table({
|
|
237
|
+
head: [
|
|
238
|
+
chalk.blue.bold("ID"),
|
|
239
|
+
chalk.blue.bold("제목"),
|
|
240
|
+
chalk.blue.bold("상태"),
|
|
241
|
+
chalk.blue.bold("우선순위"),
|
|
242
|
+
chalk.blue.bold("의존성"),
|
|
243
|
+
chalk.blue.bold("수정일"),
|
|
244
|
+
],
|
|
245
|
+
style: { head: [], border: [] },
|
|
246
|
+
colWidths,
|
|
247
|
+
wordWrap: true,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
for (const task of tasks) {
|
|
251
|
+
table.push([
|
|
252
|
+
chalk.cyan(task.id),
|
|
253
|
+
truncate(task.title, colWidths[1] - 3),
|
|
254
|
+
getStatusWithColor(task.status),
|
|
255
|
+
getPriorityWithColor(task.priority),
|
|
256
|
+
task.dependencies.length > 0
|
|
257
|
+
? chalk.cyan(task.dependencies.join(", "))
|
|
258
|
+
: chalk.gray("-"),
|
|
259
|
+
formatRelativeDate(task.updatedAt),
|
|
260
|
+
]);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return table.toString();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── Task Detail ────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
export function formatTaskDetail(task: Task): string {
|
|
269
|
+
const content = [
|
|
270
|
+
`${chalk.cyan.bold("ID:")} ${chalk.cyan(task.id)}`,
|
|
271
|
+
`${chalk.white.bold("제목:")} ${task.title}`,
|
|
272
|
+
`${chalk.white.bold("상태:")} ${getStatusWithColor(task.status)}`,
|
|
273
|
+
`${chalk.white.bold("우선순위:")} ${getPriorityWithColor(task.priority)}`,
|
|
274
|
+
`${chalk.white.bold("의존성:")} ${
|
|
275
|
+
task.dependencies.length > 0
|
|
276
|
+
? chalk.cyan(task.dependencies.join(", "))
|
|
277
|
+
: chalk.gray("없음")
|
|
278
|
+
}`,
|
|
279
|
+
];
|
|
280
|
+
|
|
281
|
+
if (task.parentId) {
|
|
282
|
+
content.push(`${chalk.white.bold("상위 태스크:")} ${chalk.cyan(task.parentId)}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
content.push(
|
|
286
|
+
`${chalk.white.bold("생성일:")} ${task.createdAt}`,
|
|
287
|
+
`${chalk.white.bold("수정일:")} ${task.updatedAt}`,
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
if (task.description) {
|
|
291
|
+
content.push("", chalk.white.bold("설명:"), task.description);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const boxContent = content.join("\n");
|
|
295
|
+
|
|
296
|
+
return boxen(boxContent, {
|
|
297
|
+
padding: 1,
|
|
298
|
+
borderColor: "cyan",
|
|
299
|
+
borderStyle: "round",
|
|
300
|
+
title: `Task #${task.id}`,
|
|
301
|
+
titleAlignment: "left",
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ── Kanban Board ──────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
const KANBAN_COLUMNS: { status: TaskStatus; label: string; color: (t: string) => string; dot: string }[] = [
|
|
308
|
+
{ status: "Todo", label: "대기", color: chalk.yellow, dot: "○" },
|
|
309
|
+
{ status: "InProgress", label: "진행중", color: chalk.hex("#FFA500"), dot: "▶" },
|
|
310
|
+
{ status: "Blocked", label: "차단", color: chalk.red, dot: "!" },
|
|
311
|
+
{ status: "Done", label: "완료", color: chalk.green, dot: "✓" },
|
|
312
|
+
];
|
|
313
|
+
|
|
314
|
+
function groupByStatus(tasks: Task[]): Record<TaskStatus, Task[]> {
|
|
315
|
+
const groups: Record<TaskStatus, Task[]> = { Todo: [], InProgress: [], Blocked: [], Done: [] };
|
|
316
|
+
for (const t of tasks) groups[t.status].push(t);
|
|
317
|
+
return groups;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function renderCard(task: Task, cardWidth: number, compact?: boolean): string[] {
|
|
321
|
+
const lines: string[] = [];
|
|
322
|
+
const inner = cardWidth - 4; // 양쪽 패딩/테두리
|
|
323
|
+
|
|
324
|
+
// 상단 테두리
|
|
325
|
+
lines.push(chalk.gray("┌" + "─".repeat(cardWidth - 2) + "┐"));
|
|
326
|
+
|
|
327
|
+
// ID + 우선순위
|
|
328
|
+
const idLine = ` ${chalk.cyan("#" + task.id)} ${getPriorityWithColor(task.priority)}`;
|
|
329
|
+
lines.push(chalk.gray("│") + padToWidth(idLine, inner) + chalk.gray("│"));
|
|
330
|
+
|
|
331
|
+
// 제목 (줄바꿈 처리)
|
|
332
|
+
const titleChunks = wrapText(task.title, inner - 1);
|
|
333
|
+
for (const chunk of titleChunks) {
|
|
334
|
+
lines.push(chalk.gray("│") + " " + padToWidth(chunk, inner - 1) + chalk.gray("│"));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// 설명 (compact 아닐 때만, 첫 줄만)
|
|
338
|
+
if (!compact && task.description) {
|
|
339
|
+
const descPreview = truncate(task.description.split("\n")[0], inner - 2);
|
|
340
|
+
lines.push(chalk.gray("│") + " " + chalk.gray(padToWidth(descPreview, inner - 1)) + chalk.gray("│"));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// 의존성
|
|
344
|
+
if (task.dependencies.length > 0) {
|
|
345
|
+
const depText = chalk.gray("의존: ") + chalk.cyan(task.dependencies.join(", "));
|
|
346
|
+
lines.push(chalk.gray("│") + " " + padToWidth(depText, inner - 1) + chalk.gray("│"));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// 수정일
|
|
350
|
+
const dateLine = chalk.gray(formatRelativeDate(task.updatedAt));
|
|
351
|
+
lines.push(chalk.gray("│") + " " + padToWidth(dateLine, inner - 1) + chalk.gray("│"));
|
|
352
|
+
|
|
353
|
+
// 하단 테두리
|
|
354
|
+
lines.push(chalk.gray("└" + "─".repeat(cardWidth - 2) + "┘"));
|
|
355
|
+
|
|
356
|
+
return lines;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function sliceByWidth(text: string, maxWidth: number): { taken: string; rest: string } {
|
|
360
|
+
let width = 0;
|
|
361
|
+
let i = 0;
|
|
362
|
+
const chars = [...text]; // 유니코드 안전 분해
|
|
363
|
+
for (; i < chars.length; i++) {
|
|
364
|
+
const code = chars[i].codePointAt(0)!;
|
|
365
|
+
const cw = isCjk(code) ? 2 : 1;
|
|
366
|
+
if (width + cw > maxWidth) break;
|
|
367
|
+
width += cw;
|
|
368
|
+
}
|
|
369
|
+
return { taken: chars.slice(0, i).join(""), rest: chars.slice(i).join("") };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function isCjk(code: number): boolean {
|
|
373
|
+
return (
|
|
374
|
+
(code >= 0x1100 && code <= 0x115f) ||
|
|
375
|
+
(code >= 0x2e80 && code <= 0x303e) ||
|
|
376
|
+
(code >= 0x3040 && code <= 0x33bf) ||
|
|
377
|
+
(code >= 0x3400 && code <= 0x4dbf) ||
|
|
378
|
+
(code >= 0x4e00 && code <= 0xa4cf) ||
|
|
379
|
+
(code >= 0xac00 && code <= 0xd7af) ||
|
|
380
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
381
|
+
(code >= 0xfe30 && code <= 0xfe4f) ||
|
|
382
|
+
(code >= 0xff01 && code <= 0xff60) ||
|
|
383
|
+
(code >= 0xffe0 && code <= 0xffe6) ||
|
|
384
|
+
(code >= 0x20000 && code <= 0x2fffd)
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function wrapText(text: string, maxWidth: number): string[] {
|
|
389
|
+
if (visibleLength(text) <= maxWidth) return [text];
|
|
390
|
+
const lines: string[] = [];
|
|
391
|
+
let remaining = text;
|
|
392
|
+
while (remaining.length > 0) {
|
|
393
|
+
if (visibleLength(remaining) <= maxWidth) {
|
|
394
|
+
lines.push(remaining);
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
const { taken, rest } = sliceByWidth(remaining, maxWidth);
|
|
398
|
+
lines.push(taken);
|
|
399
|
+
remaining = rest.trimStart();
|
|
400
|
+
}
|
|
401
|
+
return lines.slice(0, 3); // 최대 3줄
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function stripAnsi(str: string): string {
|
|
405
|
+
// eslint-disable-next-line no-control-regex
|
|
406
|
+
return str.replace(/\x1b\[[0-9;]*m/g, "");
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function visibleLength(str: string): number {
|
|
410
|
+
const clean = stripAnsi(str);
|
|
411
|
+
let len = 0;
|
|
412
|
+
for (const ch of clean) {
|
|
413
|
+
const code = ch.codePointAt(0)!;
|
|
414
|
+
// CJK 문자 (한글, 한자, 일본어 등)는 터미널에서 2칸 차지
|
|
415
|
+
if (
|
|
416
|
+
(code >= 0x1100 && code <= 0x115f) || // 한글 자모
|
|
417
|
+
(code >= 0x2e80 && code <= 0x303e) || // CJK 부수/기호
|
|
418
|
+
(code >= 0x3040 && code <= 0x33bf) || // 히라가나/카타카나
|
|
419
|
+
(code >= 0x3400 && code <= 0x4dbf) || // CJK 확장 A
|
|
420
|
+
(code >= 0x4e00 && code <= 0xa4cf) || // CJK 통합 한자
|
|
421
|
+
(code >= 0xac00 && code <= 0xd7af) || // 한글 음절
|
|
422
|
+
(code >= 0xf900 && code <= 0xfaff) || // CJK 호환 한자
|
|
423
|
+
(code >= 0xfe30 && code <= 0xfe4f) || // CJK 호환 형태
|
|
424
|
+
(code >= 0xff01 && code <= 0xff60) || // 전각 문자
|
|
425
|
+
(code >= 0xffe0 && code <= 0xffe6) || // 전각 기호
|
|
426
|
+
(code >= 0x20000 && code <= 0x2fffd) // CJK 확장 B~
|
|
427
|
+
) {
|
|
428
|
+
len += 2;
|
|
429
|
+
} else {
|
|
430
|
+
len += 1;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return len;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function padToWidth(str: string, width: number): string {
|
|
437
|
+
const visible = visibleLength(str);
|
|
438
|
+
if (visible >= width) return str;
|
|
439
|
+
return str + " ".repeat(width - visible);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export function formatKanbanBoard(
|
|
443
|
+
tasks: Task[],
|
|
444
|
+
options?: { compact?: boolean },
|
|
445
|
+
): string {
|
|
446
|
+
const compact = options?.compact;
|
|
447
|
+
const groups = groupByStatus(tasks);
|
|
448
|
+
const termWidth = process.stdout.columns || 80;
|
|
449
|
+
const columnCount = KANBAN_COLUMNS.length;
|
|
450
|
+
const gap = 2;
|
|
451
|
+
const colWidth = Math.max(Math.floor((termWidth - gap * (columnCount - 1)) / columnCount), 20);
|
|
452
|
+
const cardWidth = colWidth - 2;
|
|
453
|
+
|
|
454
|
+
// 진행률 헤더
|
|
455
|
+
const doneCount = groups.Done.length;
|
|
456
|
+
const total = tasks.length;
|
|
457
|
+
const pct = total > 0 ? Math.round((doneCount / total) * 100) : 0;
|
|
458
|
+
const barWidth = Math.min(30, termWidth - 30);
|
|
459
|
+
const filledCount = Math.round((pct / 100) * barWidth);
|
|
460
|
+
const progressBar =
|
|
461
|
+
chalk.green("█").repeat(filledCount) +
|
|
462
|
+
chalk.gray("░").repeat(barWidth - filledCount);
|
|
463
|
+
|
|
464
|
+
const header =
|
|
465
|
+
chalk.white.bold(" 칸반 보드") +
|
|
466
|
+
chalk.gray(` — ${total}개 태스크`) +
|
|
467
|
+
" " +
|
|
468
|
+
progressBar +
|
|
469
|
+
" " +
|
|
470
|
+
chalk.cyan(`${pct}%`);
|
|
471
|
+
|
|
472
|
+
const output: string[] = [header, ""];
|
|
473
|
+
|
|
474
|
+
// 컬럼 헤더
|
|
475
|
+
const colHeaders: string[] = [];
|
|
476
|
+
for (const col of KANBAN_COLUMNS) {
|
|
477
|
+
const count = groups[col.status].length;
|
|
478
|
+
const label = col.color(`${col.dot} ${col.label}`) + chalk.gray(` (${count})`);
|
|
479
|
+
colHeaders.push(padToWidth(label, colWidth));
|
|
480
|
+
}
|
|
481
|
+
output.push(colHeaders.join(" ".repeat(gap)));
|
|
482
|
+
|
|
483
|
+
// 구분선
|
|
484
|
+
const separators: string[] = [];
|
|
485
|
+
for (const col of KANBAN_COLUMNS) {
|
|
486
|
+
separators.push(col.color("─".repeat(colWidth)));
|
|
487
|
+
}
|
|
488
|
+
output.push(separators.join(" ".repeat(gap)));
|
|
489
|
+
|
|
490
|
+
// 카드 렌더링 — 각 컬럼의 카드를 줄별로 나란히 배치
|
|
491
|
+
const columnCards: string[][][] = KANBAN_COLUMNS.map((col) => {
|
|
492
|
+
const colTasks = groups[col.status].sort((a, b) => b.priority - a.priority);
|
|
493
|
+
return colTasks.map((task) => renderCard(task, cardWidth, compact));
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// 행 단위로 출력 (각 행 = 4개 컬럼에서 하나씩 카드)
|
|
497
|
+
const maxRows = Math.max(...columnCards.map((cards) => cards.length));
|
|
498
|
+
|
|
499
|
+
for (let row = 0; row < maxRows; row++) {
|
|
500
|
+
// 이 행에서 각 컬럼의 카드 줄 수 중 최대값
|
|
501
|
+
const cardLines: string[][] = columnCards.map((cards) =>
|
|
502
|
+
row < cards.length ? cards[row] : [],
|
|
503
|
+
);
|
|
504
|
+
const maxLines = Math.max(...cardLines.map((lines) => lines.length));
|
|
505
|
+
|
|
506
|
+
for (let line = 0; line < maxLines; line++) {
|
|
507
|
+
const rowParts: string[] = [];
|
|
508
|
+
for (let c = 0; c < columnCount; c++) {
|
|
509
|
+
const content = line < cardLines[c].length ? cardLines[c][line] : "";
|
|
510
|
+
rowParts.push(padToWidth(content, colWidth));
|
|
511
|
+
}
|
|
512
|
+
output.push(rowParts.join(" ".repeat(gap)));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// 카드 사이 빈 줄
|
|
516
|
+
if (row < maxRows - 1) {
|
|
517
|
+
output.push("");
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (tasks.length === 0) {
|
|
522
|
+
output.push("");
|
|
523
|
+
output.push(chalk.gray(" 태스크가 없습니다. task parse-prd로 태스크를 생성해보세요."));
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return output.join("\n");
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ── Dependency Tree ───────────────────────────────────────────
|
|
530
|
+
|
|
531
|
+
interface TreeNode {
|
|
532
|
+
task: Task;
|
|
533
|
+
children: TreeNode[];
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// 깊이별 연결선 색상
|
|
537
|
+
const DEPTH_COLORS: ((t: string) => string)[] = [
|
|
538
|
+
chalk.white,
|
|
539
|
+
chalk.cyan,
|
|
540
|
+
chalk.blue,
|
|
541
|
+
chalk.magenta,
|
|
542
|
+
chalk.yellow,
|
|
543
|
+
];
|
|
544
|
+
|
|
545
|
+
function depthColor(depth: number): (t: string) => string {
|
|
546
|
+
return DEPTH_COLORS[depth % DEPTH_COLORS.length];
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// 미니 우선순위 바
|
|
550
|
+
function priorityBar(priority: number): string {
|
|
551
|
+
const maxBlocks = 5;
|
|
552
|
+
const filled = Math.round((priority / 10) * maxBlocks);
|
|
553
|
+
const empty = maxBlocks - filled;
|
|
554
|
+
|
|
555
|
+
let barColor: (t: string) => string;
|
|
556
|
+
if (priority >= 9) barColor = chalk.red;
|
|
557
|
+
else if (priority >= 7) barColor = chalk.hex("#FFA500");
|
|
558
|
+
else if (priority >= 4) barColor = chalk.yellow;
|
|
559
|
+
else barColor = chalk.gray;
|
|
560
|
+
|
|
561
|
+
return barColor("█".repeat(filled)) + chalk.gray("░".repeat(empty)) + " " + barColor(String(priority).padStart(2));
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// 서브트리 진행률
|
|
565
|
+
function subtreeProgress(node: TreeNode): { done: number; total: number } {
|
|
566
|
+
let done = node.task.status === "Done" ? 1 : 0;
|
|
567
|
+
let total = 1;
|
|
568
|
+
for (const child of node.children) {
|
|
569
|
+
const sub = subtreeProgress(child);
|
|
570
|
+
done += sub.done;
|
|
571
|
+
total += sub.total;
|
|
572
|
+
}
|
|
573
|
+
return { done, total };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function buildTree(tasks: Task[], rootId?: string): TreeNode[] {
|
|
577
|
+
const taskMap = new Map(tasks.map((t) => [t.id, t]));
|
|
578
|
+
|
|
579
|
+
const childrenOf = new Map<string, string[]>();
|
|
580
|
+
for (const t of tasks) {
|
|
581
|
+
for (const dep of t.dependencies) {
|
|
582
|
+
if (!childrenOf.has(dep)) childrenOf.set(dep, []);
|
|
583
|
+
childrenOf.get(dep)!.push(t.id);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const visited = new Set<string>();
|
|
588
|
+
|
|
589
|
+
function buildNode(id: string): TreeNode | null {
|
|
590
|
+
if (visited.has(id)) return null;
|
|
591
|
+
const task = taskMap.get(id);
|
|
592
|
+
if (!task) return null;
|
|
593
|
+
visited.add(id);
|
|
594
|
+
|
|
595
|
+
const kids = (childrenOf.get(id) ?? [])
|
|
596
|
+
.map((cid) => buildNode(cid))
|
|
597
|
+
.filter((n): n is TreeNode => n !== null)
|
|
598
|
+
.sort((a, b) => b.task.priority - a.task.priority);
|
|
599
|
+
|
|
600
|
+
return { task, children: kids };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (rootId) {
|
|
604
|
+
const node = buildNode(rootId);
|
|
605
|
+
return node ? [node] : [];
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const roots = tasks
|
|
609
|
+
.filter((t) => t.dependencies.length === 0)
|
|
610
|
+
.sort((a, b) => b.priority - a.priority);
|
|
611
|
+
|
|
612
|
+
const trees: TreeNode[] = [];
|
|
613
|
+
for (const root of roots) {
|
|
614
|
+
const node = buildNode(root.id);
|
|
615
|
+
if (node) trees.push(node);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
for (const t of tasks) {
|
|
619
|
+
if (!visited.has(t.id)) {
|
|
620
|
+
const node = buildNode(t.id);
|
|
621
|
+
if (node) trees.push(node);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return trees;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function isActionable(node: TreeNode, doneIds: Set<string>): boolean {
|
|
629
|
+
return node.task.status !== "Done" && node.task.dependencies.every((d) => doneIds.has(d));
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function renderTreeNode(
|
|
633
|
+
node: TreeNode,
|
|
634
|
+
prefix: string,
|
|
635
|
+
isLast: boolean,
|
|
636
|
+
depth: number,
|
|
637
|
+
doneIds: Set<string>,
|
|
638
|
+
maxDepth?: number,
|
|
639
|
+
output: string[] = [],
|
|
640
|
+
): string[] {
|
|
641
|
+
const { task } = node;
|
|
642
|
+
const isDone = task.status === "Done";
|
|
643
|
+
const actionable = isActionable(node, doneIds);
|
|
644
|
+
const dc = depthColor(depth);
|
|
645
|
+
|
|
646
|
+
// 연결선
|
|
647
|
+
const connector = depth === 0 ? "" : dc(isLast ? "└─ " : "├─ ");
|
|
648
|
+
const childPrefix = depth === 0 ? "" : dc(isLast ? " " : "│ ");
|
|
649
|
+
|
|
650
|
+
// 상태 아이콘 (고정 2칸: 아이콘 + 공백)
|
|
651
|
+
const statusIcons: Record<TaskStatus, string> = {
|
|
652
|
+
Done: chalk.green("✔"),
|
|
653
|
+
InProgress: chalk.hex("#FFA500")("▶"),
|
|
654
|
+
Todo: chalk.yellow("○"),
|
|
655
|
+
Blocked: chalk.red("✕"),
|
|
656
|
+
};
|
|
657
|
+
const icon = statusIcons[task.status];
|
|
658
|
+
|
|
659
|
+
// ID (고정 4칸: #001)
|
|
660
|
+
const idStr = `#${task.id}`;
|
|
661
|
+
const id = isDone ? chalk.gray(idStr) : chalk.cyan.bold(idStr);
|
|
662
|
+
const idCol = padToWidth(id, 4);
|
|
663
|
+
|
|
664
|
+
// 우선순위 바 (고정 8칸: █████ + 공백 + 2자리숫자 = "███░░ 10")
|
|
665
|
+
const pBar = isDone
|
|
666
|
+
? chalk.gray(`${"·".repeat(5)} ${String(task.priority).padStart(2)}`)
|
|
667
|
+
: priorityBar(task.priority);
|
|
668
|
+
|
|
669
|
+
// 제목 — 완료는 흐리게, 미완료는 밝게
|
|
670
|
+
const title = isDone ? chalk.gray(task.title) : chalk.white.bold(task.title);
|
|
671
|
+
|
|
672
|
+
// 작업 가능 뱃지 (제목 뒤에 배치)
|
|
673
|
+
const actionBadge = actionable ? " " + chalk.bgYellow.black(" 작업 가능 ") : "";
|
|
674
|
+
|
|
675
|
+
// 서브트리 진행률 (자식이 있을 때만)
|
|
676
|
+
let progressBadge = "";
|
|
677
|
+
if (node.children.length > 0) {
|
|
678
|
+
const { done, total } = subtreeProgress(node);
|
|
679
|
+
const pct = Math.round((done / total) * 100);
|
|
680
|
+
if (pct === 100) {
|
|
681
|
+
progressBadge = chalk.green(` (${done}/${total} 완료)`);
|
|
682
|
+
} else {
|
|
683
|
+
progressBadge = chalk.gray(` (${done}/${total} `) + chalk.cyan(`${pct}%`) + chalk.gray(")");
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// 메인 라인: [prefix][connector][icon] [id] [pBar] [title][progress][badge]
|
|
688
|
+
const line = `${prefix}${connector}${icon} ${idCol} ${pBar} ${title}${progressBadge}${actionBadge}`;
|
|
689
|
+
output.push(line);
|
|
690
|
+
|
|
691
|
+
// 깊이 제한
|
|
692
|
+
if (maxDepth !== undefined && depth >= maxDepth) {
|
|
693
|
+
if (node.children.length > 0) {
|
|
694
|
+
output.push(`${prefix}${childPrefix} ${chalk.gray(`⋯ ${node.children.length}개 하위 태스크 (--depth로 확장)`)}`);
|
|
695
|
+
}
|
|
696
|
+
return output;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// 자식 노드
|
|
700
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
701
|
+
const child = node.children[i];
|
|
702
|
+
const childIsLast = i === node.children.length - 1;
|
|
703
|
+
|
|
704
|
+
// 깊이 1 분기 사이에 여백 추가
|
|
705
|
+
if (depth === 0 && i > 0) {
|
|
706
|
+
output.push(prefix + childPrefix + dc("│"));
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
renderTreeNode(child, prefix + childPrefix, childIsLast, depth + 1, doneIds, maxDepth, output);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
return output;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
export function formatDependencyTree(
|
|
716
|
+
tasks: Task[],
|
|
717
|
+
options?: { rootId?: string; maxDepth?: number },
|
|
718
|
+
): string {
|
|
719
|
+
if (tasks.length === 0) {
|
|
720
|
+
return chalk.gray(" 태스크가 없습니다.");
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const trees = buildTree(tasks, options?.rootId);
|
|
724
|
+
const doneIds = new Set(tasks.filter((t) => t.status === "Done").map((t) => t.id));
|
|
725
|
+
const output: string[] = [];
|
|
726
|
+
|
|
727
|
+
// 헤더
|
|
728
|
+
const total = tasks.length;
|
|
729
|
+
const doneCount = doneIds.size;
|
|
730
|
+
const pct = Math.round((doneCount / total) * 100);
|
|
731
|
+
const barWidth = 20;
|
|
732
|
+
const filled = Math.round((pct / 100) * barWidth);
|
|
733
|
+
const headerBar = chalk.green("█".repeat(filled)) + chalk.gray("░".repeat(barWidth - filled));
|
|
734
|
+
|
|
735
|
+
output.push(
|
|
736
|
+
chalk.white.bold(" 의존성 트리") +
|
|
737
|
+
" " + headerBar + " " +
|
|
738
|
+
chalk.cyan.bold(`${pct}%`) +
|
|
739
|
+
chalk.gray(` (${doneCount}/${total})`),
|
|
740
|
+
);
|
|
741
|
+
output.push("");
|
|
742
|
+
|
|
743
|
+
// 범례
|
|
744
|
+
output.push(
|
|
745
|
+
" " +
|
|
746
|
+
chalk.green("✔") + chalk.gray(" 완료 ") +
|
|
747
|
+
chalk.hex("#FFA500")("▶") + chalk.gray(" 진행중 ") +
|
|
748
|
+
chalk.yellow("○") + chalk.gray(" 대기 ") +
|
|
749
|
+
chalk.red("✕") + chalk.gray(" 차단 ") +
|
|
750
|
+
chalk.bgYellow.black(" 작업 가능 "),
|
|
751
|
+
);
|
|
752
|
+
output.push(" " + chalk.gray("─".repeat(60)));
|
|
753
|
+
output.push("");
|
|
754
|
+
|
|
755
|
+
// 트리 렌더링
|
|
756
|
+
for (let i = 0; i < trees.length; i++) {
|
|
757
|
+
renderTreeNode(trees[i], " ", false, 0, doneIds, options?.maxDepth, output);
|
|
758
|
+
if (i < trees.length - 1) {
|
|
759
|
+
output.push("");
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return output.join("\n");
|
|
764
|
+
}
|