@walwal-harness/cli 7.1.8 → 7.1.10

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 (86) hide show
  1. package/HR-Resource/ceo/SKILL.md +4 -4
  2. package/HR-Resource/cqo/SKILL.md +3 -3
  3. package/HR-Resource/cto/SKILL.md +4 -4
  4. package/README.md +58 -5
  5. package/apps/harness-dashboard/app/api/env/route.ts +62 -0
  6. package/apps/harness-dashboard/app/api/log/route.ts +19 -0
  7. package/apps/harness-dashboard/app/api/missions/route.ts +15 -0
  8. package/apps/harness-dashboard/app/api/snapshot/route.ts +15 -0
  9. package/apps/harness-dashboard/app/api/stream/route.ts +71 -0
  10. package/apps/harness-dashboard/app/globals.css +17 -0
  11. package/apps/harness-dashboard/app/layout.tsx +29 -0
  12. package/apps/harness-dashboard/app/page.tsx +35 -0
  13. package/apps/harness-dashboard/components/Drawer.tsx +97 -0
  14. package/apps/harness-dashboard/components/Header.tsx +40 -0
  15. package/apps/harness-dashboard/components/MissionTimeline.tsx +214 -0
  16. package/apps/harness-dashboard/components/OrgTree.tsx +330 -0
  17. package/apps/harness-dashboard/components/Scene.tsx +563 -0
  18. package/apps/harness-dashboard/components/Wordmark.tsx +14 -0
  19. package/apps/harness-dashboard/components/drawer/AgentLogTab.tsx +56 -0
  20. package/apps/harness-dashboard/components/drawer/GotchasTab.tsx +89 -0
  21. package/apps/harness-dashboard/components/drawer/HypothesisTab.tsx +42 -0
  22. package/apps/harness-dashboard/components/drawer/IncidentsTab.tsx +50 -0
  23. package/apps/harness-dashboard/components/drawer/MissionDocTab.tsx +92 -0
  24. package/apps/harness-dashboard/components/drawer/MissionFlowTab.tsx +520 -0
  25. package/apps/harness-dashboard/components/drawer/OwnerHistoryTab.tsx +160 -0
  26. package/apps/harness-dashboard/components/drawer/RoomMetricsTab.tsx +90 -0
  27. package/apps/harness-dashboard/components/drawer/TracksTab.tsx +47 -0
  28. package/apps/harness-dashboard/components/three/ArchiveBoxes.tsx +95 -0
  29. package/apps/harness-dashboard/components/three/DeptBoard.tsx +175 -0
  30. package/apps/harness-dashboard/components/three/Floor3D.tsx +404 -0
  31. package/apps/harness-dashboard/components/three/GoalCard3D.tsx +123 -0
  32. package/apps/harness-dashboard/components/three/HypothesisCards.tsx +71 -0
  33. package/apps/harness-dashboard/components/three/MeetingWhiteboard.tsx +98 -0
  34. package/apps/harness-dashboard/components/three/Minifig3D.tsx +391 -0
  35. package/apps/harness-dashboard/components/three/OpsSiren.tsx +99 -0
  36. package/apps/harness-dashboard/components/three/Stage3D.tsx +104 -0
  37. package/apps/harness-dashboard/components/three/TrackRibbons.tsx +81 -0
  38. package/apps/harness-dashboard/hooks/useHarnessStream.ts +53 -0
  39. package/apps/harness-dashboard/lib/__tests__/company-flow.test.ts +270 -0
  40. package/apps/harness-dashboard/lib/__tests__/harness-state.test.ts +194 -0
  41. package/apps/harness-dashboard/lib/__tests__/i18n.test.ts +16 -0
  42. package/apps/harness-dashboard/lib/__tests__/iso.test.ts +42 -0
  43. package/apps/harness-dashboard/lib/__tests__/safe-json.test.ts +46 -0
  44. package/apps/harness-dashboard/lib/__tests__/state-mapping.test.ts +159 -0
  45. package/apps/harness-dashboard/lib/__tests__/watcher.test.ts +48 -0
  46. package/apps/harness-dashboard/lib/agent-roster.ts +42 -0
  47. package/apps/harness-dashboard/lib/furniture.ts +165 -0
  48. package/apps/harness-dashboard/lib/harness-root.ts +20 -0
  49. package/apps/harness-dashboard/lib/harness-state.ts +1193 -0
  50. package/apps/harness-dashboard/lib/i18n.ts +26 -0
  51. package/apps/harness-dashboard/lib/iso.ts +150 -0
  52. package/apps/harness-dashboard/lib/log-filter.ts +23 -0
  53. package/apps/harness-dashboard/lib/markdown.tsx +306 -0
  54. package/apps/harness-dashboard/lib/path-planning.ts +72 -0
  55. package/apps/harness-dashboard/lib/safe-json.ts +24 -0
  56. package/apps/harness-dashboard/lib/state-mapping.ts +126 -0
  57. package/apps/harness-dashboard/lib/types.ts +329 -0
  58. package/apps/harness-dashboard/lib/watcher.ts +64 -0
  59. package/apps/harness-dashboard/next-env.d.ts +6 -0
  60. package/apps/harness-dashboard/next.config.mjs +9 -0
  61. package/apps/harness-dashboard/package-lock.json +4280 -0
  62. package/apps/harness-dashboard/package.json +38 -0
  63. package/apps/harness-dashboard/playwright.config.ts +23 -0
  64. package/apps/harness-dashboard/postcss.config.js +6 -0
  65. package/apps/harness-dashboard/tailwind.config.ts +49 -0
  66. package/apps/harness-dashboard/tsconfig.json +24 -0
  67. package/apps/harness-dashboard/vitest.config.ts +15 -0
  68. package/assets/templates/AGENTS-ko.md.template +19 -6
  69. package/assets/templates/AGENTS.md.template +20 -7
  70. package/assets/templates/HARNESS.md +75 -199
  71. package/assets/templates/config.json +2 -2
  72. package/bin/init.js +11 -11
  73. package/commands/goal.md +6 -4
  74. package/commands/hot-fix.md +16 -13
  75. package/commands/submission.md +28 -0
  76. package/conventions/ceo.md +3 -3
  77. package/conventions/shared.md +4 -3
  78. package/package.json +11 -1
  79. package/scripts/harness-dashboard-up.sh +24 -4
  80. package/scripts/harness-session-start.sh +1 -1
  81. package/scripts/harness-stop.sh +1 -1
  82. package/scripts/harness-user-prompt-submit.sh +12 -4
  83. package/scripts/harness-worker-evidence-validate.sh +8 -3
  84. package/scripts/import-agency-agents.js +1 -1
  85. package/scripts/init-agents-md.sh +17 -2
  86. package/assets/templates/AGENTS-ko /341/204/207/341/205/251/341/206/250/341/204/211/341/205/241/341/204/207/341/205/251/341/206/253.md" +0 -114
