@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.
- package/README.md +40 -0
- package/apps/harness-dashboard/app/api/env/route.ts +62 -0
- package/apps/harness-dashboard/app/api/log/route.ts +19 -0
- package/apps/harness-dashboard/app/api/missions/route.ts +15 -0
- package/apps/harness-dashboard/app/api/snapshot/route.ts +15 -0
- package/apps/harness-dashboard/app/api/stream/route.ts +71 -0
- package/apps/harness-dashboard/app/globals.css +17 -0
- package/apps/harness-dashboard/app/layout.tsx +29 -0
- package/apps/harness-dashboard/app/page.tsx +35 -0
- package/apps/harness-dashboard/components/Drawer.tsx +97 -0
- package/apps/harness-dashboard/components/Header.tsx +40 -0
- package/apps/harness-dashboard/components/MissionTimeline.tsx +210 -0
- package/apps/harness-dashboard/components/OrgTree.tsx +330 -0
- package/apps/harness-dashboard/components/Scene.tsx +563 -0
- package/apps/harness-dashboard/components/Wordmark.tsx +14 -0
- package/apps/harness-dashboard/components/drawer/AgentLogTab.tsx +56 -0
- package/apps/harness-dashboard/components/drawer/GotchasTab.tsx +89 -0
- package/apps/harness-dashboard/components/drawer/HypothesisTab.tsx +42 -0
- package/apps/harness-dashboard/components/drawer/IncidentsTab.tsx +50 -0
- package/apps/harness-dashboard/components/drawer/MissionDocTab.tsx +92 -0
- package/apps/harness-dashboard/components/drawer/MissionFlowTab.tsx +517 -0
- package/apps/harness-dashboard/components/drawer/OwnerHistoryTab.tsx +156 -0
- package/apps/harness-dashboard/components/drawer/RoomMetricsTab.tsx +90 -0
- package/apps/harness-dashboard/components/drawer/TracksTab.tsx +47 -0
- package/apps/harness-dashboard/components/three/ArchiveBoxes.tsx +95 -0
- package/apps/harness-dashboard/components/three/DeptBoard.tsx +175 -0
- package/apps/harness-dashboard/components/three/Floor3D.tsx +404 -0
- package/apps/harness-dashboard/components/three/GoalCard3D.tsx +123 -0
- package/apps/harness-dashboard/components/three/HypothesisCards.tsx +71 -0
- package/apps/harness-dashboard/components/three/MeetingWhiteboard.tsx +98 -0
- package/apps/harness-dashboard/components/three/Minifig3D.tsx +391 -0
- package/apps/harness-dashboard/components/three/OpsSiren.tsx +99 -0
- package/apps/harness-dashboard/components/three/Stage3D.tsx +104 -0
- package/apps/harness-dashboard/components/three/TrackRibbons.tsx +81 -0
- package/apps/harness-dashboard/hooks/useHarnessStream.ts +53 -0
- package/apps/harness-dashboard/lib/__tests__/company-flow.test.ts +270 -0
- package/apps/harness-dashboard/lib/__tests__/harness-state.test.ts +194 -0
- package/apps/harness-dashboard/lib/__tests__/i18n.test.ts +16 -0
- package/apps/harness-dashboard/lib/__tests__/iso.test.ts +42 -0
- package/apps/harness-dashboard/lib/__tests__/safe-json.test.ts +46 -0
- package/apps/harness-dashboard/lib/__tests__/state-mapping.test.ts +159 -0
- package/apps/harness-dashboard/lib/__tests__/watcher.test.ts +48 -0
- package/apps/harness-dashboard/lib/agent-roster.ts +42 -0
- package/apps/harness-dashboard/lib/furniture.ts +165 -0
- package/apps/harness-dashboard/lib/harness-root.ts +20 -0
- package/apps/harness-dashboard/lib/harness-state.ts +1162 -0
- package/apps/harness-dashboard/lib/i18n.ts +26 -0
- package/apps/harness-dashboard/lib/iso.ts +150 -0
- package/apps/harness-dashboard/lib/log-filter.ts +23 -0
- package/apps/harness-dashboard/lib/markdown.tsx +306 -0
- package/apps/harness-dashboard/lib/path-planning.ts +72 -0
- package/apps/harness-dashboard/lib/safe-json.ts +24 -0
- package/apps/harness-dashboard/lib/state-mapping.ts +126 -0
- package/apps/harness-dashboard/lib/types.ts +329 -0
- package/apps/harness-dashboard/lib/watcher.ts +64 -0
- package/apps/harness-dashboard/next-env.d.ts +6 -0
- package/apps/harness-dashboard/next.config.mjs +9 -0
- package/apps/harness-dashboard/package-lock.json +4280 -0
- package/apps/harness-dashboard/package.json +38 -0
- package/apps/harness-dashboard/playwright.config.ts +23 -0
- package/apps/harness-dashboard/postcss.config.js +6 -0
- package/apps/harness-dashboard/tailwind.config.ts +49 -0
- package/apps/harness-dashboard/tsconfig.json +24 -0
- package/apps/harness-dashboard/vitest.config.ts +15 -0
- package/package.json +11 -1
- 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
|
+
}
|