@walwal-harness/cli 7.1.41 → 7.1.42

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.
@@ -0,0 +1,238 @@
1
+ "use client";
2
+
3
+ /* =============================================================
4
+ walwal-harness · VIEW 3 — TIMELINE (cadence heatmap + swimlanes)
5
+ Ported from harness/views-timeline.jsx (design handoff).
6
+ Exports: TimelineView, Cadence
7
+ ============================================================= */
8
+
9
+ import React, { useRef } from "react";
10
+ import type { CSSProperties } from "react";
11
+
12
+ import type {
13
+ AgentRole,
14
+ ContractState,
15
+ ContractWorker,
16
+ DocTarget,
17
+ } from "../../lib/brick/contract";
18
+ import { StatusDot, Label } from "./ui";
19
+
20
+ const SPAN_MS = 12 * 3600 * 1000; // 12h window, now=left
21
+
22
+ /** Inline style that also carries the per-agent `--hue` CSS custom property. */
23
+ type HueStyle = CSSProperties & { "--hue": string };
24
+
25
+ /* ---- Cadence (also rendered by the COMMAND view) ---------------------- */
26
+
27
+ export interface CadenceProps {
28
+ /** 24 hourly buckets = owner prompts/hour. */
29
+ cadence: number[];
30
+ }
31
+
32
+ export function Cadence({ cadence }: CadenceProps) {
33
+ const max = Math.max(...cadence, 1);
34
+ return (
35
+ <div className="cadence">
36
+ <Label right={"max " + max + " · 24h"}>CADENCE · owner prompts / h</Label>
37
+ <div className="cad-row">
38
+ {cadence.map((v, i) => (
39
+ <div
40
+ key={i}
41
+ className="cad-cell"
42
+ title={v + " / h"}
43
+ style={{
44
+ background:
45
+ v === 0 ? "transparent" : `rgba(229,72,77,${0.28 + 0.55 * (v / max)})`,
46
+ borderColor: v === 0 ? "var(--bd)" : "rgba(229,72,77,0.5)",
47
+ }}
48
+ />
49
+ ))}
50
+ </div>
51
+ <div className="cad-axis">
52
+ <span>now</span>
53
+ <span>−12h</span>
54
+ <span>−24h</span>
55
+ </div>
56
+ </div>
57
+ );
58
+ }
59
+
60
+ /* ---- TimelineView ----------------------------------------------------- */
61
+
62
+ export interface TimelineViewProps {
63
+ /** The single source-of-truth contract state. */
64
+ s: ContractState;
65
+ /** Open a document (agent report or worker brief) in the DocViewer. */
66
+ openDoc?: (target: DocTarget) => void;
67
+ }
68
+
69
+ type SwimMark = { ago: number; dur: number };
70
+
71
+ type SwimRow = {
72
+ kind: "exec" | "worker";
73
+ id: string;
74
+ label: string;
75
+ hue: string;
76
+ agent: AgentRole;
77
+ status: string;
78
+ progress?: number | null;
79
+ };
80
+
81
+ export function TimelineView({ s, openDoc }: TimelineViewProps) {
82
+ // stable seeded marks per row id
83
+ const markCache = useRef<Record<string, SwimMark[]>>({});
84
+ function seedMarks(id: string, density: number): SwimMark[] {
85
+ const cached = markCache.current[id];
86
+ if (cached) return cached;
87
+ const arr: SwimMark[] = [];
88
+ const n = Math.floor(density * (4 + Math.random() * 7));
89
+ for (let i = 0; i < n; i++) {
90
+ arr.push({ ago: Math.random() * SPAN_MS, dur: 40000 + Math.random() * 600000 });
91
+ }
92
+ markCache.current[id] = arr;
93
+ return arr;
94
+ }
95
+
96
+ // build rows: exec + its workers
97
+ const order: AgentRole[] = ["ceo", "coo", "cto", "cqo", "cdo", "ops"];
98
+ const rows: SwimRow[] = [];
99
+ order.forEach((id) => {
100
+ const a = s.agents.find((x) => x.id === id);
101
+ if (!a) return;
102
+ rows.push({ kind: "exec", id: a.id, label: a.name, hue: a.hue, agent: a.id, status: a.status });
103
+ s.workers
104
+ .filter((w) => w.agent === id)
105
+ .forEach((w) => {
106
+ rows.push({
107
+ kind: "worker",
108
+ id: w.id,
109
+ label: w.name,
110
+ hue: a.hue,
111
+ agent: a.id,
112
+ status: w.status,
113
+ progress: w.progress,
114
+ });
115
+ });
116
+ });
117
+
118
+ // live marks from recent events (last 6 min) keyed to exec rows
119
+ const liveByAgent: Record<string, number[]> = {};
120
+ s.events.forEach((e) => {
121
+ if (e.at == null || e.agent === "") return;
122
+ const age = s.now - e.at;
123
+ if (age < 6 * 60 * 1000) {
124
+ (liveByAgent[e.agent] = liveByAgent[e.agent] || []).push(age);
125
+ }
126
+ });
127
+
128
+ const xOf = (ago: number): number => Math.max(0, Math.min(100, (ago / SPAN_MS) * 100));
129
+
130
+ // axis ticks
131
+ const ticks: number[] = [];
132
+ for (let h = 0; h <= 12; h += 2) ticks.push(h);
133
+
134
+ const openRow = (r: SwimRow) => {
135
+ if (!openDoc) return;
136
+ if (r.kind === "exec") {
137
+ openDoc({ type: "agent", agent: r.agent });
138
+ return;
139
+ }
140
+ const worker: ContractWorker | undefined = s.workers.find((w) => w.id === r.id);
141
+ if (worker) openDoc({ type: "worker", agent: r.agent, worker });
142
+ };
143
+
144
+ return (
145
+ <div className="timelineview">
146
+ <Cadence cadence={s.cadence} />
147
+ <div className="swim">
148
+ <Label
149
+ right={
150
+ rows.filter((r) => r.kind === "worker").length +
151
+ " workers · " +
152
+ order.length +
153
+ " execs"
154
+ }
155
+ >
156
+ ORCHESTRATION TIMELINE · now → −12h
157
+ </Label>
158
+ <div className="swim-axis">
159
+ <div className="swim-axis-pad" />
160
+ <div className="swim-axis-track">
161
+ {ticks.map((h) => (
162
+ <span
163
+ key={h}
164
+ className="swim-tick"
165
+ style={{ left: (h / 12) * 100 + "%" }}
166
+ >
167
+ {h === 0 ? "now" : "−" + h + "h"}
168
+ </span>
169
+ ))}
170
+ </div>
171
+ </div>
172
+ <div className="swim-rows">
173
+ {rows.map((r) => {
174
+ const marks = seedMarks(r.id, r.kind === "exec" ? 1.4 : 0.8);
175
+ const live = r.kind === "exec" ? liveByAgent[r.agent] || [] : [];
176
+ return (
177
+ <div
178
+ key={r.id}
179
+ className={"swim-row " + (r.kind === "exec" ? "is-exec" : "is-worker")}
180
+ onClick={() => openRow(r)}
181
+ >
182
+ <div className="swim-label" style={{ "--hue": r.hue } as HueStyle}>
183
+ {r.kind === "exec" ? (
184
+ <span
185
+ className="glyph glyph-sm"
186
+ style={{ color: r.hue, borderColor: r.hue + "55" }}
187
+ >
188
+ {r.label}
189
+ </span>
190
+ ) : (
191
+ <span className="swim-wlabel">
192
+ <span className="swim-tree">└</span>
193
+ {r.label}
194
+ </span>
195
+ )}
196
+ {r.kind === "exec" && <StatusDot status={r.status} size={6} />}
197
+ </div>
198
+ <div className="swim-track">
199
+ <div className="swim-gridlines">
200
+ {ticks.map((h) => (
201
+ <span key={h} style={{ left: (h / 12) * 100 + "%" }} />
202
+ ))}
203
+ </div>
204
+ {marks.map((m, i) => (
205
+ <span
206
+ key={i}
207
+ className="swim-block"
208
+ style={{
209
+ left: xOf(m.ago) + "%",
210
+ width: Math.max(0.6, (m.dur / SPAN_MS) * 100) + "%",
211
+ background: r.hue,
212
+ opacity: r.kind === "exec" ? 0.85 : 0.55,
213
+ }}
214
+ />
215
+ ))}
216
+ {live.map((age, i) => (
217
+ <span
218
+ key={"l" + i}
219
+ className="swim-live"
220
+ style={{ left: xOf(age) + "%", background: r.hue }}
221
+ />
222
+ ))}
223
+ {r.kind === "worker" && r.progress != null && (
224
+ <span
225
+ className="swim-now-prog"
226
+ style={{ background: r.hue }}
227
+ title={Math.round(r.progress) + "%"}
228
+ />
229
+ )}
230
+ </div>
231
+ </div>
232
+ );
233
+ })}
234
+ </div>
235
+ </div>
236
+ </div>
237
+ );
238
+ }
@@ -0,0 +1,423 @@
1
+ /* =============================================================
2
+ walwal-harness · BRICK OFFICE real-data adapter
3
+ Maps a real HarnessSnapshot onto the design's Contract shape.
4
+
5
+ Honesty over fake values: where the real feed has no granular
6
+ source (token/min, cost, cpu, per-task %, per-worker %), the
7
+ field is `null` and the UI renders "—". Nothing is fabricated.
8
+
9
+ This module is pure (no React, no DOM).
10
+ ============================================================= */
11
+
12
+ import type {
13
+ HarnessSnapshot,
14
+ WorkerDocEntry,
15
+ MissionDoc,
16
+ CxxTodo,
17
+ HarnessEvent,
18
+ ActivitySample,
19
+ OwnerPromptEntry,
20
+ } from "../types";
21
+ import {
22
+ AGENT_DEFS,
23
+ AGENT_ROLES,
24
+ type AgentRole,
25
+ type AgentStatus,
26
+ type ContractState,
27
+ type ContractAgent,
28
+ type ContractWorker,
29
+ type ContractTask,
30
+ type ContractTasks,
31
+ type ContractEvent,
32
+ type ContractAlert,
33
+ type ContractMetrics,
34
+ } from "./contract";
35
+
36
+ /* ---- constants mirrored from Scene.tsx bucketing ---------------------- */
37
+ const BUCKET_MS = 10 * 60_000; // 10-minute heat buckets (matches Scene heatmap)
38
+ const SPARK_BUCKETS = 24; // 24 buckets of recent activity -> the bar sparkline
39
+ const HOUR_MS = 60 * 60 * 1000;
40
+ const CADENCE_BUCKETS = 24; // 24 hourly buckets of owner prompts
41
+ const STALE_MS = 120_000; // running but no visible movement > 120s -> stale
42
+ const THROUGHPUT_WINDOW_MS = 60_000; // events in the last 60s
43
+
44
+ const ROLE_SET = new Set<string>(AGENT_ROLES);
45
+
46
+ /* ---- helpers ---------------------------------------------------------- */
47
+
48
+ /** Strip the `harness-` prefix and lowercase, returning a role or null. */
49
+ function toRole(value: string | null | undefined): AgentRole | null {
50
+ const r = (value ?? "").toLowerCase().replace(/^harness-/, "").trim();
51
+ return ROLE_SET.has(r) ? (r as AgentRole) : null;
52
+ }
53
+
54
+ /** Parse an ISO timestamp to epoch ms, or null when unparseable. */
55
+ function parseTs(ts: string | null | undefined): number | null {
56
+ if (!ts) return null;
57
+ const t = Date.parse(ts);
58
+ return Number.isFinite(t) ? t : null;
59
+ }
60
+
61
+ /** Strip leading YAML frontmatter from a markdown string. */
62
+ export function stripFrontmatter(md: string | null | undefined): string {
63
+ if (!md) return "";
64
+ return md.replace(/^---[\s\S]*?---\n+/, "");
65
+ }
66
+
67
+ /** The active mission (active flag), else the first mission, else null. */
68
+ function pickActiveMission(snapshot: HarnessSnapshot): MissionDoc | null {
69
+ return snapshot.missions.find((m) => m.active) ?? snapshot.missions[0] ?? null;
70
+ }
71
+
72
+ /** Read a role's report markdown off a mission doc. */
73
+ function missionReportFor(
74
+ mission: MissionDoc | null,
75
+ role: AgentRole
76
+ ): string | undefined {
77
+ if (!mission) return undefined;
78
+ const raw = mission[role];
79
+ return typeof raw === "string" && raw.length > 0 ? raw : undefined;
80
+ }
81
+
82
+ /**
83
+ * Build a per-role spark array: SPARK_BUCKETS buckets of activitySamples,
84
+ * newest bucket last (mirrors Scene.tsx heat bucketing — x = nowMinute -
85
+ * sampleMinute, keep the strongest count seen in each bucket).
86
+ */
87
+ function buildSpark(samples: ActivitySample[], role: AgentRole, nowMs: number): number[] {
88
+ const nowBucket = Math.floor(nowMs / BUCKET_MS);
89
+ const buckets = new Array<number>(SPARK_BUCKETS).fill(0);
90
+ for (const sample of samples) {
91
+ // A role owns its own lane (id === role) and all of its worker lanes
92
+ // (id === `${role}:...`). Both contribute to the role's activity spark.
93
+ const lane = sample.laneId;
94
+ if (lane !== role && !lane.startsWith(role + ":")) continue;
95
+ const t = parseTs(sample.ts);
96
+ if (t == null) continue;
97
+ const x = nowBucket - Math.floor(t / BUCKET_MS);
98
+ if (x < 0 || x >= SPARK_BUCKETS) continue;
99
+ // index SPARK_BUCKETS-1 = now (newest), index 0 = oldest visible bucket
100
+ const idx = SPARK_BUCKETS - 1 - x;
101
+ buckets[idx] = Math.max(buckets[idx], sample.count);
102
+ }
103
+ return buckets;
104
+ }
105
+
106
+ /** Newest activity epoch across a role's lanes + its workers + events. */
107
+ function heartbeatFor(
108
+ snapshot: HarnessSnapshot,
109
+ role: AgentRole,
110
+ workers: WorkerDocEntry[],
111
+ eventEpochByRole: Map<AgentRole, number>
112
+ ): number | null {
113
+ let hb: number | null = null;
114
+ const bump = (t: number | null) => {
115
+ if (t != null && (hb == null || t > hb)) hb = t;
116
+ };
117
+ for (const sample of snapshot.activitySamples) {
118
+ const lane = sample.laneId;
119
+ if (lane === role || lane.startsWith(role + ":")) bump(parseTs(sample.ts));
120
+ }
121
+ for (const w of workers) bump(parseTs(w.updatedAt));
122
+ bump(eventEpochByRole.get(role) ?? null);
123
+ return hb;
124
+ }
125
+
126
+ /** Determine if the company loop is active for status/loop derivation. */
127
+ function loopIsActive(snapshot: HarnessSnapshot): boolean {
128
+ const rt = snapshot.runtime;
129
+ const agentStatus = (rt.agentStatus ?? "").toLowerCase();
130
+ const conductor = (rt.conductorState ?? "").toLowerCase();
131
+ const agentDone = agentStatus === "completed" || agentStatus === "complete";
132
+ const conductorDone =
133
+ conductor === "completed" || conductor === "complete" || conductor === "blocked";
134
+ return !agentDone && !conductorDone;
135
+ }
136
+
137
+ /* ---- the adapter ------------------------------------------------------ */
138
+
139
+ /**
140
+ * Map a real HarnessSnapshot onto the design Contract shape.
141
+ *
142
+ * @param snapshot the real harness feed snapshot
143
+ * @param connected whether the SSE stream is currently open
144
+ * @param nowMs the shell's ~1/s clock value (epoch ms)
145
+ */
146
+ export function toContractState(
147
+ snapshot: HarnessSnapshot,
148
+ connected: boolean,
149
+ nowMs: number
150
+ ): ContractState {
151
+ const rt = snapshot.runtime;
152
+ const currentRole = toRole(rt.currentAgent);
153
+ const loopActive = loopIsActive(snapshot);
154
+ const activeMission = pickActiveMission(snapshot);
155
+
156
+ // Workers from the active mission(s) — index by role.
157
+ const activeMissions = snapshot.missions.filter((m) => m.active);
158
+ const missionsForWorkers = activeMissions.length > 0 ? activeMissions : snapshot.missions;
159
+ const workerEntries: Array<{ worker: WorkerDocEntry; mission: MissionDoc }> = [];
160
+ const workersByRole = new Map<AgentRole, WorkerDocEntry[]>();
161
+ for (const role of AGENT_ROLES) workersByRole.set(role, []);
162
+ for (const mission of missionsForWorkers) {
163
+ for (const worker of mission.workers) {
164
+ const role = toRole(worker.owner);
165
+ if (!role) continue;
166
+ workerEntries.push({ worker, mission });
167
+ workersByRole.get(role)!.push(worker);
168
+ }
169
+ }
170
+
171
+ // Events newest-first; capture per-role newest epoch for heartbeats.
172
+ const eventEpochByRole = new Map<AgentRole, number>();
173
+ const firstLaneRole = (): AgentRole | null => {
174
+ const s = snapshot.activitySamples[snapshot.activitySamples.length - 1];
175
+ return s ? toRole(s.laneId.split(":")[0]) : null;
176
+ };
177
+ for (const ev of snapshot.events) {
178
+ const role = toRole(ev.owner);
179
+ const t = parseTs(ev.ts);
180
+ if (role && t != null) {
181
+ const prev = eventEpochByRole.get(role);
182
+ if (prev == null || t > prev) eventEpochByRole.set(role, t);
183
+ }
184
+ }
185
+
186
+ // ---- agents --------------------------------------------------------
187
+ const hasRecentSample = (role: AgentRole): boolean =>
188
+ snapshot.activitySamples.some(
189
+ (s) => (s.laneId === role || s.laneId.startsWith(role + ":")) && s.count > 0
190
+ );
191
+ const runtimeBlocked =
192
+ (rt.agentStatus ?? "").toLowerCase() === "blocked" ||
193
+ (rt.conductorState ?? "").toLowerCase() === "blocked";
194
+
195
+ const agents: ContractAgent[] = AGENT_DEFS.map((def) => {
196
+ const role = def.id;
197
+ const isCurrent = role === currentRole;
198
+ const roleWorkers = workersByRole.get(role) ?? [];
199
+ const openTodos = snapshot.todos.filter(
200
+ (t) => toRole(t.owner) === role && !isTerminalTodo(t)
201
+ ).length;
202
+ const liveWorkers = roleWorkers.filter(
203
+ (w) => w.active || w.status === "IN_PROGRESS"
204
+ ).length;
205
+
206
+ let status: AgentStatus;
207
+ if (isCurrent && loopActive) status = "live";
208
+ else if (hasRecentSample(role)) status = "active";
209
+ else if (runtimeBlocked && isCurrent) status = "alert";
210
+ else status = "idle";
211
+
212
+ const loop = (isCurrent && loopActive) || openTodos > 0 ? "running" : "idle";
213
+ const work = isCurrent ? rt.currentAction ?? "—" : "—";
214
+
215
+ return {
216
+ id: role,
217
+ name: def.name,
218
+ role: def.role,
219
+ hue: def.hue,
220
+ status,
221
+ loop,
222
+ work,
223
+ todos: openTodos,
224
+ workers: liveWorkers,
225
+ spark: buildSpark(snapshot.activitySamples, role, nowMs),
226
+ heartbeat: heartbeatFor(snapshot, role, roleWorkers, eventEpochByRole),
227
+ report: missionReportFor(activeMission, role),
228
+ };
229
+ });
230
+
231
+ // ---- workers -------------------------------------------------------
232
+ const workers: ContractWorker[] = workerEntries.map(({ worker }) => {
233
+ const role = toRole(worker.owner)!;
234
+ const status: AgentStatus =
235
+ worker.status === "IN_PROGRESS" ? "active" : "idle";
236
+ return {
237
+ id: `${worker.owner}:${worker.name}`,
238
+ agent: role,
239
+ name: worker.displayName,
240
+ status,
241
+ started: parseTs(worker.updatedAt),
242
+ progress: worker.status === "COMPLETE" ? 100 : null,
243
+ report: stripFrontmatter(worker.content) || undefined,
244
+ };
245
+ });
246
+
247
+ // ---- tasks ---------------------------------------------------------
248
+ const tasks = buildTasks(snapshot.todos);
249
+
250
+ // ---- events --------------------------------------------------------
251
+ const events: ContractEvent[] = snapshot.events
252
+ .slice(-60)
253
+ .map((ev, i): ContractEvent => {
254
+ const role = toRole(ev.owner) ?? firstLaneRole() ?? "";
255
+ return {
256
+ id: eventId(ev, i),
257
+ at: parseTs(ev.ts),
258
+ agent: role,
259
+ worker: "",
260
+ verb: ev.type || ev.status || "",
261
+ obj: ev.title || ev.summary || ev.command || "",
262
+ };
263
+ });
264
+
265
+ // ---- cadence -------------------------------------------------------
266
+ const cadence = buildCadence(snapshot.ownerHistory, nowMs);
267
+
268
+ // ---- alerts (synthesized) -----------------------------------------
269
+ const alerts = buildAlerts(snapshot, agents, currentRole, runtimeBlocked, nowMs);
270
+
271
+ // ---- metrics -------------------------------------------------------
272
+ const throughput = snapshot.events.filter((ev) => {
273
+ const t = parseTs(ev.ts);
274
+ return t != null && nowMs - t <= THROUGHPUT_WINDOW_MS;
275
+ }).length;
276
+
277
+ const metrics: ContractMetrics = {
278
+ sse: connected ? "open" : "closed",
279
+ tokensPerMin: null,
280
+ costToday: null,
281
+ throughput,
282
+ cpu: null,
283
+ hotfix: snapshot.missions.filter((m) => m.type === "hotfix").length,
284
+ eventsTotal: snapshot.events.length,
285
+ };
286
+
287
+ return {
288
+ now: nowMs,
289
+ agents,
290
+ workers,
291
+ tasks,
292
+ events,
293
+ cadence,
294
+ alerts,
295
+ metrics,
296
+ mtrend: [],
297
+ ctrend: [],
298
+ };
299
+ }
300
+
301
+ /* ---- task building ---------------------------------------------------- */
302
+
303
+ function isTerminalTodo(t: CxxTodo): boolean {
304
+ const s = (t.status ?? "").toLowerCase();
305
+ return s === "done" || s === "completed";
306
+ }
307
+
308
+ function buildTasks(todos: CxxTodo[]): ContractTasks {
309
+ const running: ContractTask[] = [];
310
+ const queued: ContractTask[] = [];
311
+ const done: ContractTask[] = [];
312
+
313
+ for (const t of todos) {
314
+ const role = ((t.owner ?? "").toLowerCase().replace(/^harness-/, "")) as AgentRole;
315
+ const status = (t.status ?? "").toLowerCase();
316
+ const at = parseTs(t.updatedAt);
317
+ const base: ContractTask = {
318
+ id: t.id,
319
+ owner: (t.owner ?? "").toUpperCase(),
320
+ title: t.title,
321
+ agent: role,
322
+ status: "queued",
323
+ progress: null,
324
+ started: at,
325
+ at,
326
+ };
327
+ if (status === "active") {
328
+ running.push({ ...base, status: "running" });
329
+ } else if (status === "pending" || status === "paused") {
330
+ queued.push({ ...base, status: "queued" });
331
+ } else if (status === "done" || status === "completed") {
332
+ done.push({ ...base, status: "done" });
333
+ }
334
+ }
335
+
336
+ // done: newest first, cap ~14
337
+ done.sort((a, b) => (b.at ?? 0) - (a.at ?? 0));
338
+ return { running, queued, done: done.slice(0, 14) };
339
+ }
340
+
341
+ /* ---- cadence building (mirrors Scene.tsx CadenceStrip) ---------------- */
342
+
343
+ function buildCadence(ownerHistory: OwnerPromptEntry[], nowMs: number): number[] {
344
+ const buckets = new Array<number>(CADENCE_BUCKETS).fill(0);
345
+ for (const entry of ownerHistory) {
346
+ const t = parseTs(entry.ts);
347
+ if (t == null) continue;
348
+ const hoursAgo = Math.floor((nowMs - t) / HOUR_MS);
349
+ if (hoursAgo < 0 || hoursAgo >= CADENCE_BUCKETS) continue;
350
+ buckets[hoursAgo] += 1;
351
+ }
352
+ return buckets;
353
+ }
354
+
355
+ /* ---- alert synthesis -------------------------------------------------- */
356
+
357
+ function buildAlerts(
358
+ snapshot: HarnessSnapshot,
359
+ agents: ContractAgent[],
360
+ currentRole: AgentRole | null,
361
+ runtimeBlocked: boolean,
362
+ nowMs: number
363
+ ): ContractAlert[] {
364
+ const alerts: ContractAlert[] = [];
365
+ const rt = snapshot.runtime;
366
+ let seq = 0;
367
+ const nextId = (k: string) => `alert-${k}-${seq++}`;
368
+
369
+ // runtime blocked OR ownerPrompt awaiting-authority -> attention
370
+ const awaiting = (rt.ownerPrompt?.status ?? "").toLowerCase() === "awaiting-authority";
371
+ if (runtimeBlocked || awaiting) {
372
+ const agent = currentRole ?? "ceo";
373
+ const msg =
374
+ rt.currentAction ?? rt.ownerPrompt?.summary ?? (runtimeBlocked ? "runtime blocked" : "awaiting authority");
375
+ alerts.push({
376
+ id: nextId("rt"),
377
+ level: "attention",
378
+ agent,
379
+ msg,
380
+ at: parseTs(rt.updatedAt) ?? nowMs,
381
+ });
382
+ }
383
+
384
+ // running-but-stale heartbeat -> stale
385
+ for (const a of agents) {
386
+ if (a.loop !== "running") continue;
387
+ if (a.heartbeat == null) continue;
388
+ if (nowMs - a.heartbeat > STALE_MS) {
389
+ alerts.push({
390
+ id: nextId("stale-" + a.id),
391
+ level: "stale",
392
+ agent: a.id,
393
+ msg: "running, but no recent visible movement",
394
+ at: a.heartbeat,
395
+ });
396
+ }
397
+ }
398
+
399
+ // protocolViolations -> attention
400
+ for (const mission of snapshot.missions) {
401
+ for (const v of mission.protocolViolations) {
402
+ const agent = currentRole ?? "ceo";
403
+ alerts.push({
404
+ id: nextId("pv"),
405
+ level: "attention",
406
+ agent,
407
+ msg: v,
408
+ at: parseTs(mission.ts) ?? nowMs,
409
+ });
410
+ }
411
+ }
412
+
413
+ return alerts;
414
+ }
415
+
416
+ /* ---- misc ------------------------------------------------------------- */
417
+
418
+ function eventId(ev: HarnessEvent, i: number): string {
419
+ const t = ev.ts ?? "";
420
+ const owner = ev.owner ?? "";
421
+ const kind = ev.type ?? ev.status ?? "";
422
+ return `ev-${i}-${t}-${owner}-${kind}`;
423
+ }