@kmgeon/taskflow 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +374 -0
  2. package/bin/task-mcp.mjs +19 -0
  3. package/bin/task.mjs +19 -0
  4. package/docs/clean-code.md +29 -0
  5. package/docs/git.md +36 -0
  6. package/docs/guideline.md +25 -0
  7. package/docs/security.md +32 -0
  8. package/docs/step-by-step.md +29 -0
  9. package/docs/superpowers/specs/2026-03-21-cli-advisor-design.md +383 -0
  10. package/docs/superpowers/specs/2026-03-21-init-redesign-design.md +429 -0
  11. package/docs/superpowers/specs/2026-03-21-skill-architecture-design.md +362 -0
  12. package/docs/superpowers/specs/2026-03-23-t-create-task-run-design.md +40 -0
  13. package/docs/superpowers/specs/2026-03-23-task-run-design.md +44 -0
  14. package/docs/tdd.md +41 -0
  15. package/package.json +114 -0
  16. package/src/app/(protected)/dashboard/page.tsx +7 -0
  17. package/src/app/(protected)/layout.tsx +10 -0
  18. package/src/app/api/[[...hono]]/route.ts +13 -0
  19. package/src/app/example/page.tsx +11 -0
  20. package/src/app/favicon.ico +0 -0
  21. package/src/app/globals.css +168 -0
  22. package/src/app/layout.tsx +35 -0
  23. package/src/app/page.tsx +5 -0
  24. package/src/app/providers.tsx +57 -0
  25. package/src/backend/config/index.ts +36 -0
  26. package/src/backend/hono/app.ts +32 -0
  27. package/src/backend/hono/context.ts +38 -0
  28. package/src/backend/http/response.ts +64 -0
  29. package/src/backend/middleware/context.ts +23 -0
  30. package/src/backend/middleware/error.ts +31 -0
  31. package/src/backend/middleware/supabase.ts +23 -0
  32. package/src/backend/supabase/client.ts +17 -0
  33. package/src/cli/commands/__tests__/task-commands.test.ts +170 -0
  34. package/src/cli/commands/advisor.ts +45 -0
  35. package/src/cli/commands/ask.ts +50 -0
  36. package/src/cli/commands/board.ts +72 -0
  37. package/src/cli/commands/init.ts +184 -0
  38. package/src/cli/commands/list.ts +138 -0
  39. package/src/cli/commands/run.ts +143 -0
  40. package/src/cli/commands/set-status.ts +50 -0
  41. package/src/cli/commands/show.ts +28 -0
  42. package/src/cli/commands/tree.ts +72 -0
  43. package/src/cli/index.ts +38 -0
  44. package/src/cli/lib/__tests__/formatter.test.ts +123 -0
  45. package/src/cli/lib/error-boundary.test.ts +135 -0
  46. package/src/cli/lib/error-boundary.ts +70 -0
  47. package/src/cli/lib/formatter.ts +764 -0
  48. package/src/cli/lib/trd.ts +33 -0
  49. package/src/cli/lib/validate.test.ts +89 -0
  50. package/src/cli/lib/validate.ts +43 -0
  51. package/src/cli/prompts/task-run.md +25 -0
  52. package/src/components/layout/AppLayout.tsx +15 -0
  53. package/src/components/layout/Sidebar.tsx +124 -0
  54. package/src/components/ui/accordion.tsx +58 -0
  55. package/src/components/ui/avatar.tsx +50 -0
  56. package/src/components/ui/badge.tsx +36 -0
  57. package/src/components/ui/button.tsx +56 -0
  58. package/src/components/ui/card.tsx +79 -0
  59. package/src/components/ui/checkbox.tsx +30 -0
  60. package/src/components/ui/dialog.tsx +122 -0
  61. package/src/components/ui/dropdown-menu.tsx +200 -0
  62. package/src/components/ui/file-upload.tsx +50 -0
  63. package/src/components/ui/form.tsx +179 -0
  64. package/src/components/ui/input.tsx +25 -0
  65. package/src/components/ui/label.tsx +26 -0
  66. package/src/components/ui/scroll-area.tsx +48 -0
  67. package/src/components/ui/select.tsx +160 -0
  68. package/src/components/ui/separator.tsx +31 -0
  69. package/src/components/ui/sheet.tsx +140 -0
  70. package/src/components/ui/textarea.tsx +22 -0
  71. package/src/components/ui/toast.tsx +129 -0
  72. package/src/components/ui/toaster.tsx +35 -0
  73. package/src/core/ai/claude-client.ts +79 -0
  74. package/src/core/claude-runner/flag-builder.ts +57 -0
  75. package/src/core/claude-runner/index.ts +2 -0
  76. package/src/core/claude-runner/spawner.ts +86 -0
  77. package/src/core/prd/__tests__/auto-analyzer.test.ts +35 -0
  78. package/src/core/prd/__tests__/generator.test.ts +26 -0
  79. package/src/core/prd/__tests__/scanner.test.ts +35 -0
  80. package/src/core/prd/auto-analyzer.ts +9 -0
  81. package/src/core/prd/generator.ts +8 -0
  82. package/src/core/prd/scanner.ts +117 -0
  83. package/src/core/project/__tests__/claude-setup.test.ts +133 -0
  84. package/src/core/project/__tests__/config.test.ts +30 -0
  85. package/src/core/project/__tests__/init.test.ts +37 -0
  86. package/src/core/project/__tests__/skill-setup.test.ts +62 -0
  87. package/src/core/project/claude-setup.ts +224 -0
  88. package/src/core/project/config.ts +34 -0
  89. package/src/core/project/docs-setup.ts +26 -0
  90. package/src/core/project/docs-templates.ts +205 -0
  91. package/src/core/project/init.ts +40 -0
  92. package/src/core/project/skill-setup.ts +32 -0
  93. package/src/core/project/skill-templates.ts +277 -0
  94. package/src/core/task/index.ts +16 -0
  95. package/src/core/types.ts +58 -0
  96. package/src/features/example/backend/error.ts +9 -0
  97. package/src/features/example/backend/route.ts +52 -0
  98. package/src/features/example/backend/schema.ts +25 -0
  99. package/src/features/example/backend/service.ts +73 -0
  100. package/src/features/example/components/example-status.test.tsx +97 -0
  101. package/src/features/example/components/example-status.tsx +160 -0
  102. package/src/features/example/hooks/useExampleQuery.ts +23 -0
  103. package/src/features/example/lib/dto.test.ts +57 -0
  104. package/src/features/example/lib/dto.ts +5 -0
  105. package/src/features/kanban/backend/__tests__/sse-broadcaster.test.ts +137 -0
  106. package/src/features/kanban/backend/__tests__/sse-event-format.test.ts +55 -0
  107. package/src/features/kanban/backend/route.ts +55 -0
  108. package/src/features/kanban/backend/sse-broadcaster.ts +142 -0
  109. package/src/features/kanban/backend/sse-route.ts +43 -0
  110. package/src/features/kanban/components/KanbanBoard.tsx +105 -0
  111. package/src/features/kanban/components/KanbanColumn.tsx +51 -0
  112. package/src/features/kanban/components/KanbanError.tsx +29 -0
  113. package/src/features/kanban/components/KanbanSkeleton.tsx +46 -0
  114. package/src/features/kanban/components/ProgressCard.tsx +42 -0
  115. package/src/features/kanban/components/TaskCard.tsx +76 -0
  116. package/src/features/kanban/components/__tests__/kanban-components.test.tsx +86 -0
  117. package/src/features/kanban/hooks/useTaskSse.ts +66 -0
  118. package/src/features/kanban/hooks/useTasksQuery.ts +52 -0
  119. package/src/features/kanban/lib/__tests__/kanban-utils.test.ts +97 -0
  120. package/src/features/kanban/lib/kanban-utils.ts +37 -0
  121. package/src/features/taskflow/constants.ts +54 -0
  122. package/src/features/taskflow/index.ts +27 -0
  123. package/src/features/taskflow/lib/__tests__/filter.test.ts +89 -0
  124. package/src/features/taskflow/lib/__tests__/graph.test.ts +247 -0
  125. package/src/features/taskflow/lib/__tests__/repository.test.ts +233 -0
  126. package/src/features/taskflow/lib/__tests__/serializer.test.ts +98 -0
  127. package/src/features/taskflow/lib/advisor/__tests__/advisor-integration.test.ts +98 -0
  128. package/src/features/taskflow/lib/advisor/ai-advisor.test.ts +40 -0
  129. package/src/features/taskflow/lib/advisor/ai-advisor.ts +20 -0
  130. package/src/features/taskflow/lib/advisor/context-builder.test.ts +73 -0
  131. package/src/features/taskflow/lib/advisor/context-builder.ts +151 -0
  132. package/src/features/taskflow/lib/advisor/db.test.ts +106 -0
  133. package/src/features/taskflow/lib/advisor/db.ts +185 -0
  134. package/src/features/taskflow/lib/advisor/local-summary.test.ts +53 -0
  135. package/src/features/taskflow/lib/advisor/local-summary.ts +72 -0
  136. package/src/features/taskflow/lib/advisor/prompts.ts +86 -0
  137. package/src/features/taskflow/lib/filter.ts +54 -0
  138. package/src/features/taskflow/lib/fs-utils.ts +50 -0
  139. package/src/features/taskflow/lib/graph.ts +148 -0
  140. package/src/features/taskflow/lib/index-builder.ts +42 -0
  141. package/src/features/taskflow/lib/repository.ts +168 -0
  142. package/src/features/taskflow/lib/serializer.ts +62 -0
  143. package/src/features/taskflow/lib/watcher.ts +40 -0
  144. package/src/features/taskflow/types.ts +71 -0
  145. package/src/hooks/use-toast.ts +194 -0
  146. package/src/lib/remote/api-client.ts +40 -0
  147. package/src/lib/supabase/client.ts +8 -0
  148. package/src/lib/supabase/server.ts +46 -0
  149. package/src/lib/supabase/types.ts +3 -0
  150. package/src/lib/utils.ts +6 -0
  151. package/src/mcp/index.ts +7 -0
  152. package/src/mcp/server.ts +21 -0
  153. package/src/mcp/tools/brainstorm.ts +48 -0
  154. package/src/mcp/tools/prd.ts +71 -0
  155. package/src/mcp/tools/project.ts +39 -0
  156. package/src/mcp/tools/task-status.ts +40 -0
  157. package/src/mcp/tools/task.ts +82 -0
  158. package/src/mcp/util.ts +6 -0
