@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
|
@@ -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 };
|