create-claude-pipeline 0.1.0

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 (76) hide show
  1. package/bin/cli.js +359 -0
  2. package/package.json +32 -0
  3. package/template/.claude/agents/be-developer.md +218 -0
  4. package/template/.claude/agents/designer.md +192 -0
  5. package/template/.claude/agents/fe-developer.md +175 -0
  6. package/template/.claude/agents/infra-developer.md +270 -0
  7. package/template/.claude/agents/planner.md +126 -0
  8. package/template/.claude/agents/pm.md +130 -0
  9. package/template/.claude/agents/qa-engineer.md +270 -0
  10. package/template/.claude/agents/security-reviewer.md +281 -0
  11. package/template/.claude/settings.json +5 -0
  12. package/template/.claude/skills/analyze-requirements/SKILL.md +166 -0
  13. package/template/.claude/skills/api-integration/SKILL.md +354 -0
  14. package/template/.claude/skills/assemble-context/SKILL.md +192 -0
  15. package/template/.claude/skills/db-migration/SKILL.md +228 -0
  16. package/template/.claude/skills/explore-be-codebase/SKILL.md +260 -0
  17. package/template/.claude/skills/explore-codebase/SKILL.md +190 -0
  18. package/template/.claude/skills/explore-design-system/SKILL.md +150 -0
  19. package/template/.claude/skills/explore-fe-codebase/SKILL.md +209 -0
  20. package/template/.claude/skills/explore-implementation/SKILL.md +147 -0
  21. package/template/.claude/skills/explore-infra/SKILL.md +242 -0
  22. package/template/.claude/skills/implement-api/SKILL.md +477 -0
  23. package/template/.claude/skills/implement-components/SKILL.md +217 -0
  24. package/template/.claude/skills/review-auth/SKILL.md +175 -0
  25. package/template/.claude/skills/scan-vulnerabilities/SKILL.md +200 -0
  26. package/template/.claude/skills/write-cicd/SKILL.md +293 -0
  27. package/template/.claude/skills/write-design-spec/SKILL.md +363 -0
  28. package/template/.claude/skills/write-dockerfile/SKILL.md +269 -0
  29. package/template/.claude/skills/write-plan-doc/SKILL.md +164 -0
  30. package/template/.claude/skills/write-plan-doc/assets/plan_template.html +251 -0
  31. package/template/.claude/skills/write-qa-report/SKILL.md +151 -0
  32. package/template/.claude/skills/write-security-report/SKILL.md +185 -0
  33. package/template/.claude/skills/write-test-cases/SKILL.md +234 -0
  34. package/template/.claude-pipeline/dashboard/.env.example +1 -0
  35. package/template/.claude-pipeline/dashboard/.eslintrc.json +3 -0
  36. package/template/.claude-pipeline/dashboard/README.md +36 -0
  37. package/template/.claude-pipeline/dashboard/next.config.mjs +6 -0
  38. package/template/.claude-pipeline/dashboard/package-lock.json +8148 -0
  39. package/template/.claude-pipeline/dashboard/package.json +36 -0
  40. package/template/.claude-pipeline/dashboard/postcss.config.mjs +8 -0
  41. package/template/.claude-pipeline/dashboard/server.ts +24 -0
  42. package/template/.claude-pipeline/dashboard/src/app/api/pipelines/[id]/checkpoint/route.ts +23 -0
  43. package/template/.claude-pipeline/dashboard/src/app/api/pipelines/[id]/outputs/[...filepath]/route.ts +18 -0
  44. package/template/.claude-pipeline/dashboard/src/app/api/pipelines/[id]/route.ts +10 -0
  45. package/template/.claude-pipeline/dashboard/src/app/api/pipelines/route.ts +64 -0
  46. package/template/.claude-pipeline/dashboard/src/app/favicon.ico +0 -0
  47. package/template/.claude-pipeline/dashboard/src/app/fonts/GeistMonoVF.woff +0 -0
  48. package/template/.claude-pipeline/dashboard/src/app/fonts/GeistVF.woff +0 -0
  49. package/template/.claude-pipeline/dashboard/src/app/globals.css +52 -0
  50. package/template/.claude-pipeline/dashboard/src/app/layout.tsx +33 -0
  51. package/template/.claude-pipeline/dashboard/src/app/page.tsx +49 -0
  52. package/template/.claude-pipeline/dashboard/src/app/pipeline/[id]/page.tsx +84 -0
  53. package/template/.claude-pipeline/dashboard/src/components/agent-card.tsx +40 -0
  54. package/template/.claude-pipeline/dashboard/src/components/agent-logs.tsx +65 -0
  55. package/template/.claude-pipeline/dashboard/src/components/artifact-viewer.tsx +130 -0
  56. package/template/.claude-pipeline/dashboard/src/components/checkpoint-banner.tsx +59 -0
  57. package/template/.claude-pipeline/dashboard/src/components/new-pipeline-modal.tsx +63 -0
  58. package/template/.claude-pipeline/dashboard/src/components/output-list.tsx +57 -0
  59. package/template/.claude-pipeline/dashboard/src/components/phase-dots.tsx +37 -0
  60. package/template/.claude-pipeline/dashboard/src/components/pipeline-card.tsx +53 -0
  61. package/template/.claude-pipeline/dashboard/src/components/resizable-panels.tsx +91 -0
  62. package/template/.claude-pipeline/dashboard/src/hooks/use-pipeline-detail.ts +65 -0
  63. package/template/.claude-pipeline/dashboard/src/hooks/use-pipelines.ts +60 -0
  64. package/template/.claude-pipeline/dashboard/src/hooks/use-websocket.ts +58 -0
  65. package/template/.claude-pipeline/dashboard/src/lib/agents.ts +30 -0
  66. package/template/.claude-pipeline/dashboard/src/lib/checkpoint.ts +37 -0
  67. package/template/.claude-pipeline/dashboard/src/lib/pipelines.ts +91 -0
  68. package/template/.claude-pipeline/dashboard/src/lib/watcher.ts +90 -0
  69. package/template/.claude-pipeline/dashboard/src/lib/ws-server.ts +123 -0
  70. package/template/.claude-pipeline/dashboard/src/types/pipeline.ts +61 -0
  71. package/template/.claude-pipeline/dashboard/tailwind.config.ts +31 -0
  72. package/template/.claude-pipeline/dashboard/tsconfig.json +26 -0
  73. package/template/CLAUDE.md +301 -0
  74. package/template/references/context-structure.md +34 -0
  75. package/template/references/pm-context-assembly.md +34 -0
  76. package/template/references/task-context-template.md +65 -0
