@walwal-harness/cli 7.1.8 → 7.1.9

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 (66) hide show
  1. package/README.md +40 -0
  2. package/apps/harness-dashboard/app/api/env/route.ts +62 -0
  3. package/apps/harness-dashboard/app/api/log/route.ts +19 -0
  4. package/apps/harness-dashboard/app/api/missions/route.ts +15 -0
  5. package/apps/harness-dashboard/app/api/snapshot/route.ts +15 -0
  6. package/apps/harness-dashboard/app/api/stream/route.ts +71 -0
  7. package/apps/harness-dashboard/app/globals.css +17 -0
  8. package/apps/harness-dashboard/app/layout.tsx +29 -0
  9. package/apps/harness-dashboard/app/page.tsx +35 -0
  10. package/apps/harness-dashboard/components/Drawer.tsx +97 -0
  11. package/apps/harness-dashboard/components/Header.tsx +40 -0
  12. package/apps/harness-dashboard/components/MissionTimeline.tsx +210 -0
  13. package/apps/harness-dashboard/components/OrgTree.tsx +330 -0
  14. package/apps/harness-dashboard/components/Scene.tsx +563 -0
  15. package/apps/harness-dashboard/components/Wordmark.tsx +14 -0
  16. package/apps/harness-dashboard/components/drawer/AgentLogTab.tsx +56 -0
  17. package/apps/harness-dashboard/components/drawer/GotchasTab.tsx +89 -0
  18. package/apps/harness-dashboard/components/drawer/HypothesisTab.tsx +42 -0
  19. package/apps/harness-dashboard/components/drawer/IncidentsTab.tsx +50 -0
  20. package/apps/harness-dashboard/components/drawer/MissionDocTab.tsx +92 -0
  21. package/apps/harness-dashboard/components/drawer/MissionFlowTab.tsx +517 -0
  22. package/apps/harness-dashboard/components/drawer/OwnerHistoryTab.tsx +156 -0
  23. package/apps/harness-dashboard/components/drawer/RoomMetricsTab.tsx +90 -0
  24. package/apps/harness-dashboard/components/drawer/TracksTab.tsx +47 -0
  25. package/apps/harness-dashboard/components/three/ArchiveBoxes.tsx +95 -0
  26. package/apps/harness-dashboard/components/three/DeptBoard.tsx +175 -0
  27. package/apps/harness-dashboard/components/three/Floor3D.tsx +404 -0
  28. package/apps/harness-dashboard/components/three/GoalCard3D.tsx +123 -0
  29. package/apps/harness-dashboard/components/three/HypothesisCards.tsx +71 -0
  30. package/apps/harness-dashboard/components/three/MeetingWhiteboard.tsx +98 -0
  31. package/apps/harness-dashboard/components/three/Minifig3D.tsx +391 -0
  32. package/apps/harness-dashboard/components/three/OpsSiren.tsx +99 -0
  33. package/apps/harness-dashboard/components/three/Stage3D.tsx +104 -0
  34. package/apps/harness-dashboard/components/three/TrackRibbons.tsx +81 -0
  35. package/apps/harness-dashboard/hooks/useHarnessStream.ts +53 -0
  36. package/apps/harness-dashboard/lib/__tests__/company-flow.test.ts +270 -0
  37. package/apps/harness-dashboard/lib/__tests__/harness-state.test.ts +194 -0
  38. package/apps/harness-dashboard/lib/__tests__/i18n.test.ts +16 -0
  39. package/apps/harness-dashboard/lib/__tests__/iso.test.ts +42 -0
  40. package/apps/harness-dashboard/lib/__tests__/safe-json.test.ts +46 -0
  41. package/apps/harness-dashboard/lib/__tests__/state-mapping.test.ts +159 -0
  42. package/apps/harness-dashboard/lib/__tests__/watcher.test.ts +48 -0
  43. package/apps/harness-dashboard/lib/agent-roster.ts +42 -0
  44. package/apps/harness-dashboard/lib/furniture.ts +165 -0
  45. package/apps/harness-dashboard/lib/harness-root.ts +20 -0
  46. package/apps/harness-dashboard/lib/harness-state.ts +1162 -0
  47. package/apps/harness-dashboard/lib/i18n.ts +26 -0
  48. package/apps/harness-dashboard/lib/iso.ts +150 -0
  49. package/apps/harness-dashboard/lib/log-filter.ts +23 -0
  50. package/apps/harness-dashboard/lib/markdown.tsx +306 -0
  51. package/apps/harness-dashboard/lib/path-planning.ts +72 -0
  52. package/apps/harness-dashboard/lib/safe-json.ts +24 -0
  53. package/apps/harness-dashboard/lib/state-mapping.ts +126 -0
  54. package/apps/harness-dashboard/lib/types.ts +329 -0
  55. package/apps/harness-dashboard/lib/watcher.ts +64 -0
  56. package/apps/harness-dashboard/next-env.d.ts +6 -0
  57. package/apps/harness-dashboard/next.config.mjs +9 -0
  58. package/apps/harness-dashboard/package-lock.json +4280 -0
  59. package/apps/harness-dashboard/package.json +38 -0
  60. package/apps/harness-dashboard/playwright.config.ts +23 -0
  61. package/apps/harness-dashboard/postcss.config.js +6 -0
  62. package/apps/harness-dashboard/tailwind.config.ts +49 -0
  63. package/apps/harness-dashboard/tsconfig.json +24 -0
  64. package/apps/harness-dashboard/vitest.config.ts +15 -0
  65. package/package.json +11 -1
  66. package/scripts/harness-dashboard-up.sh +24 -4
