harness-bujang 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 (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +87 -0
  3. package/dist/index.js +906 -0
  4. package/package.json +57 -0
  5. package/templates/agents/en/architect-team.md +105 -0
  6. package/templates/agents/en/code-review-team.md +106 -0
  7. package/templates/agents/en/consultant.md +106 -0
  8. package/templates/agents/en/db-guard-team.md +94 -0
  9. package/templates/agents/en/dev-team.md +143 -0
  10. package/templates/agents/en/director.md +401 -0
  11. package/templates/agents/en/doc-sync-team.md +92 -0
  12. package/templates/agents/en/qa-team.md +100 -0
  13. package/templates/agents/en/security-team.md +99 -0
  14. package/templates/agents/en/verifier-team.md +97 -0
  15. package/templates/agents/ko/architect-team.md +110 -0
  16. package/templates/agents/ko/code-review-team.md +124 -0
  17. package/templates/agents/ko/consultant.md +106 -0
  18. package/templates/agents/ko/db-guard-team.md +116 -0
  19. package/templates/agents/ko/dev-team.md +144 -0
  20. package/templates/agents/ko/director.md +401 -0
  21. package/templates/agents/ko/doc-sync-team.md +114 -0
  22. package/templates/agents/ko/qa-team.md +122 -0
  23. package/templates/agents/ko/security-team.md +121 -0
  24. package/templates/agents/ko/verifier-team.md +119 -0
  25. package/templates/project-template/app/admin/harness/harness-client.tsx +493 -0
  26. package/templates/project-template/app/admin/harness/page.tsx +27 -0
  27. package/templates/project-template/app/api/harness/logs/route.ts +111 -0
  28. package/templates/project-template/app/api/harness/reply/route.ts +45 -0
  29. package/templates/project-template/lib/harness-db/index.ts +45 -0
  30. package/templates/project-template/lib/harness-db/sqlite.ts +128 -0
  31. package/templates/project-template/lib/harness-db/supabase.ts +64 -0
  32. package/templates/project-template/lib/harness-db/types.ts +35 -0
  33. package/templates/project-template/migrations/00010_harness_messages.sql +56 -0
  34. package/templates/project-template/migrations/00025_harness_insert_admin_only.sql +17 -0
  35. package/templates/templates/en/AGENT_LEARNING_LOG.seed.md +43 -0
  36. package/templates/templates/en/CLAUDE.md.harness-section.template +59 -0
  37. package/templates/templates/ko/AGENT_LEARNING_LOG.seed.md +43 -0
  38. package/templates/templates/ko/CLAUDE.md.harness-section.template +59 -0
@@ -0,0 +1,493 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState, useRef } from 'react';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Types
7
+ // ---------------------------------------------------------------------------
8
+
9
+ interface ChatMessage {
10
+ id: string;
11
+ timestamp: string;
12
+ from: string;
13
+ to: string;
14
+ type: 'command' | 'report' | 'feedback' | 'info';
15
+ message: string;
16
+ severity?: 'error' | 'warning' | 'info';
17
+ data?: Record<string, unknown>;
18
+ }
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Roles & Rooms
22
+ //
23
+ // Edit these constants to match your harness configuration.
24
+ // Role names must match what the Director / teams use as `from` / `to` in
25
+ // chat-room INSERTs.
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const ROLES: Record<string, { icon: string; color: string; bg: string; label?: string }> = {
29
+ 'λŒ€ν‘œλ‹˜': { icon: 'πŸ‘”', color: 'text-purple-700', bg: 'bg-purple-100' },
30
+ 'λΆ€μž₯': { icon: 'πŸ§‘β€πŸ’Ό', color: 'text-blue-700', bg: 'bg-blue-100', label: 'λΆ€μž₯' },
31
+ 'consultant': { icon: '🀝', color: 'text-indigo-700', bg: 'bg-indigo-100', label: 'μ»¨μ„€ν„΄νŠΈ' },
32
+ 'dev-team': { icon: 'πŸ’»', color: 'text-violet-700', bg: 'bg-violet-100', label: 'κ°œλ°œνŒ€' },
33
+ 'architect-team': { icon: 'πŸ—οΈ', color: 'text-cyan-700', bg: 'bg-cyan-100', label: 'μ•„ν‚€ν…μ²˜νŒ€' },
34
+ 'code-review-team': { icon: 'πŸ“', color: 'text-yellow-700', bg: 'bg-yellow-100', label: 'μ½”λ“œλ¦¬λ·°νŒ€' },
35
+ 'doc-sync-team': { icon: 'πŸ“„', color: 'text-orange-700', bg: 'bg-orange-100', label: 'λ¬Έμ„œκ΄€λ¦¬νŒ€' },
36
+ 'security-team': { icon: 'πŸ›‘οΈ', color: 'text-red-700', bg: 'bg-red-100', label: 'λ³΄μ•ˆνŒ€' },
37
+ 'db-guard-team': { icon: 'πŸ—„οΈ', color: 'text-green-700', bg: 'bg-green-100', label: 'DBνŒ€' },
38
+ 'qa-team': { icon: 'πŸ§ͺ', color: 'text-teal-700', bg: 'bg-teal-100', label: 'QAνŒ€' },
39
+ 'verifier-team': { icon: 'βœ…', color: 'text-emerald-700', bg: 'bg-emerald-100', label: 'κ²€μˆ˜νŒ€' },
40
+ };
41
+
42
+ const ROOMS = [
43
+ { id: 'λŒ€ν‘œλ‹˜', name: 'λŒ€ν‘œ 보고', icon: 'πŸ‘”', members: ['λŒ€ν‘œλ‹˜', 'consultant', 'λΆ€μž₯'] },
44
+ { id: 'consultant', name: 'μ»¨μ„€ν„΄νŠΈ', icon: '🀝', members: ['consultant', 'λΆ€μž₯'] },
45
+ { id: 'architect-team', name: 'μ•„ν‚€ν…μ²˜νŒ€', icon: 'πŸ—οΈ', members: ['λΆ€μž₯', 'architect-team'] },
46
+ { id: 'code-review-team', name: 'μ½”λ“œλ¦¬λ·°νŒ€', icon: 'πŸ“', members: ['λΆ€μž₯', 'code-review-team'] },
47
+ { id: 'doc-sync-team', name: 'λ¬Έμ„œκ΄€λ¦¬νŒ€', icon: 'πŸ“„', members: ['λΆ€μž₯', 'doc-sync-team'] },
48
+ { id: 'security-team', name: 'λ³΄μ•ˆνŒ€', icon: 'πŸ›‘οΈ', members: ['λΆ€μž₯', 'security-team'] },
49
+ { id: 'db-guard-team', name: 'DBνŒ€', icon: 'πŸ—„οΈ', members: ['λΆ€μž₯', 'db-guard-team'] },
50
+ { id: 'qa-team', name: 'QAνŒ€', icon: 'πŸ§ͺ', members: ['λΆ€μž₯', 'qa-team'] },
51
+ { id: 'verifier-team', name: 'κ²€μˆ˜νŒ€', icon: 'βœ…', members: ['λΆ€μž₯', 'verifier-team'] },
52
+ { id: 'dev-team', name: 'κ°œλ°œνŒ€', icon: 'πŸ’»', members: ['λΆ€μž₯', 'dev-team'] },
53
+ ];
54
+
55
+ const STORAGE_KEY = 'harness-bujang-read';
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Helpers
59
+ // ---------------------------------------------------------------------------
60
+
61
+ function getRole(name: string) {
62
+ return ROLES[name] || { icon: 'πŸ’¬', color: 'text-gray-700', bg: 'bg-gray-100' };
63
+ }
64
+
65
+ function getRoleLabel(name: string) {
66
+ return ROLES[name]?.label ?? name;
67
+ }
68
+
69
+ function formatTime(timestamp: string) {
70
+ const d = new Date(timestamp);
71
+ const h = d.getHours();
72
+ const m = d.getMinutes().toString().padStart(2, '0');
73
+ const ampm = h < 12 ? 'μ˜€μ „' : 'μ˜€ν›„';
74
+ const hour = h === 0 ? 12 : h > 12 ? h - 12 : h;
75
+ return `${ampm} ${hour}:${m}`;
76
+ }
77
+
78
+ function formatDate(timestamp: string) {
79
+ const d = new Date(timestamp);
80
+ return `${d.getFullYear()}λ…„ ${d.getMonth() + 1}μ›” ${d.getDate()}일`;
81
+ }
82
+
83
+ function filterMessages(messages: ChatMessage[], roomId: string): ChatMessage[] {
84
+ if (roomId === 'all') return messages;
85
+
86
+ const room = ROOMS.find((r) => r.id === roomId);
87
+ if (!room) return [];
88
+
89
+ return messages.filter((m) => {
90
+ if (!room.members.includes(m.from) || !room.members.includes(m.to)) return false;
91
+
92
+ // Prefer the more specific (smaller-membered) room when multiple match.
93
+ const smallerRoom = ROOMS.find(
94
+ (r) =>
95
+ r.id !== roomId &&
96
+ r.members.length < room.members.length &&
97
+ r.members.includes(m.from) &&
98
+ r.members.includes(m.to),
99
+ );
100
+ return !smallerRoom;
101
+ });
102
+ }
103
+
104
+ function getLastMessage(messages: ChatMessage[], roomId: string): ChatMessage | null {
105
+ const filtered = filterMessages(messages, roomId);
106
+ return filtered.length > 0 ? filtered[filtered.length - 1] : null;
107
+ }
108
+
109
+ function getSeverityBadge(severity?: string) {
110
+ switch (severity) {
111
+ case 'error': return <span className="inline-block px-1.5 py-0.5 text-[10px] font-bold bg-red-500 text-white rounded">ERROR</span>;
112
+ case 'warning': return <span className="inline-block px-1.5 py-0.5 text-[10px] font-bold bg-yellow-500 text-white rounded">WARN</span>;
113
+ case 'info': return <span className="inline-block px-1.5 py-0.5 text-[10px] font-bold bg-green-500 text-white rounded">INFO</span>;
114
+ default: return null;
115
+ }
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Component
120
+ // ---------------------------------------------------------------------------
121
+
122
+ export function HarnessClient() {
123
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
124
+ const [loading, setLoading] = useState(true);
125
+ const [selectedRoom, setSelectedRoom] = useState<string | null>(null);
126
+ const [readCounts, setReadCounts] = useState<Record<string, number>>({});
127
+ const [loadingOlder, setLoadingOlder] = useState(false);
128
+ const [hasOlder, setHasOlder] = useState(true);
129
+ const [inputText, setInputText] = useState('');
130
+ const [sending, setSending] = useState(false);
131
+ const messagesEndRef = useRef<HTMLDivElement>(null);
132
+ const chatContainerRef = useRef<HTMLDivElement>(null);
133
+ const isNearBottomRef = useRef(true);
134
+
135
+ useEffect(() => {
136
+ const stored = localStorage.getItem(STORAGE_KEY);
137
+ if (stored) setReadCounts(JSON.parse(stored));
138
+ }, []);
139
+
140
+ // Polling: load last 2 days, refresh every 2 seconds.
141
+ useEffect(() => {
142
+ function fetchMessages() {
143
+ fetch('/api/harness/logs?days=2')
144
+ .then((res) => res.json())
145
+ .then((res) => {
146
+ const fresh: ChatMessage[] = res.data || [];
147
+ setMessages((prev) => {
148
+ if (prev.length === 0) return fresh;
149
+ const oldestFreshTs = fresh.length > 0 ? fresh[0].timestamp : '';
150
+ const olderMessages = prev.filter((m) => m.timestamp < oldestFreshTs);
151
+ return [...olderMessages, ...fresh];
152
+ });
153
+ setLoading(false);
154
+ })
155
+ .catch(() => setLoading(false));
156
+ }
157
+ fetchMessages();
158
+ const interval = setInterval(fetchMessages, 2000);
159
+ return () => clearInterval(interval);
160
+ }, []);
161
+
162
+ async function loadOlderMessages() {
163
+ if (loadingOlder || !hasOlder || messages.length === 0) return;
164
+ setLoadingOlder(true);
165
+ const oldestTs = messages[0].timestamp;
166
+ try {
167
+ const res = await fetch(`/api/harness/logs?before=${encodeURIComponent(oldestTs)}&limit=50`);
168
+ const data = await res.json();
169
+ const older: ChatMessage[] = data.data || [];
170
+ if (older.length === 0) {
171
+ setHasOlder(false);
172
+ } else {
173
+ const el = chatContainerRef.current;
174
+ const prevHeight = el?.scrollHeight || 0;
175
+ setMessages((prev) => [...older, ...prev]);
176
+ requestAnimationFrame(() => {
177
+ if (el) el.scrollTop = el.scrollHeight - prevHeight;
178
+ });
179
+ }
180
+ } catch { /* ignore */ }
181
+ setLoadingOlder(false);
182
+ }
183
+
184
+ useEffect(() => {
185
+ const el = chatContainerRef.current;
186
+ if (!el) return;
187
+ const handleScroll = () => {
188
+ const threshold = 80;
189
+ isNearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
190
+ if (el.scrollTop < 50 && hasOlder && !loadingOlder) {
191
+ loadOlderMessages();
192
+ }
193
+ };
194
+ el.addEventListener('scroll', handleScroll);
195
+ return () => el.removeEventListener('scroll', handleScroll);
196
+ // eslint-disable-next-line react-hooks/exhaustive-deps
197
+ }, [selectedRoom, hasOlder, loadingOlder, messages]);
198
+
199
+ useEffect(() => {
200
+ if (isNearBottomRef.current) {
201
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
202
+ }
203
+ }, [messages]);
204
+
205
+ useEffect(() => {
206
+ if (selectedRoom) {
207
+ isNearBottomRef.current = true;
208
+ setHasOlder(true);
209
+ messagesEndRef.current?.scrollIntoView({ behavior: 'instant' });
210
+ }
211
+ }, [selectedRoom]);
212
+
213
+ function enterRoom(roomId: string) {
214
+ setSelectedRoom(roomId);
215
+ const count = filterMessages(messages, roomId).length;
216
+ const newCounts = { ...readCounts, [roomId]: count };
217
+ setReadCounts(newCounts);
218
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(newCounts));
219
+ }
220
+
221
+ const roomMessages = selectedRoom ? filterMessages(messages, selectedRoom) : [];
222
+ const selectedRoomInfo = ROOMS.find((r) => r.id === selectedRoom);
223
+
224
+ const dateGroups: { date: string; messages: ChatMessage[] }[] = [];
225
+ let lastDate = '';
226
+ for (const msg of roomMessages) {
227
+ const date = formatDate(msg.timestamp);
228
+ if (date !== lastDate) {
229
+ dateGroups.push({ date, messages: [msg] });
230
+ lastDate = date;
231
+ } else {
232
+ dateGroups[dateGroups.length - 1].messages.push(msg);
233
+ }
234
+ }
235
+
236
+ const errors = messages.filter((m) => m.severity === 'error').length;
237
+ const warnings = messages.filter((m) => m.severity === 'warning').length;
238
+ const infos = messages.filter((m) => m.severity === 'info').length;
239
+
240
+ if (loading) {
241
+ return (
242
+ <div className="flex items-center justify-center h-[calc(100vh-4rem)] text-gray-400">
243
+ λΆˆλŸ¬μ˜€λŠ” 쀑...
244
+ </div>
245
+ );
246
+ }
247
+
248
+ return (
249
+ <div className="flex h-[calc(100vh-4rem)]">
250
+ {/* Sidebar β€” room list */}
251
+ <div className="w-80 border-r border-gray-200 bg-white flex flex-col">
252
+ <div className="p-4 border-b border-gray-200">
253
+ <h1 className="text-lg font-bold text-gray-900">ν•˜λ„€μŠ€ 톑방</h1>
254
+ <p className="text-xs text-gray-500 mt-1">μ—μ΄μ „νŠΈ κ°„ 보고 & μ§€μ‹œ</p>
255
+
256
+ {messages.length > 0 && (
257
+ <div className="flex gap-2 mt-2">
258
+ {errors > 0 && (
259
+ <span className="px-2 py-0.5 text-xs font-bold bg-red-100 text-red-700 rounded-full">
260
+ ERR {errors}
261
+ </span>
262
+ )}
263
+ {warnings > 0 && (
264
+ <span className="px-2 py-0.5 text-xs font-bold bg-yellow-100 text-yellow-700 rounded-full">
265
+ WARN {warnings}
266
+ </span>
267
+ )}
268
+ {infos > 0 && (
269
+ <span className="px-2 py-0.5 text-xs font-bold bg-green-100 text-green-700 rounded-full">
270
+ INFO {infos}
271
+ </span>
272
+ )}
273
+ </div>
274
+ )}
275
+ </div>
276
+
277
+ <div className="flex-1 overflow-y-auto">
278
+ {ROOMS.map((room) => {
279
+ const last = getLastMessage(messages, room.id);
280
+ const count = filterMessages(messages, room.id).length;
281
+ const isSelected = selectedRoom === room.id;
282
+ const unread = count - (readCounts[room.id] || 0);
283
+
284
+ return (
285
+ <button
286
+ key={room.id}
287
+ onDoubleClick={() => enterRoom(room.id)}
288
+ className={`w-full flex items-center gap-3 px-4 py-3 text-left transition-colors ${
289
+ isSelected ? 'bg-indigo-50' : 'hover:bg-gray-50'
290
+ }`}
291
+ >
292
+ <div className="flex-shrink-0 w-12 h-12 rounded-2xl bg-gray-100 flex items-center justify-center text-2xl">
293
+ {room.icon}
294
+ </div>
295
+
296
+ <div className="flex-1 min-w-0">
297
+ <div className="flex items-center justify-between">
298
+ <span className="text-sm font-semibold text-gray-900 truncate">
299
+ {room.name}
300
+ <span className="text-xs text-gray-400 font-normal ml-1">
301
+ {room.members.length}
302
+ </span>
303
+ </span>
304
+ {last && (
305
+ <span className="text-xs text-gray-400 flex-shrink-0 ml-2">
306
+ {formatTime(last.timestamp)}
307
+ </span>
308
+ )}
309
+ </div>
310
+ <div className="flex items-center gap-1 mt-0.5">
311
+ {last?.severity && getSeverityBadge(last.severity)}
312
+ <p className="text-xs text-gray-500 truncate">
313
+ {last ? last.message : 'λŒ€ν™” μ—†μŒ'}
314
+ </p>
315
+ </div>
316
+ </div>
317
+
318
+ {unread > 0 && (
319
+ <span className="flex-shrink-0 min-w-[20px] h-5 px-1.5 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center">
320
+ {unread}
321
+ </span>
322
+ )}
323
+ </button>
324
+ );
325
+ })}
326
+ </div>
327
+ </div>
328
+
329
+ {/* Right pane β€” conversation */}
330
+ <div className="flex-1 flex flex-col bg-[#b2c7d9]">
331
+ {!selectedRoom ? (
332
+ <div className="flex-1 flex items-center justify-center">
333
+ <div className="text-center">
334
+ <p className="text-5xl mb-3">🏒</p>
335
+ <p className="text-sm text-white/70">μ±„νŒ…λ°©μ„ λ”λΈ”ν΄λ¦­ν•΄μ„œ μ—΄μ–΄μ£Όμ„Έμš”</p>
336
+ </div>
337
+ </div>
338
+ ) : (
339
+ <>
340
+ <div className="px-5 py-3 bg-white border-b border-gray-200 flex items-center gap-3">
341
+ <span className="text-xl">{selectedRoomInfo?.icon}</span>
342
+ <div>
343
+ <h2 className="text-sm font-semibold text-gray-900">
344
+ {selectedRoomInfo?.name}
345
+ </h2>
346
+ <p className="text-xs text-gray-400">
347
+ {selectedRoomInfo?.members.map(getRoleLabel).join(', ')}
348
+ </p>
349
+ </div>
350
+ <span className="ml-auto text-xs text-gray-400">
351
+ {roomMessages.length}개 λ©”μ‹œμ§€
352
+ </span>
353
+ </div>
354
+
355
+ <div ref={chatContainerRef} className="flex-1 overflow-y-auto px-5 py-4">
356
+ {roomMessages.length === 0 ? (
357
+ <div className="flex items-center justify-center h-full">
358
+ <div className="text-center">
359
+ <p className="text-4xl mb-2">πŸ’¬</p>
360
+ <p className="text-sm text-white/70">아직 λŒ€ν™”κ°€ μ—†μŠ΅λ‹ˆλ‹€</p>
361
+ </div>
362
+ </div>
363
+ ) : (
364
+ <div className="space-y-1">
365
+ {loadingOlder && (
366
+ <div className="flex justify-center py-3">
367
+ <span className="text-xs text-white/60">이전 λŒ€ν™” λΆˆλŸ¬μ˜€λŠ” 쀑...</span>
368
+ </div>
369
+ )}
370
+ {!hasOlder && messages.length > 0 && (
371
+ <div className="flex justify-center py-3">
372
+ <span className="text-xs text-white/40">더 이상 이전 λŒ€ν™”κ°€ μ—†μŠ΅λ‹ˆλ‹€</span>
373
+ </div>
374
+ )}
375
+ {dateGroups.map((group) => (
376
+ <div key={group.date}>
377
+ <div className="flex items-center justify-center my-4">
378
+ <span className="bg-black/20 text-white text-xs px-3 py-1 rounded-full">
379
+ {group.date}
380
+ </span>
381
+ </div>
382
+
383
+ {group.messages.map((msg, i) => {
384
+ const role = getRole(msg.from);
385
+ const prevMsg = i > 0 ? group.messages[i - 1] : null;
386
+ const isSameAuthor = prevMsg?.from === msg.from;
387
+ const isMe = msg.from === 'λŒ€ν‘œλ‹˜';
388
+
389
+ if (isMe) {
390
+ return (
391
+ <div key={msg.id} className={`flex justify-end ${isSameAuthor ? 'mt-0.5' : 'mt-3'}`}>
392
+ <div className="flex items-end gap-1.5 max-w-[70%]">
393
+ <span className="text-xs text-gray-500/70 flex-shrink-0 pb-0.5">
394
+ {formatTime(msg.timestamp)}
395
+ </span>
396
+ <div className="bg-[#fee500] rounded-xl px-3 py-2 shadow-sm">
397
+ <p className="text-sm text-gray-800 whitespace-pre-wrap break-words">
398
+ {msg.message}
399
+ </p>
400
+ </div>
401
+ </div>
402
+ </div>
403
+ );
404
+ }
405
+
406
+ return (
407
+ <div key={msg.id} className={`flex gap-2 ${isSameAuthor ? 'mt-0.5' : 'mt-3'}`}>
408
+ {!isSameAuthor ? (
409
+ <div className={`flex-shrink-0 w-10 h-10 rounded-xl ${role.bg} flex items-center justify-center text-lg`}>
410
+ {role.icon}
411
+ </div>
412
+ ) : (
413
+ <div className="w-10 flex-shrink-0" />
414
+ )}
415
+
416
+ <div className="flex-1 min-w-0">
417
+ {!isSameAuthor && (
418
+ <p className={`text-xs font-semibold mb-1 ${role.color}`}>
419
+ {getRoleLabel(msg.from)}
420
+ </p>
421
+ )}
422
+
423
+ <div className="flex items-end gap-1.5">
424
+ <div className="max-w-[70%]">
425
+ <div className="bg-white rounded-xl px-3 py-2 shadow-sm">
426
+ {msg.severity && (
427
+ <div className="mb-1">{getSeverityBadge(msg.severity)}</div>
428
+ )}
429
+ <p className="text-sm text-gray-800 whitespace-pre-wrap break-words">
430
+ {msg.message}
431
+ </p>
432
+ </div>
433
+ </div>
434
+
435
+ <span className="text-xs text-gray-500/70 flex-shrink-0 pb-0.5">
436
+ {formatTime(msg.timestamp)}
437
+ </span>
438
+ </div>
439
+ </div>
440
+ </div>
441
+ );
442
+ })}
443
+ </div>
444
+ ))}
445
+ <div ref={messagesEndRef} />
446
+ </div>
447
+ )}
448
+ </div>
449
+
450
+ <div className="px-4 py-3 bg-white border-t border-gray-200">
451
+ <form
452
+ onSubmit={async (e) => {
453
+ e.preventDefault();
454
+ if (!inputText.trim() || sending || !selectedRoom) return;
455
+ setSending(true);
456
+ try {
457
+ await fetch('/api/harness/reply', {
458
+ method: 'POST',
459
+ headers: { 'Content-Type': 'application/json' },
460
+ body: JSON.stringify({ message: inputText.trim(), room: selectedRoom }),
461
+ });
462
+ setInputText('');
463
+ isNearBottomRef.current = true;
464
+ } catch { /* ignore */ }
465
+ setSending(false);
466
+ }}
467
+ className="flex items-center gap-2"
468
+ >
469
+ <input
470
+ type="text"
471
+ value={inputText}
472
+ onChange={(e) => setInputText(e.target.value)}
473
+ placeholder="λŒ€ν‘œλ‹˜μœΌλ‘œ λ©”μ‹œμ§€ 보내기..."
474
+ disabled={sending}
475
+ className="flex-1 px-4 py-2.5 bg-gray-100 rounded-full text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-300 disabled:opacity-50"
476
+ />
477
+ <button
478
+ type="submit"
479
+ disabled={!inputText.trim() || sending}
480
+ className="flex-shrink-0 w-10 h-10 bg-indigo-500 text-white rounded-full flex items-center justify-center hover:bg-indigo-600 disabled:opacity-30 disabled:hover:bg-indigo-500 transition-colors"
481
+ >
482
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
483
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
484
+ </svg>
485
+ </button>
486
+ </form>
487
+ </div>
488
+ </>
489
+ )}
490
+ </div>
491
+ </div>
492
+ );
493
+ }
@@ -0,0 +1,27 @@
1
+ import { redirect } from 'next/navigation';
2
+ import { verifySuperAdmin } from '@/lib/utils/admin';
3
+ import { HarnessClient } from './harness-client';
4
+ import type { Metadata } from 'next';
5
+
6
+ // Harness-Bujang chat-room admin page.
7
+ //
8
+ // Assumptions:
9
+ // - Next.js 16 App Router
10
+ // - A `verifySuperAdmin()` helper exists at `@/lib/utils/admin` and returns
11
+ // `{ isSuperAdmin: boolean, supabase?: SupabaseClient }` based on the
12
+ // authenticated user being in `SUPER_ADMIN_EMAILS` (or your own scheme).
13
+ // - The `harness_messages` table exists (see migrations).
14
+ //
15
+ // Adjust the import / redirect target to match your project's admin layout.
16
+
17
+ export const metadata: Metadata = { title: 'ν•˜λ„€μŠ€ 톑방' };
18
+
19
+ export default async function HarnessPage() {
20
+ const { isSuperAdmin } = await verifySuperAdmin();
21
+
22
+ if (!isSuperAdmin) {
23
+ redirect('/admin');
24
+ }
25
+
26
+ return <HarnessClient />;
27
+ }
@@ -0,0 +1,111 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { verifySuperAdmin } from '@/lib/utils/admin';
3
+ import { getHarnessDb, type ChatMessage } from '@/lib/harness-db';
4
+
5
+ // GET /api/harness/logs?days=2 β†’ recent N days
6
+ // GET /api/harness/logs?before=<iso>&limit=50 β†’ infinite scroll (older)
7
+ // POST /api/harness/logs β†’ INSERT a chat message
8
+ //
9
+ // Two callers are supported on POST:
10
+ // 1. A super-admin browser session (verifySuperAdmin): used by the chat-room
11
+ // UI when the principal types a message. The server forces `from='λŒ€ν‘œλ‹˜'`.
12
+ // 2. A bot / script with `x-harness-secret` header (HARNESS_WRITE_SECRET):
13
+ // lets agents (Director, dev-team, etc.) post under their own role names.
14
+ // `from='λŒ€ν‘œλ‹˜'` is rejected on this path to prevent spoofing.
15
+ //
16
+ // The active database (sqlite | supabase) is decided by the HARNESS_DB env var
17
+ // β€” see `lib/harness-db/index.ts`.
18
+
19
+ export async function GET(request: NextRequest) {
20
+ try {
21
+ const { isSuperAdmin } = await verifySuperAdmin();
22
+ if (!isSuperAdmin) {
23
+ return NextResponse.json({ data: [], error: 'super-admin only' }, { status: 403 });
24
+ }
25
+
26
+ const { searchParams } = new URL(request.url);
27
+ const days = parseInt(searchParams.get('days') ?? '2', 10);
28
+ const before = searchParams.get('before') ?? undefined;
29
+ const limit = parseInt(searchParams.get('limit') ?? '50', 10);
30
+
31
+ const db = getHarnessDb();
32
+ const messages = before ? await db.list({ before, limit }) : await db.list({ days });
33
+
34
+ return NextResponse.json({ data: messages });
35
+ } catch (err) {
36
+ return NextResponse.json(
37
+ { data: [], error: err instanceof Error ? err.message : 'unknown' },
38
+ { status: 500 },
39
+ );
40
+ }
41
+ }
42
+
43
+ export async function POST(request: NextRequest) {
44
+ try {
45
+ const secret = request.headers.get('x-harness-secret');
46
+ const hasSecret =
47
+ !!process.env.HARNESS_WRITE_SECRET &&
48
+ secret === process.env.HARNESS_WRITE_SECRET;
49
+
50
+ let isSuperAdminSession = false;
51
+ if (!hasSecret) {
52
+ const { isSuperAdmin } = await verifySuperAdmin();
53
+ if (!isSuperAdmin) {
54
+ return NextResponse.json({ error: 'super-admin only' }, { status: 403 });
55
+ }
56
+ isSuperAdminSession = true;
57
+ }
58
+
59
+ const body = await request.json();
60
+ const { id, from: rawFrom, to, type, message, severity, data, timestamp } = body;
61
+
62
+ if (!to || !message) {
63
+ return NextResponse.json({ error: 'to and message are required' }, { status: 400 });
64
+ }
65
+
66
+ let from: string;
67
+ if (isSuperAdminSession) {
68
+ from = 'λŒ€ν‘œλ‹˜';
69
+ } else {
70
+ if (!rawFrom || typeof rawFrom !== 'string') {
71
+ return NextResponse.json({ error: 'from is required' }, { status: 400 });
72
+ }
73
+ const trimmed = rawFrom.trim();
74
+ const compact = trimmed.replace(/[\s​ ]+/g, '');
75
+ if (compact === 'λŒ€ν‘œλ‹˜') {
76
+ return NextResponse.json(
77
+ { error: 'secret path may not use from="λŒ€ν‘œλ‹˜" (super-admin session only)' },
78
+ { status: 403 },
79
+ );
80
+ }
81
+ from = trimmed;
82
+ }
83
+
84
+ const db = getHarnessDb();
85
+
86
+ let msgId = id;
87
+ if (!msgId) {
88
+ const total = await db.count();
89
+ msgId = `harness-${String(total + 1).padStart(4, '0')}`;
90
+ }
91
+
92
+ const msg: ChatMessage = {
93
+ id: msgId,
94
+ timestamp: timestamp || new Date().toISOString(),
95
+ from,
96
+ to,
97
+ type: type || 'report',
98
+ message,
99
+ ...(severity ? { severity } : {}),
100
+ ...(data ? { data } : {}),
101
+ };
102
+
103
+ await db.upsert(msg);
104
+ return NextResponse.json({ data: msg });
105
+ } catch (err) {
106
+ return NextResponse.json(
107
+ { error: err instanceof Error ? err.message : 'failed to store message' },
108
+ { status: 500 },
109
+ );
110
+ }
111
+ }
@@ -0,0 +1,45 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { verifySuperAdmin } from '@/lib/utils/admin';
3
+ import { getHarnessDb, type ChatMessage } from '@/lib/harness-db';
4
+
5
+ // POST /api/harness/reply
6
+ //
7
+ // Stores a message from the principal (super-admin session) into the chat room.
8
+ // The server forces `from='λŒ€ν‘œλ‹˜'` to prevent spoofing.
9
+ //
10
+ // The active database (sqlite | supabase) is decided by the HARNESS_DB env var
11
+ // β€” see `lib/harness-db/index.ts`.
12
+
13
+ export async function POST(request: NextRequest) {
14
+ try {
15
+ const { isSuperAdmin } = await verifySuperAdmin();
16
+ if (!isSuperAdmin) {
17
+ return NextResponse.json({ error: 'super-admin only' }, { status: 403 });
18
+ }
19
+
20
+ const { message, room } = await request.json();
21
+ if (!message) {
22
+ return NextResponse.json({ error: 'message is required' }, { status: 400 });
23
+ }
24
+
25
+ const db = getHarnessDb();
26
+ const total = await db.count();
27
+
28
+ const msg: ChatMessage = {
29
+ id: `harness-${String(total + 1).padStart(4, '0')}`,
30
+ timestamp: new Date().toISOString(),
31
+ from: 'λŒ€ν‘œλ‹˜',
32
+ to: room || 'λΆ€μž₯',
33
+ type: 'command',
34
+ message,
35
+ };
36
+
37
+ await db.upsert(msg);
38
+ return NextResponse.json({ data: { sent: msg } });
39
+ } catch (err) {
40
+ return NextResponse.json(
41
+ { error: err instanceof Error ? err.message : 'failed to send message' },
42
+ { status: 500 },
43
+ );
44
+ }
45
+ }