@@ -0,0 +1,160 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import type { FormEvent } from 'react';
5
+ import { Button } from '@/components/ui/button';
6
+ import { Input } from '@/components/ui/input';
7
+ import { useExampleQuery } from '@/features/example/hooks/useExampleQuery';
8
+
9
+ const statusBadge = (
10
+ label: string,
11
+ tone: 'success' | 'error' | 'idle',
12
+ ) => {
13
+ const toneStyles: Record<typeof tone, string> = {
14
+ success: 'bg-emerald-500/10 text-emerald-300 border-emerald-400/40',
15
+ error: 'bg-rose-500/10 text-rose-300 border-rose-400/40',
16
+ idle: 'bg-slate-500/10 text-slate-200 border-slate-400/30',
17
+ };
18
+
19
+ return (
20
+ <span
21
+ className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium ${toneStyles[tone]}`}
22
+ >
23
+ {label}
24
+ </span>
25
+ );
26
+ };
27
+
28
+ export const ExampleStatus = () => {
29
+ const [inputValue, setInputValue] = useState('');
30
+ const [exampleId, setExampleId] = useState('');
31
+ const query = useExampleQuery(exampleId);
32
+
33
+ const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
34
+ event.preventDefault();
35
+ const trimmed = inputValue.trim();
36
+
37
+ if (!trimmed) {
38
+ setExampleId('');
39
+ return;
40
+ }
41
+
42
+ if (trimmed === exampleId) {
43
+ void query.refetch();
44
+ return;
45
+ }
46
+
47
+ setExampleId(trimmed);
48
+ };
49
+
50
+ return (
51
+ <section className="mx-auto flex w-full max-w-3xl flex-col gap-6">
52
+ <header className="space-y-2 text-slate-100">
53
+ <h1 className="text-3xl font-semibold tracking-tight">Backend Health Check</h1>
54
+ <p className="text-sm text-slate-300">
55
+ 예시 API(`/api/example/:id`)가 정상 동작하는지 확인합니다. Supabase 예시
56
+ 레코드의 UUID를 입력하면 React Query를 통해 백엔드 응답을 확인할 수
57
+ 있습니다.
58
+ </p>
59
+ </header>
60
+
61
+ <form
62
+ onSubmit={handleSubmit}
63
+ className="flex flex-col gap-3 rounded-xl border border-slate-800 bg-slate-950/60 p-4 md:flex-row md:items-center"
64
+ >
65
+ <div className="flex-1 space-y-1">
66
+ <label className="text-xs uppercase tracking-wide text-slate-400">
67
+ Example UUID
68
+ </label>
69
+ <Input
70
+ value={inputValue}
71
+ onChange={(event) => setInputValue(event.target.value)}
72
+ placeholder="00000000-0000-0000-0000-000000000000"
73
+ className="bg-slate-900/70 text-slate-100 placeholder:text-slate-600"
74
+ />
75
+ </div>
76
+ <Button
77
+ type="submit"
78
+ variant="secondary"
79
+ className="mt-2 h-12 rounded-lg border border-slate-600 bg-slate-800 text-slate-100 hover:bg-slate-700 md:mt-6"
80
+ >
81
+ 조회하기
82
+ </Button>
83
+ </form>
84
+
85
+ <article className="space-y-3 rounded-xl border border-slate-800 bg-slate-950/60 p-6 text-slate-100">
86
+ <div className="flex items-center justify-between">
87
+ <h2 className="text-lg font-semibold">현재 상태</h2>
88
+ {exampleId
89
+ ? query.status === 'pending'
90
+ ? statusBadge('Fetching', 'idle')
91
+ : query.status === 'error'
92
+ ? statusBadge('Error', 'error')
93
+ : statusBadge('Success', 'success')
94
+ : statusBadge('Idle', 'idle')}
95
+ </div>
96
+
97
+ {!exampleId && (
98
+ <p className="text-sm text-slate-300">
99
+ UUID를 입력하고 조회하기 버튼을 누르면 결과가 이곳에 표시됩니다.
100
+ </p>
101
+ )}
102
+
103
+ {exampleId && query.status === 'pending' && (
104
+ <p className="text-sm text-slate-300">Supabase에서 데이터를 가져오는 중...</p>
105
+ )}
106
+
107
+ {query.status === 'error' && (
108
+ <div className="space-y-2 rounded-lg border border-rose-400/30 bg-rose-500/5 p-4">
109
+ <p className="text-sm font-medium text-rose-300">요청 실패</p>
110
+ <p className="text-xs text-rose-200/80">
111
+ {query.error instanceof Error
112
+ ? query.error.message
113
+ : '알 수 없는 에러가 발생했습니다.'}
114
+ </p>
115
+ </div>
116
+ )}
117
+
118
+ {query.data && (
119
+ <div className="space-y-3 rounded-lg border border-emerald-400/30 bg-emerald-500/5 p-4 text-sm text-emerald-100">
120
+ <div>
121
+ <p className="text-xs uppercase tracking-wide text-emerald-300">ID</p>
122
+ <p className="font-mono text-xs md:text-sm">{query.data.id}</p>
123
+ </div>
124
+ <div>
125
+ <p className="text-xs uppercase tracking-wide text-emerald-300">
126
+ 이름
127
+ </p>
128
+ <p>{query.data.fullName}</p>
129
+ </div>
130
+ <div>
131
+ <p className="text-xs uppercase tracking-wide text-emerald-300">
132
+ 소개
133
+ </p>
134
+ <p>{query.data.bio ?? '—'}</p>
135
+ </div>
136
+ <div>
137
+ <p className="text-xs uppercase tracking-wide text-emerald-300">
138
+ 아바타
139
+ </p>
140
+ <a
141
+ href={query.data.avatarUrl}
142
+ target="_blank"
143
+ rel="noreferrer"
144
+ className="underline"
145
+ >
146
+ {query.data.avatarUrl}
147
+ </a>
148
+ </div>
149
+ <div>
150
+ <p className="text-xs uppercase tracking-wide text-emerald-300">
151
+ 업데이트 시각
152
+ </p>
153
+ <p>{query.data.updatedAt}</p>
154
+ </div>
155
+ </div>
156
+ )}
157
+ </article>
158
+ </section>
159
+ );
160
+ };
@@ -0,0 +1,23 @@
1
+ 'use client';
2
+
3
+ import { useQuery } from '@tanstack/react-query';
4
+ import { apiClient, extractApiErrorMessage } from '@/lib/remote/api-client';
5
+ import { ExampleResponseSchema } from '@/features/example/lib/dto';
6
+
7
+ const fetchExample = async (id: string) => {
8
+ try {
9
+ const { data } = await apiClient.get(`/api/example/${id}`);
10
+ return ExampleResponseSchema.parse(data);
11
+ } catch (error) {
12
+ const message = extractApiErrorMessage(error, 'Failed to fetch example.');
13
+ throw new Error(message);
14
+ }
15
+ };
16
+
17
+ export const useExampleQuery = (id: string) =>
18
+ useQuery({
19
+ queryKey: ['example', id],
20
+ queryFn: () => fetchExample(id),
21
+ enabled: Boolean(id),
22
+ staleTime: 60 * 1000,
23
+ });
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ExampleResponseSchema } from './dto';
3
+
4
+ describe('ExampleResponseSchema', () => {
5
+ it('should validate a correct example response', () => {
6
+ const validData = {
7
+ id: '123e4567-e89b-12d3-a456-426614174000',
8
+ fullName: 'Test User',
9
+ avatarUrl: 'https://example.com/avatar.jpg',
10
+ bio: 'This is a test bio',
11
+ updatedAt: '2024-01-01T00:00:00Z',
12
+ };
13
+
14
+ const result = ExampleResponseSchema.safeParse(validData);
15
+
16
+ expect(result.success).toBe(true);
17
+ if (result.success) {
18
+ expect(result.data).toEqual(validData);
19
+ }
20
+ });
21
+
22
+ it('should allow null bio', () => {
23
+ const validData = {
24
+ id: '123e4567-e89b-12d3-a456-426614174000',
25
+ fullName: 'Test User',
26
+ avatarUrl: 'https://example.com/avatar.jpg',
27
+ bio: null,
28
+ updatedAt: '2024-01-01T00:00:00Z',
29
+ };
30
+
31
+ const result = ExampleResponseSchema.safeParse(validData);
32
+
33
+ expect(result.success).toBe(true);
34
+ });
35
+
36
+ it('should reject invalid data structure', () => {
37
+ const invalidData = {
38
+ id: 123, // should be string
39
+ fullName: 'Test User',
40
+ };
41
+
42
+ const result = ExampleResponseSchema.safeParse(invalidData);
43
+
44
+ expect(result.success).toBe(false);
45
+ });
46
+
47
+ it('should reject missing required fields', () => {
48
+ const invalidData = {
49
+ id: 'test-id',
50
+ // missing fullName, avatarUrl, updatedAt
51
+ };
52
+
53
+ const result = ExampleResponseSchema.safeParse(invalidData);
54
+
55
+ expect(result.success).toBe(false);
56
+ });
57
+ });
@@ -0,0 +1,5 @@
1
+ export {
2
+ ExampleParamsSchema,
3
+ ExampleResponseSchema,
4
+ type ExampleResponse,
5
+ } from '@/features/example/backend/schema';
@@ -0,0 +1,137 @@
1
+ import { describe, it, expect, vi, 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 { getSseBroadcaster, resetBroadcaster, type SseEvent } from "../sse-broadcaster";
6
+ import { ensureRepo, createTask, updateTask, deleteTask } from "@/features/taskflow/lib/repository";
7
+
8
+ let tmpDir: string;
9
+
10
+ beforeEach(async () => {
11
+ resetBroadcaster();
12
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "sse-test-"));
13
+ await ensureRepo(tmpDir);
14
+ });
15
+
16
+ afterEach(async () => {
17
+ const broadcaster = getSseBroadcaster();
18
+ await broadcaster.destroy();
19
+ resetBroadcaster();
20
+ await fs.rm(tmpDir, { recursive: true, force: true });
21
+ });
22
+
23
+ describe("SseBroadcaster", () => {
24
+ it("should be a singleton", () => {
25
+ const a = getSseBroadcaster();
26
+ const b = getSseBroadcaster();
27
+ expect(a).toBe(b);
28
+ });
29
+
30
+ it("should reset properly", () => {
31
+ const a = getSseBroadcaster();
32
+ resetBroadcaster();
33
+ const b = getSseBroadcaster();
34
+ expect(a).not.toBe(b);
35
+ });
36
+
37
+ it("should subscribe and unsubscribe listeners", () => {
38
+ const broadcaster = getSseBroadcaster();
39
+ const listener = vi.fn();
40
+
41
+ const unsub = broadcaster.subscribe(listener);
42
+ expect(broadcaster.subscriberCount).toBe(1);
43
+
44
+ unsub();
45
+ expect(broadcaster.subscriberCount).toBe(0);
46
+ });
47
+
48
+ it("should support multiple subscribers", () => {
49
+ const broadcaster = getSseBroadcaster();
50
+ const l1 = vi.fn();
51
+ const l2 = vi.fn();
52
+ const l3 = vi.fn();
53
+
54
+ const u1 = broadcaster.subscribe(l1);
55
+ const u2 = broadcaster.subscribe(l2);
56
+ const u3 = broadcaster.subscribe(l3);
57
+
58
+ expect(broadcaster.subscriberCount).toBe(3);
59
+
60
+ u2();
61
+ expect(broadcaster.subscriberCount).toBe(2);
62
+
63
+ u1();
64
+ u3();
65
+ expect(broadcaster.subscriberCount).toBe(0);
66
+ });
67
+
68
+ it("should broadcast file change events to subscribers", async () => {
69
+ const broadcaster = getSseBroadcaster();
70
+ broadcaster.init(tmpDir);
71
+
72
+ const events: SseEvent[] = [];
73
+ broadcaster.subscribe((e) => events.push(e));
74
+
75
+ // Create a task file — triggers chokidar "add"
76
+ await createTask(tmpDir, { title: "SSE test task", priority: 5 });
77
+
78
+ // Wait for chokidar + throttle (awaitWriteFinish 100ms + throttle 300ms + margin)
79
+ await new Promise((r) => setTimeout(r, 1000));
80
+
81
+ // Should have received at least one event
82
+ expect(events.length).toBeGreaterThanOrEqual(1);
83
+ const taskEvent = events.find((e) => e.type === "created" || e.type === "updated");
84
+ // created or updated depending on timing
85
+ expect(taskEvent || events.find((e) => e.type === "index")).toBeTruthy();
86
+ });
87
+
88
+ it("should throttle rapid events", async () => {
89
+ const broadcaster = getSseBroadcaster();
90
+ broadcaster.init(tmpDir);
91
+
92
+ const events: SseEvent[] = [];
93
+ broadcaster.subscribe((e) => {
94
+ if (e.path) events.push(e);
95
+ });
96
+
97
+ // Rapid create 5 tasks
98
+ for (let i = 0; i < 5; i++) {
99
+ await createTask(tmpDir, { title: `Rapid ${i}` });
100
+ }
101
+
102
+ // Wait for chokidar awaitWriteFinish + throttle to settle
103
+ await new Promise((r) => setTimeout(r, 1500));
104
+
105
+ // Throttling should produce fewer events than raw file operations
106
+ // (5 creates × 2 files each = 10 raw events, throttle collapses them)
107
+ expect(events.length).toBeGreaterThan(0);
108
+ expect(events.length).toBeLessThan(15);
109
+ });
110
+
111
+ it("should remove broken listeners gracefully", () => {
112
+ const broadcaster = getSseBroadcaster();
113
+ broadcaster.init(tmpDir);
114
+
115
+ const brokenListener = () => {
116
+ throw new Error("broken");
117
+ };
118
+ const goodListener = vi.fn();
119
+
120
+ broadcaster.subscribe(brokenListener);
121
+ broadcaster.subscribe(goodListener);
122
+
123
+ expect(broadcaster.subscriberCount).toBe(2);
124
+ });
125
+
126
+ it("should clean up on destroy", async () => {
127
+ const broadcaster = getSseBroadcaster();
128
+ broadcaster.init(tmpDir);
129
+
130
+ const listener = vi.fn();
131
+ broadcaster.subscribe(listener);
132
+
133
+ await broadcaster.destroy();
134
+
135
+ expect(broadcaster.subscriberCount).toBe(0);
136
+ });
137
+ });
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import type { SseEvent, SseEventType } from "../sse-broadcaster";
3
+
4
+ describe("SseEvent format", () => {
5
+ it("should have required fields for task events", () => {
6
+ const event: SseEvent = {
7
+ type: "created",
8
+ id: "001",
9
+ path: ".taskflow/tasks/task-001.md",
10
+ ts: Date.now(),
11
+ };
12
+
13
+ expect(event.type).toBe("created");
14
+ expect(event.id).toBe("001");
15
+ expect(event.path).toContain("task-001.md");
16
+ expect(typeof event.ts).toBe("number");
17
+ });
18
+
19
+ it("should allow optional id for index events", () => {
20
+ const event: SseEvent = {
21
+ type: "index",
22
+ path: ".taskflow/index/TASKS.md",
23
+ ts: Date.now(),
24
+ };
25
+
26
+ expect(event.type).toBe("index");
27
+ expect(event.id).toBeUndefined();
28
+ });
29
+
30
+ it("should serialize to valid JSON", () => {
31
+ const event: SseEvent = {
32
+ type: "updated",
33
+ id: "002",
34
+ path: ".taskflow/tasks/task-002.md",
35
+ ts: 1710979200000,
36
+ };
37
+
38
+ const json = JSON.stringify(event);
39
+ const parsed = JSON.parse(json);
40
+
41
+ expect(parsed.type).toBe("updated");
42
+ expect(parsed.id).toBe("002");
43
+ expect(parsed.ts).toBe(1710979200000);
44
+ });
45
+
46
+ it("should cover all event types", () => {
47
+ const types: SseEventType[] = ["created", "updated", "deleted", "index"];
48
+ expect(types).toHaveLength(4);
49
+
50
+ for (const type of types) {
51
+ const event: SseEvent = { type, path: "test", ts: Date.now() };
52
+ expect(event.type).toBe(type);
53
+ }
54
+ });
55
+ });
@@ -0,0 +1,55 @@
1
+ import type { Hono } from "hono";
2
+ import { z } from "zod";
3
+ import {
4
+ listTasks,
5
+ readTask,
6
+ updateTask,
7
+ ensureRepo,
8
+ } from "@/features/taskflow/lib/repository";
9
+ import { TASK_STATUSES } from "@/features/taskflow/types";
10
+
11
+ const statusBodySchema = z.object({
12
+ to: z.enum(TASK_STATUSES),
13
+ });
14
+
15
+ export const registerTaskRoutes = (app: Hono<any>) => {
16
+ const projectRoot = process.cwd();
17
+
18
+ app.get("/api/tasks", async (c) => {
19
+ await ensureRepo(projectRoot);
20
+ const tasks = await listTasks(projectRoot);
21
+ return c.json(tasks);
22
+ });
23
+
24
+ app.get("/api/tasks/:id", async (c) => {
25
+ const id = c.req.param("id");
26
+ const task = await readTask(projectRoot, id);
27
+
28
+ if (!task) {
29
+ return c.json({ error: { code: "NOT_FOUND", message: `Task ${id} not found` } }, 404);
30
+ }
31
+
32
+ return c.json(task);
33
+ });
34
+
35
+ app.post("/api/tasks/:id/status", async (c) => {
36
+ const id = c.req.param("id");
37
+ const body = await c.req.json();
38
+ const parsed = statusBodySchema.safeParse(body);
39
+
40
+ if (!parsed.success) {
41
+ return c.json(
42
+ { error: { code: "INVALID_BODY", message: "Invalid status", details: parsed.error.format() } },
43
+ 400,
44
+ );
45
+ }
46
+
47
+ try {
48
+ const updated = await updateTask(projectRoot, id, { status: parsed.data.to });
49
+ return c.json(updated);
50
+ } catch (error) {
51
+ const message = error instanceof Error ? error.message : "Unknown error";
52
+ return c.json({ error: { code: "UPDATE_FAILED", message } }, 404);
53
+ }
54
+ });
55
+ };
@@ -0,0 +1,142 @@
1
+ import path from "node:path";
2
+ import { watch, type FSWatcher } from "chokidar";
3
+ import { getTasksDir, getIndexFilePath, extractTaskId } from "@/features/taskflow/constants";
4
+
5
+ // ── SSE Event Types ──
6
+
7
+ export type SseEventType = "created" | "updated" | "deleted" | "index";
8
+
9
+ export interface SseEvent {
10
+ type: SseEventType;
11
+ id?: string;
12
+ path: string;
13
+ ts: number;
14
+ }
15
+
16
+ export type SseListener = (event: SseEvent) => void;
17
+
18
+ // ── Broadcaster ──
19
+
20
+ const THROTTLE_MS = 300;
21
+ const KEEPALIVE_INTERVAL_MS = 30_000;
22
+
23
+ class SseBroadcaster {
24
+ private listeners = new Set<SseListener>();
25
+ private watcher: FSWatcher | null = null;
26
+ private lastEmit = 0;
27
+ private pendingEvent: SseEvent | null = null;
28
+ private throttleTimer: ReturnType<typeof setTimeout> | null = null;
29
+ private keepAliveTimer: ReturnType<typeof setInterval> | null = null;
30
+ private initialized = false;
31
+
32
+ init(projectRoot: string): void {
33
+ if (this.initialized) return;
34
+ this.initialized = true;
35
+
36
+ const tasksDir = getTasksDir(projectRoot);
37
+ const indexPath = getIndexFilePath(projectRoot);
38
+
39
+ this.watcher = watch(
40
+ [path.join(tasksDir, "task-*.md"), indexPath],
41
+ {
42
+ ignoreInitial: true,
43
+ awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
44
+ },
45
+ );
46
+
47
+ this.watcher.on("add", (fp) => this.handleFsEvent("created", fp, indexPath));
48
+ this.watcher.on("change", (fp) => this.handleFsEvent("updated", fp, indexPath));
49
+ this.watcher.on("unlink", (fp) => this.handleFsEvent("deleted", fp, indexPath));
50
+
51
+ this.keepAliveTimer = setInterval(() => {
52
+ this.broadcast({ type: "index", path: "", ts: Date.now() });
53
+ }, KEEPALIVE_INTERVAL_MS);
54
+ }
55
+
56
+ subscribe(listener: SseListener): () => void {
57
+ this.listeners.add(listener);
58
+ return () => {
59
+ this.listeners.delete(listener);
60
+ };
61
+ }
62
+
63
+ get subscriberCount(): number {
64
+ return this.listeners.size;
65
+ }
66
+
67
+ async destroy(): Promise<void> {
68
+ if (this.throttleTimer) clearTimeout(this.throttleTimer);
69
+ if (this.keepAliveTimer) clearInterval(this.keepAliveTimer);
70
+ if (this.watcher) await this.watcher.close();
71
+ this.listeners.clear();
72
+ this.initialized = false;
73
+ this.watcher = null;
74
+ }
75
+
76
+ private handleFsEvent(fsEvent: "created" | "updated" | "deleted", filePath: string, indexPath: string): void {
77
+ const isIndex = path.resolve(filePath) === path.resolve(indexPath);
78
+
79
+ const event: SseEvent = {
80
+ type: isIndex ? "index" : fsEvent,
81
+ id: isIndex ? undefined : (extractTaskId(path.basename(filePath)) ?? undefined),
82
+ path: filePath,
83
+ ts: Date.now(),
84
+ };
85
+
86
+ this.throttledBroadcast(event);
87
+ }
88
+
89
+ private throttledBroadcast(event: SseEvent): void {
90
+ const now = Date.now();
91
+ const elapsed = now - this.lastEmit;
92
+
93
+ if (elapsed >= THROTTLE_MS) {
94
+ this.lastEmit = now;
95
+ this.broadcast(event);
96
+ return;
97
+ }
98
+
99
+ // Throttle: replace pending event (latest wins)
100
+ this.pendingEvent = event;
101
+
102
+ if (!this.throttleTimer) {
103
+ this.throttleTimer = setTimeout(() => {
104
+ this.throttleTimer = null;
105
+ if (this.pendingEvent) {
106
+ this.lastEmit = Date.now();
107
+ this.broadcast(this.pendingEvent);
108
+ this.pendingEvent = null;
109
+ }
110
+ }, THROTTLE_MS - elapsed);
111
+ }
112
+ }
113
+
114
+ private broadcast(event: SseEvent): void {
115
+ for (const listener of this.listeners) {
116
+ try {
117
+ listener(event);
118
+ } catch {
119
+ // Remove broken listeners
120
+ this.listeners.delete(listener);
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ // ── Singleton ──
127
+
128
+ let instance: SseBroadcaster | null = null;
129
+
130
+ export function getSseBroadcaster(): SseBroadcaster {
131
+ if (!instance) {
132
+ instance = new SseBroadcaster();
133
+ }
134
+ return instance;
135
+ }
136
+
137
+ export function resetBroadcaster(): void {
138
+ if (instance) {
139
+ instance.destroy();
140
+ instance = null;
141
+ }
142
+ }
@@ -0,0 +1,43 @@
1
+ import type { Hono } from "hono";
2
+ import { streamSSE } from "hono/streaming";
3
+ import { getSseBroadcaster, type SseEvent } from "./sse-broadcaster";
4
+
5
+ export const registerSseRoute = (app: Hono<any>) => {
6
+ const projectRoot = process.cwd();
7
+
8
+ app.get("/api/sse", (c) => {
9
+ const broadcaster = getSseBroadcaster();
10
+ broadcaster.init(projectRoot);
11
+
12
+ return streamSSE(c, async (stream) => {
13
+ // Send initial connection event
14
+ await stream.writeSSE({
15
+ event: "connected",
16
+ data: JSON.stringify({ ts: Date.now(), subscribers: broadcaster.subscriberCount + 1 }),
17
+ });
18
+
19
+ let closed = false;
20
+
21
+ const unsubscribe = broadcaster.subscribe((event: SseEvent) => {
22
+ if (closed) return;
23
+ stream.writeSSE({
24
+ event: "task-change",
25
+ data: JSON.stringify(event),
26
+ }).catch(() => {
27
+ closed = true;
28
+ });
29
+ });
30
+
31
+ // Keep stream alive until client disconnects
32
+ stream.onAbort(() => {
33
+ closed = true;
34
+ unsubscribe();
35
+ });
36
+
37
+ // Block until aborted
38
+ await new Promise<void>((resolve) => {
39
+ stream.onAbort(resolve);
40
+ });
41
+ });
42
+ });
43
+ };