@@ -0,0 +1,517 @@
1
+ "use client";
2
+ import { useState } from "react";
3
+ import type { MissionDoc, OwnerPromptEntry } from "@/lib/types";
4
+ import { MarkdownView } from "@/lib/markdown";
5
+
6
+ type DocView = "overview" | "ceo" | "cto" | "cqo" | "coo" | "cdo" | "ops" | string;
7
+
8
+ interface Props {
9
+ mission: MissionDoc | null;
10
+ ownerHistory: OwnerPromptEntry[];
11
+ }
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Section extractors
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /** Extract up to maxLines non-empty lines from a ## section */
18
+ function extractSection(content: string, heading: string, maxLines = 3): string | null {
19
+ const body = content.replace(/^---[\s\S]*?---\n+/, "");
20
+ const pattern = new RegExp(`^##\\s+${heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "m");
21
+ const match = pattern.exec(body);
22
+ if (!match) return null;
23
+ const start = match.index + match[0].length;
24
+ const rest = body.slice(start);
25
+ const lines = rest.split("\n");
26
+ const snippet: string[] = [];
27
+ for (const line of lines) {
28
+ if (line.startsWith("## ") || line.startsWith("# ")) break;
29
+ const trimmed = line.trim();
30
+ if (trimmed && !trimmed.startsWith("---")) snippet.push(trimmed);
31
+ if (snippet.length >= maxLines) break;
32
+ }
33
+ const result = snippet.join(" · ").slice(0, 240);
34
+ return result || null;
35
+ }
36
+
37
+ /** Try multiple heading alternatives, return first match */
38
+ function extractSectionAny(content: string, ...headings: string[]): string | null {
39
+ for (const h of headings) {
40
+ const r = extractSection(content, h, 4);
41
+ if (r) return r;
42
+ }
43
+ return null;
44
+ }
45
+
46
+ /** Parse markdown table rows from "생성/수정 파일" or "구현 파일 목록" sections */
47
+ function extractFilesTable(content: string, maxRows = 5): Array<{ file: string; action: string }> {
48
+ const body = content.replace(/^---[\s\S]*?---\n+/, "");
49
+ const lines = body.split("\n");
50
+ const results: Array<{ file: string; action: string }> = [];
51
+ let inFileTable = false;
52
+
53
+ for (const line of lines) {
54
+ // Detect table header with "파일" column
55
+ if (line.startsWith("|") && (line.includes("파일") || line.includes("File"))) {
56
+ inFileTable = true;
57
+ continue;
58
+ }
59
+ if (inFileTable) {
60
+ if (line.trim() === "" || (!line.startsWith("|") && !line.startsWith("|-"))) {
61
+ inFileTable = false;
62
+ continue;
63
+ }
64
+ if (line.includes("---")) continue; // divider row
65
+ if (!line.startsWith("|")) continue;
66
+ const cells = line.split("|").map((c) => c.trim()).filter(Boolean);
67
+ if (cells.length < 1) continue;
68
+ const file = cells[0].replace(/`/g, "").trim();
69
+ if (!file || file.toLowerCase() === "파일" || file.toLowerCase() === "file") continue;
70
+ // Truncate long action text
71
+ const raw = cells[1]?.replace(/`/g, "").trim() ?? "";
72
+ const action = raw.length > 60 ? raw.slice(0, 60) + "…" : raw;
73
+ results.push({ file, action });
74
+ if (results.length >= maxRows) break;
75
+ }
76
+ }
77
+ return results;
78
+ }
79
+
80
+ /** Extract CTO's worker instruction section */
81
+ function extractCtoWorkerInstructions(content: string): string | null {
82
+ return extractSection(content, "워커 지시", 6);
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Chip helpers
87
+ // ---------------------------------------------------------------------------
88
+ function StepChip({
89
+ role,
90
+ label,
91
+ onClick,
92
+ }: {
93
+ role: string;
94
+ label: string;
95
+ onClick?: () => void;
96
+ }) {
97
+ const colorMap: Record<string, string> = {
98
+ owner: "bg-amber-500/15 text-amber-300 border-amber-500/30",
99
+ ceo: "bg-cyan-400/15 text-cyan-300 border-cyan-400/30",
100
+ coo: "bg-emerald-500/15 text-emerald-300 border-emerald-500/30",
101
+ cdo: "bg-purple-500/15 text-purple-300 border-purple-500/30",
102
+ cto: "bg-blue-400/15 text-blue-300 border-blue-400/30",
103
+ cqo: "bg-amber-400/15 text-amber-300 border-amber-400/30",
104
+ ops: "bg-rose-400/15 text-rose-300 border-rose-400/30",
105
+ worker: "bg-gray-500/15 text-gray-300 border-gray-500/30",
106
+ };
107
+ const cls = colorMap[role] ?? "bg-gray-600/15 text-gray-400 border-gray-600/30";
108
+ return (
109
+ <span
110
+ className={`inline-flex items-center gap-1 rounded border px-1.5 py-0.5 font-mono text-[9px] ${cls} ${onClick ? "cursor-pointer hover:opacity-80" : ""}`}
111
+ onClick={onClick}
112
+ >
113
+ {label}
114
+ </span>
115
+ );
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // DocRow: one step in the flow tree
120
+ // ---------------------------------------------------------------------------
121
+ function DocRow({
122
+ step,
123
+ roleLabel,
124
+ roleKey,
125
+ snippet,
126
+ onViewDoc,
127
+ }: {
128
+ step: number;
129
+ roleLabel: string;
130
+ roleKey: string;
131
+ snippet?: string | null;
132
+ onViewDoc?: () => void;
133
+ }) {
134
+ return (
135
+ <div className="relative pl-4 border-l border-gray-700/40 ml-2 space-y-1">
136
+ <div className="flex items-center justify-between gap-2">
137
+ <div className="flex items-center gap-1.5">
138
+ <span className="font-mono text-[9px] text-gray-600">{step}</span>
139
+ <StepChip role={roleKey} label={roleLabel} />
140
+ </div>
141
+ {onViewDoc && (
142
+ <button
143
+ type="button"
144
+ onClick={onViewDoc}
145
+ className="shrink-0 font-mono text-[9px] text-cyan-400/70 hover:text-cyan-300 transition-colors"
146
+ >
147
+ View Doc →
148
+ </button>
149
+ )}
150
+ </div>
151
+ {snippet && (
152
+ <p className="text-[10px] text-gray-500 leading-relaxed pl-5 line-clamp-3">
153
+ {snippet}
154
+ </p>
155
+ )}
156
+ </div>
157
+ );
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // WorkerCard: shows worker + file list
162
+ // ---------------------------------------------------------------------------
163
+ function WorkerCard({
164
+ worker,
165
+ onViewDoc,
166
+ active,
167
+ }: {
168
+ worker: { name: string; content: string; status: string; owner?: string };
169
+ onViewDoc?: () => void;
170
+ active?: boolean;
171
+ }) {
172
+ const files = extractFilesTable(worker.content, 4);
173
+ return (
174
+ <div
175
+ className={`relative pl-3 border-l border-gray-700/30 ml-2 space-y-1 pb-1 ${active ? "border-cyan-400/40" : ""}`}
176
+ >
177
+ <div className="flex items-center justify-between gap-2">
178
+ <div className="flex items-center gap-1.5">
179
+ <span className="font-mono text-[9px] text-gray-600">├─</span>
180
+ <StepChip role="worker" label={worker.name} />
181
+ <span
182
+ className={`font-mono text-[9px] ${
183
+ worker.status === "COMPLETE" ? "text-emerald-400" : "text-amber-400"
184
+ }`}
185
+ >
186
+ {worker.status === "COMPLETE" ? "COMPLETE ✅" : "⏳"}
187
+ </span>
188
+ </div>
189
+ {onViewDoc && (
190
+ <button
191
+ type="button"
192
+ onClick={onViewDoc}
193
+ className="shrink-0 font-mono text-[9px] text-cyan-400/70 hover:text-cyan-300 transition-colors"
194
+ >
195
+ View Doc →
196
+ </button>
197
+ )}
198
+ </div>
199
+
200
+ {/* Changed files */}
201
+ {files.length > 0 && (
202
+ <div className="pl-5 space-y-0.5">
203
+ {files.map((f, i) => (
204
+ <div key={i} className="flex items-start gap-1.5">
205
+ <span className="font-mono text-[9px] text-gray-600 shrink-0 mt-0.5">▸</span>
206
+ <div className="min-w-0">
207
+ <span className="font-mono text-[9px] text-blue-300/80 break-all leading-tight">
208
+ {f.file}
209
+ </span>
210
+ {f.action && (
211
+ <span className="font-mono text-[9px] text-gray-600 ml-1">— {f.action}</span>
212
+ )}
213
+ </div>
214
+ </div>
215
+ ))}
216
+ </div>
217
+ )}
218
+ </div>
219
+ );
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Main component
224
+ // ---------------------------------------------------------------------------
225
+ export function MissionFlowTab({ mission, ownerHistory }: Props) {
226
+ const [docView, setDocView] = useState<DocView>("overview");
227
+
228
+ if (!mission) {
229
+ return (
230
+ <div className="text-gray-500 text-xs">
231
+ No mission selected. Click a mission from the timeline below.
232
+ </div>
233
+ );
234
+ }
235
+
236
+ // -- Doc view mode --
237
+ if (docView !== "overview") {
238
+ let content: string | null = null;
239
+ let docTitle = docView;
240
+
241
+ if (["ceo", "cto", "cqo", "coo", "cdo", "ops"].includes(docView)) {
242
+ content = mission[docView as "ceo" | "cto" | "cqo" | "coo" | "cdo" | "ops"];
243
+ docTitle = `harness-${docView}`;
244
+ } else {
245
+ const w = mission.workers.find((x) => x.name === docView);
246
+ content = w?.content ?? null;
247
+ docTitle = docView;
248
+ }
249
+
250
+ return (
251
+ <div className="space-y-3">
252
+ <button
253
+ type="button"
254
+ onClick={() => setDocView("overview")}
255
+ className="flex items-center gap-1.5 font-mono text-[10px] text-cyan-400/70 hover:text-cyan-300 transition-colors"
256
+ >
257
+ ← Back
258
+ </button>
259
+ <div className="font-mono text-[10px] text-gray-500">
260
+ {mission.missionId} · {docTitle}
261
+ </div>
262
+ {content ? (
263
+ <MarkdownView source={content} />
264
+ ) : (
265
+ <div className="text-gray-500 text-xs">No document available.</div>
266
+ )}
267
+ </div>
268
+ );
269
+ }
270
+
271
+ // -- Overview mode --
272
+
273
+ // Find owner prompt closest to mission date
274
+ let ownerPromptSnippet: string | null = null;
275
+ if (ownerHistory.length > 0) {
276
+ const missionTime = Date.parse(mission.ts);
277
+ let closest: OwnerPromptEntry | null = null;
278
+ let closestDiff = Infinity;
279
+ for (const entry of ownerHistory) {
280
+ const t = Date.parse(entry.ts);
281
+ if (!Number.isNaN(t)) {
282
+ const diff = Math.abs(t - missionTime);
283
+ if (diff < closestDiff) { closestDiff = diff; closest = entry; }
284
+ }
285
+ }
286
+ if (closest) ownerPromptSnippet = closest.content.slice(0, 140);
287
+ }
288
+
289
+ const typeBadge =
290
+ mission.type === "hotfix" ? "[🔥 hot-fix]"
291
+ : mission.type === "feature" ? "[✦ goal]"
292
+ : "[— unknown]";
293
+
294
+ let dateStr = "—";
295
+ try {
296
+ dateStr = new Date(mission.ts).toLocaleDateString(undefined, {
297
+ year: "numeric", month: "short", day: "numeric",
298
+ });
299
+ } catch { /* ignore */ }
300
+
301
+ // Routing breadcrumb
302
+ const routeChips: string[] = ["Owner", "CEO"];
303
+ const cxxOrder: Array<"coo" | "cdo" | "cto" | "cqo" | "ops"> = ["coo", "cdo", "cto", "cqo", "ops"];
304
+ for (const r of cxxOrder) {
305
+ if (mission.cxxPresent.includes(r)) routeChips.push(r.toUpperCase());
306
+ }
307
+
308
+ // CEO snippets
309
+ const ceoRequestSnippet = mission.ceo
310
+ ? extractSectionAny(mission.ceo, "Owner 요청 요약", "Owner Request")
311
+ : null;
312
+ const ceoDecisionSnippet = mission.ceo
313
+ ? extractSectionAny(mission.ceo, "CEO 결정", "CXX Decisions")
314
+ : null;
315
+ const ceoSnippet = [ceoRequestSnippet, ceoDecisionSnippet].filter(Boolean).join(" | ").slice(0, 240) || null;
316
+
317
+ // CTO snippet + worker instructions
318
+ const ctoSnippet = mission.cto
319
+ ? extractSectionAny(mission.cto, "변경 요약", "Summary")
320
+ : null;
321
+ const ctoWorkerInstructions = mission.cto
322
+ ? extractCtoWorkerInstructions(mission.cto)
323
+ : null;
324
+
325
+ // CQO verdict
326
+ let cqoVerdict: string | null = null;
327
+ if (mission.cqo) {
328
+ if (mission.cqo.includes("ACCEPTED")) cqoVerdict = "검증 결과: ACCEPTED ✅";
329
+ else if (mission.cqo.includes("REJECTED")) cqoVerdict = "검증 결과: REJECTED ❌";
330
+ else cqoVerdict = extractSectionAny(mission.cqo, "검증 결과", "Verdict") ?? null;
331
+ }
332
+
333
+ let stepNum = 1;
334
+
335
+ return (
336
+ <div className="space-y-3">
337
+ {/* Mission header */}
338
+ <div>
339
+ <div className="flex items-center gap-2 flex-wrap mb-1">
340
+ <span
341
+ className={`font-mono text-[9px] rounded border px-1.5 py-0.5 ${
342
+ mission.type === "hotfix"
343
+ ? "bg-rose-500/15 text-rose-300 border-rose-500/30"
344
+ : "bg-emerald-500/15 text-emerald-300 border-emerald-500/30"
345
+ }`}
346
+ >
347
+ {typeBadge}
348
+ </span>
349
+ <span className="font-mono text-[10px] text-gray-300 truncate flex-1 min-w-0">
350
+ {mission.missionId}
351
+ </span>
352
+ <span className="font-mono text-[9px] text-gray-500 shrink-0">{dateStr}</span>
353
+ </div>
354
+ {/* Routing breadcrumb */}
355
+ <div className="flex flex-wrap items-center gap-1 mt-1">
356
+ {routeChips.map((label, idx) => {
357
+ const roleKey = label === "Owner" ? "owner" : label === "CEO" ? "ceo" : label.toLowerCase();
358
+ return (
359
+ <span key={idx} className="flex items-center gap-1">
360
+ {idx > 0 && <span className="text-gray-600 text-[9px]">→</span>}
361
+ <StepChip role={roleKey} label={label} />
362
+ </span>
363
+ );
364
+ })}
365
+ </div>
366
+ </div>
367
+
368
+ {/* Flow tree */}
369
+ <div className="space-y-2">
370
+ {/* OWNER */}
371
+ <DocRow step={stepNum++} roleLabel="OWNER" roleKey="owner" />
372
+ {ownerPromptSnippet && (
373
+ <p className="pl-7 text-[10px] text-gray-500 italic leading-relaxed line-clamp-2">
374
+ {ownerPromptSnippet}{ownerPromptSnippet.length >= 140 ? "…" : ""}
375
+ </p>
376
+ )}
377
+
378
+ {/* CEO */}
379
+ <div className="pl-3 space-y-1">
380
+ <p className="font-mono text-[9px] text-gray-600 pl-2">↓ dispatched to</p>
381
+ <DocRow
382
+ step={stepNum++}
383
+ roleLabel="harness-ceo"
384
+ roleKey="ceo"
385
+ snippet={ceoSnippet}
386
+ onViewDoc={mission.ceo ? () => setDocView("ceo") : undefined}
387
+ />
388
+ {/* Legacy flat workers: protocol violation / unknown owner */}
389
+ {mission.workers.some((w) => w.owner === "unknown") && (
390
+ <div className="pl-4 space-y-2 mt-1">
391
+ <p className="font-mono text-[9px] text-rose-300/80 pl-1">
392
+ Unowned legacy workers (CEO/CXX bypass suspected):
393
+ </p>
394
+ {mission.workers.filter((w) => w.owner === "unknown").map((worker) => (
395
+ <WorkerCard
396
+ key={worker.name}
397
+ worker={worker}
398
+ onViewDoc={worker.content ? () => setDocView(worker.name) : undefined}
399
+ />
400
+ ))}
401
+ </div>
402
+ )}
403
+ </div>
404
+
405
+ {/* COO */}
406
+ {mission.cxxPresent.includes("coo") && (
407
+ <div className="pl-6 space-y-1">
408
+ <p className="font-mono text-[9px] text-gray-600 pl-2">↓ routed to</p>
409
+ <DocRow
410
+ step={stepNum++}
411
+ roleLabel="harness-coo"
412
+ roleKey="coo"
413
+ snippet={mission.coo ? extractSectionAny(mission.coo, "요약", "Summary") : null}
414
+ onViewDoc={mission.coo ? () => setDocView("coo") : undefined}
415
+ />
416
+ </div>
417
+ )}
418
+
419
+ {/* CDO */}
420
+ {mission.cxxPresent.includes("cdo") && (
421
+ <div className="pl-6 space-y-1">
422
+ <p className="font-mono text-[9px] text-gray-600 pl-2">↓ routed to</p>
423
+ <DocRow
424
+ step={stepNum++}
425
+ roleLabel="harness-cdo"
426
+ roleKey="cdo"
427
+ snippet={mission.cdo ? extractSectionAny(mission.cdo, "요약", "Summary") : null}
428
+ onViewDoc={mission.cdo ? () => setDocView("cdo") : undefined}
429
+ />
430
+ </div>
431
+ )}
432
+
433
+ {/* CTO + Workers */}
434
+ {mission.cxxPresent.includes("cto") && (
435
+ <div className="pl-6 space-y-1">
436
+ <p className="font-mono text-[9px] text-gray-600 pl-2">↓ routed to</p>
437
+ <DocRow
438
+ step={stepNum++}
439
+ roleLabel="harness-cto"
440
+ roleKey="cto"
441
+ snippet={ctoSnippet}
442
+ onViewDoc={mission.cto ? () => setDocView("cto") : undefined}
443
+ />
444
+
445
+ {/* CTO worker instructions */}
446
+ {ctoWorkerInstructions && (
447
+ <div className="pl-7 mt-1">
448
+ <div className="rounded border border-blue-400/15 bg-blue-400/5 px-2 py-1.5">
449
+ <span className="font-mono text-[9px] text-blue-300/50 uppercase tracking-wider">
450
+ 워커 지시
451
+ </span>
452
+ <p className="text-[10px] text-gray-400 leading-relaxed mt-0.5">
453
+ {ctoWorkerInstructions}
454
+ </p>
455
+ </div>
456
+ </div>
457
+ )}
458
+
459
+ {/* Workers */}
460
+ {mission.workers.some((w) => w.owner === "cto") && (
461
+ <div className="pl-4 space-y-2 mt-1">
462
+ <p className="font-mono text-[9px] text-gray-500 pl-1">CTO workers:</p>
463
+ {mission.workers.filter((w) => w.owner === "cto").map((worker) => (
464
+ <WorkerCard
465
+ key={worker.name}
466
+ worker={worker}
467
+ onViewDoc={worker.content ? () => setDocView(worker.name) : undefined}
468
+ />
469
+ ))}
470
+ </div>
471
+ )}
472
+ </div>
473
+ )}
474
+
475
+ {/* OPS */}
476
+ {mission.cxxPresent.includes("ops") && (
477
+ <div className="pl-6 space-y-1">
478
+ <p className="font-mono text-[9px] text-gray-600 pl-2">↓ routed to</p>
479
+ <DocRow
480
+ step={stepNum++}
481
+ roleLabel="harness-ops"
482
+ roleKey="ops"
483
+ snippet={mission.ops ? extractSectionAny(mission.ops, "요약", "Summary") : null}
484
+ onViewDoc={mission.ops ? () => setDocView("ops") : undefined}
485
+ />
486
+ </div>
487
+ )}
488
+
489
+ {/* CQO */}
490
+ {mission.cxxPresent.includes("cqo") && (
491
+ <div className="pl-6 space-y-1">
492
+ <p className="font-mono text-[9px] text-gray-600 pl-2">↓ routed to</p>
493
+ <DocRow
494
+ step={stepNum++}
495
+ roleLabel="harness-cqo"
496
+ roleKey="cqo"
497
+ snippet={cqoVerdict}
498
+ onViewDoc={mission.cqo ? () => setDocView("cqo") : undefined}
499
+ />
500
+ {mission.workers.some((w) => w.owner === "cqo") && (
501
+ <div className="pl-4 space-y-2 mt-1">
502
+ <p className="font-mono text-[9px] text-gray-500 pl-1">CQO evaluators:</p>
503
+ {mission.workers.filter((w) => w.owner === "cqo").map((worker) => (
504
+ <WorkerCard
505
+ key={worker.name}
506
+ worker={worker}
507
+ onViewDoc={worker.content ? () => setDocView(worker.name) : undefined}
508
+ />
509
+ ))}
510
+ </div>
511
+ )}
512
+ </div>
513
+ )}
514
+ </div>
515
+ </div>
516
+ );
517
+ }
@@ -0,0 +1,156 @@
1
+ "use client";
2
+ import type { MissionDoc, OwnerPromptEntry } from "@/lib/types";
3
+
4
+ interface Props {
5
+ ownerHistory: OwnerPromptEntry[];
6
+ mission?: MissionDoc | null;
7
+ onEntryClick?: (entry: OwnerPromptEntry) => void;
8
+ }
9
+
10
+ function extractSectionText(content: string, ...headings: string[]): string | null {
11
+ const body = content.replace(/^---[\s\S]*?---\n+/, "");
12
+ for (const heading of headings) {
13
+ const pattern = new RegExp(`^##\\s+${heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "m");
14
+ const match = pattern.exec(body);
15
+ if (!match) continue;
16
+ const start = match.index + match[0].length;
17
+ const rest = body.slice(start);
18
+ const lines = rest.split("\n");
19
+ const snippet: string[] = [];
20
+ for (const line of lines) {
21
+ if (line.startsWith("## ") || line.startsWith("# ")) break;
22
+ const trimmed = line.trim();
23
+ if (trimmed && !trimmed.startsWith("---")) snippet.push(trimmed);
24
+ if (snippet.length >= 6) break;
25
+ }
26
+ const result = snippet.join("\n").slice(0, 600);
27
+ if (result) return result;
28
+ }
29
+ return null;
30
+ }
31
+
32
+ export function OwnerHistoryTab({ ownerHistory, mission, onEntryClick }: Props) {
33
+ // Mission-specific view
34
+ if (mission) {
35
+ const requestText = mission.ceo
36
+ ? extractSectionText(mission.ceo, "Owner 요청 요약", "Owner Request")
37
+ : null;
38
+
39
+ // Find closest owner history entry by timestamp
40
+ let closestEntry: OwnerPromptEntry | null = null;
41
+ if (ownerHistory.length > 0) {
42
+ const missionTime = Date.parse(mission.ts);
43
+ let closestDiff = Infinity;
44
+ for (const entry of ownerHistory) {
45
+ const t = Date.parse(entry.ts);
46
+ if (!Number.isNaN(t)) {
47
+ const diff = Math.abs(t - missionTime);
48
+ if (diff < closestDiff) { closestDiff = diff; closestEntry = entry; }
49
+ }
50
+ }
51
+ }
52
+
53
+ return (
54
+ <div className="space-y-3">
55
+ <div className="flex items-center gap-2 mb-3">
56
+ <span className="font-mono text-[9px] uppercase tracking-[0.2em] text-gray-500">from</span>
57
+ <span className="rounded bg-amber-500/10 border border-amber-500/30 px-2 py-0.5 text-[10px] text-amber-300 font-mono">
58
+ Owner
59
+ </span>
60
+ <span className="font-mono text-[9px] text-gray-600">→</span>
61
+ <span className="rounded bg-cyan-400/10 border border-cyan-400/30 px-2 py-0.5 text-[10px] text-cyan-300 font-mono">
62
+ CEO
63
+ </span>
64
+ <span className="ml-auto font-mono text-[9px] text-gray-600 truncate max-w-[160px]">
65
+ {mission.missionId}
66
+ </span>
67
+ </div>
68
+
69
+ {/* CEO's summary of owner request */}
70
+ {requestText && (
71
+ <div className="rounded border border-cyan-400/20 bg-cyan-400/5 p-2.5">
72
+ <div className="font-mono text-[9px] uppercase tracking-[0.15em] text-cyan-400/60 mb-1.5">
73
+ CEO가 정리한 Owner 요청
74
+ </div>
75
+ <p className="text-[11px] leading-relaxed text-gray-300 whitespace-pre-line">
76
+ {requestText}
77
+ </p>
78
+ </div>
79
+ )}
80
+
81
+ {/* Closest matching progress.log entry */}
82
+ {closestEntry && (
83
+ <div
84
+ className={`rounded border border-gray-700/60 bg-black/20 p-2 ${onEntryClick ? "cursor-pointer hover:border-amber-400/30 hover:bg-amber-400/5 transition-colors" : ""}`}
85
+ onClick={onEntryClick ? () => onEntryClick(closestEntry!) : undefined}
86
+ >
87
+ <div className="flex items-center justify-between gap-2 mb-1">
88
+ <span className="font-mono text-[9px] text-gray-500">progress.log</span>
89
+ <div className="flex items-center gap-1.5">
90
+ <span className="font-mono text-[9px] text-gray-600">{closestEntry.ts}</span>
91
+ <span
92
+ className={`font-mono text-[9px] px-1.5 py-0.5 rounded ${
93
+ closestEntry.type === "goal"
94
+ ? "bg-emerald-500/15 text-emerald-300"
95
+ : closestEntry.type === "hot-fix"
96
+ ? "bg-rose-500/15 text-rose-300"
97
+ : "bg-gray-500/15 text-gray-400"
98
+ }`}
99
+ >
100
+ {closestEntry.type}
101
+ </span>
102
+ </div>
103
+ </div>
104
+ <p className="text-[11px] leading-relaxed text-gray-400 break-words">{closestEntry.content}</p>
105
+ </div>
106
+ )}
107
+
108
+ {!requestText && !closestEntry && (
109
+ <div className="text-gray-500 text-xs">No owner prompt found for this mission.</div>
110
+ )}
111
+ </div>
112
+ );
113
+ }
114
+
115
+ // All-history fallback (no mission selected)
116
+ if (!ownerHistory || ownerHistory.length === 0) {
117
+ return <div className="text-gray-500">No prompt history found in progress.log.</div>;
118
+ }
119
+ return (
120
+ <div className="space-y-2">
121
+ <div className="mb-3 flex items-center gap-2">
122
+ <span className="font-mono text-[9px] uppercase tracking-[0.2em] text-gray-500">from</span>
123
+ <span className="rounded bg-amber-500/10 border border-amber-500/30 px-2 py-0.5 text-[10px] text-amber-300 font-mono">
124
+ Owner
125
+ </span>
126
+ <span className="font-mono text-[9px] text-gray-600">→</span>
127
+ <span className="rounded bg-cyan-400/10 border border-cyan-400/30 px-2 py-0.5 text-[10px] text-cyan-300 font-mono">
128
+ CEO
129
+ </span>
130
+ </div>
131
+ {ownerHistory.map((entry, i) => (
132
+ <div
133
+ key={i}
134
+ className={`rounded border border-gray-700/60 bg-black/20 p-2 ${onEntryClick ? "cursor-pointer hover:border-cyan-400/30 hover:bg-cyan-400/5 transition-colors" : ""}`}
135
+ onClick={onEntryClick ? () => onEntryClick(entry) : undefined}
136
+ >
137
+ <div className="flex items-center justify-between gap-2 mb-1">
138
+ <span className="font-mono text-[9px] text-gray-500">{entry.ts}</span>
139
+ <span
140
+ className={`font-mono text-[9px] px-1.5 py-0.5 rounded ${
141
+ entry.type === "goal"
142
+ ? "bg-emerald-500/15 text-emerald-300"
143
+ : entry.type === "hot-fix"
144
+ ? "bg-rose-500/15 text-rose-300"
145
+ : "bg-gray-500/15 text-gray-400"
146
+ }`}
147
+ >
148
+ {entry.type}
149
+ </span>
150
+ </div>
151
+ <p className="text-[11px] leading-relaxed text-gray-300 break-words">{entry.content}</p>
152
+ </div>
153
+ ))}
154
+ </div>
155
+ );
156
+ }