@walwal-harness/cli 7.1.7 → 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 (89) hide show
  1. package/HR-Resource/index.json +19 -43
  2. package/HR-Resource/marketing-marketing-cross-border-ecommerce/SKILL.md +108 -223
  3. package/HR-Resource/marketing-marketing-global-marketplace-operator/SKILL.md +175 -0
  4. package/HR-Resource/marketing-marketing-google-seo-specialist/SKILL.md +91 -0
  5. package/HR-Resource/marketing-marketing-instagram-commerce-strategist/SKILL.md +81 -0
  6. package/HR-Resource/marketing-marketing-korea-global-market-localization-strategist/SKILL.md +186 -0
  7. package/HR-Resource/marketing-marketing-livestream-commerce-coach/SKILL.md +88 -272
  8. package/HR-Resource/marketing-marketing-naver-seo-specialist/SKILL.md +88 -0
  9. package/HR-Resource/marketing-marketing-short-video-editing-coach/SKILL.md +99 -395
  10. package/HR-Resource/marketing-marketing-youtube-shopping-strategist/SKILL.md +82 -0
  11. package/README.md +41 -0
  12. package/apps/harness-dashboard/app/api/env/route.ts +62 -0
  13. package/apps/harness-dashboard/app/api/log/route.ts +19 -0
  14. package/apps/harness-dashboard/app/api/missions/route.ts +15 -0
  15. package/apps/harness-dashboard/app/api/snapshot/route.ts +15 -0
  16. package/apps/harness-dashboard/app/api/stream/route.ts +71 -0
  17. package/apps/harness-dashboard/app/globals.css +17 -0
  18. package/apps/harness-dashboard/app/layout.tsx +29 -0
  19. package/apps/harness-dashboard/app/page.tsx +35 -0
  20. package/apps/harness-dashboard/components/Drawer.tsx +97 -0
  21. package/apps/harness-dashboard/components/Header.tsx +40 -0
  22. package/apps/harness-dashboard/components/MissionTimeline.tsx +210 -0
  23. package/apps/harness-dashboard/components/OrgTree.tsx +330 -0
  24. package/apps/harness-dashboard/components/Scene.tsx +563 -0
  25. package/apps/harness-dashboard/components/Wordmark.tsx +14 -0
  26. package/apps/harness-dashboard/components/drawer/AgentLogTab.tsx +56 -0
  27. package/apps/harness-dashboard/components/drawer/GotchasTab.tsx +89 -0
  28. package/apps/harness-dashboard/components/drawer/HypothesisTab.tsx +42 -0
  29. package/apps/harness-dashboard/components/drawer/IncidentsTab.tsx +50 -0
  30. package/apps/harness-dashboard/components/drawer/MissionDocTab.tsx +92 -0
  31. package/apps/harness-dashboard/components/drawer/MissionFlowTab.tsx +517 -0
  32. package/apps/harness-dashboard/components/drawer/OwnerHistoryTab.tsx +156 -0
  33. package/apps/harness-dashboard/components/drawer/RoomMetricsTab.tsx +90 -0
  34. package/apps/harness-dashboard/components/drawer/TracksTab.tsx +47 -0
  35. package/apps/harness-dashboard/components/three/ArchiveBoxes.tsx +95 -0
  36. package/apps/harness-dashboard/components/three/DeptBoard.tsx +175 -0
  37. package/apps/harness-dashboard/components/three/Floor3D.tsx +404 -0
  38. package/apps/harness-dashboard/components/three/GoalCard3D.tsx +123 -0
  39. package/apps/harness-dashboard/components/three/HypothesisCards.tsx +71 -0
  40. package/apps/harness-dashboard/components/three/MeetingWhiteboard.tsx +98 -0
  41. package/apps/harness-dashboard/components/three/Minifig3D.tsx +391 -0
  42. package/apps/harness-dashboard/components/three/OpsSiren.tsx +99 -0
  43. package/apps/harness-dashboard/components/three/Stage3D.tsx +104 -0
  44. package/apps/harness-dashboard/components/three/TrackRibbons.tsx +81 -0
  45. package/apps/harness-dashboard/hooks/useHarnessStream.ts +53 -0
  46. package/apps/harness-dashboard/lib/__tests__/company-flow.test.ts +270 -0
  47. package/apps/harness-dashboard/lib/__tests__/harness-state.test.ts +194 -0
  48. package/apps/harness-dashboard/lib/__tests__/i18n.test.ts +16 -0
  49. package/apps/harness-dashboard/lib/__tests__/iso.test.ts +42 -0
  50. package/apps/harness-dashboard/lib/__tests__/safe-json.test.ts +46 -0
  51. package/apps/harness-dashboard/lib/__tests__/state-mapping.test.ts +159 -0
  52. package/apps/harness-dashboard/lib/__tests__/watcher.test.ts +48 -0
  53. package/apps/harness-dashboard/lib/agent-roster.ts +42 -0
  54. package/apps/harness-dashboard/lib/furniture.ts +165 -0
  55. package/apps/harness-dashboard/lib/harness-root.ts +20 -0
  56. package/apps/harness-dashboard/lib/harness-state.ts +1162 -0
  57. package/apps/harness-dashboard/lib/i18n.ts +26 -0
  58. package/apps/harness-dashboard/lib/iso.ts +150 -0
  59. package/apps/harness-dashboard/lib/log-filter.ts +23 -0
  60. package/apps/harness-dashboard/lib/markdown.tsx +306 -0
  61. package/apps/harness-dashboard/lib/path-planning.ts +72 -0
  62. package/apps/harness-dashboard/lib/safe-json.ts +24 -0
  63. package/apps/harness-dashboard/lib/state-mapping.ts +126 -0
  64. package/apps/harness-dashboard/lib/types.ts +329 -0
  65. package/apps/harness-dashboard/lib/watcher.ts +64 -0
  66. package/apps/harness-dashboard/next-env.d.ts +6 -0
  67. package/apps/harness-dashboard/next.config.mjs +9 -0
  68. package/apps/harness-dashboard/package-lock.json +4280 -0
  69. package/apps/harness-dashboard/package.json +38 -0
  70. package/apps/harness-dashboard/playwright.config.ts +23 -0
  71. package/apps/harness-dashboard/postcss.config.js +6 -0
  72. package/apps/harness-dashboard/tailwind.config.ts +49 -0
  73. package/apps/harness-dashboard/tsconfig.json +24 -0
  74. package/apps/harness-dashboard/vitest.config.ts +15 -0
  75. 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" +114 -0
  76. package/package.json +11 -1
  77. package/scripts/harness-dashboard-up.sh +24 -4
  78. package/HR-Resource/engineering-engineering-feishu-integration-developer/SKILL.md +0 -615
  79. package/HR-Resource/engineering-engineering-wechat-mini-program-developer/SKILL.md +0 -367
  80. package/HR-Resource/marketing-marketing-baidu-seo-specialist/SKILL.md +0 -243
  81. package/HR-Resource/marketing-marketing-bilibili-content-strategist/SKILL.md +0 -216
  82. package/HR-Resource/marketing-marketing-china-ecommerce-operator/SKILL.md +0 -300
  83. package/HR-Resource/marketing-marketing-china-market-localization-strategist/SKILL.md +0 -300
  84. package/HR-Resource/marketing-marketing-douyin-strategist/SKILL.md +0 -166
  85. package/HR-Resource/marketing-marketing-kuaishou-strategist/SKILL.md +0 -240
  86. package/HR-Resource/marketing-marketing-wechat-official-account/SKILL.md +0 -162
  87. package/HR-Resource/marketing-marketing-weibo-strategist/SKILL.md +0 -257
  88. package/HR-Resource/marketing-marketing-xiaohongshu-specialist/SKILL.md +0 -155
  89. package/HR-Resource/marketing-marketing-zhihu-strategist/SKILL.md +0 -179