@@ -0,0 +1,520 @@
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 === "submission" ? "[+ submission]"
292
+ : mission.type === "goal" || mission.type === "feature" ? "[✦ goal]"
293
+ : "[— unknown]";
294
+
295
+ let dateStr = "—";
296
+ try {
297
+ dateStr = new Date(mission.ts).toLocaleDateString(undefined, {
298
+ year: "numeric", month: "short", day: "numeric",
299
+ });
300
+ } catch { /* ignore */ }
301
+
302
+ // Routing breadcrumb
303
+ const routeChips: string[] = ["Owner", "CEO"];
304
+ const cxxOrder: Array<"coo" | "cdo" | "cto" | "cqo" | "ops"> = ["coo", "cdo", "cto", "cqo", "ops"];
305
+ for (const r of cxxOrder) {
306
+ if (mission.cxxPresent.includes(r)) routeChips.push(r.toUpperCase());
307
+ }
308
+
309
+ // CEO snippets
310
+ const ceoRequestSnippet = mission.ceo
311
+ ? extractSectionAny(mission.ceo, "Owner 요청 요약", "Owner Request")
312
+ : null;
313
+ const ceoDecisionSnippet = mission.ceo
314
+ ? extractSectionAny(mission.ceo, "CEO 결정", "CXX Decisions")
315
+ : null;
316
+ const ceoSnippet = [ceoRequestSnippet, ceoDecisionSnippet].filter(Boolean).join(" | ").slice(0, 240) || null;
317
+
318
+ // CTO snippet + worker instructions
319
+ const ctoSnippet = mission.cto
320
+ ? extractSectionAny(mission.cto, "변경 요약", "Summary")
321
+ : null;
322
+ const ctoWorkerInstructions = mission.cto
323
+ ? extractCtoWorkerInstructions(mission.cto)
324
+ : null;
325
+
326
+ // CQO verdict
327
+ let cqoVerdict: string | null = null;
328
+ if (mission.cqo) {
329
+ if (mission.cqo.includes("ACCEPTED")) cqoVerdict = "검증 결과: ACCEPTED ✅";
330
+ else if (mission.cqo.includes("REJECTED")) cqoVerdict = "검증 결과: REJECTED ❌";
331
+ else cqoVerdict = extractSectionAny(mission.cqo, "검증 결과", "Verdict") ?? null;
332
+ }
333
+
334
+ let stepNum = 1;
335
+
336
+ return (
337
+ <div className="space-y-3">
338
+ {/* Mission header */}
339
+ <div>
340
+ <div className="flex items-center gap-2 flex-wrap mb-1">
341
+ <span
342
+ className={`font-mono text-[9px] rounded border px-1.5 py-0.5 ${
343
+ mission.type === "hotfix"
344
+ ? "bg-rose-500/15 text-rose-300 border-rose-500/30"
345
+ : mission.type === "submission"
346
+ ? "bg-sky-500/15 text-sky-300 border-sky-500/30"
347
+ : "bg-emerald-500/15 text-emerald-300 border-emerald-500/30"
348
+ }`}
349
+ >
350
+ {typeBadge}
351
+ </span>
352
+ <span className="font-mono text-[10px] text-gray-300 truncate flex-1 min-w-0">
353
+ {mission.missionId}
354
+ </span>
355
+ <span className="font-mono text-[9px] text-gray-500 shrink-0">{dateStr}</span>
356
+ </div>
357
+ {/* Routing breadcrumb */}
358
+ <div className="flex flex-wrap items-center gap-1 mt-1">
359
+ {routeChips.map((label, idx) => {
360
+ const roleKey = label === "Owner" ? "owner" : label === "CEO" ? "ceo" : label.toLowerCase();
361
+ return (
362
+ <span key={idx} className="flex items-center gap-1">
363
+ {idx > 0 && <span className="text-gray-600 text-[9px]">→</span>}
364
+ <StepChip role={roleKey} label={label} />
365
+ </span>
366
+ );
367
+ })}
368
+ </div>
369
+ </div>
370
+
371
+ {/* Flow tree */}
372
+ <div className="space-y-2">
373
+ {/* OWNER */}
374
+ <DocRow step={stepNum++} roleLabel="OWNER" roleKey="owner" />
375
+ {ownerPromptSnippet && (
376
+ <p className="pl-7 text-[10px] text-gray-500 italic leading-relaxed line-clamp-2">
377
+ {ownerPromptSnippet}{ownerPromptSnippet.length >= 140 ? "…" : ""}
378
+ </p>
379
+ )}
380
+
381
+ {/* CEO */}
382
+ <div className="pl-3 space-y-1">
383
+ <p className="font-mono text-[9px] text-gray-600 pl-2">↓ dispatched to</p>
384
+ <DocRow
385
+ step={stepNum++}
386
+ roleLabel="harness-ceo"
387
+ roleKey="ceo"
388
+ snippet={ceoSnippet}
389
+ onViewDoc={mission.ceo ? () => setDocView("ceo") : undefined}
390
+ />
391
+ {/* Legacy flat workers: protocol violation / unknown owner */}
392
+ {mission.workers.some((w) => w.owner === "unknown") && (
393
+ <div className="pl-4 space-y-2 mt-1">
394
+ <p className="font-mono text-[9px] text-rose-300/80 pl-1">
395
+ Unowned legacy workers (CEO/CXX bypass suspected):
396
+ </p>
397
+ {mission.workers.filter((w) => w.owner === "unknown").map((worker) => (
398
+ <WorkerCard
399
+ key={worker.name}
400
+ worker={worker}
401
+ onViewDoc={worker.content ? () => setDocView(worker.name) : undefined}
402
+ />
403
+ ))}
404
+ </div>
405
+ )}
406
+ </div>
407
+
408
+ {/* COO */}
409
+ {mission.cxxPresent.includes("coo") && (
410
+ <div className="pl-6 space-y-1">
411
+ <p className="font-mono text-[9px] text-gray-600 pl-2">↓ routed to</p>
412
+ <DocRow
413
+ step={stepNum++}
414
+ roleLabel="harness-coo"
415
+ roleKey="coo"
416
+ snippet={mission.coo ? extractSectionAny(mission.coo, "요약", "Summary") : null}
417
+ onViewDoc={mission.coo ? () => setDocView("coo") : undefined}
418
+ />
419
+ </div>
420
+ )}
421
+
422
+ {/* CDO */}
423
+ {mission.cxxPresent.includes("cdo") && (
424
+ <div className="pl-6 space-y-1">
425
+ <p className="font-mono text-[9px] text-gray-600 pl-2">↓ routed to</p>
426
+ <DocRow
427
+ step={stepNum++}
428
+ roleLabel="harness-cdo"
429
+ roleKey="cdo"
430
+ snippet={mission.cdo ? extractSectionAny(mission.cdo, "요약", "Summary") : null}
431
+ onViewDoc={mission.cdo ? () => setDocView("cdo") : undefined}
432
+ />
433
+ </div>
434
+ )}
435
+
436
+ {/* CTO + Workers */}
437
+ {mission.cxxPresent.includes("cto") && (
438
+ <div className="pl-6 space-y-1">
439
+ <p className="font-mono text-[9px] text-gray-600 pl-2">↓ routed to</p>
440
+ <DocRow
441
+ step={stepNum++}
442
+ roleLabel="harness-cto"
443
+ roleKey="cto"
444
+ snippet={ctoSnippet}
445
+ onViewDoc={mission.cto ? () => setDocView("cto") : undefined}
446
+ />
447
+
448
+ {/* CTO worker instructions */}
449
+ {ctoWorkerInstructions && (
450
+ <div className="pl-7 mt-1">
451
+ <div className="rounded border border-blue-400/15 bg-blue-400/5 px-2 py-1.5">
452
+ <span className="font-mono text-[9px] text-blue-300/50 uppercase tracking-wider">
453
+ 워커 지시
454
+ </span>
455
+ <p className="text-[10px] text-gray-400 leading-relaxed mt-0.5">
456
+ {ctoWorkerInstructions}
457
+ </p>
458
+ </div>
459
+ </div>
460
+ )}
461
+
462
+ {/* Workers */}
463
+ {mission.workers.some((w) => w.owner === "cto") && (
464
+ <div className="pl-4 space-y-2 mt-1">
465
+ <p className="font-mono text-[9px] text-gray-500 pl-1">CTO workers:</p>
466
+ {mission.workers.filter((w) => w.owner === "cto").map((worker) => (
467
+ <WorkerCard
468
+ key={worker.name}
469
+ worker={worker}
470
+ onViewDoc={worker.content ? () => setDocView(worker.name) : undefined}
471
+ />
472
+ ))}
473
+ </div>
474
+ )}
475
+ </div>
476
+ )}
477
+
478
+ {/* OPS */}
479
+ {mission.cxxPresent.includes("ops") && (
480
+ <div className="pl-6 space-y-1">
481
+ <p className="font-mono text-[9px] text-gray-600 pl-2">↓ routed to</p>
482
+ <DocRow
483
+ step={stepNum++}
484
+ roleLabel="harness-ops"
485
+ roleKey="ops"
486
+ snippet={mission.ops ? extractSectionAny(mission.ops, "요약", "Summary") : null}
487
+ onViewDoc={mission.ops ? () => setDocView("ops") : undefined}
488
+ />
489
+ </div>
490
+ )}
491
+
492
+ {/* CQO */}
493
+ {mission.cxxPresent.includes("cqo") && (
494
+ <div className="pl-6 space-y-1">
495
+ <p className="font-mono text-[9px] text-gray-600 pl-2">↓ routed to</p>
496
+ <DocRow
497
+ step={stepNum++}
498
+ roleLabel="harness-cqo"
499
+ roleKey="cqo"
500
+ snippet={cqoVerdict}
501
+ onViewDoc={mission.cqo ? () => setDocView("cqo") : undefined}
502
+ />
503
+ {mission.workers.some((w) => w.owner === "cqo") && (
504
+ <div className="pl-4 space-y-2 mt-1">
505
+ <p className="font-mono text-[9px] text-gray-500 pl-1">CQO evaluators:</p>
506
+ {mission.workers.filter((w) => w.owner === "cqo").map((worker) => (
507
+ <WorkerCard
508
+ key={worker.name}
509
+ worker={worker}
510
+ onViewDoc={worker.content ? () => setDocView(worker.name) : undefined}
511
+ />
512
+ ))}
513
+ </div>
514
+ )}
515
+ </div>
516
+ )}
517
+ </div>
518
+ </div>
519
+ );
520
+ }
@@ -0,0 +1,160 @@
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 === "submission"
96
+ ? "bg-sky-500/15 text-sky-300"
97
+ : closestEntry.type === "hot-fix"
98
+ ? "bg-rose-500/15 text-rose-300"
99
+ : "bg-gray-500/15 text-gray-400"
100
+ }`}
101
+ >
102
+ {closestEntry.type}
103
+ </span>
104
+ </div>
105
+ </div>
106
+ <p className="text-[11px] leading-relaxed text-gray-400 break-words">{closestEntry.content}</p>
107
+ </div>
108
+ )}
109
+
110
+ {!requestText && !closestEntry && (
111
+ <div className="text-gray-500 text-xs">No owner prompt found for this mission.</div>
112
+ )}
113
+ </div>
114
+ );
115
+ }
116
+
117
+ // All-history fallback (no mission selected)
118
+ if (!ownerHistory || ownerHistory.length === 0) {
119
+ return <div className="text-gray-500">No prompt history found in progress.log.</div>;
120
+ }
121
+ return (
122
+ <div className="space-y-2">
123
+ <div className="mb-3 flex items-center gap-2">
124
+ <span className="font-mono text-[9px] uppercase tracking-[0.2em] text-gray-500">from</span>
125
+ <span className="rounded bg-amber-500/10 border border-amber-500/30 px-2 py-0.5 text-[10px] text-amber-300 font-mono">
126
+ Owner
127
+ </span>
128
+ <span className="font-mono text-[9px] text-gray-600">→</span>
129
+ <span className="rounded bg-cyan-400/10 border border-cyan-400/30 px-2 py-0.5 text-[10px] text-cyan-300 font-mono">
130
+ CEO
131
+ </span>
132
+ </div>
133
+ {ownerHistory.map((entry, i) => (
134
+ <div
135
+ key={i}
136
+ 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" : ""}`}
137
+ onClick={onEntryClick ? () => onEntryClick(entry) : undefined}
138
+ >
139
+ <div className="flex items-center justify-between gap-2 mb-1">
140
+ <span className="font-mono text-[9px] text-gray-500">{entry.ts}</span>
141
+ <span
142
+ className={`font-mono text-[9px] px-1.5 py-0.5 rounded ${
143
+ entry.type === "goal"
144
+ ? "bg-emerald-500/15 text-emerald-300"
145
+ : entry.type === "submission"
146
+ ? "bg-sky-500/15 text-sky-300"
147
+ : entry.type === "hot-fix"
148
+ ? "bg-rose-500/15 text-rose-300"
149
+ : "bg-gray-500/15 text-gray-400"
150
+ }`}
151
+ >
152
+ {entry.type}
153
+ </span>
154
+ </div>
155
+ <p className="text-[11px] leading-relaxed text-gray-300 break-words">{entry.content}</p>
156
+ </div>
157
+ ))}
158
+ </div>
159
+ );
160
+ }