@@ -0,0 +1,59 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import type { CheckpointInfo } from "@/types/pipeline";
5
+
6
+ interface CheckpointBannerProps {
7
+ checkpoint: CheckpointInfo;
8
+ onRespond: (action: "approve" | "reject", message?: string) => void;
9
+ }
10
+
11
+ export function CheckpointBanner({ checkpoint, onRespond }: CheckpointBannerProps) {
12
+ const [showFeedback, setShowFeedback] = useState(false);
13
+ const [feedback, setFeedback] = useState("");
14
+
15
+ if (showFeedback) {
16
+ return (
17
+ <div className="mx-4 mb-3 bg-gradient-to-r from-accent-purple/10 to-accent-purple-light/10 border border-accent-purple rounded-lg p-4">
18
+ <div className="text-accent-purple-light text-[12px] font-semibold mb-2">피드백 작성</div>
19
+ <textarea
20
+ value={feedback}
21
+ onChange={(e) => setFeedback(e.target.value)}
22
+ className="w-full bg-[#111827] border border-border rounded-lg p-2 text-text-primary text-sm resize-none h-20 focus:outline-none focus:border-accent-purple"
23
+ placeholder="피드백을 입력하세요..."
24
+ autoFocus
25
+ />
26
+ <div className="flex justify-end gap-2 mt-2">
27
+ <button onClick={() => setShowFeedback(false)} className="px-3 py-1 text-[11px] text-text-secondary bg-border rounded-md">
28
+ 취소
29
+ </button>
30
+ <button
31
+ onClick={() => onRespond("reject", feedback)}
32
+ className="px-3 py-1 text-[11px] text-white bg-gradient-to-r from-accent-purple to-accent-purple-light rounded-md"
33
+ >
34
+ 전송
35
+ </button>
36
+ </div>
37
+ </div>
38
+ );
39
+ }
40
+
41
+ return (
42
+ <div className="mx-4 mb-3 bg-gradient-to-r from-accent-purple/10 to-accent-purple-light/10 border border-accent-purple rounded-lg px-4 py-3 flex justify-between items-center">
43
+ <div>
44
+ <div className="text-accent-purple-light text-[12px] font-semibold">
45
+ ⏳ CHECKPOINT {checkpoint.phase}
46
+ </div>
47
+ <div className="text-text-primary text-[12px] mt-1">{checkpoint.description}</div>
48
+ </div>
49
+ <div className="flex gap-2">
50
+ <button onClick={() => setShowFeedback(true)} className="px-3 py-1 text-[11px] text-text-primary bg-border rounded-md hover:bg-text-muted/30">
51
+ 피드백
52
+ </button>
53
+ <button onClick={() => onRespond("approve")} className="px-3 py-1 text-[11px] text-white bg-gradient-to-r from-accent-purple to-accent-purple-light rounded-md">
54
+ 승인
55
+ </button>
56
+ </div>
57
+ </div>
58
+ );
59
+ }
@@ -0,0 +1,63 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { useRouter } from "next/navigation";
5
+
6
+ interface NewPipelineModalProps {
7
+ open: boolean;
8
+ onClose: () => void;
9
+ }
10
+
11
+ export function NewPipelineModal({ open, onClose }: NewPipelineModalProps) {
12
+ const [requirements, setRequirements] = useState("");
13
+ const [loading, setLoading] = useState(false);
14
+ const router = useRouter();
15
+
16
+ if (!open) return null;
17
+
18
+ const handleSubmit = async (e: React.FormEvent) => {
19
+ e.preventDefault();
20
+ if (!requirements.trim() || loading) return;
21
+
22
+ setLoading(true);
23
+ try {
24
+ const res = await fetch("/api/pipelines", {
25
+ method: "POST",
26
+ headers: { "Content-Type": "application/json" },
27
+ body: JSON.stringify({ requirements: requirements.trim() }),
28
+ });
29
+ const data = await res.json();
30
+ if (res.ok) {
31
+ router.push(`/pipeline/${data.id}`);
32
+ onClose();
33
+ }
34
+ } finally {
35
+ setLoading(false);
36
+ }
37
+ };
38
+
39
+ return (
40
+ <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
41
+ <div className="bg-panel border border-border rounded-lg p-6 w-full max-w-md" onClick={(e) => e.stopPropagation()}>
42
+ <h2 className="text-text-primary text-lg font-semibold mb-4">New Pipeline</h2>
43
+ <form onSubmit={handleSubmit}>
44
+ <textarea
45
+ value={requirements}
46
+ onChange={(e) => setRequirements(e.target.value)}
47
+ placeholder="요구사항을 입력하세요..."
48
+ className="w-full bg-[#111827] border border-border rounded-lg p-3 text-text-primary text-sm resize-none h-32 focus:outline-none focus:border-accent-purple"
49
+ autoFocus
50
+ />
51
+ <div className="flex justify-end gap-2 mt-4">
52
+ <button type="button" onClick={onClose} className="px-4 py-2 text-sm text-text-secondary bg-border rounded-lg hover:bg-text-muted/30">
53
+ 취소
54
+ </button>
55
+ <button type="submit" disabled={!requirements.trim() || loading} className="px-4 py-2 text-sm text-white bg-gradient-to-r from-accent-purple to-accent-purple-light rounded-lg disabled:opacity-50">
56
+ {loading ? "생성 중..." : "생성"}
57
+ </button>
58
+ </div>
59
+ </form>
60
+ </div>
61
+ </div>
62
+ );
63
+ }
@@ -0,0 +1,57 @@
1
+ "use client";
2
+
3
+ import type { OutputEntry } from "@/types/pipeline";
4
+ import { PHASE_NAMES } from "@/lib/agents";
5
+
6
+ interface OutputListProps {
7
+ outputs: OutputEntry[];
8
+ onSelect: (filename: string) => void;
9
+ selected?: string;
10
+ }
11
+
12
+ export function OutputList({ outputs, onSelect, selected }: OutputListProps) {
13
+ const grouped = outputs.reduce((acc, o) => {
14
+ (acc[o.phase] ||= []).push(o);
15
+ return acc;
16
+ }, {} as Record<number, OutputEntry[]>);
17
+
18
+ return (
19
+ <div className="flex flex-col h-full">
20
+ <div className="px-3 py-2 border-b border-border">
21
+ <div className="text-text-secondary text-[11px] font-semibold">OUTPUTS</div>
22
+ </div>
23
+ <div className="flex-1 px-3 py-2 overflow-y-auto">
24
+ {outputs.length === 0 ? (
25
+ <div className="text-text-muted text-[11px] text-center py-10">아직 산출물이 없습니다.</div>
26
+ ) : (
27
+ Object.entries(grouped)
28
+ .sort(([a], [b]) => Number(a) - Number(b))
29
+ .map(([phase, files]) => (
30
+ <div key={phase}>
31
+ <div className="text-text-secondary text-[9px] font-semibold mt-2 mb-1">
32
+ PHASE {phase} {PHASE_NAMES[Number(phase)] || ""}
33
+ </div>
34
+ {files.map((f) => {
35
+ const icon = f.filename.endsWith(".html") ? "🌐" : "📄";
36
+ const name = f.filename.split("/").pop();
37
+ return (
38
+ <div
39
+ key={f.filename}
40
+ onClick={() => onSelect(f.filename)}
41
+ className={`px-2 py-1 rounded text-[11px] cursor-pointer ${
42
+ selected === f.filename
43
+ ? "bg-border text-accent-purple-light"
44
+ : "text-text-primary hover:bg-panel"
45
+ }`}
46
+ >
47
+ {icon} {name}
48
+ </div>
49
+ );
50
+ })}
51
+ </div>
52
+ ))
53
+ )}
54
+ </div>
55
+ </div>
56
+ );
57
+ }
@@ -0,0 +1,37 @@
1
+ "use client";
2
+
3
+ import { PHASE_NAMES } from "@/lib/agents";
4
+
5
+ interface PhaseDotsProp {
6
+ currentPhase: number;
7
+ showLabel?: boolean;
8
+ }
9
+
10
+ export function PhaseDots({ currentPhase, showLabel = false }: PhaseDotsProp) {
11
+ return (
12
+ <div className="flex items-center gap-[6px]">
13
+ {PHASE_NAMES.map((name, i) => {
14
+ const isComplete = i < currentPhase;
15
+ const isCurrent = i === currentPhase;
16
+ return (
17
+ <div
18
+ key={i}
19
+ className={`rounded-full ${
20
+ isCurrent
21
+ ? "w-[10px] h-[10px] bg-accent-purple-light shadow-[0_0_6px_rgba(139,92,246,0.5)]"
22
+ : isComplete
23
+ ? "w-2 h-2 bg-accent-purple"
24
+ : "w-2 h-2 bg-border"
25
+ }`}
26
+ title={`P${i} ${name}`}
27
+ />
28
+ );
29
+ })}
30
+ {showLabel && (
31
+ <span className="text-text-secondary text-[11px] ml-1">
32
+ P{currentPhase} {PHASE_NAMES[currentPhase]}
33
+ </span>
34
+ )}
35
+ </div>
36
+ );
37
+ }
@@ -0,0 +1,53 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { PhaseDots } from "./phase-dots";
5
+ import { AGENT_MAP } from "@/lib/agents";
6
+ import type { PipelineSummary } from "@/types/pipeline";
7
+
8
+ function timeAgo(dateStr: string): string {
9
+ const diff = Date.now() - new Date(dateStr).getTime();
10
+ const mins = Math.floor(diff / 60000);
11
+ if (mins < 60) return `${mins}m ago`;
12
+ const hours = Math.floor(mins / 60);
13
+ if (hours < 24) return `${hours}h ago`;
14
+ const days = Math.floor(hours / 24);
15
+ return `${days}d ago`;
16
+ }
17
+
18
+ export function PipelineCard({ pipeline }: { pipeline: PipelineSummary }) {
19
+ const workingAgents = Object.values(pipeline.agents)
20
+ .filter((a) => a.status === "working")
21
+ .map((a) => AGENT_MAP[a.id]?.emoji)
22
+ .filter(Boolean);
23
+
24
+ const statusColor =
25
+ pipeline.status === "running" ? "text-accent-green" :
26
+ pipeline.status === "completed" ? "text-text-muted" :
27
+ pipeline.status === "failed" ? "text-red-500" :
28
+ "text-yellow-500";
29
+
30
+ return (
31
+ <Link href={`/pipeline/${pipeline.id}`}>
32
+ <div className="bg-panel p-4 rounded-lg border border-border hover:border-accent-purple/50 transition-colors cursor-pointer flex justify-between items-center">
33
+ <div>
34
+ <div className="flex items-center gap-2 mb-1">
35
+ <span className={`text-[11px] ${statusColor}`}>
36
+ ● {pipeline.status.toUpperCase()}
37
+ </span>
38
+ <span className="text-text-primary text-[13px] font-medium">
39
+ {pipeline.requirements}
40
+ </span>
41
+ </div>
42
+ <div className="text-text-muted text-[11px]">
43
+ Phase {pipeline.currentPhase}
44
+ {workingAgents.length > 0 && ` · ${workingAgents.join("")} working`}
45
+ {" · "}
46
+ {timeAgo(pipeline.createdAt)}
47
+ </div>
48
+ </div>
49
+ <PhaseDots currentPhase={pipeline.currentPhase} />
50
+ </div>
51
+ </Link>
52
+ );
53
+ }
@@ -0,0 +1,91 @@
1
+ "use client";
2
+
3
+ import { useState, useRef, useCallback, useEffect, ReactNode } from "react";
4
+
5
+ interface ResizablePanelsProps {
6
+ left: ReactNode;
7
+ center: ReactNode;
8
+ right: ReactNode;
9
+ }
10
+
11
+ const STORAGE_KEY = "panel-sizes";
12
+ const DEFAULT_SIZES = [25, 50, 25];
13
+ const MIN_SIZES = [160, 200, 180];
14
+
15
+ export function ResizablePanels({ left, center, right }: ResizablePanelsProps) {
16
+ const containerRef = useRef<HTMLDivElement>(null);
17
+ const [sizes, setSizes] = useState(DEFAULT_SIZES);
18
+ const dragState = useRef<{ index: number; startX: number; startSizes: number[] } | null>(null);
19
+
20
+ useEffect(() => {
21
+ const saved = localStorage.getItem(STORAGE_KEY);
22
+ if (saved) {
23
+ try {
24
+ setSizes(JSON.parse(saved));
25
+ } catch { /* ignore */ }
26
+ }
27
+ }, []);
28
+
29
+ useEffect(() => {
30
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(sizes));
31
+ }, [sizes]);
32
+
33
+ const onMouseDown = useCallback((index: number, e: React.MouseEvent) => {
34
+ e.preventDefault();
35
+ dragState.current = { index, startX: e.clientX, startSizes: [...sizes] };
36
+
37
+ const onMouseMove = (e: MouseEvent) => {
38
+ if (!dragState.current || !containerRef.current) return;
39
+ const { index, startX, startSizes } = dragState.current;
40
+ const containerWidth = containerRef.current.offsetWidth;
41
+ const deltaPct = ((e.clientX - startX) / containerWidth) * 100;
42
+
43
+ const newSizes = [...startSizes];
44
+ newSizes[index] = Math.max((MIN_SIZES[index] / containerWidth) * 100, startSizes[index] + deltaPct);
45
+ newSizes[index + 1] = Math.max((MIN_SIZES[index + 1] / containerWidth) * 100, startSizes[index + 1] - deltaPct);
46
+
47
+ setSizes(newSizes);
48
+ };
49
+
50
+ const onMouseUp = () => {
51
+ dragState.current = null;
52
+ document.removeEventListener("mousemove", onMouseMove);
53
+ document.removeEventListener("mouseup", onMouseUp);
54
+ };
55
+
56
+ document.addEventListener("mousemove", onMouseMove);
57
+ document.addEventListener("mouseup", onMouseUp);
58
+ }, [sizes]);
59
+
60
+ const onDoubleClick = useCallback(() => {
61
+ setSizes(DEFAULT_SIZES);
62
+ }, []);
63
+
64
+ return (
65
+ <div ref={containerRef} className="flex h-full">
66
+ <div style={{ width: `${sizes[0]}%` }} className="overflow-y-auto">
67
+ {left}
68
+ </div>
69
+ <div
70
+ className="w-[6px] bg-panel cursor-col-resize flex items-center justify-center flex-shrink-0 hover:bg-border transition-colors"
71
+ onMouseDown={(e) => onMouseDown(0, e)}
72
+ onDoubleClick={onDoubleClick}
73
+ >
74
+ <span className="text-text-muted text-[9px] writing-mode-vertical select-none">⋮⋮</span>
75
+ </div>
76
+ <div style={{ width: `${sizes[1]}%` }} className="overflow-hidden flex flex-col">
77
+ {center}
78
+ </div>
79
+ <div
80
+ className="w-[6px] bg-panel cursor-col-resize flex items-center justify-center flex-shrink-0 hover:bg-border transition-colors"
81
+ onMouseDown={(e) => onMouseDown(1, e)}
82
+ onDoubleClick={onDoubleClick}
83
+ >
84
+ <span className="text-text-muted text-[9px] writing-mode-vertical select-none">⋮⋮</span>
85
+ </div>
86
+ <div style={{ width: `${sizes[2]}%` }} className="overflow-y-auto">
87
+ {right}
88
+ </div>
89
+ </div>
90
+ );
91
+ }
@@ -0,0 +1,65 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback } from "react";
4
+ import type { PipelineState, CheckpointInfo, ServerMessage } from "@/types/pipeline";
5
+ import { useWebSocket } from "./use-websocket";
6
+
7
+ export function usePipelineDetail(id: string) {
8
+ const [pipeline, setPipeline] = useState<PipelineState | null>(null);
9
+ const [checkpoint, setCheckpoint] = useState<CheckpointInfo | null>(null);
10
+ const [loading, setLoading] = useState(true);
11
+ const [notFound, setNotFound] = useState(false);
12
+
13
+ const fetchPipeline = useCallback(async () => {
14
+ try {
15
+ const res = await fetch(`/api/pipelines/${id}`);
16
+ if (res.status === 404) {
17
+ setNotFound(true);
18
+ return;
19
+ }
20
+ const data = await res.json();
21
+ setPipeline(data);
22
+ } catch {
23
+ // Ignore
24
+ } finally {
25
+ setLoading(false);
26
+ }
27
+ }, [id]);
28
+
29
+ const handleMessage = useCallback((msg: ServerMessage) => {
30
+ if (msg.type === "pipeline:updated" && msg.id === id) {
31
+ setPipeline(msg.state);
32
+ } else if (msg.type === "pipeline:activity" && msg.id === id) {
33
+ setPipeline((prev) => {
34
+ if (!prev) return prev;
35
+ return { ...prev, activities: [...prev.activities, msg.activity] };
36
+ });
37
+ } else if (msg.type === "pipeline:checkpoint" && msg.id === id) {
38
+ setCheckpoint(msg.checkpoint);
39
+ } else if (msg.type === "pipeline:removed" && msg.id === id) {
40
+ setNotFound(true);
41
+ }
42
+ }, [id]);
43
+
44
+ const { send, connected } = useWebSocket(handleMessage, fetchPipeline);
45
+
46
+ useEffect(() => {
47
+ fetchPipeline();
48
+ }, [fetchPipeline]);
49
+
50
+ useEffect(() => {
51
+ if (connected) {
52
+ send({ type: "subscribe", pipelineId: id });
53
+ return () => {
54
+ send({ type: "unsubscribe", pipelineId: id });
55
+ };
56
+ }
57
+ }, [connected, id, send]);
58
+
59
+ const respondToCheckpoint = useCallback((action: "approve" | "reject", message?: string) => {
60
+ send({ type: "checkpoint:respond", pipelineId: id, action, message });
61
+ setCheckpoint(null);
62
+ }, [id, send]);
63
+
64
+ return { pipeline, checkpoint, loading, notFound, respondToCheckpoint };
65
+ }
@@ -0,0 +1,60 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback } from "react";
4
+ import type { PipelineSummary, ServerMessage } from "@/types/pipeline";
5
+ import { useWebSocket } from "./use-websocket";
6
+
7
+ export function usePipelines() {
8
+ const [pipelines, setPipelines] = useState<PipelineSummary[]>([]);
9
+ const [loading, setLoading] = useState(true);
10
+
11
+ const fetchPipelines = useCallback(async () => {
12
+ try {
13
+ const res = await fetch("/api/pipelines");
14
+ const data = await res.json();
15
+ setPipelines(data.pipelines);
16
+ } catch {
17
+ // Ignore fetch errors
18
+ } finally {
19
+ setLoading(false);
20
+ }
21
+ }, []);
22
+
23
+ const handleMessage = useCallback((msg: ServerMessage) => {
24
+ if (msg.type === "pipeline:updated") {
25
+ setPipelines((prev) => {
26
+ const idx = prev.findIndex((p) => p.id === msg.id);
27
+ const summary: PipelineSummary = {
28
+ id: msg.state.id,
29
+ requirements: msg.state.requirements,
30
+ status: msg.state.status,
31
+ currentPhase: msg.state.currentPhase,
32
+ createdAt: msg.state.createdAt,
33
+ agents: msg.state.agents,
34
+ };
35
+ if (idx >= 0) {
36
+ const next = [...prev];
37
+ next[idx] = summary;
38
+ return next;
39
+ }
40
+ return [summary, ...prev];
41
+ });
42
+ } else if (msg.type === "pipeline:removed") {
43
+ setPipelines((prev) => prev.filter((p) => p.id !== msg.id));
44
+ }
45
+ }, []);
46
+
47
+ const { send, connected } = useWebSocket(handleMessage, fetchPipelines);
48
+
49
+ useEffect(() => {
50
+ fetchPipelines();
51
+ }, [fetchPipelines]);
52
+
53
+ useEffect(() => {
54
+ if (connected) {
55
+ send({ type: "subscribe:all" });
56
+ }
57
+ }, [connected, send]);
58
+
59
+ return { pipelines, loading };
60
+ }
@@ -0,0 +1,58 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useCallback, useState } from "react";
4
+ import type { ServerMessage, ClientMessage } from "@/types/pipeline";
5
+
6
+ export function useWebSocket(onMessage: (msg: ServerMessage) => void, onReconnect?: () => void) {
7
+ const wsRef = useRef<WebSocket | null>(null);
8
+ const reconnectDelay = useRef(1000);
9
+ const [connected, setConnected] = useState(false);
10
+
11
+ const connect = useCallback(() => {
12
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
13
+ const ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
14
+
15
+ ws.onopen = () => {
16
+ const wasDisconnected = !connected;
17
+ setConnected(true);
18
+ reconnectDelay.current = 1000;
19
+ // Signal reconnect so data hooks can re-fetch
20
+ if (wasDisconnected && onReconnect) onReconnect();
21
+ };
22
+
23
+ ws.onmessage = (event) => {
24
+ try {
25
+ const msg = JSON.parse(event.data) as ServerMessage;
26
+ onMessage(msg);
27
+ } catch {
28
+ // Ignore parse errors
29
+ }
30
+ };
31
+
32
+ ws.onclose = () => {
33
+ setConnected(false);
34
+ wsRef.current = null;
35
+ // Exponential backoff reconnection
36
+ const delay = reconnectDelay.current;
37
+ reconnectDelay.current = Math.min(delay * 2, 30000);
38
+ setTimeout(connect, delay);
39
+ };
40
+
41
+ wsRef.current = ws;
42
+ }, [onMessage]);
43
+
44
+ useEffect(() => {
45
+ connect();
46
+ return () => {
47
+ wsRef.current?.close();
48
+ };
49
+ }, [connect]);
50
+
51
+ const send = useCallback((msg: ClientMessage) => {
52
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
53
+ wsRef.current.send(JSON.stringify(msg));
54
+ }
55
+ }, []);
56
+
57
+ return { send, connected };
58
+ }
@@ -0,0 +1,30 @@
1
+ export interface AgentMeta {
2
+ id: string;
3
+ emoji: string;
4
+ name: string;
5
+ role: string;
6
+ workingColor: string;
7
+ }
8
+
9
+ export const AGENTS: AgentMeta[] = [
10
+ { id: "alex", emoji: "🦁", name: "Alex", role: "PM", workingColor: "#8b5cf6" },
11
+ { id: "mina", emoji: "🦉", name: "Mina", role: "기획", workingColor: "#8b5cf6" },
12
+ { id: "lena", emoji: "🦋", name: "Lena", role: "디자인", workingColor: "#34d399" },
13
+ { id: "jay", emoji: "🦊", name: "Jay", role: "FE", workingColor: "#34d399" },
14
+ { id: "sam", emoji: "🐻", name: "Sam", role: "BE", workingColor: "#34d399" },
15
+ { id: "dex", emoji: "🐺", name: "Dex", role: "Infra", workingColor: "#34d399" },
16
+ { id: "eva", emoji: "🐱", name: "Eva", role: "QA", workingColor: "#34d399" },
17
+ { id: "rex", emoji: "🐍", name: "Rex", role: "보안", workingColor: "#8b5cf6" },
18
+ { id: "nora", emoji: "🦅", name: "Nora", role: "리뷰", workingColor: "#34d399" },
19
+ ];
20
+
21
+ export const AGENT_MAP = Object.fromEntries(AGENTS.map((a) => [a.id, a]));
22
+
23
+ export const PHASE_NAMES = ["인풋", "기획", "설계", "구현", "QA"] as const;
24
+
25
+ export const ACTIVITY_TAG: Record<string, { label: string; color: string }> = {
26
+ success: { label: "완료", color: "#34d399" },
27
+ progress: { label: "진행", color: "#a5b4fc" },
28
+ info: { label: "info", color: "#a5b4fc" },
29
+ error: { label: "에러", color: "#ef4444" },
30
+ };
@@ -0,0 +1,37 @@
1
+ import type { Activity, CheckpointInfo } from "@/types/pipeline";
2
+
3
+ export function detectCheckpoint(activities: Activity[]): CheckpointInfo | null {
4
+ let lastCheckpointIdx = -1;
5
+ let checkpointPhase = 0;
6
+ let checkpointDesc = "";
7
+
8
+ for (let i = activities.length - 1; i >= 0; i--) {
9
+ const a = activities[i];
10
+ if (a.agentId === "system" && a.message.includes("Checkpoint")) {
11
+ if (a.message.includes("approved") || a.message.includes("rejected")) {
12
+ return null;
13
+ }
14
+ lastCheckpointIdx = i;
15
+ checkpointDesc = a.message;
16
+ const phaseMatch = a.message.match(/Phase (\d+)/);
17
+ if (phaseMatch) checkpointPhase = parseInt(phaseMatch[1]);
18
+ break;
19
+ }
20
+ }
21
+
22
+ if (lastCheckpointIdx === -1) return null;
23
+
24
+ for (let i = lastCheckpointIdx + 1; i < activities.length; i++) {
25
+ const a = activities[i];
26
+ if (a.agentId === "system") {
27
+ if (a.message.includes("Checkpoint approved")) return null;
28
+ if (a.message.includes("Checkpoint rejected")) return null;
29
+ }
30
+ }
31
+
32
+ return {
33
+ phase: checkpointPhase,
34
+ description: checkpointDesc,
35
+ status: "pending",
36
+ };
37
+ }