@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
package/README.md CHANGED
@@ -128,6 +128,45 @@ The harness ships with a real-time dashboard that reads `.harness/documents/` di
128
128
  bash scripts/harness-dashboard-up.sh
129
129
  ```
130
130
 
131
+ Run on a different port:
132
+
133
+ ```bash
134
+ bash scripts/harness-dashboard-up.sh --port 3002
135
+ ```
136
+
137
+ Force-refresh the isolated dashboard cache when an older dashboard is still shown:
138
+
139
+ ```bash
140
+ bash scripts/harness-dashboard-up.sh --port 3002 --reinstall
141
+ ```
142
+
143
+ Dashboard options:
144
+
145
+ | Option | Role |
146
+ |---|---|
147
+ | `--port <port>` | Run Brick Office on a custom port, e.g. `3002` |
148
+ | `--reinstall` | Delete and rebuild the isolated dashboard cache under `~/.walwal-harness/dashboard/` |
149
+
150
+ Existing users should update and migrate from the target project:
151
+
152
+ ```bash
153
+ npm i @walwal-harness/cli@latest
154
+ npx walwal-harness migrate
155
+ ```
156
+
157
+ If command discovery or installed harness files look stale, refresh the install contract:
158
+
159
+ ```bash
160
+ npx walwal-harness init --force
161
+ ```
162
+
163
+ For an explicit project root:
164
+
165
+ ```bash
166
+ npx walwal-harness migrate --project-root /path/to/project
167
+ npx walwal-harness init --force --project-root /path/to/project
168
+ ```
169
+
131
170
  Features:
132
171
 
133
172
  - **Org Tree** — live status of Owner → CEO → CXX → Workers hierarchy
@@ -169,6 +208,7 @@ harness-hiring → register and onboard worker
169
208
 
170
209
  | Version | Summary |
171
210
  |---|---|
211
+ | 7.1.9 | Dashboard is packaged with npm and auto-syncs isolated cache by package version; README documents dashboard port/reinstall and update/migrate commands |
172
212
  | 7.1.8 | HR-Resource: replace China-platform-specific workers with global equivalents; refine cross-border/video/livestream skills |
173
213
  | 7.1.7 | Implementation Notes mandatory in all CXX docs and worker reports; harness-worker-evidence-validate.sh |
174
214
  | 7.1.6 | CXX hierarchy enforcement: CEO→CXX-only gate, CTO prerequisite gate, CQO worker-evidence mandate; dashboard gotchas tab, mission-specific history tab, worker file list in flow |
@@ -0,0 +1,62 @@
1
+ import { NextResponse } from "next/server";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { resolveHarnessRoot } from "@/lib/harness-root";
5
+
6
+ export const dynamic = "force-dynamic";
7
+ export const runtime = "nodejs";
8
+ export const revalidate = 0;
9
+
10
+ const ALLOWED_FILES = new Set([
11
+ ".env",
12
+ ".env.local",
13
+ ".env.production",
14
+ ".env.development",
15
+ ".env.example",
16
+ ]);
17
+
18
+ function isSafeKey(key: string): boolean {
19
+ return /^[A-Z_][A-Z0-9_]*$/i.test(key);
20
+ }
21
+
22
+ function quoteValue(value: string): string {
23
+ if (/^[A-Za-z0-9_./:@+-]*$/.test(value)) return value;
24
+ return JSON.stringify(value);
25
+ }
26
+
27
+ export async function POST(request: Request) {
28
+ const body = await request.json().catch(() => null);
29
+ const file = String(body?.file ?? "");
30
+ const key = String(body?.key ?? "");
31
+ const value = String(body?.value ?? "");
32
+
33
+ if (!ALLOWED_FILES.has(file) || !isSafeKey(key)) {
34
+ return NextResponse.json({ ok: false, error: "invalid env target" }, { status: 400 });
35
+ }
36
+
37
+ const root = resolveHarnessRoot();
38
+ const target = path.join(root, file);
39
+ if (!existsSync(target)) {
40
+ return NextResponse.json({ ok: false, error: "env file not found" }, { status: 404 });
41
+ }
42
+
43
+ const original = readFileSync(target, "utf-8");
44
+ const lines = original.split("\n");
45
+ const nextLine = `${key}=${quoteValue(value)}`;
46
+ let replaced = false;
47
+ const next = lines.map((line) => {
48
+ if (line.trimStart().startsWith("#")) return line;
49
+ const idx = line.indexOf("=");
50
+ if (idx < 0) return line;
51
+ if (line.slice(0, idx).trim() !== key) return line;
52
+ replaced = true;
53
+ return nextLine;
54
+ });
55
+ if (!replaced) next.push(nextLine);
56
+ writeFileSync(target, next.join("\n").replace(/\n*$/, "\n"));
57
+
58
+ return NextResponse.json(
59
+ { ok: true, file, key, replaced },
60
+ { status: 200, headers: { "Cache-Control": "no-store" } }
61
+ );
62
+ }
@@ -0,0 +1,19 @@
1
+ import { NextResponse } from "next/server";
2
+ import { readProgressLog } from "@/lib/log-filter";
3
+ import { resolveHarnessRoot } from "@/lib/harness-root";
4
+
5
+ export const dynamic = "force-dynamic";
6
+ export const runtime = "nodejs";
7
+ export const revalidate = 0;
8
+
9
+ export async function GET(request: Request) {
10
+ const url = new URL(request.url);
11
+ const agentId = url.searchParams.get("agent") ?? undefined;
12
+ const limitParam = url.searchParams.get("limit");
13
+ const limit = limitParam ? Math.max(1, Math.min(500, parseInt(limitParam, 10))) : 50;
14
+ const lines = readProgressLog(resolveHarnessRoot(), { agentId, limit });
15
+ return NextResponse.json(
16
+ { agentId: agentId ?? null, lines },
17
+ { status: 200, headers: { "Cache-Control": "no-store" } }
18
+ );
19
+ }
@@ -0,0 +1,15 @@
1
+ import { NextResponse } from "next/server";
2
+ import { readHarnessState } from "@/lib/harness-state";
3
+ import { resolveHarnessRoot } from "@/lib/harness-root";
4
+
5
+ export const dynamic = "force-dynamic";
6
+ export const runtime = "nodejs";
7
+ export const revalidate = 0;
8
+
9
+ export async function GET() {
10
+ const snapshot = readHarnessState(resolveHarnessRoot());
11
+ return NextResponse.json(
12
+ { missions: snapshot.missions },
13
+ { status: 200, headers: { "Cache-Control": "no-store" } }
14
+ );
15
+ }
@@ -0,0 +1,15 @@
1
+ import { NextResponse } from "next/server";
2
+ import { readHarnessState } from "@/lib/harness-state";
3
+ import { resolveHarnessRoot } from "@/lib/harness-root";
4
+
5
+ export const dynamic = "force-dynamic";
6
+ export const revalidate = 0;
7
+
8
+ export async function GET() {
9
+ const root = resolveHarnessRoot();
10
+ const snapshot = readHarnessState(root);
11
+ return NextResponse.json(snapshot, {
12
+ status: 200,
13
+ headers: { "Cache-Control": "no-store" },
14
+ });
15
+ }
@@ -0,0 +1,71 @@
1
+ import { readHarnessState } from "@/lib/harness-state";
2
+ import { resolveHarnessRoot } from "@/lib/harness-root";
3
+ import { watchHarness } from "@/lib/watcher";
4
+
5
+ export const dynamic = "force-dynamic";
6
+ export const runtime = "nodejs";
7
+ export const revalidate = 0;
8
+
9
+ const SSE_HEADERS = {
10
+ "Content-Type": "text/event-stream",
11
+ "Cache-Control": "no-cache, no-transform",
12
+ Connection: "keep-alive",
13
+ } as const;
14
+
15
+ function sseFrame(snapshotJson: string): string {
16
+ // Each SSE frame ends with a blank line. Default event = "message".
17
+ return `data: ${snapshotJson}\n\n`;
18
+ }
19
+
20
+ export async function GET(request: Request) {
21
+ const root = resolveHarnessRoot();
22
+ const encoder = new TextEncoder();
23
+
24
+ const stream = new ReadableStream<Uint8Array>({
25
+ start(controller) {
26
+ const send = () => {
27
+ try {
28
+ const snap = readHarnessState(root);
29
+ controller.enqueue(encoder.encode(sseFrame(JSON.stringify(snap))));
30
+ } catch (err) {
31
+ // readHarnessState 는 이미 errorBanner 로 fallback 하지만, 만약을
32
+ // 위해 여기서도 안전하게 처리.
33
+ const fallback = JSON.stringify({
34
+ version: "1.1.0",
35
+ ts: new Date().toISOString(),
36
+ errorBanner: {
37
+ level: "error",
38
+ message_ko: "스트림 처리 중 오류가 발생했습니다.",
39
+ message_en: "Stream processing error.",
40
+ },
41
+ });
42
+ controller.enqueue(encoder.encode(sseFrame(fallback)));
43
+ }
44
+ };
45
+
46
+ // Initial snapshot.
47
+ send();
48
+
49
+ const handle = watchHarness(root, () => send());
50
+
51
+ const cleanup = async () => {
52
+ try {
53
+ await handle.close();
54
+ } catch {
55
+ /* ignore */
56
+ }
57
+ try {
58
+ controller.close();
59
+ } catch {
60
+ /* ignore — already closed */
61
+ }
62
+ };
63
+
64
+ request.signal.addEventListener("abort", () => {
65
+ void cleanup();
66
+ });
67
+ },
68
+ });
69
+
70
+ return new Response(stream, { status: 200, headers: SSE_HEADERS });
71
+ }
@@ -0,0 +1,17 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ html,
6
+ body {
7
+ background:
8
+ radial-gradient(circle at 12% 0%, rgba(34, 211, 238, 0.08), transparent 26rem),
9
+ radial-gradient(circle at 88% 6%, rgba(251, 191, 36, 0.06), transparent 24rem),
10
+ #101216;
11
+ color: #e5e7eb;
12
+ }
13
+
14
+ body {
15
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
16
+ sans-serif;
17
+ }
@@ -0,0 +1,29 @@
1
+ import path from "node:path";
2
+ import type { Metadata } from "next";
3
+ import "./globals.css";
4
+ import { Header } from "@/components/Header";
5
+ import { resolveHarnessRoot } from "@/lib/harness-root";
6
+
7
+ export async function generateMetadata(): Promise<Metadata> {
8
+ const root = resolveHarnessRoot();
9
+ const name = path.basename(root);
10
+ return {
11
+ title: `Brick Office — ${name}`,
12
+ description: `walwal-harness 라이브 운영 대시보드 — ${name}`,
13
+ };
14
+ }
15
+
16
+ export default function RootLayout({
17
+ children,
18
+ }: {
19
+ children: React.ReactNode;
20
+ }) {
21
+ return (
22
+ <html lang="ko">
23
+ <body>
24
+ <Header />
25
+ {children}
26
+ </body>
27
+ </html>
28
+ );
29
+ }
@@ -0,0 +1,35 @@
1
+ import { readHarnessState } from "@/lib/harness-state";
2
+ import { resolveHarnessRoot } from "@/lib/harness-root";
3
+ import { Scene } from "@/components/Scene";
4
+
5
+ export const dynamic = "force-dynamic";
6
+ export const revalidate = 0;
7
+
8
+ export default function Page() {
9
+ const snapshot = readHarnessState(resolveHarnessRoot());
10
+ const banner = snapshot.errorBanner;
11
+
12
+ return (
13
+ <main className="min-h-[100dvh]">
14
+ {banner && (
15
+ <div
16
+ role="alert"
17
+ data-testid="error-banner"
18
+ className={`mx-auto max-w-[1280px] mt-4 px-4 py-3 rounded border text-sm ${
19
+ banner.level === "error"
20
+ ? "border-aura-alert/60 bg-aura-alert/10 text-aura-alert"
21
+ : "border-aura-talking/60 bg-aura-talking/10 text-aura-talking"
22
+ }`}
23
+ >
24
+ <div>{banner.message_ko}</div>
25
+ <div className="opacity-60 text-xs mt-1">{banner.message_en}</div>
26
+ </div>
27
+ )}
28
+ <Scene snapshot={snapshot} />
29
+ <footer className="mx-auto max-w-[1280px] px-6 pb-8 text-xs text-gray-500 font-mono flex items-center justify-between">
30
+ <span>Sprint 1 / Phase C-1 — Foundation</span>
31
+ <span data-testid="snapshot-ts">{snapshot.ts}</span>
32
+ </footer>
33
+ </main>
34
+ );
35
+ }
@@ -0,0 +1,97 @@
1
+ "use client";
2
+ import { useEffect } from "react";
3
+
4
+ export type DrawerTab =
5
+ | "mission-flow" // Mission document hierarchy (PRIMARY)
6
+ | "history" // Owner prompt history
7
+ | "gotchas" // Harness gotcha knowledge base
8
+ | "mission-doc" // Single doc markdown view
9
+ | "logs"; // Agent log (technical)
10
+
11
+ interface DrawerProps {
12
+ open: boolean;
13
+ tab: DrawerTab;
14
+ title: string;
15
+ onClose: () => void;
16
+ onTabChange: (tab: DrawerTab) => void;
17
+ children: React.ReactNode;
18
+ }
19
+
20
+ const TABS: Array<{ id: DrawerTab; label: string }> = [
21
+ { id: "mission-flow", label: "Mission Flow" },
22
+ { id: "history", label: "History" },
23
+ { id: "gotchas", label: "Gotchas" },
24
+ { id: "mission-doc", label: "Document" },
25
+ { id: "logs", label: "Agent Log" },
26
+ ];
27
+
28
+ export function Drawer({ open, tab, title, onClose, onTabChange, children }: DrawerProps) {
29
+ useEffect(() => {
30
+ if (!open) return;
31
+ const onKey = (e: KeyboardEvent) => {
32
+ if (e.key === "Escape") onClose();
33
+ };
34
+ window.addEventListener("keydown", onKey);
35
+ return () => window.removeEventListener("keydown", onKey);
36
+ }, [open, onClose]);
37
+
38
+ return (
39
+ <>
40
+ {/* Click-outside backdrop. Pointer-events flip with `open`. */}
41
+ <div
42
+ data-testid="drawer-backdrop"
43
+ aria-hidden="true"
44
+ onClick={onClose}
45
+ className={`fixed inset-0 bg-black/30 transition-opacity duration-200 ${
46
+ open ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
47
+ }`}
48
+ />
49
+ <aside
50
+ role="complementary"
51
+ aria-label="Detail panel"
52
+ aria-hidden={!open}
53
+ data-testid="drawer"
54
+ data-open={open}
55
+ className={`fixed top-0 right-0 h-[100dvh] w-full sm:w-[50vw] sm:min-w-[600px] bg-brick-bg border-l border-brick-wall shadow-2xl flex flex-col transition-transform duration-300 ease-out ${
56
+ open ? "translate-x-0" : "translate-x-full"
57
+ }`}
58
+ >
59
+ <header className="flex items-center justify-between border-b border-brick-wall px-4 py-3">
60
+ <h2 className="text-sm font-mono uppercase tracking-widest text-gray-200">
61
+ {title}
62
+ </h2>
63
+ <button
64
+ type="button"
65
+ onClick={onClose}
66
+ aria-label="Close panel"
67
+ data-testid="drawer-close"
68
+ className="rounded p-1 text-gray-400 hover:text-gray-100 hover:bg-brick-wall/40"
69
+ >
70
+ ✕
71
+ </button>
72
+ </header>
73
+ <nav className="flex flex-wrap border-b border-brick-wall text-[11px] font-mono">
74
+ {TABS.map((t) => (
75
+ <button
76
+ key={t.id}
77
+ type="button"
78
+ data-testid={`drawer-tab-${t.id}`}
79
+ data-active={t.id === tab}
80
+ onClick={() => onTabChange(t.id)}
81
+ className={`min-w-[33%] flex-1 px-2 py-2 transition-colors ${
82
+ t.id === tab
83
+ ? "bg-brick-wall/50 text-gray-100 border-b-2 border-aura-typing"
84
+ : "text-gray-500 hover:text-gray-200"
85
+ }`}
86
+ >
87
+ {t.label}
88
+ </button>
89
+ ))}
90
+ </nav>
91
+ <div className="flex-1 overflow-y-auto px-4 py-3 text-xs text-gray-200 font-mono">
92
+ {children}
93
+ </div>
94
+ </aside>
95
+ </>
96
+ );
97
+ }
@@ -0,0 +1,40 @@
1
+ import path from "node:path";
2
+ import { Wordmark } from "./Wordmark";
3
+ import { resolveHarnessRoot } from "@/lib/harness-root";
4
+
5
+ export function Header() {
6
+ const root = resolveHarnessRoot();
7
+ const projectName = path.basename(root);
8
+ return (
9
+ <header className="border-b border-brick-wall bg-brick-bg/80 backdrop-blur">
10
+ <div className="mx-auto flex max-w-[1280px] items-center justify-between px-6 py-4">
11
+ <div className="flex items-baseline gap-4">
12
+ <Wordmark />
13
+ <span className="hidden items-baseline gap-2 sm:inline-flex">
14
+ <span
15
+ className="rounded bg-brick-wall/40 px-2 py-0.5 font-mono text-xs font-semibold text-amber-300"
16
+ title={root}
17
+ >
18
+ {projectName}
19
+ </span>
20
+ <span className="text-xs text-gray-400">walwal-harness 라이브 운영 대시보드</span>
21
+ </span>
22
+ </div>
23
+ <nav className="flex items-center gap-3 text-xs font-mono text-gray-500">
24
+ <span className="inline-flex items-center gap-1">
25
+ <span className="size-2 rounded-full bg-aura-idle" /> idle
26
+ </span>
27
+ <span className="inline-flex items-center gap-1">
28
+ <span className="size-2 rounded-full bg-aura-typing" /> typing
29
+ </span>
30
+ <span className="inline-flex items-center gap-1">
31
+ <span className="size-2 rounded-full bg-aura-talking" /> talking
32
+ </span>
33
+ <span className="inline-flex items-center gap-1">
34
+ <span className="size-2 rounded-full bg-aura-alert" /> alert
35
+ </span>
36
+ </nav>
37
+ </div>
38
+ </header>
39
+ );
40
+ }
@@ -0,0 +1,210 @@
1
+ "use client";
2
+ import type { MissionDoc } from "@/lib/types";
3
+
4
+ interface Props {
5
+ missions: MissionDoc[];
6
+ activeMissionId: string | null;
7
+ onMissionClick: (mission: MissionDoc) => void;
8
+ }
9
+
10
+ type ChipRole =
11
+ | "owner"
12
+ | "ceo"
13
+ | "coo"
14
+ | "cdo"
15
+ | "cto"
16
+ | "cqo"
17
+ | "ops"
18
+ | "worker";
19
+
20
+ function chipColor(role: ChipRole): string {
21
+ switch (role) {
22
+ case "owner":
23
+ return "bg-amber-500/15 text-amber-300 border-amber-500/30";
24
+ case "ceo":
25
+ return "bg-cyan-400/15 text-cyan-300 border-cyan-400/30";
26
+ case "coo":
27
+ return "bg-emerald-500/15 text-emerald-300 border-emerald-500/30";
28
+ case "cdo":
29
+ return "bg-purple-500/15 text-purple-300 border-purple-500/30";
30
+ case "cto":
31
+ return "bg-blue-400/15 text-blue-300 border-blue-400/30";
32
+ case "cqo":
33
+ return "bg-amber-400/15 text-amber-300 border-amber-400/30";
34
+ case "ops":
35
+ return "bg-rose-400/15 text-rose-300 border-rose-400/30";
36
+ case "worker":
37
+ return "bg-gray-500/15 text-gray-300 border-gray-500/30";
38
+ default:
39
+ return "bg-gray-500/15 text-gray-400 border-gray-600/30";
40
+ }
41
+ }
42
+
43
+ function Chip({
44
+ role,
45
+ label,
46
+ badge,
47
+ }: {
48
+ role: ChipRole;
49
+ label: string;
50
+ badge?: string;
51
+ }) {
52
+ return (
53
+ <span
54
+ className={`inline-flex items-center gap-1 rounded border px-1.5 py-0.5 font-mono text-[9px] ${chipColor(role)}`}
55
+ >
56
+ {label}
57
+ {badge && <span className="opacity-80">{badge}</span>}
58
+ </span>
59
+ );
60
+ }
61
+
62
+ function Arrow() {
63
+ return <span className="text-[10px] text-gray-600 shrink-0">→</span>;
64
+ }
65
+
66
+ function buildFlowChain(mission: MissionDoc): React.ReactNode {
67
+ // Pre-worker CXX: planning phase (before implementation)
68
+ const preWorker: Array<{ key: "coo" | "cdo" | "cto"; role: ChipRole }> = [
69
+ { key: "coo", role: "coo" },
70
+ { key: "cdo", role: "cdo" },
71
+ { key: "cto", role: "cto" },
72
+ ];
73
+ // Post-worker CXX: verification phase (after implementation)
74
+ const postWorker: Array<{ key: "cqo" | "ops"; role: ChipRole }> = [
75
+ { key: "cqo", role: "cqo" },
76
+ { key: "ops", role: "ops" },
77
+ ];
78
+
79
+ const isCqoAccepted =
80
+ typeof mission.cqo === "string" && mission.cqo.includes("ACCEPTED");
81
+
82
+ const chips: React.ReactNode[] = [];
83
+ let key = 0;
84
+
85
+ chips.push(<Chip key={key++} role="owner" label="Owner" />);
86
+ chips.push(<Arrow key={key++} />);
87
+ chips.push(<Chip key={key++} role="ceo" label="CEO" />);
88
+
89
+ // Planning phase: COO, CDO, CTO
90
+ for (const { key: roleKey, role } of preWorker) {
91
+ if (mission.cxxPresent.includes(roleKey)) {
92
+ chips.push(<Arrow key={key++} />);
93
+ chips.push(<Chip key={key++} role={role} label={roleKey.toUpperCase()} />);
94
+ }
95
+ }
96
+
97
+ // CTO-owned implementation workers. Legacy flat workers remain visible here
98
+ // with owner=unknown so protocol bypasses are not hidden.
99
+ for (const worker of mission.workers.filter((w) => w.owner === "cto" || w.owner === "unknown")) {
100
+ chips.push(<Arrow key={key++} />);
101
+ const badge = worker.status === "COMPLETE" ? "✅" : "⏳";
102
+ const shortName =
103
+ worker.name.length > 12 ? worker.name.slice(0, 12) + "…" : worker.name;
104
+ chips.push(
105
+ <Chip key={key++} role="worker" label={shortName} badge={badge} />
106
+ );
107
+ }
108
+
109
+ // Verification phase: CQO, OPS
110
+ for (const { key: roleKey, role } of postWorker) {
111
+ if (mission.cxxPresent.includes(roleKey)) {
112
+ chips.push(<Arrow key={key++} />);
113
+ const badge = roleKey === "cqo" && isCqoAccepted ? "✅" : undefined;
114
+ chips.push(
115
+ <Chip key={key++} role={role} label={roleKey.toUpperCase()} badge={badge} />
116
+ );
117
+ if (roleKey === "cqo") {
118
+ for (const worker of mission.workers.filter((w) => w.owner === "cqo")) {
119
+ chips.push(<Arrow key={key++} />);
120
+ const workerBadge = worker.status === "COMPLETE" ? "✅" : "⏳";
121
+ const shortName =
122
+ worker.name.length > 12 ? worker.name.slice(0, 12) + "…" : worker.name;
123
+ chips.push(
124
+ <Chip key={key++} role="worker" label={shortName} badge={workerBadge} />
125
+ );
126
+ }
127
+ }
128
+ }
129
+ }
130
+
131
+ return (
132
+ <div className="flex flex-wrap items-center gap-1 min-w-0">{chips}</div>
133
+ );
134
+ }
135
+
136
+ export function MissionTimeline({
137
+ missions,
138
+ activeMissionId,
139
+ onMissionClick,
140
+ }: Props) {
141
+ if (!missions.length) {
142
+ return (
143
+ <div className="rounded border border-dashed border-gray-700 px-3 py-4 text-center text-[11px] text-gray-500">
144
+ No missions found in .harness/documents/
145
+ </div>
146
+ );
147
+ }
148
+
149
+ return (
150
+ <div className="space-y-1.5">
151
+ {missions.map((mission) => {
152
+ const isActive = mission.missionId === activeMissionId;
153
+ const typeBadge =
154
+ mission.type === "hotfix"
155
+ ? "[🔥 hot-fix]"
156
+ : mission.type === "feature"
157
+ ? "[✦ goal]"
158
+ : "[— unknown]";
159
+ const shortId =
160
+ mission.missionId.length > 28
161
+ ? mission.missionId.slice(0, 28) + "…"
162
+ : mission.missionId;
163
+
164
+ let dateStr = "—";
165
+ try {
166
+ dateStr = new Date(mission.ts).toLocaleDateString(undefined, {
167
+ month: "short",
168
+ day: "numeric",
169
+ });
170
+ } catch {
171
+ // ignore invalid date
172
+ }
173
+
174
+ return (
175
+ <button
176
+ key={mission.missionId}
177
+ type="button"
178
+ onClick={() => onMissionClick(mission)}
179
+ className={`w-full rounded border px-3 py-2.5 text-left transition-colors ${
180
+ isActive
181
+ ? "border-cyan-400/40 bg-cyan-400/5"
182
+ : "border-gray-700/40 bg-black/10 hover:border-gray-600/50"
183
+ }`}
184
+ >
185
+ <div className="flex items-start justify-between gap-2 mb-1.5">
186
+ <div className="flex items-center gap-2 min-w-0">
187
+ <span
188
+ className={`shrink-0 font-mono text-[9px] rounded px-1.5 py-0.5 border ${
189
+ mission.type === "hotfix"
190
+ ? "bg-rose-500/15 text-rose-300 border-rose-500/30"
191
+ : "bg-emerald-500/15 text-emerald-300 border-emerald-500/30"
192
+ }`}
193
+ >
194
+ {typeBadge}
195
+ </span>
196
+ <span className="font-mono text-[10px] text-gray-200 truncate">
197
+ {shortId}
198
+ </span>
199
+ </div>
200
+ <span className="shrink-0 font-mono text-[9px] text-gray-500">
201
+ {dateStr}
202
+ </span>
203
+ </div>
204
+ {buildFlowChain(mission)}
205
+ </button>
206
+ );
207
+ })}
208
+ </div>
209
+ );
210
+ }