@@ -0,0 +1,1162 @@
1
+ import { existsSync, readdirSync, statSync, readFileSync, type Dirent } from "node:fs";
2
+ import path from "node:path";
3
+ import { readJsonSafe } from "./safe-json";
4
+ import { AGENT_ROSTER, ROOM_LABELS } from "./agent-roster";
5
+ import { deriveMinifigState, pickTalkingPreview } from "./state-mapping";
6
+ import type {
7
+ AgentId,
8
+ AgentState,
9
+ ArchiveEntry,
10
+ ArchiveStat,
11
+ ContractSnapshot,
12
+ CurrentMeeting,
13
+ Dept,
14
+ ErrorBanner,
15
+ EnvFileSummary,
16
+ EscalationEntry,
17
+ EvalScores,
18
+ FeatureSummary,
19
+ GoalCard,
20
+ HarnessSnapshot,
21
+ HypothesisEntry,
22
+ IncidentEntry,
23
+ MeetingCadence,
24
+ MeetingRecord,
25
+ MeetingsState,
26
+ GotchaEntry,
27
+ MissionDoc,
28
+ OperationsDashboard,
29
+ OpsServiceHealth,
30
+ OwnerPromptEntry,
31
+ ParallelTrack,
32
+ Pipeline,
33
+ RoomId,
34
+ RoomMetrics,
35
+ RoomState,
36
+ TrackStatus,
37
+ WorkerDocEntry,
38
+ WorkerSnapshot,
39
+ } from "./types";
40
+
41
+ const SNAPSHOT_VERSION = "1.2.0";
42
+ const GOAL_DESC_TRUNCATE = 200;
43
+
44
+ interface RawProgress {
45
+ goals?: {
46
+ active_id?: string | null;
47
+ list?: Array<{ id: string; title?: string; description?: string }>;
48
+ current_adherence?: number | null;
49
+ };
50
+ current_agent?: AgentId | null;
51
+ next_agent?: AgentId | null;
52
+ agent_status?: string;
53
+ pipeline?: Pipeline;
54
+ failure?: { agent?: AgentId | null; message?: string | null; location?: string | null; retry_target?: AgentId | null } | null;
55
+ meetings?: {
56
+ active?: AgentId[];
57
+ cadence?: MeetingCadence | string;
58
+ next_scheduled?: string | null;
59
+ current?: { type?: string; topic?: string; convened_at?: string | null } | null;
60
+ // Real-harness schema (v6 NEXUS) — used as fallback when `current` is absent.
61
+ requested_type?: string | null;
62
+ requested_reason?: string | null;
63
+ last_type?: string | null;
64
+ last_reason?: string | null;
65
+ decision?: { owner?: string; action_type?: string; tracks?: unknown[] } | null;
66
+ };
67
+ cto?: {
68
+ last_review?: string | null;
69
+ open_arch_risks?: number;
70
+ contract_signed?: { be?: boolean; fe?: boolean };
71
+ };
72
+ cqo?: {
73
+ last_audit?: string | null;
74
+ sprint_verdict?: "pending" | "PASS" | "FAIL";
75
+ open_regressions?: number;
76
+ last_scores?: EvalScores | null;
77
+ };
78
+ service_ops?: {
79
+ monitor?: {
80
+ last_check?: string | null;
81
+ alerts_this_sprint?: number;
82
+ // G-006: when generator/eval co-spawns service-ops in monitor mode,
83
+ // these fields toggle while the streamed child runs.
84
+ stream_active?: boolean;
85
+ stream_target?: string | null;
86
+ };
87
+ incident?: { open?: Array<{ id: string; dept?: string; severity?: string; message?: string; ts?: string | null }> };
88
+ requested_mode?: string | null;
89
+ health?: Array<{
90
+ name?: string;
91
+ host?: string;
92
+ port?: number;
93
+ status?: string;
94
+ port_state?: string | null;
95
+ health_status?: number | null;
96
+ health_path?: string | null;
97
+ log?: { recent_errors?: number | null };
98
+ }>;
99
+ };
100
+ sprint?: { number?: number };
101
+ parallel_tracks?: Array<{
102
+ id: string;
103
+ from_meeting?: string;
104
+ to_dept?: string;
105
+ to_room?: RoomId;
106
+ status?: TrackStatus | string;
107
+ label?: string;
108
+ }>;
109
+ // Real-harness conductor track shape (CLAUDE.md §parallel-tracks). Used when
110
+ // top-level `parallel_tracks` is absent.
111
+ conductor?: {
112
+ tracks?: Array<{
113
+ id: string;
114
+ owner?: string;
115
+ action_type?: string;
116
+ deliverable?: string;
117
+ deliverable_path?: string | null;
118
+ status?: string;
119
+ started_at?: string | null;
120
+ }>;
121
+ rendezvous?: { type?: string; when?: string } | null;
122
+ fork_meeting_id?: string | null;
123
+ };
124
+ hypothesis?: {
125
+ active?: Array<{ id: string; brief?: string; verdict?: string; ts?: string | null }>;
126
+ };
127
+ escalations?: {
128
+ open?: Array<{ id: string; reason?: string; message?: string; ts?: string | null }>;
129
+ };
130
+ contracts?: {
131
+ api?: { version?: string | null };
132
+ feature_list?: { total?: number; passed?: number; failed?: number };
133
+ };
134
+ company_state?: {
135
+ active_workers?: number;
136
+ workers?: Array<{
137
+ team?: number | string;
138
+ feature?: string;
139
+ agent?: string;
140
+ phase?: string;
141
+ prompt?: string | null;
142
+ log?: string | null;
143
+ spawn_status?: string;
144
+ status?: string;
145
+ pid?: number | null;
146
+ }>;
147
+ last_dispatch?: Array<{
148
+ team?: number | string;
149
+ feature?: string;
150
+ agent?: string;
151
+ phase?: string;
152
+ prompt?: string | null;
153
+ log?: string | null;
154
+ status?: string;
155
+ spawn_status?: string;
156
+ pid?: number | null;
157
+ }>;
158
+ last_dispatch_at?: string | null;
159
+ };
160
+ }
161
+
162
+ function emptyContract(): ContractSnapshot {
163
+ return {
164
+ sprint_number: null,
165
+ pipeline: null,
166
+ api_version: null,
167
+ feature_total: 0,
168
+ feature_passed: 0,
169
+ feature_failed: 0,
170
+ contract_signed: { be: false, fe: false },
171
+ };
172
+ }
173
+
174
+ function emptySnapshot(banner: ErrorBanner | null = null, rootDir?: string): HarnessSnapshot {
175
+ const agents: AgentState[] = AGENT_ROSTER.map((entry) => ({
176
+ id: entry.id,
177
+ name: entry.name,
178
+ dept: entry.dept,
179
+ room: entry.room,
180
+ homeRoom: entry.room,
181
+ minifigState: "idle",
182
+ lastActivity: null,
183
+ }));
184
+ const rooms: RoomState[] = (Object.keys(ROOM_LABELS) as RoomId[]).map((id) => ({
185
+ id,
186
+ label_ko: ROOM_LABELS[id].ko,
187
+ label_en: ROOM_LABELS[id].en,
188
+ dept: ROOM_LABELS[id].dept,
189
+ }));
190
+ return {
191
+ version: SNAPSHOT_VERSION,
192
+ ts: new Date().toISOString(),
193
+ projectName: rootDir ? path.basename(rootDir) : "(unknown)",
194
+ projectPath: rootDir ?? "",
195
+ agents,
196
+ rooms,
197
+ goal: null,
198
+ archive: { sprintCount: 0, recent: [], all: [] },
199
+ meetings: { active: [], cadence: "normal", next_scheduled: null, current: null },
200
+ tracks: [],
201
+ incidents: [],
202
+ hypothesis: [],
203
+ escalations: [],
204
+ contract: emptyContract(),
205
+ evalScores: null,
206
+ errorBanner: banner,
207
+ dashboard: { workers: [], features: [], recentMeetings: [], opsHealth: [], envFiles: [] },
208
+ missions: [],
209
+ ownerHistory: [],
210
+ gotchas: [],
211
+ };
212
+ }
213
+
214
+ function readTextSafe(filePath: string): string | null {
215
+ try {
216
+ return readFileSync(filePath, "utf-8");
217
+ } catch {
218
+ return null;
219
+ }
220
+ }
221
+
222
+ function firstLine(text: string | null): string {
223
+ if (!text) return "";
224
+ return text
225
+ .split("\n")
226
+ .map((line) => line.trim())
227
+ .find((line) => line.length > 0 && !line.startsWith("---")) ?? "";
228
+ }
229
+
230
+ function truncateText(text: string, max = 150): string {
231
+ const cleaned = text.replace(/\s+/g, " ").trim();
232
+ if (cleaned.length <= max) return cleaned;
233
+ return `${cleaned.slice(0, max - 1)}…`;
234
+ }
235
+
236
+ function titleFromMarkdown(text: string | null): string | null {
237
+ if (!text) return null;
238
+ const h1 = text.match(/^#\s+(.+)$/m)?.[1]?.trim();
239
+ if (h1) return h1;
240
+ const mission = text.match(/(?:mission|goal|title)\s*[:=]\s*(.+)$/im)?.[1]?.trim();
241
+ return mission || null;
242
+ }
243
+
244
+ function buildGoal(rootDir: string, progress: RawProgress | null): GoalCard | null {
245
+ const list = progress?.goals?.list ?? [];
246
+ const activeId = progress?.goals?.active_id;
247
+ const goalFile = readTextSafe(path.join(rootDir, ".harness", "actions", "goals.md"));
248
+ const mission = (progress?.goals as { mission?: string; title?: string; description?: string } | undefined)?.mission;
249
+ const topLevelTitle = (progress?.goals as { title?: string } | undefined)?.title;
250
+ if (!activeId && !mission && !topLevelTitle && !goalFile) return null;
251
+ if (!activeId) {
252
+ const title = mission ?? topLevelTitle ?? titleFromMarkdown(goalFile) ?? "Active autonomous company goal";
253
+ return {
254
+ id: "goal",
255
+ title,
256
+ description_truncated: truncateText(
257
+ (progress?.goals as { description?: string } | undefined)?.description ?? goalFile ?? "",
258
+ GOAL_DESC_TRUNCATE,
259
+ ),
260
+ adherence: progress?.goals?.current_adherence ?? null,
261
+ };
262
+ }
263
+ const found = list.find((g) => g.id === activeId);
264
+ if (!found) {
265
+ const title = mission ?? topLevelTitle ?? titleFromMarkdown(goalFile) ?? activeId;
266
+ return {
267
+ id: activeId,
268
+ title,
269
+ description_truncated: truncateText(
270
+ (progress?.goals as { description?: string } | undefined)?.description ?? goalFile ?? "",
271
+ GOAL_DESC_TRUNCATE,
272
+ ),
273
+ adherence: progress?.goals?.current_adherence ?? null,
274
+ };
275
+ }
276
+ const desc = found.description ?? "";
277
+ const truncated =
278
+ desc.length > GOAL_DESC_TRUNCATE
279
+ ? desc.slice(0, GOAL_DESC_TRUNCATE) + "…"
280
+ : desc;
281
+ return {
282
+ id: found.id,
283
+ title: found.title ?? found.id,
284
+ description_truncated: truncated,
285
+ adherence: progress?.goals?.current_adherence ?? null,
286
+ };
287
+ }
288
+
289
+ function readArchiveVerdict(sprintDir: string): "PASS" | "FAIL" | "unknown" {
290
+ // Sprint verdict can live in a handful of well-known spots. Try them in
291
+ // order; first hit wins. Returns "unknown" if nothing definitive is found.
292
+ const verdictJson = path.join(sprintDir, "verdict.json");
293
+ if (existsSync(verdictJson)) {
294
+ const r = readJsonSafe<{ result?: string; verdict?: string }>(verdictJson);
295
+ if (r.ok) {
296
+ const v = (r.value.result ?? r.value.verdict ?? "").toUpperCase();
297
+ if (v === "PASS" || v === "FAIL") return v;
298
+ }
299
+ }
300
+ // CQO sprint summary is the canonical source if verdict.json is absent.
301
+ try {
302
+ const candidates = readdirSync(sprintDir).filter(
303
+ (n) => n.startsWith("cqo-audit-") || n === "evaluation-summary.md"
304
+ );
305
+ for (const name of candidates) {
306
+ const text = readFileSync(path.join(sprintDir, name), "utf-8");
307
+ if (/sprint[_\s-]*verdict\s*[:=]\s*PASS/i.test(text)) return "PASS";
308
+ if (/sprint[_\s-]*verdict\s*[:=]\s*FAIL/i.test(text)) return "FAIL";
309
+ }
310
+ } catch {
311
+ // ignore
312
+ }
313
+ return "unknown";
314
+ }
315
+
316
+ function buildArchive(harnessRoot: string): ArchiveStat {
317
+ const archiveDir = path.join(harnessRoot, ".harness", "archive");
318
+ if (!existsSync(archiveDir)) {
319
+ return { sprintCount: 0, recent: [], all: [] };
320
+ }
321
+ let entries: string[] = [];
322
+ try {
323
+ entries = readdirSync(archiveDir).filter((name) => name.startsWith("sprint-"));
324
+ } catch {
325
+ return { sprintCount: 0, recent: [], all: [] };
326
+ }
327
+ const all: ArchiveEntry[] = entries.map((name) => {
328
+ const sprintDir = path.join(archiveDir, name);
329
+ let ts: string | null = null;
330
+ try {
331
+ ts = statSync(sprintDir).mtime.toISOString();
332
+ } catch {
333
+ ts = null;
334
+ }
335
+ const result = readArchiveVerdict(sprintDir);
336
+ return { dir: name, label: name, result, ts };
337
+ });
338
+ all.sort((a, b) => (b.ts ?? "").localeCompare(a.ts ?? ""));
339
+ return { sprintCount: all.length, recent: all.slice(0, 3), all };
340
+ }
341
+
342
+ function passAxis(pass: unknown): string | null {
343
+ if (typeof pass === "string") return pass;
344
+ if (pass && typeof pass === "object") {
345
+ const p = pass as { axis?: string; by?: string; status?: string };
346
+ if (p.status && p.status.toUpperCase() !== "PASS") return null;
347
+ return p.axis ?? p.by ?? null;
348
+ }
349
+ return null;
350
+ }
351
+
352
+ function buildFeatureSummaries(rootDir: string): FeatureSummary[] {
353
+ const featurePath = path.join(rootDir, ".harness", "actions", "feature-list.json");
354
+ const queuePath = path.join(rootDir, ".harness", "actions", "feature-queue.json");
355
+ const featureResult = readJsonSafe<{
356
+ features?: Array<{
357
+ id?: string;
358
+ title?: string;
359
+ name?: string;
360
+ description?: string;
361
+ status?: string;
362
+ passes?: unknown[];
363
+ }>;
364
+ }>(featurePath);
365
+ const queueResult = readJsonSafe<{
366
+ queue?: {
367
+ ready?: string[];
368
+ blocked?: Record<string, unknown>;
369
+ in_progress?: Record<string, unknown>;
370
+ passed?: string[];
371
+ failed?: string[];
372
+ };
373
+ }>(queuePath);
374
+ const queue = queueResult.ok ? queueResult.value.queue : undefined;
375
+ const statusById = new Map<string, FeatureSummary["status"]>();
376
+ for (const id of queue?.ready ?? []) statusById.set(id, "ready");
377
+ for (const id of Object.keys(queue?.blocked ?? {})) statusById.set(id, "blocked");
378
+ for (const id of Object.keys(queue?.in_progress ?? {})) statusById.set(id, "in_progress");
379
+ for (const id of queue?.passed ?? []) statusById.set(id, "passed");
380
+ for (const id of queue?.failed ?? []) statusById.set(id, "failed");
381
+
382
+ if (!featureResult.ok) {
383
+ return [...statusById.entries()].map(([id, status]) => ({
384
+ id,
385
+ title: id,
386
+ status,
387
+ passes: [],
388
+ }));
389
+ }
390
+ return (featureResult.value.features ?? [])
391
+ .filter((f) => !!f.id)
392
+ .map((f) => {
393
+ const passes = (f.passes ?? []).map(passAxis).filter((x): x is string => !!x);
394
+ const explicitStatus = (f.status ?? "").toLowerCase();
395
+ const status =
396
+ explicitStatus === "pass" || explicitStatus === "passed" || explicitStatus === "done"
397
+ ? "passed"
398
+ : explicitStatus === "fail" || explicitStatus === "failed"
399
+ ? "failed"
400
+ : statusById.get(f.id!) ?? (passes.length > 0 ? "passed" : "unknown");
401
+ return {
402
+ id: f.id!,
403
+ title: f.title ?? f.name ?? f.description ?? f.id!,
404
+ status,
405
+ passes,
406
+ };
407
+ });
408
+ }
409
+
410
+ function buildContract(rootDir: string, progress: RawProgress | null, features: FeatureSummary[]): ContractSnapshot {
411
+ const total = progress?.contracts?.feature_list?.total ?? features.length;
412
+ const passed =
413
+ progress?.contracts?.feature_list?.passed ??
414
+ features.filter((f) => f.status === "passed" || f.passes.length > 0).length;
415
+ const failed =
416
+ progress?.contracts?.feature_list?.failed ??
417
+ features.filter((f) => f.status === "failed").length;
418
+ return {
419
+ sprint_number: progress?.sprint?.number ?? null,
420
+ pipeline: (progress?.pipeline ?? null) as Pipeline,
421
+ api_version: progress?.contracts?.api?.version ?? null,
422
+ feature_total: total,
423
+ feature_passed: passed,
424
+ feature_failed: failed,
425
+ contract_signed: {
426
+ be: !!progress?.cto?.contract_signed?.be,
427
+ fe: !!progress?.cto?.contract_signed?.fe,
428
+ },
429
+ };
430
+ }
431
+
432
+ // Map a conductor-track owner (real-harness schema) to a dashboard room.
433
+ // Owners are agent IDs / dept names; we collapse them to the room their
434
+ // minifig sits in.
435
+ function ownerToRoom(owner: string | undefined): RoomId {
436
+ if (!owner) return "cto-team";
437
+ const o = owner.toLowerCase();
438
+ if (o === "planner" || o === "coo" || o.includes("documentation")) return "coo";
439
+ if (o === "cto" || o.startsWith("generator-") || o === "conductor") return "cto-team";
440
+ if (o === "cqo" || o.startsWith("evaluator-")) return "cqo-team";
441
+ if (o === "service-ops" || o === "ops") return "service-ops";
442
+ if (o === "meeting-manager") return "meeting";
443
+ if (o === "dispatcher" || o === "ceo") return "ceo";
444
+ return "cto-team";
445
+ }
446
+
447
+ function ownerToDept(owner: string | undefined): ParallelTrack["to_dept"] {
448
+ if (!owner) return "Multi";
449
+ const o = owner.toLowerCase();
450
+ if (o === "planner" || o === "coo") return "Planner";
451
+ if (o === "cto" || o.startsWith("generator-")) return "CTO";
452
+ if (o === "cqo" || o.startsWith("evaluator-")) return "CQO";
453
+ if (o === "service-ops" || o === "ops") return "Operations";
454
+ if (o === "meeting-manager") return "Meeting";
455
+ if (o === "dispatcher" || o === "ceo") return "CEO";
456
+ return "Multi";
457
+ }
458
+
459
+ // Real-harness track status values use a different vocabulary. Normalize so
460
+ // the visualization and tests stay on the same enum.
461
+ function normalizeTrackStatus(raw: string | undefined): TrackStatus {
462
+ const allowed: TrackStatus[] = ["dispatched", "in_progress", "joined", "blocked"];
463
+ if (raw && allowed.includes(raw as TrackStatus)) return raw as TrackStatus;
464
+ switch ((raw ?? "").toLowerCase()) {
465
+ case "running":
466
+ case "active":
467
+ return "in_progress";
468
+ case "completed":
469
+ case "done":
470
+ case "succeeded":
471
+ return "joined";
472
+ case "blocked":
473
+ case "failed":
474
+ case "error":
475
+ return "blocked";
476
+ case "pending":
477
+ case "queued":
478
+ case "scheduled":
479
+ default:
480
+ return "dispatched";
481
+ }
482
+ }
483
+
484
+ function buildTracks(progress: RawProgress | null): ParallelTrack[] {
485
+ // Prefer the explicit dashboard schema if the harness opts in; otherwise
486
+ // fall back to the real-harness `conductor.tracks[]` shape.
487
+ const explicit = progress?.parallel_tracks;
488
+ if (explicit && explicit.length > 0) {
489
+ return explicit.map((t) => ({
490
+ id: t.id,
491
+ from_meeting: t.from_meeting ?? "meeting",
492
+ to_dept: (t.to_dept ?? "Multi") as ParallelTrack["to_dept"],
493
+ to_room: t.to_room ?? "cto-team",
494
+ status: normalizeTrackStatus(t.status as string | undefined),
495
+ label: t.label,
496
+ }));
497
+ }
498
+ const conductorTracks = progress?.conductor?.tracks ?? [];
499
+ return conductorTracks.map((t) => ({
500
+ id: t.id,
501
+ from_meeting: progress?.conductor?.fork_meeting_id ?? "meeting",
502
+ to_dept: ownerToDept(t.owner),
503
+ to_room: ownerToRoom(t.owner),
504
+ status: normalizeTrackStatus(t.status),
505
+ label: t.action_type ?? t.deliverable,
506
+ }));
507
+ }
508
+
509
+ function buildIncidents(progress: RawProgress | null): IncidentEntry[] {
510
+ const list = progress?.service_ops?.incident?.open ?? [];
511
+ return list.map((it) => {
512
+ const sev = (it.severity ?? "medium").toLowerCase();
513
+ const severity =
514
+ sev === "low" || sev === "medium" || sev === "high" || sev === "critical"
515
+ ? (sev as IncidentEntry["severity"])
516
+ : "medium";
517
+ return {
518
+ id: it.id,
519
+ dept: it.dept ?? "Operations",
520
+ severity,
521
+ message: it.message,
522
+ ts: it.ts ?? null,
523
+ };
524
+ });
525
+ }
526
+
527
+ function buildHypothesis(progress: RawProgress | null): HypothesisEntry[] {
528
+ const list = progress?.hypothesis?.active ?? [];
529
+ return list.map((h) => {
530
+ const v = (h.verdict ?? "pending").toLowerCase();
531
+ const verdict =
532
+ v === "valid" || v === "invalid" ? (v as HypothesisEntry["verdict"]) : "pending";
533
+ return {
534
+ id: h.id,
535
+ brief: h.brief ?? h.id,
536
+ verdict,
537
+ ts: h.ts ?? null,
538
+ };
539
+ });
540
+ }
541
+
542
+ function buildEscalations(progress: RawProgress | null): EscalationEntry[] {
543
+ return (progress?.escalations?.open ?? []).map((e) => ({
544
+ id: e.id,
545
+ reason: e.reason ?? "other",
546
+ message: e.message,
547
+ ts: e.ts ?? null,
548
+ }));
549
+ }
550
+
551
+ function buildCurrentMeeting(progress: RawProgress | null): CurrentMeeting | null {
552
+ const m = progress?.meetings;
553
+ if (!m) return null;
554
+ // Prefer explicit `current` (dashboard schema), then real-harness
555
+ // `requested_type` (currently being convened), then `last_type` (most-recent).
556
+ const rawType =
557
+ (m.current && m.current.type) ||
558
+ m.requested_type ||
559
+ m.last_type ||
560
+ null;
561
+ if (!rawType) return null;
562
+ const allowed: CurrentMeeting["type"][] = [
563
+ "standup",
564
+ "sprint-review",
565
+ "spec-review",
566
+ "incident-war-room",
567
+ "all-hands",
568
+ "followup-review",
569
+ ];
570
+ const type = allowed.includes(rawType as CurrentMeeting["type"])
571
+ ? (rawType as CurrentMeeting["type"])
572
+ : "standup";
573
+ // Topic falls back to the decision summary when the harness hasn't written
574
+ // a free-text topic yet.
575
+ const decisionTopic = m.decision?.owner
576
+ ? `${m.decision.owner}${m.decision.action_type ? ` · ${m.decision.action_type}` : ""}`
577
+ : undefined;
578
+ return {
579
+ type,
580
+ topic: m.current?.topic ?? m.requested_reason ?? m.last_reason ?? decisionTopic,
581
+ convened_at: m.current?.convened_at ?? null,
582
+ };
583
+ }
584
+
585
+ function deriveCadence(raw: string | undefined): MeetingCadence {
586
+ if (raw === "light" || raw === "normal" || raw === "heavy") return raw;
587
+ return "normal";
588
+ }
589
+
590
+ function deptToRoom(dept: string): RoomId | null {
591
+ const map: Record<string, RoomId> = {
592
+ CEO: "ceo",
593
+ Meeting: "meeting",
594
+ Planner: "coo",
595
+ COO: "coo",
596
+ CTO: "cto-team",
597
+ CQO: "cqo-team",
598
+ Operations: "service-ops",
599
+ };
600
+ return map[dept] ?? null;
601
+ }
602
+
603
+ function buildRooms(
604
+ progress: RawProgress | null,
605
+ meetingActive: AgentId[],
606
+ meetings: MeetingsState,
607
+ tracks: ParallelTrack[],
608
+ incidents: IncidentEntry[],
609
+ hypothesisList: HypothesisEntry[],
610
+ contract: ContractSnapshot,
611
+ evalScores: EvalScores | null,
612
+ ): RoomState[] {
613
+ const cto = progress?.cto;
614
+ const cqo = progress?.cqo;
615
+ const ops = progress?.service_ops;
616
+ const sprintNumber = progress?.sprint?.number;
617
+
618
+ const passRate =
619
+ contract.feature_total > 0
620
+ ? contract.feature_passed / contract.feature_total
621
+ : null;
622
+
623
+ const tracksByRoom = new Map<RoomId, number>();
624
+ for (const t of tracks) {
625
+ tracksByRoom.set(t.to_room, (tracksByRoom.get(t.to_room) ?? 0) + 1);
626
+ }
627
+
628
+ const incidentByDept = new Map<string, number>();
629
+ for (const it of incidents) {
630
+ incidentByDept.set(it.dept, (incidentByDept.get(it.dept) ?? 0) + 1);
631
+ }
632
+
633
+ const metrics: Partial<Record<RoomId, RoomMetrics>> = {
634
+ ceo: {
635
+ sprint_number: sprintNumber,
636
+ },
637
+ meeting: {
638
+ cadence: meetings.cadence,
639
+ next_scheduled: meetings.next_scheduled,
640
+ active_tracks: tracks.length,
641
+ },
642
+ coo: {
643
+ sprint_number: sprintNumber,
644
+ active_hypothesis: hypothesisList.length,
645
+ },
646
+ "cto-team": {
647
+ sprint_number: sprintNumber,
648
+ last_review: cto?.last_review ?? null,
649
+ open_arch_risks: cto?.open_arch_risks ?? 0,
650
+ active_workers: progress?.company_state?.active_workers ?? 0,
651
+ contract_signed: {
652
+ be: !!cto?.contract_signed?.be,
653
+ fe: !!cto?.contract_signed?.fe,
654
+ },
655
+ pass_rate: passRate,
656
+ },
657
+ "cqo-team": {
658
+ sprint_number: sprintNumber,
659
+ last_audit: cqo?.last_audit ?? null,
660
+ sprint_verdict: cqo?.sprint_verdict ?? "pending",
661
+ open_regressions: cqo?.open_regressions ?? 0,
662
+ pass_rate: passRate,
663
+ eval_scores: evalScores,
664
+ },
665
+ "service-ops": {
666
+ sprint_number: sprintNumber,
667
+ last_check: ops?.monitor?.last_check ?? null,
668
+ open_alerts: ops?.monitor?.alerts_this_sprint ?? 0,
669
+ open_incidents: incidents.length,
670
+ },
671
+ };
672
+
673
+ return (Object.keys(ROOM_LABELS) as RoomId[]).map((id) => {
674
+ const base: RoomState = {
675
+ id,
676
+ label_ko: ROOM_LABELS[id].ko,
677
+ label_en: ROOM_LABELS[id].en,
678
+ dept: ROOM_LABELS[id].dept,
679
+ };
680
+ if (metrics[id]) base.metrics = metrics[id];
681
+ if (id === "meeting") {
682
+ const seats = 7;
683
+ const occupants = meetingActive.slice(0, seats);
684
+ const overflow = Math.max(0, meetingActive.length - seats);
685
+ base.seatLayout = { seats, occupants, overflow };
686
+ }
687
+ return base;
688
+ });
689
+ }
690
+
691
+ function buildAgents(
692
+ progress: RawProgress | null,
693
+ meetingActive: AgentId[],
694
+ incidents: IncidentEntry[],
695
+ ): AgentState[] {
696
+ const meetingSet = new Set<AgentId>(meetingActive);
697
+ const incidentDepts = new Set(incidents.map((i) => i.dept));
698
+ return AGENT_ROSTER.map((entry) => {
699
+ const minifigState = deriveMinifigState(entry.id, progress ?? null, {
700
+ incidentDepts,
701
+ dept: entry.dept,
702
+ });
703
+ const inMeeting = meetingSet.has(entry.id);
704
+ const room = inMeeting ? "meeting" : entry.room;
705
+ const lastActivity =
706
+ progress?.current_agent === entry.id
707
+ ? `current_agent (${progress.agent_status ?? "unknown"})`
708
+ : progress?.next_agent === entry.id
709
+ ? "next_agent queued"
710
+ : null;
711
+ const matchedIncident = incidents.find((i) => i.dept === entry.dept);
712
+ return {
713
+ id: entry.id,
714
+ name: entry.name,
715
+ dept: entry.dept,
716
+ room,
717
+ homeRoom: entry.room,
718
+ minifigState,
719
+ lastActivity,
720
+ talkingPreview: minifigState === "talking" ? pickTalkingPreview(entry.id) : undefined,
721
+ alertReason:
722
+ minifigState === "red-alert"
723
+ ? matchedIncident?.message ??
724
+ progress?.failure?.message ??
725
+ "incident"
726
+ : undefined,
727
+ };
728
+ });
729
+ }
730
+
731
+ function readFeatureTitles(rootDir: string): Map<string, string> {
732
+ const map = new Map<string, string>();
733
+ const candidates = [
734
+ path.join(rootDir, ".harness", "actions", "feature-list.json"),
735
+ path.join(rootDir, ".harness", "actions", "feature-queue.json"),
736
+ ];
737
+ for (const file of candidates) {
738
+ const result = readJsonSafe<{
739
+ features?: Array<{ id?: string; title?: string; summary?: string }>;
740
+ queue?: {
741
+ ready?: Array<{ id?: string; title?: string; summary?: string }>;
742
+ in_progress?: Array<{ id?: string; title?: string; summary?: string }>;
743
+ passed?: Array<{ id?: string; title?: string; summary?: string }>;
744
+ failed?: Array<{ id?: string; title?: string; summary?: string }>;
745
+ };
746
+ }>(file);
747
+ if (!result.ok) continue;
748
+ for (const f of result.value.features ?? []) {
749
+ if (f.id) map.set(f.id, f.title ?? f.summary ?? f.id);
750
+ }
751
+ const q = result.value.queue;
752
+ for (const list of [q?.ready, q?.in_progress, q?.passed, q?.failed]) {
753
+ if (!Array.isArray(list)) continue;
754
+ for (const f of list) {
755
+ if (f.id) map.set(f.id, f.title ?? f.summary ?? f.id);
756
+ }
757
+ }
758
+ }
759
+ return map;
760
+ }
761
+
762
+ type RawWorker = NonNullable<NonNullable<RawProgress["company_state"]>["workers"]>[number];
763
+
764
+ function deriveWorkerProgress(worker: RawWorker): number | null {
765
+ const status = (worker?.status ?? worker?.spawn_status ?? "").toLowerCase();
766
+ if (status === "blocked" || status === "failed") return 0.35;
767
+ if (status === "recorded") return 0.15;
768
+ if (status === "spawned" || status === "running") return 0.55;
769
+ if (status === "completed" || status === "done") return 1;
770
+ return null;
771
+ }
772
+
773
+ function buildWorkers(rootDir: string, progress: RawProgress | null): WorkerSnapshot[] {
774
+ const titles = readFeatureTitles(rootDir);
775
+ let workers = progress?.company_state?.workers ?? [];
776
+ if (workers.length === 0) {
777
+ const queue = readJsonSafe<{
778
+ teams?: Record<string, {
779
+ status?: string;
780
+ feature?: string | null;
781
+ pid?: number | null;
782
+ agent?: string;
783
+ phase?: string;
784
+ prompt?: string | null;
785
+ log?: string | null;
786
+ spawn_status?: string;
787
+ }>;
788
+ }>(path.join(rootDir, ".harness", "actions", "feature-queue.json"));
789
+ if (queue.ok) {
790
+ workers = Object.entries(queue.value.teams ?? {})
791
+ .filter(([, t]) => t.status === "busy" && !!t.feature)
792
+ .map(([team, t]) => ({ ...t, team, feature: t.feature ?? undefined }));
793
+ }
794
+ }
795
+ if (workers.length === 0) {
796
+ workers = progress?.company_state?.last_dispatch ?? [];
797
+ }
798
+ return workers.map((w, idx) => {
799
+ const feature = w.feature ?? `worker-${idx + 1}`;
800
+ const promptText = w.prompt ? readTextSafe(path.join(rootDir, w.prompt)) : null;
801
+ const logText = w.log ? readTextSafe(path.join(rootDir, w.log)) : null;
802
+ const summarySource = firstLine(logText) || firstLine(promptText) || `${w.agent ?? "worker"} assigned`;
803
+ const material = w.prompt ?? w.log ?? null;
804
+ const rawStatus = (w.status ?? w.spawn_status ?? "unknown").toLowerCase();
805
+ const status = ["spawned", "recorded", "running", "idle", "blocked"].includes(rawStatus)
806
+ ? (rawStatus as WorkerSnapshot["status"])
807
+ : "unknown";
808
+ return {
809
+ team: w.team ?? idx + 1,
810
+ feature,
811
+ title: titles.get(feature) ?? feature,
812
+ agent: w.agent ?? "unknown",
813
+ phase: w.phase,
814
+ status,
815
+ pid: w.pid ?? null,
816
+ prompt: w.prompt ?? null,
817
+ log: w.log ?? null,
818
+ progress: deriveWorkerProgress(w),
819
+ summary: truncateText(summarySource, 140),
820
+ next_material: material,
821
+ };
822
+ });
823
+ }
824
+
825
+ function meetingTitleFromMarkdown(text: string, fallback: string): string {
826
+ const heading = text.match(/^#\s+(.+)$/m)?.[1]?.trim();
827
+ return heading || fallback;
828
+ }
829
+
830
+ function meetingVerdict(text: string): string | null {
831
+ const explicit = text.match(/verdict:\s*([^\n]+)/i)?.[1]?.trim();
832
+ if (explicit) return explicit;
833
+ if (/incident/i.test(text)) return "incident";
834
+ if (/PASS|passed|working|continue autonomous/i.test(text)) return "working";
835
+ return null;
836
+ }
837
+
838
+ function buildRecentMeetings(rootDir: string): MeetingRecord[] {
839
+ const meetingsDir = path.join(rootDir, ".harness", "actions", "meetings");
840
+ if (!existsSync(meetingsDir)) return [];
841
+ const records: MeetingRecord[] = [];
842
+ for (const dir of readdirSync(meetingsDir)) {
843
+ const fullDir = path.join(meetingsDir, dir);
844
+ try {
845
+ if (!statSync(fullDir).isDirectory()) continue;
846
+ } catch {
847
+ continue;
848
+ }
849
+ const md = readdirSync(fullDir).find((name) => name.startsWith("meeting-") && name.endsWith(".md"));
850
+ if (!md) continue;
851
+ const fullPath = path.join(fullDir, md);
852
+ const text = readTextSafe(fullPath) ?? "";
853
+ let ts: string | null = null;
854
+ try {
855
+ ts = statSync(fullPath).mtime.toISOString();
856
+ } catch {
857
+ ts = null;
858
+ }
859
+ const summary =
860
+ text.match(/## Decision\s+([\s\S]*?)(?=\n## |\n# |$)/i)?.[1] ??
861
+ text.match(/## Summary\s+([\s\S]*?)(?=\n## |\n# |$)/i)?.[1] ??
862
+ text.split("\n").slice(0, 12).join(" ");
863
+ records.push({
864
+ id: dir,
865
+ path: path.relative(rootDir, fullPath),
866
+ ts,
867
+ title: meetingTitleFromMarkdown(text, dir),
868
+ verdict: meetingVerdict(text),
869
+ summary: truncateText(summary, 220),
870
+ content: text,
871
+ });
872
+ }
873
+ records.sort((a, b) => (b.ts ?? "").localeCompare(a.ts ?? ""));
874
+ return records.slice(0, 6);
875
+ }
876
+
877
+ function buildOpsHealth(progress: RawProgress | null): OpsServiceHealth[] {
878
+ return (progress?.service_ops?.health ?? []).map((h) => {
879
+ const statusRaw = (h.status ?? "unknown").toLowerCase();
880
+ const status = statusRaw === "ok" || statusRaw === "degraded" || statusRaw === "down"
881
+ ? statusRaw
882
+ : "unknown";
883
+ return {
884
+ name: h.name ?? "service",
885
+ host: h.host ?? "127.0.0.1",
886
+ port: h.port ?? 0,
887
+ status,
888
+ port_state: h.port_state ?? null,
889
+ health_status: h.health_status ?? null,
890
+ health_path: h.health_path ?? null,
891
+ recent_errors: h.log?.recent_errors ?? null,
892
+ };
893
+ });
894
+ }
895
+
896
+ function categorizeEnvKey(key: string): EnvFileSummary["keys"][number]["category"] {
897
+ if (/TOKEN|SECRET|KEY|PASSWORD|PASS|COOKIE|JWT/i.test(key)) return "secret";
898
+ if (/URL|HOST|ORIGIN|ENDPOINT/i.test(key)) return "endpoint";
899
+ if (/PORT/i.test(key)) return "port";
900
+ if (/MODE|ENV|NODE_ENV|LIVE|PAPER/i.test(key)) return "mode";
901
+ return "other";
902
+ }
903
+
904
+ function maskEnvValue(value: string, category: EnvFileSummary["keys"][number]["category"]): string {
905
+ if (!value) return "(empty)";
906
+ if (category === "secret") return value.length <= 4 ? "••••" : `${value.slice(0, 2)}••••${value.slice(-2)}`;
907
+ if (value.length <= 18) return value;
908
+ return `${value.slice(0, 10)}…${value.slice(-5)}`;
909
+ }
910
+
911
+ function buildEnvFiles(rootDir: string): EnvFileSummary[] {
912
+ const names = [".env", ".env.local", ".env.production", ".env.development", ".env.example"];
913
+ return names
914
+ .map((name) => {
915
+ const fullPath = path.join(rootDir, name);
916
+ if (!existsSync(fullPath)) return null;
917
+ const text = readTextSafe(fullPath) ?? "";
918
+ const keys = text
919
+ .split("\n")
920
+ .map((line) => line.trim())
921
+ .filter((line) => line && !line.startsWith("#") && line.includes("="))
922
+ .map((line) => {
923
+ const idx = line.indexOf("=");
924
+ const key = line.slice(0, idx).trim();
925
+ const value = line.slice(idx + 1).trim().replace(/^['"]|['"]$/g, "");
926
+ const category = categorizeEnvKey(key);
927
+ return { key, category, masked: maskEnvValue(value, category) };
928
+ });
929
+ let updated_at: string | null = null;
930
+ try {
931
+ updated_at = statSync(fullPath).mtime.toISOString();
932
+ } catch {
933
+ updated_at = null;
934
+ }
935
+ return {
936
+ path: name,
937
+ updated_at,
938
+ key_count: keys.length,
939
+ keys: keys.slice(0, 12),
940
+ };
941
+ })
942
+ .filter((x): x is EnvFileSummary => x !== null);
943
+ }
944
+
945
+ function buildDashboard(rootDir: string, progress: RawProgress | null): OperationsDashboard {
946
+ const features = buildFeatureSummaries(rootDir);
947
+ return {
948
+ workers: buildWorkers(rootDir, progress),
949
+ features,
950
+ recentMeetings: buildRecentMeetings(rootDir),
951
+ opsHealth: buildOpsHealth(progress),
952
+ envFiles: buildEnvFiles(rootDir),
953
+ };
954
+ }
955
+
956
+ function readMissions(rootDir: string, limit = 15): MissionDoc[] {
957
+ const docsDir = path.join(rootDir, ".harness", "documents");
958
+ if (!existsSync(docsDir)) return [];
959
+
960
+ let entries: Dirent[];
961
+ try {
962
+ entries = readdirSync(docsDir, { withFileTypes: true });
963
+ } catch { return []; }
964
+
965
+ const missions = entries
966
+ .filter(d => d.isDirectory() && !d.name.startsWith("."))
967
+ .map(d => {
968
+ const missionPath = path.join(docsDir, d.name);
969
+ let mtime: Date;
970
+ try { mtime = statSync(missionPath).mtime; } catch { mtime = new Date(0); }
971
+
972
+ const readMd = (name: string): string | null => {
973
+ const p = path.join(missionPath, name);
974
+ if (!existsSync(p)) return null;
975
+ try { return readFileSync(p, "utf8"); } catch { return null; }
976
+ };
977
+
978
+ const workers: WorkerDocEntry[] = [];
979
+ const addWorkersFromDir = (owner: WorkerDocEntry["owner"], relDir: string) => {
980
+ const workersDir = path.join(missionPath, relDir);
981
+ if (!existsSync(workersDir)) return;
982
+ try {
983
+ for (const f of readdirSync(workersDir)) {
984
+ if (!f.endsWith(".md")) continue;
985
+ const content = readMd(`${relDir}/${f}`) ?? "";
986
+ const statusMatch = content.match(/##\s*Status\s*\n+([A-Z_]+)/);
987
+ workers.push({
988
+ name: f.replace(".md", ""),
989
+ content,
990
+ status: (statusMatch?.[1] as WorkerDocEntry["status"]) ?? "unknown",
991
+ owner,
992
+ });
993
+ }
994
+ } catch { /* ignore */ }
995
+ };
996
+
997
+ addWorkersFromDir("cto", "cto/workers");
998
+ addWorkersFromDir("cqo", "cqo/workers");
999
+ addWorkersFromDir("coo", "coo/workers");
1000
+ addWorkersFromDir("cdo", "cdo/workers");
1001
+ addWorkersFromDir("ops", "ops/workers");
1002
+ addWorkersFromDir("unknown", "workers");
1003
+
1004
+ const cxxRoles = ["ceo", "cto", "cqo", "coo", "cdo", "ops"] as const;
1005
+ const cxxPresent = cxxRoles.filter(role => existsSync(path.join(missionPath, `${role}.md`)));
1006
+
1007
+ const id = d.name;
1008
+ return {
1009
+ missionId: id,
1010
+ ts: mtime.toISOString(),
1011
+ type: id.startsWith("hotfix") ? "hotfix" : id.match(/^F\d+/) ? "feature" : "unknown",
1012
+ label: id,
1013
+ ceo: readMd("ceo.md"),
1014
+ cto: readMd("cto.md"),
1015
+ cqo: readMd("cqo.md"),
1016
+ coo: readMd("coo.md"),
1017
+ cdo: readMd("cdo.md"),
1018
+ ops: readMd("ops.md"),
1019
+ workers,
1020
+ cxxPresent: [...cxxPresent],
1021
+ } satisfies MissionDoc;
1022
+ })
1023
+ .sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime())
1024
+ .slice(0, limit);
1025
+
1026
+ return missions;
1027
+ }
1028
+
1029
+ function readOwnerHistory(rootDir: string, limit = 30): OwnerPromptEntry[] {
1030
+ const logFile = path.join(rootDir, ".harness", "progress.log");
1031
+ if (!existsSync(logFile)) return [];
1032
+ let raw: string;
1033
+ try { raw = readFileSync(logFile, "utf8"); } catch { return []; }
1034
+
1035
+ const entries: OwnerPromptEntry[] = [];
1036
+ for (const line of raw.split("\n")) {
1037
+ if (!line.includes("user-prompt") && !line.includes("input")) continue;
1038
+ if (line.trim().startsWith("#")) continue;
1039
+ const parts = line.split(" | ");
1040
+ if (parts.length < 4) continue;
1041
+ const ts = parts[0]?.trim() ?? "";
1042
+ const content = parts.slice(3).join(" | ").trim();
1043
+ if (!content) continue;
1044
+ const lc = content.toLowerCase();
1045
+ const type = content.startsWith("/goal") ? "goal" :
1046
+ (content.startsWith("/hot-fix") || lc.includes("/hot-fix")) ? "hot-fix" : "other";
1047
+ entries.push({ ts, content, type });
1048
+ }
1049
+ return entries.reverse().slice(0, limit);
1050
+ }
1051
+
1052
+ function readGotchas(rootDir: string): GotchaEntry[] {
1053
+ const gotchasDir = path.join(rootDir, ".harness", "gotchas");
1054
+ if (!existsSync(gotchasDir)) return [];
1055
+ let entries: Dirent[];
1056
+ try { entries = readdirSync(gotchasDir, { withFileTypes: true }); } catch { return []; }
1057
+ const results: GotchaEntry[] = [];
1058
+ for (const entry of entries) {
1059
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
1060
+ if (entry.name === "README.md") continue;
1061
+ const id = entry.name.replace(/\.md$/, "");
1062
+ let content: string;
1063
+ try { content = readFileSync(path.join(gotchasDir, entry.name), "utf8"); } catch { continue; }
1064
+ // Extract title from first H1 after optional frontmatter
1065
+ const body = content.replace(/^---[\s\S]*?---\n+/, "");
1066
+ const h1 = body.match(/^#\s+(.+)/m);
1067
+ const title = h1 ? h1[1].trim() : id;
1068
+ // Extract tags from docmeta frontmatter
1069
+ const tagsMatch = content.match(/^\s*tags:\s*\[([^\]]*)\]/m);
1070
+ const tags = tagsMatch
1071
+ ? tagsMatch[1].split(",").map((t) => t.trim().replace(/^['"]|['"]$/g, "")).filter(Boolean)
1072
+ : [];
1073
+ results.push({ id, title, content, tags });
1074
+ }
1075
+ return results.sort((a, b) => a.id.localeCompare(b.id));
1076
+ }
1077
+
1078
+ export function readHarnessState(rootDir: string): HarnessSnapshot {
1079
+ const harnessDir = path.join(rootDir, ".harness");
1080
+ if (!existsSync(harnessDir)) {
1081
+ return emptySnapshot({
1082
+ level: "error",
1083
+ message_ko: "하네스 상태가 없습니다 — Dispatcher를 먼저 실행하세요.",
1084
+ message_en: "Harness state not found — run Dispatcher first.",
1085
+ }, rootDir);
1086
+ }
1087
+ const progressPath = path.join(harnessDir, "progress.json");
1088
+ const result = readJsonSafe<RawProgress>(progressPath);
1089
+ if (!result.ok) {
1090
+ const banner: ErrorBanner =
1091
+ result.reason === "missing"
1092
+ ? {
1093
+ level: "error",
1094
+ message_ko: "progress.json을 찾을 수 없습니다 — Dispatcher를 먼저 실행하세요.",
1095
+ message_en: "progress.json not found — run Dispatcher first.",
1096
+ }
1097
+ : {
1098
+ level: "error",
1099
+ message_ko: "progress.json이 손상되었습니다 — 파일을 확인하세요.",
1100
+ message_en: "progress.json is corrupt — check the file.",
1101
+ };
1102
+ const snapshot = emptySnapshot(banner, rootDir);
1103
+ snapshot.archive = buildArchive(rootDir);
1104
+ snapshot.missions = readMissions(rootDir);
1105
+ snapshot.ownerHistory = readOwnerHistory(rootDir);
1106
+ snapshot.gotchas = readGotchas(rootDir);
1107
+ return snapshot;
1108
+ }
1109
+
1110
+ const progress = result.value;
1111
+ const meetingActive = progress.meetings?.active ?? [];
1112
+ const meetings: MeetingsState = {
1113
+ active: meetingActive,
1114
+ cadence: deriveCadence(progress.meetings?.cadence as string | undefined),
1115
+ next_scheduled: progress.meetings?.next_scheduled ?? null,
1116
+ current: buildCurrentMeeting(progress),
1117
+ };
1118
+
1119
+ const tracks = buildTracks(progress);
1120
+ const incidents = buildIncidents(progress);
1121
+ const hypothesisList = buildHypothesis(progress);
1122
+ const escalations = buildEscalations(progress);
1123
+ const features = buildFeatureSummaries(rootDir);
1124
+ const contract = buildContract(rootDir, progress, features);
1125
+ const evalScores = progress.cqo?.last_scores ?? null;
1126
+ const dashboard = buildDashboard(rootDir, progress);
1127
+
1128
+ return {
1129
+ version: SNAPSHOT_VERSION,
1130
+ ts: new Date().toISOString(),
1131
+ projectName: path.basename(rootDir),
1132
+ projectPath: rootDir,
1133
+ agents: buildAgents(progress, meetingActive, incidents),
1134
+ rooms: buildRooms(
1135
+ progress,
1136
+ meetingActive,
1137
+ meetings,
1138
+ tracks,
1139
+ incidents,
1140
+ hypothesisList,
1141
+ contract,
1142
+ evalScores,
1143
+ ),
1144
+ goal: buildGoal(rootDir, progress),
1145
+ archive: buildArchive(rootDir),
1146
+ meetings,
1147
+ tracks,
1148
+ incidents,
1149
+ hypothesis: hypothesisList,
1150
+ escalations,
1151
+ contract,
1152
+ evalScores,
1153
+ errorBanner: null,
1154
+ dashboard,
1155
+ missions: readMissions(rootDir),
1156
+ ownerHistory: readOwnerHistory(rootDir),
1157
+ gotchas: readGotchas(rootDir),
1158
+ };
1159
+ }
1160
+
1161
+ // Exported for tests — silences the unused warning when only the type is used.
1162
+ export type { Dept };