nexus-prime 7.9.30 → 7.9.31

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.
@@ -132,7 +132,21 @@
132
132
  .kcard.dragging { opacity: 0.35; transform: scale(0.96); cursor: grabbing; pointer-events: none; }
133
133
 
134
134
  /* ── Agents Live Strip ── */
135
- #agents-live-strip { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 10px; }
135
+ #agents-live-strip { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 10px; }
136
+ .agent-live-summary {
137
+ display: inline-flex;
138
+ align-items: center;
139
+ gap: 5px;
140
+ padding: 3px 8px;
141
+ border: 1px solid rgba(0,255,136,0.18);
142
+ border-radius: 20px;
143
+ background: rgba(0,255,136,0.05);
144
+ color: var(--text-dim);
145
+ font-family: var(--font-mono);
146
+ font-size: 0.7rem;
147
+ }
148
+ .agent-live-summary strong { color: var(--accent); }
149
+ .agent-live-summary .agent-live-warn { color: var(--warning); }
136
150
  .agent-live-pill {
137
151
  display: inline-flex; align-items: center; gap: 5px;
138
152
  font-family: var(--font-mono); font-size: 0.78rem;
@@ -143,6 +157,8 @@
143
157
  .agent-live-pill .dot { width: 5px; height: 5px; border-radius: 50%; background: var(--text-dim); flex-shrink: 0; }
144
158
  .agent-live-pill.active .dot { background: var(--accent); box-shadow: 0 0 4px rgba(0,255,136,0.6); }
145
159
  .agent-live-pill.active { border-color: rgba(0,255,136,0.25); }
160
+ .agent-live-pill.idle .dot { background: var(--text-dim); opacity: 0.8; }
161
+ .agent-live-pill.idle { border-color: rgba(255,255,255,0.08); opacity: 0.78; }
146
162
  .agent-live-pill.blocked .dot { background: var(--warning); }
147
163
  .agent-live-pill.blocked { border-color: rgba(255,95,87,0.25); }
148
164
  .agent-inline-badge {
@@ -2,6 +2,74 @@
2
2
 
3
3
  .governance-container { padding: 4px 0; }
4
4
 
5
+ .governance-status-grid {
6
+ display: grid;
7
+ grid-template-columns: repeat(2, minmax(0, 1fr));
8
+ gap: 12px;
9
+ max-width: 1120px;
10
+ }
11
+
12
+ .governance-status-card {
13
+ padding: 18px 20px;
14
+ min-height: 150px;
15
+ }
16
+
17
+ .governance-card-head {
18
+ display: flex;
19
+ align-items: center;
20
+ justify-content: space-between;
21
+ gap: 10px;
22
+ margin-bottom: 16px;
23
+ }
24
+
25
+ .governance-card-title {
26
+ color: var(--text);
27
+ font-weight: var(--weight-semibold);
28
+ }
29
+
30
+ .governance-status-body {
31
+ display: grid;
32
+ gap: 9px;
33
+ }
34
+
35
+ .governance-status-body.compact {
36
+ margin-top: 14px;
37
+ }
38
+
39
+ .governance-status-row {
40
+ display: grid;
41
+ grid-template-columns: minmax(120px, 0.42fr) minmax(0, 1fr);
42
+ gap: 12px;
43
+ align-items: baseline;
44
+ padding-bottom: 8px;
45
+ border-bottom: 1px solid var(--border);
46
+ }
47
+
48
+ .governance-status-row:last-child {
49
+ border-bottom: none;
50
+ }
51
+
52
+ .governance-status-row span {
53
+ color: var(--text-muted);
54
+ font-size: var(--text-sm);
55
+ }
56
+
57
+ .governance-status-row strong {
58
+ min-width: 0;
59
+ color: var(--text);
60
+ font-family: var(--font-mono);
61
+ font-size: var(--text-sm);
62
+ overflow-wrap: anywhere;
63
+ text-align: right;
64
+ }
65
+
66
+ .governance-status-copy {
67
+ color: var(--text-muted);
68
+ font-size: var(--text-sm);
69
+ line-height: 1.5;
70
+ max-width: 420px;
71
+ }
72
+
5
73
  /* Darwin proposal cards */
6
74
  .darwin-card { margin-bottom: 14px; padding: 16px 18px; }
7
75
 
@@ -37,12 +105,21 @@
37
105
  }
38
106
 
39
107
  .darwin-metric {
108
+ display: inline-grid;
109
+ grid-template-columns: auto auto auto;
110
+ align-items: center;
111
+ gap: 6px;
40
112
  font-family: var(--font-mono);
41
113
  font-size: var(--caption);
42
114
  padding: 2px 8px;
43
115
  border-radius: var(--radius-sm);
44
116
  background: var(--surface-2);
45
117
  }
118
+ .darwin-metric strong,
119
+ .darwin-metric em {
120
+ font-style: normal;
121
+ font-weight: 600;
122
+ }
46
123
  .darwin-metric.metric-up { color: var(--ok); }
47
124
  .darwin-metric.metric-down { color: var(--bad); }
48
125
  .darwin-metric.metric-flat { color: var(--text-muted); }
@@ -119,3 +196,19 @@
119
196
 
120
197
  .btn-ok { color: var(--ok); border-color: var(--ok); }
121
198
  .btn-bad { color: var(--bad); border-color: var(--bad); }
199
+
200
+ @media (max-width: 900px) {
201
+ .governance-status-grid {
202
+ grid-template-columns: 1fr;
203
+ }
204
+ .governance-status-row {
205
+ grid-template-columns: 1fr;
206
+ gap: 3px;
207
+ }
208
+ .governance-status-row strong {
209
+ text-align: left;
210
+ }
211
+ .darwin-metric {
212
+ grid-template-columns: 1fr;
213
+ }
214
+ }
@@ -49,7 +49,7 @@
49
49
  .memory-graph-hud {
50
50
  position: absolute; top: 10px; left: 10px;
51
51
  display: flex; flex-wrap: wrap; gap: 6px;
52
- max-width: min(560px, calc(100% - 170px));
52
+ max-width: min(560px, calc(100% - 230px));
53
53
  pointer-events: none;
54
54
  z-index: 9;
55
55
  }
@@ -63,6 +63,84 @@
63
63
  font-size: 0.68rem;
64
64
  white-space: nowrap;
65
65
  }
66
+ .memory-graph-controls {
67
+ position: absolute;
68
+ right: 12px;
69
+ bottom: 12px;
70
+ z-index: 12;
71
+ display: flex;
72
+ flex-wrap: wrap;
73
+ justify-content: flex-end;
74
+ gap: 6px;
75
+ max-width: min(360px, calc(100% - 24px));
76
+ padding: 6px;
77
+ border: 1px solid rgba(255,255,255,0.08);
78
+ border-radius: var(--radius);
79
+ background: rgba(0,0,0,0.58);
80
+ backdrop-filter: blur(10px);
81
+ }
82
+ .memory-graph-btn {
83
+ min-width: 32px;
84
+ height: 28px;
85
+ padding: 0 9px;
86
+ border: 1px solid var(--border);
87
+ border-radius: var(--radius);
88
+ background: rgba(12,12,14,0.86);
89
+ color: var(--text-muted);
90
+ font-family: var(--font-mono);
91
+ font-size: 0.7rem;
92
+ cursor: pointer;
93
+ }
94
+ .memory-graph-btn:hover,
95
+ .memory-graph-btn:focus-visible {
96
+ border-color: rgba(0,255,136,0.35);
97
+ color: var(--accent);
98
+ outline: none;
99
+ }
100
+ .memory-graph-node-browser {
101
+ display: flex;
102
+ flex-direction: column;
103
+ gap: 6px;
104
+ }
105
+ .memory-graph-node-summary {
106
+ display: flex;
107
+ gap: 6px;
108
+ flex-wrap: wrap;
109
+ margin-bottom: 6px;
110
+ }
111
+ .memory-graph-node-row {
112
+ width: 100%;
113
+ display: grid;
114
+ grid-template-columns: 82px minmax(0, 1fr) auto;
115
+ gap: 8px;
116
+ align-items: center;
117
+ padding: 8px 10px;
118
+ border: 1px solid var(--border);
119
+ border-radius: var(--radius);
120
+ background: var(--bg-panel);
121
+ color: var(--text-main);
122
+ text-align: left;
123
+ cursor: pointer;
124
+ }
125
+ .memory-graph-node-row:hover,
126
+ .memory-graph-node-row:focus-visible {
127
+ border-color: rgba(0,255,136,0.28);
128
+ outline: none;
129
+ }
130
+ .memory-graph-node-type,
131
+ .memory-graph-node-links {
132
+ color: var(--text-dim);
133
+ font-family: var(--font-mono);
134
+ font-size: 0.66rem;
135
+ white-space: nowrap;
136
+ }
137
+ .memory-graph-node-title {
138
+ min-width: 0;
139
+ overflow: hidden;
140
+ text-overflow: ellipsis;
141
+ white-space: nowrap;
142
+ font-size: 0.76rem;
143
+ }
66
144
  .leg-item {
67
145
  display: flex; align-items: center; gap: 6px;
68
146
  font-family: var(--font-mono); font-size: 0.78rem; color: var(--text-dim);
@@ -183,6 +261,8 @@
183
261
  #repo-graph-container.graph-expanded { left: 16px; right: 16px; top: 56px; bottom: 16px; }
184
262
  .repo-graph-actions { justify-content: flex-start; margin-left: 0; width: 100%; }
185
263
  .repo-graph-controls { grid-template-columns: repeat(5, auto); }
264
+ .memory-graph-hud { max-width: calc(100% - 22px); top: 52px; }
265
+ .memory-graph-controls { left: 10px; right: 10px; justify-content: flex-start; }
186
266
  .memory-bottom { grid-template-columns: 1fr; }
187
267
  }
188
268
 
@@ -9,12 +9,12 @@
9
9
 
10
10
  /* KPI row */
11
11
  .runtime-kpis {
12
- display: flex;
12
+ display: grid;
13
+ grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
13
14
  gap: 12px;
14
15
  }
15
16
 
16
17
  .rt-kpi {
17
- flex: 1;
18
18
  background: var(--surface);
19
19
  border: 1px solid var(--border);
20
20
  border-radius: 8px;
@@ -10,7 +10,10 @@
10
10
  display: flex; align-items: center; justify-content: center;
11
11
  }
12
12
  .org-box {
13
- padding: 7px 13px; background: var(--bg-panel); border: 1px solid var(--border);
13
+ display: inline-flex;
14
+ align-items: center;
15
+ gap: 7px;
16
+ padding: 8px 12px; background: var(--bg-panel); border: 1px solid var(--border);
14
17
  border-radius: var(--radius); font-family: var(--font-mono); font-size: 0.78rem;
15
18
  color: var(--text-main); white-space: nowrap; cursor: pointer; transition: border-color 0.15s;
16
19
  }
@@ -24,10 +27,32 @@
24
27
  .org-branch[data-team] > div > .org-box { border-color: rgba(0,212,255,0.25); }
25
28
  .op-status-dot {
26
29
  display: inline-block; width: 5px; height: 5px; border-radius: 50%;
27
- margin-right: 5px; background: var(--text-dim); vertical-align: middle;
30
+ background: var(--text-dim); vertical-align: middle; flex-shrink: 0;
28
31
  }
29
32
  .op-status-dot.active { background: var(--accent); }
30
33
  .op-status-dot.dead { background: var(--warning); }
34
+ .org-op-text {
35
+ display: flex;
36
+ flex-direction: column;
37
+ align-items: flex-start;
38
+ gap: 1px;
39
+ min-width: 0;
40
+ }
41
+ .org-op-name {
42
+ max-width: 180px;
43
+ overflow: hidden;
44
+ text-overflow: ellipsis;
45
+ white-space: nowrap;
46
+ }
47
+ .org-op-meta {
48
+ max-width: 180px;
49
+ overflow: hidden;
50
+ text-overflow: ellipsis;
51
+ white-space: nowrap;
52
+ color: var(--text-dim);
53
+ font-size: 0.62rem;
54
+ text-transform: uppercase;
55
+ }
31
56
 
32
57
  /* ── Missions / Dispatch ── */
33
58
  .workforce-bottom { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
@@ -49,6 +74,12 @@
49
74
  display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
50
75
  gap: 10px; margin-bottom: 14px;
51
76
  }
77
+ .spec-cost {
78
+ margin-top: 5px;
79
+ color: var(--text-dim);
80
+ font-family: var(--font-mono);
81
+ font-size: 0.68rem;
82
+ }
52
83
 
53
84
  /* ── Trust / Diff Viewer ── */
54
85
  .trust-layout { display: grid; grid-template-columns: 1fr 300px; gap: 12px; }
@@ -131,7 +162,30 @@
131
162
 
132
163
  /* ── Unified workforce kanban ── */
133
164
  .workforce-kanban { padding: 0; background: transparent; border: none !important; }
134
- .kanban-meta { font-size: 0.75rem; color: var(--text-dim); margin-bottom: 8px; padding: 0 4px; }
165
+ .kanban-meta {
166
+ display: flex;
167
+ align-items: center;
168
+ gap: 8px;
169
+ flex-wrap: wrap;
170
+ font-size: 0.75rem;
171
+ color: var(--text-dim);
172
+ margin-bottom: 8px;
173
+ padding: 0 4px;
174
+ }
175
+ .kanban-summary {
176
+ display: flex;
177
+ gap: 5px;
178
+ flex-wrap: wrap;
179
+ }
180
+ .kanban-summary-chip {
181
+ border: 1px solid var(--border);
182
+ border-radius: 999px;
183
+ padding: 1px 7px;
184
+ background: var(--bg-panel);
185
+ color: var(--text-muted);
186
+ font-family: var(--font-mono);
187
+ font-size: 0.66rem;
188
+ }
135
189
  .kanban-scroll { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 8px; }
136
190
  .kanban-lane {
137
191
  min-width: 220px; max-width: 280px; flex-shrink: 0;
@@ -67,13 +67,21 @@ function catColor(cat) {
67
67
  }
68
68
  function normalizeOperative(op) {
69
69
  if (!op || typeof op !== 'object') return op;
70
- return { ...op, role: op.roleTitle||op.role||op.name||null,
71
- status: op.state||op.healthState||op.status||'IDLE',
70
+ const displayName = op.name || op.displayName || op.roleTitle || op.role || op.specialistId || op.id || op.operativeId;
71
+ const rawStatus = op.state || op.healthState || op.status || 'IDLE';
72
+ return { ...op,
73
+ displayName,
74
+ role: op.role || op.roleTitle || op.specialistId || displayName || null,
75
+ status: String(rawStatus).toLowerCase(),
72
76
  budget: op.budgetCapUsd??op.budget??null,
73
77
  budgetUsed: op.spentUsd??op.budgetUsed??0,
74
78
  team: op.strikeTeamId||op.team||op.strikeName||null };
75
79
  }
76
80
 
81
+ function isOpActive(op) {
82
+ return ['active','running','wip','claimed'].includes(String(op?.status || '').toLowerCase());
83
+ }
84
+
77
85
  /* ── Data loader ── */
78
86
  export async function load() {
79
87
  const settle = async (jobs) => (await Promise.allSettled(jobs)).map(r => r.status === 'fulfilled' ? r.value : null);
@@ -92,12 +100,14 @@ export async function load() {
92
100
  api('/api/health', 15_000, { timeoutMs: 3_500 }),
93
101
  api('/api/memory/health', 15_000, { timeoutMs: 4_000 }),
94
102
  api('/api/runs?limit=12', 5_000, { timeoutMs: 3_500 }),
95
- ]).then(([opR, shR, healthR, memR, runsR]) => {
103
+ api('/api/workforce/kanban', 5_000, { timeoutMs: 3_500 }),
104
+ ]).then(([opR, shR, healthR, memR, runsR, kanbanR]) => {
96
105
  const op = opR.status === 'fulfilled' ? opR.value : null;
97
106
  const sh = shR.status === 'fulfilled' ? shR.value : null;
98
107
  const health = healthR.status === 'fulfilled' ? healthR.value : null;
99
108
  const memHealth = memR.status === 'fulfilled' ? memR.value : null;
100
109
  const runs = runsR.status === 'fulfilled' ? runsR.value : null;
110
+ const kanban = kanbanR.status === 'fulfilled' ? kanbanR.value : null;
101
111
  if (op) S.operateSurface = op;
102
112
  if (sh) {
103
113
  S.synapseHealthRaw = sh;
@@ -107,6 +117,7 @@ export async function load() {
107
117
  if (health) S.healthData = health;
108
118
  if (memHealth) S.memHealth = memHealth;
109
119
  if (Array.isArray(runs)) S.runs = runs;
120
+ if (kanban) S.wfKanban = kanban;
110
121
  render();
111
122
  });
112
123
  // Prefetch curated specialists for first-run hero (non-blocking)
@@ -376,7 +387,7 @@ function renderHero() {
376
387
  const pEl = $('m-pct');
377
388
  if (pEl) { pEl.innerHTML=`${pct}<sup>%</sup>`; pEl.dataset.raw=pct; }
378
389
  const memCount = op?.memorySummary?.total ?? op?.memory?.total ?? S.memHealth?.total ?? S.memories.length;
379
- const opsCount = S.synapseHealth.filter(o=>o.status==='ACTIVE'||o.status==='active').length;
390
+ const opsCount = S.synapseHealth.length;
380
391
  animCounter('m-memories', memCount);
381
392
  animCounter('m-ops', opsCount);
382
393
  if (!S.spark) S.spark = { tokens:[], pct:[], memories:[], ops:[] };
@@ -565,11 +576,18 @@ function renderAgentsLiveStrip() {
565
576
  const strip = $('agents-live-strip'); if (!strip) return;
566
577
  const ops = S.synapseHealth;
567
578
  if (!ops.length) { strip.innerHTML=''; return; }
568
- strip.innerHTML = ops.slice(0,12).map(op => {
579
+ const active = ops.filter(isOpActive).length;
580
+ const failedJobs = (S.wfKanban?.lanes?.failed || []).length;
581
+ const summary = `<div class="agent-live-summary">
582
+ <strong>${fmtNum(ops.length)}</strong><span>hired</span>
583
+ <strong>${fmtNum(active)}</strong><span>active</span>
584
+ ${failedJobs ? `<strong class="agent-live-warn">${fmtNum(failedJobs)}</strong><span>failed</span>` : ''}
585
+ </div>`;
586
+ strip.innerHTML = summary + ops.slice(0,12).map(op => {
569
587
  const st = (op.status||'idle').toLowerCase();
570
- const cls = (st==='active'||st==='running') ? 'active' : (st==='blocked'||st==='zombie'||st==='dead') ? 'blocked' : '';
571
- const label = esc((op.role||op.name||op.id||'agent').slice(0,20));
572
- return `<div class="agent-live-pill ${cls}" data-opid="${esc(op.id)}" title="${label}">
588
+ const cls = isOpActive(op) ? 'active' : (st==='blocked'||st==='zombie'||st==='dead'||st==='failed') ? 'blocked' : 'idle';
589
+ const label = esc((op.displayName||op.name||op.role||op.id||'agent').slice(0,24));
590
+ return `<div class="agent-live-pill ${cls}" data-opid="${esc(op.id)}" title="${label} · ${esc(st)}">
573
591
  <span class="dot"></span><span>${label}</span></div>`;
574
592
  }).join('');
575
593
  }
@@ -577,6 +595,7 @@ function renderAgentsLiveStrip() {
577
595
  /* ── Kanban ── */
578
596
  function buildKanbanCols() {
579
597
  const cols={planning:[],hiring:[],running:[],ghostpass:[],done:[]};
598
+ const seen = new Set();
580
599
  const op=S.operateSurface;
581
600
  if (op) {
582
601
  const pc=op.orchestration?.planningContext||op.planningContext;
@@ -588,6 +607,36 @@ function buildKanbanCols() {
588
607
  if (sg) cols[sg].push({id:w.id||w.workerId||w.goal,goal:w.goal||w.task||w.approach||'(worker)',status:st,tokens:w.tokensUsed||w.budget,time:w.startedAt||w.createdAt,role:w.role});
589
608
  }
590
609
  }
610
+ const wfLanes = S.wfKanban?.lanes || {};
611
+ const wfLaneMap = {
612
+ backlog: 'planning',
613
+ ready: 'hiring',
614
+ claimed: 'hiring',
615
+ wip: 'running',
616
+ review: 'ghostpass',
617
+ blocked: 'ghostpass',
618
+ done: 'done',
619
+ failed: 'done',
620
+ cancelled: 'done',
621
+ };
622
+ for (const [lane, cards] of Object.entries(wfLanes)) {
623
+ const target = wfLaneMap[lane] || 'planning';
624
+ for (const card of (Array.isArray(cards) ? cards : [])) {
625
+ const id = card.id || card.jobId;
626
+ if (!id || seen.has(id)) continue;
627
+ seen.add(id);
628
+ const payload = card.payload && typeof card.payload === 'object' ? card.payload : {};
629
+ cols[target].push({
630
+ id,
631
+ workerId: card.workerId || payload.operativeId,
632
+ goal: payload.goal || card.title || '(workforce job)',
633
+ status: lane === 'failed' ? 'failed' : lane === 'cancelled' ? 'cancelled' : (card.status || lane),
634
+ tokens: card.tokensUsed || payload.tokensUsed,
635
+ time: card.updatedAt || card.completedAt || card.claimedAt || card.createdAt,
636
+ role: payload.specialistId || card.client || 'workforce',
637
+ });
638
+ }
639
+ }
591
640
  const ghost = S.lastDecomposition?.autoGhostPass || S.lastCompletion?.autoGhostPass || op?.orchestration?.autoGhostPass || op?.autoGhostPass;
592
641
  if (ghost && (ghost.applied || ghost.policy?.reason)) {
593
642
  const risks = Array.isArray(ghost.riskAreas) ? ghost.riskAreas.length : 0;
@@ -606,6 +655,8 @@ function buildKanbanCols() {
606
655
  for (const r of (S.runs||[]).slice(0,8)) {
607
656
  const runId = r.runId || r.id;
608
657
  if (!runId) continue;
658
+ if (seen.has(runId)) continue;
659
+ seen.add(runId);
609
660
  const status = String(r.status || r.state || '').toLowerCase();
610
661
  const stage = String(r.stage || '').toLowerCase();
611
662
  const advisory = status === 'inspected' || /advisory|no.diff|no-diff|no mutation/i.test(String(r.result || r.summary || r.outcome || ''));
@@ -638,9 +689,10 @@ function kcardHtml(c, stage) {
638
689
  const tm = c.time ? `<div class="kcard-time">${timeAgo(c.time)}</div>` : '';
639
690
  const op = S.synapseHealth.find(o =>
640
691
  (c.role && (o.role===c.role||o.name===c.role)) ||
692
+ (c.workerId && (o.id===c.workerId||o.operativeId===c.workerId)) ||
641
693
  (c.id && (o.id===c.id||o.operativeId===c.id)));
642
694
  const opBadge = op
643
- ? `<span class="agent-inline-badge st-${esc((op.status||'idle').toLowerCase())}" title="${esc(op.role||op.name||'')}">◎ ${esc((op.role||op.name||'').slice(0,18)||'agent')}</span>`
695
+ ? `<span class="agent-inline-badge st-${esc((op.status||'idle').toLowerCase())}" title="${esc(op.displayName||op.role||op.name||'')}">◎ ${esc((op.displayName||op.role||op.name||'').slice(0,18)||'agent')}</span>`
644
696
  : '';
645
697
  return `<div class="kcard s-${esc(stage)}" data-runid="${esc(String(c.id))}">
646
698
  <div class="kcard-goal">${esc(c.goal)}</div>
@@ -689,13 +741,14 @@ function renderEvents() {
689
741
  function renderPulse() {
690
742
  const el=$('system-pulse'); if (!el) return;
691
743
  const op=S.operateSurface;
692
- const ops=S.synapseHealth.filter(o=>o.status==='ACTIVE'||o.status==='active').length;
744
+ const activeOps=S.synapseHealth.filter(isOpActive).length;
745
+ const totalOps=S.synapseHealth.length;
693
746
  const mem=op?.memorySummary?.total??op?.memory?.total??S.memories.length;
694
747
  const gt=S.tokensSummary?.gross||0, nt=S.tokensSummary?.net||0;
695
748
  const eff=gt>0?Math.round((1-nt/gt)*100)+'%':'—';
696
749
  const gh=S.worktreeHealth?.pending??0;
697
750
  const up=timeAgo(S.startTime).replace(' ago','');
698
- const rows=[['Synapse',ops?`${ops} active`:'idle'],['Memory',mem?`${fmtNum(mem)} entries`:'—'],['Efficiency',eff],['Ghost-pass',gh?`${gh} pending`:'clear'],['Uptime',up||'—']];
751
+ const rows=[['Synapse',totalOps?`${activeOps}/${totalOps} active`:'idle'],['Memory',mem?`${fmtNum(mem)} entries`:'—'],['Efficiency',eff],['Ghost-pass',gh?`${gh} pending`:'clear'],['Uptime',up||'—']];
699
752
  el.innerHTML=rows.map(([k,v])=>`<div class="row"><span class="row-k">${esc(k)}</span><span class="row-v">${esc(v)}</span></div>`).join('');
700
753
  }
701
754
 
@@ -26,6 +26,10 @@ function outcomeChip(outcome) {
26
26
  return `<span class="chip ${cls[outcome] || 'chip-muted'}">${esc(outcome)}</span>`;
27
27
  }
28
28
 
29
+ function statusRow(label, value) {
30
+ return `<div class="governance-status-row"><span>${esc(label)}</span><strong>${esc(value)}</strong></div>`;
31
+ }
32
+
29
33
  /** Render a compact unified diff with line highlighting. */
30
34
  function renderDiff(diff) {
31
35
  if (!diff) return `<div class="diff-empty">No diff recorded</div>`;
@@ -65,18 +69,22 @@ export function render(cycles, health = null) {
65
69
 
66
70
  if (!cycles.length) {
67
71
  container.innerHTML = `
68
- <div class="trust-grid">
69
- <div class="card trust-card">
70
- <div class="trust-card-hd"><span class="trust-card-title">Darwin auto-propose</span><span class="chip chip-muted">idle</span></div>
71
- <div class="trust-posture-body">
72
- <div class="trust-posture-row"><span>Proposals</span><strong>0</strong></div>
73
- <div class="trust-posture-row"><span>Trigger</span><strong>nexus_orchestrate</strong></div>
74
- <div class="trust-posture-row"><span>Self-improve</span><strong>${esc(health?.runtime?.selfImprove ? 'on' : 'off')}</strong></div>
72
+ <div class="governance-status-grid">
73
+ <div class="card governance-status-card">
74
+ <div class="governance-card-head"><span class="governance-card-title">Darwin auto-propose</span><span class="chip chip-muted">idle</span></div>
75
+ <div class="governance-status-body">
76
+ ${statusRow('Proposals', '0')}
77
+ ${statusRow('Trigger', 'nexus_orchestrate')}
78
+ ${statusRow('Self-improve', health?.runtime?.selfImprove ? 'on' : 'off')}
75
79
  </div>
76
80
  </div>
77
- <div class="card trust-card">
78
- <div class="trust-card-hd"><span class="trust-card-title">Consensus guard</span><span class="chip chip-ok">ready</span></div>
79
- <div class="empty-sub">Live Byzantine votes and review decisions appear here when proposals are created.</div>
81
+ <div class="card governance-status-card">
82
+ <div class="governance-card-head"><span class="governance-card-title">Consensus guard</span><span class="chip chip-ok">ready</span></div>
83
+ <div class="governance-status-copy">Live Byzantine votes and review decisions appear here when proposals are created.</div>
84
+ <div class="governance-status-body compact">
85
+ ${statusRow('Review mode', 'human gated')}
86
+ ${statusRow('Live votes', String(S.byzantineVotes?.size || 0))}
87
+ </div>
80
88
  </div>
81
89
  </div>`;
82
90
  _renderByzantineSection();
@@ -168,7 +176,7 @@ function _fmtMetrics(before, after) {
168
176
  const b = Number(before[k] ?? 0), a = Number(after[k] ?? 0);
169
177
  const delta = a - b;
170
178
  const cls = delta > 0 ? 'metric-up' : delta < 0 ? 'metric-down' : 'metric-flat';
171
- return `<span class="darwin-metric ${cls}">${esc(k)}: ${b.toFixed(2)} ${a.toFixed(2)} (${delta >= 0 ? '+' : ''}${delta.toFixed(2)})</span>`;
179
+ return `<span class="darwin-metric ${cls}"><span>${esc(k)}</span><strong>${b.toFixed(2)} -> ${a.toFixed(2)}</strong><em>${delta >= 0 ? '+' : ''}${delta.toFixed(2)}</em></span>`;
172
180
  }).join('');
173
181
  }
174
182
 
@@ -29,6 +29,10 @@ function timeAgo(ts) {
29
29
  }
30
30
 
31
31
  let _activeTier = null;
32
+ let _graphZoom = null;
33
+ let _graphTransform = null;
34
+ let _lastGraphNodes = [];
35
+ let _lastGraphLinks = [];
32
36
 
33
37
  function memText(m) {
34
38
  return m.title || m.excerpt || m.content || m.summary || m.id || '';
@@ -309,6 +313,58 @@ function _renderGraphHud({ nodes, links, fallbackMode }) {
309
313
  const files = nodes.filter(n => n.nodeType === 'file').length;
310
314
  const mode = fallbackMode === 'none' ? 'real topology' : fallbackMode;
311
315
  hud.innerHTML = `<span>${fmtNum(memories)} memories</span><span>${fmtNum(files)} files</span><span>${fmtNum(links.length)} links</span><span>${esc(mode)}</span>`;
316
+ _renderGraphControls();
317
+ }
318
+
319
+ function _setGraphExpanded(expanded) {
320
+ const c = $('graph-container');
321
+ const b = $('mem-graph-max-btn');
322
+ if (!c) return;
323
+ c.classList.toggle('graph-expanded', Boolean(expanded));
324
+ if (b) b.textContent = expanded ? 'Restore graph' : 'Maximize graph';
325
+ renderGraph();
326
+ }
327
+
328
+ function _zoomGraphBy(factor) {
329
+ const svg = $('graph-svg');
330
+ if (!svg || !_graphZoom || typeof d3 === 'undefined') return;
331
+ d3.select(svg).transition().duration(160).call(_graphZoom.scaleBy, factor);
332
+ }
333
+
334
+ function _resetGraphView() {
335
+ const svg = $('graph-svg');
336
+ if (!svg || !_graphZoom || typeof d3 === 'undefined') return;
337
+ d3.select(svg).transition().duration(180).call(_graphZoom.transform, d3.zoomIdentity);
338
+ }
339
+
340
+ function _renderGraphControls() {
341
+ const container = $('graph-container');
342
+ if (!container) return;
343
+ let controls = document.getElementById('memory-graph-controls');
344
+ if (!controls) {
345
+ controls = document.createElement('div');
346
+ controls.id = 'memory-graph-controls';
347
+ controls.className = 'memory-graph-controls';
348
+ container.appendChild(controls);
349
+ }
350
+ const expanded = container.classList.contains('graph-expanded');
351
+ controls.innerHTML = `
352
+ <button class="memory-graph-btn" data-graph-action="zoom-out" title="Zoom out" aria-label="Zoom out">-</button>
353
+ <button class="memory-graph-btn" data-graph-action="zoom-in" title="Zoom in" aria-label="Zoom in">+</button>
354
+ <button class="memory-graph-btn" data-graph-action="fit" title="Fit graph" aria-label="Fit graph">Fit</button>
355
+ <button class="memory-graph-btn" data-graph-action="browse" title="Browse graph nodes" aria-label="Browse graph nodes">Browse</button>
356
+ <button class="memory-graph-btn" data-graph-action="toggle" title="${expanded ? 'Minimize graph' : 'Maximize graph'}" aria-label="${expanded ? 'Minimize graph' : 'Maximize graph'}">${expanded ? 'Min' : 'Max'}</button>`;
357
+ controls.querySelectorAll('[data-graph-action]').forEach(button => {
358
+ button.addEventListener('click', event => {
359
+ event.stopPropagation();
360
+ const action = button.dataset.graphAction;
361
+ if (action === 'zoom-out') _zoomGraphBy(0.75);
362
+ if (action === 'zoom-in') _zoomGraphBy(1.25);
363
+ if (action === 'fit') _resetGraphView();
364
+ if (action === 'browse') _browseGraphNodes();
365
+ if (action === 'toggle') _setGraphExpanded(!container.classList.contains('graph-expanded'));
366
+ });
367
+ });
312
368
  }
313
369
 
314
370
  /* ── D3 graph ──
@@ -402,6 +458,8 @@ function renderGraph() {
402
458
  const t=typeof l.target==='object'?l.target.id:l.target;
403
459
  return nset.has(s)&&nset.has(t);
404
460
  }).map(l=>({...l}));
461
+ _lastGraphNodes = nodes;
462
+ _lastGraphLinks = links;
405
463
 
406
464
  if (typeof d3 === 'undefined') {
407
465
  svg.innerHTML = '';
@@ -438,6 +496,15 @@ function renderGraph() {
438
496
  .attr('cy', (hash01(`sy:${i}`) * H).toFixed(2))
439
497
  .attr('r', 0.7 + hash01(`sr:${i}`) * 1.4);
440
498
  }
499
+ const viewport=d3s.append('g').attr('class','memory-graph-viewport');
500
+ _graphZoom = d3.zoom()
501
+ .scaleExtent([0.32, 4])
502
+ .on('zoom', ev => {
503
+ _graphTransform = ev.transform;
504
+ viewport.attr('transform', ev.transform);
505
+ });
506
+ d3s.call(_graphZoom).on('dblclick.zoom', null);
507
+ if (_graphTransform) d3s.call(_graphZoom.transform, _graphTransform);
441
508
 
442
509
  // Truthfulness: if the backend couldn't produce a real graph, clear the SVG
443
510
  // and show the explicit empty-state banner. No synthetic edges rendered.
@@ -490,24 +557,25 @@ function renderGraph() {
490
557
  .force('collision',d3.forceCollide(d=>d.nodeType==='file'?8:10+d.priority*8));
491
558
  S.graphSim=sim;
492
559
 
493
- const linkEl=d3s.append('g').attr('class','memory-links').selectAll('line').data(links).enter().append('line')
560
+ const linkEl=viewport.append('g').attr('class','memory-links').selectAll('line').data(links).enter().append('line')
494
561
  .attr('stroke',d=>d.type==='tag'?'#52525b':'#27272a')
495
562
  .attr('stroke-width',d=>d.type==='tag'?1.2:0.8)
496
563
  .attr('stroke-opacity',d=>d.type==='tag'?0.45:0.22);
497
564
  const fileNodes=nodes.filter(n=>n.nodeType==='file');
498
565
  const memNodes =nodes.filter(n=>n.nodeType!=='file');
499
- const fileEl=d3s.append('g').attr('class','memory-file-nodes').selectAll('rect').data(fileNodes).enter().append('rect')
566
+ const fileEl=viewport.append('g').attr('class','memory-file-nodes').selectAll('rect').data(fileNodes).enter().append('rect')
500
567
  .attr('width',d=>5+d.priority*6).attr('height',d=>5+d.priority*6)
501
568
  .attr('rx',1.5).attr('fill','#6366f1').attr('fill-opacity',0.58).attr('stroke','#000').attr('stroke-width',1)
502
569
  .style('cursor','pointer')
570
+ .on('click',(_,d)=>_openGraphFileDrawer(d))
503
571
  .on('mouseover',function(ev,d){ _memHover(d, nodeEl, fileEl, linkEl); _showTip(ev,d.label); })
504
572
  .on('mouseout', function(){ _memHoverOut(nodeEl, fileEl, linkEl); _hideTip(); });
505
573
  const nc=d=>{ const age=Date.now()-(d.createdAt||Date.now()); if(age<3600000) return '#00d4ff'; if(age<86400000) return '#00ff88'; if(age<604800000) return '#ffd14d'; return '#ff5f57'; };
506
574
  // Mark promoted nodes (hippocampus or cortex) for pulsating ring
507
575
  const isPromoted=d=>(d.tier==='hippocampus'||d.tier==='cortex'||d.tier==='episodic'||d.tier==='semantic');
508
- const haloEl=d3s.append('g').attr('class','memory-node-halos').selectAll('circle').data(memNodes).enter().append('circle')
576
+ const haloEl=viewport.append('g').attr('class','memory-node-halos').selectAll('circle').data(memNodes).enter().append('circle')
509
577
  .attr('r',d=>10+d.priority*16).attr('fill','url(#memory-node-glow)').attr('opacity',0.18);
510
- const nodeEl=d3s.append('g').attr('class','memory-nodes').selectAll('circle').data(memNodes).enter().append('circle')
578
+ const nodeEl=viewport.append('g').attr('class','memory-nodes').selectAll('circle').data(memNodes).enter().append('circle')
511
579
  .attr('r',d=>4+d.priority*9).attr('fill',nc).attr('fill-opacity',0.85)
512
580
  .attr('stroke',d=>decayStroke(d.decayState)).attr('stroke-width',d=>isPromoted(d)||d.decayState==='stale'||d.decayState==='retiring'?1.8:1)
513
581
  .style('cursor','pointer')
@@ -647,6 +715,99 @@ function _browseMemories() {
647
715
  });
648
716
  }
649
717
 
718
+ function _linkedNodeIds(id) {
719
+ const linked = new Set();
720
+ for (const link of _lastGraphLinks) {
721
+ const source = typeof link.source === 'object' ? link.source.id : link.source;
722
+ const target = typeof link.target === 'object' ? link.target.id : link.target;
723
+ if (source === id) linked.add(target);
724
+ if (target === id) linked.add(source);
725
+ }
726
+ return linked;
727
+ }
728
+
729
+ function _browseGraphNodes() {
730
+ const nodes = (_lastGraphNodes.length ? _lastGraphNodes : S.gNodes).slice(0, 180);
731
+ const files = nodes.filter(n => n.nodeType === 'file').length;
732
+ const memories = nodes.length - files;
733
+ openDrawer({
734
+ title: `Graph nodes (${nodes.length})`,
735
+ body: nodes.length ? `<div class="memory-graph-node-browser">
736
+ <div class="memory-graph-node-summary">
737
+ <span class="chip chip-muted">${fmtNum(memories)} memories</span>
738
+ <span class="chip chip-muted">${fmtNum(files)} files</span>
739
+ <span class="chip chip-muted">${fmtNum(_lastGraphLinks.length)} links</span>
740
+ </div>
741
+ ${nodes.map(n => {
742
+ const linked = _linkedNodeIds(n.id).size;
743
+ const type = n.nodeType === 'file' ? 'file' : (n.tier || 'memory');
744
+ return `<button class="memory-graph-node-row" data-graph-node="${esc(n.id)}" data-graph-node-type="${esc(n.nodeType || 'memory')}">
745
+ <span class="memory-graph-node-type">${esc(type)}</span>
746
+ <span class="memory-graph-node-title">${esc(n.label || n.id)}</span>
747
+ <span class="memory-graph-node-links">${fmtNum(linked)} links</span>
748
+ </button>`;
749
+ }).join('')}
750
+ </div>` : '<div class="empty-sub">No graph nodes are visible for the current filter.</div>',
751
+ });
752
+ document.querySelectorAll('[data-graph-node]').forEach(row => {
753
+ row.addEventListener('click', () => {
754
+ const node = nodes.find(item => item.id === row.dataset.graphNode);
755
+ if (!node) return;
756
+ if (node.nodeType === 'file') _openGraphFileDrawer(node);
757
+ else _openMemDrawer(node);
758
+ });
759
+ });
760
+ }
761
+
762
+ function _openGraphFileDrawer(node) {
763
+ const linkedIds = _linkedNodeIds(node.id);
764
+ const linkedMemories = S.gNodes
765
+ .filter(item => linkedIds.has(item.id) && item.nodeType !== 'file')
766
+ .slice(0, 12);
767
+ const filePath = node.data?.path || node.label || node.id;
768
+ openDrawer({
769
+ title: filePath.split('/').pop() || 'File node',
770
+ body: `<div class="dsec"><div class="dsec-title">File node</div>
771
+ <div class="drow"><span class="drow-k">Path</span><span class="drow-v">${esc(filePath)}</span></div>
772
+ <div class="drow"><span class="drow-k">Links</span><span class="drow-v">${fmtNum(linkedIds.size)}</span></div>
773
+ <div class="drow"><span class="drow-k">Graph source</span><span class="drow-v">${esc(S.topology?.provenance?.graphSource || S.topology?.meta?.graphSource || 'topology')}</span></div>
774
+ </div>
775
+ <div class="dsec memory-drawer-actions">
776
+ <button class="btn btn-sm" id="mem-file-filter-btn">Filter memories</button>
777
+ <button class="btn btn-sm" id="mem-file-repo-btn">Open repo graph</button>
778
+ </div>
779
+ <div class="dsec"><div class="dsec-title">Linked memories</div>
780
+ ${linkedMemories.length ? linkedMemories.map(m => `<button class="memory-graph-node-row" data-linked-memory="${esc(m.id)}">
781
+ <span class="memory-graph-node-type">${esc(m.tier || 'memory')}</span>
782
+ <span class="memory-graph-node-title">${esc(m.label || m.id)}</span>
783
+ </button>`).join('') : '<div class="empty-sub">No memory node is linked to this file in the visible graph.</div>'}
784
+ </div>`,
785
+ });
786
+ $('mem-file-filter-btn')?.addEventListener('click', () => {
787
+ const input = $('mem-search');
788
+ S.memQuery = filePath.split('/').pop() || filePath;
789
+ if (input) input.value = S.memQuery;
790
+ renderMemList();
791
+ renderGraph();
792
+ });
793
+ $('mem-file-repo-btn')?.addEventListener('click', () => {
794
+ window.location.hash = '#repo';
795
+ setTimeout(() => {
796
+ const input = $('repo-search-input');
797
+ if (input) {
798
+ input.value = filePath.split('/').pop() || filePath;
799
+ input.dispatchEvent(new Event('input'));
800
+ }
801
+ }, 120);
802
+ });
803
+ document.querySelectorAll('[data-linked-memory]').forEach(row => {
804
+ row.addEventListener('click', () => {
805
+ const memory = S.gNodes.find(item => item.id === row.dataset.linkedMemory);
806
+ if (memory) _openMemDrawer(memory);
807
+ });
808
+ });
809
+ }
810
+
650
811
  function _openMemDrawer(node) {
651
812
  const m=node.data||node;
652
813
  const tags=(m.tags||[]).map(t=>`<span class="chip">${esc(t)}</span>`).join(' ');
@@ -698,11 +859,8 @@ export function init() {
698
859
  $('mem-list-browse-btn')?.addEventListener('click', _browseMemories);
699
860
  $('mem-graph-max-btn')?.addEventListener('click', () => {
700
861
  const c = $('graph-container');
701
- const b = $('mem-graph-max-btn');
702
- if (!c || !b) return;
703
- const expanded = c.classList.toggle('graph-expanded');
704
- b.textContent = expanded ? 'Restore graph' : 'Maximize graph';
705
- renderGraph();
862
+ if (!c) return;
863
+ _setGraphExpanded(!c.classList.contains('graph-expanded'));
706
864
  });
707
865
  $('mem-graph-focus-btn')?.addEventListener('click', () => {
708
866
  const selected = selectedMemories();
@@ -34,6 +34,8 @@ let _tokenFlyoutOpen = false;
34
34
  let _tokenFlyoutLoading = false;
35
35
  let _tokenFlyoutError = '';
36
36
  let _tokenTelemetry = null;
37
+ let _runtimeSnapshot = null;
38
+ let _runtimeSnapshotLoading = false;
37
39
 
38
40
  /* ── Category metadata ──────────────────────────────────────────────────────── */
39
41
  const CATEGORY_META = {
@@ -84,6 +86,28 @@ function fmtPct(n) {
84
86
  return `${Math.round(v)}%`;
85
87
  }
86
88
 
89
+ function countKanbanJobs(board) {
90
+ const lanes = board?.lanes ?? {};
91
+ let active = 0;
92
+ let terminal = 0;
93
+ let total = Number(board?.totalJobs ?? 0);
94
+ for (const [lane, cards] of Object.entries(lanes)) {
95
+ const count = Array.isArray(cards) ? cards.length : 0;
96
+ if (!total) total += count;
97
+ if (['done','failed','cancelled'].includes(lane)) terminal += count;
98
+ else active += count;
99
+ }
100
+ return { active, terminal, total };
101
+ }
102
+
103
+ function normalizeOps(raw) {
104
+ const ops = Array.isArray(raw) ? raw : (Array.isArray(raw?.operatives) ? raw.operatives : []);
105
+ return ops.map(op => {
106
+ const status = String(op?.state ?? op?.healthState ?? op?.status ?? 'idle').toLowerCase();
107
+ return { ...op, status };
108
+ });
109
+ }
110
+
87
111
  function fmtTime(ts) {
88
112
  const n = Number(ts ?? 0);
89
113
  if (!Number.isFinite(n) || n <= 0) return 'recent';
@@ -190,7 +214,19 @@ function mount() {
190
214
  </button>
191
215
  <div class="rt-kpi">
192
216
  <div class="rt-kpi-val" id="rt-active-count">0</div>
193
- <div class="rt-kpi-lbl">Active Now</div>
217
+ <div class="rt-kpi-lbl">Active Tools</div>
218
+ </div>
219
+ <div class="rt-kpi">
220
+ <div class="rt-kpi-val" id="rt-operative-count">—</div>
221
+ <div class="rt-kpi-lbl">Operatives</div>
222
+ </div>
223
+ <div class="rt-kpi">
224
+ <div class="rt-kpi-val" id="rt-job-count">—</div>
225
+ <div class="rt-kpi-lbl">Jobs</div>
226
+ </div>
227
+ <div class="rt-kpi">
228
+ <div class="rt-kpi-val" id="rt-client-count">—</div>
229
+ <div class="rt-kpi-lbl">Clients</div>
194
230
  </div>
195
231
  </div>
196
232
 
@@ -263,15 +299,68 @@ function renderKPIs() {
263
299
  const toolEl = $('rt-toolcalls');
264
300
  const savedEl = $('rt-tokens-saved');
265
301
  const activeEl = $('rt-active-count');
302
+ const operativeEl = $('rt-operative-count');
303
+ const jobEl = $('rt-job-count');
304
+ const clientEl = $('rt-client-count');
305
+ const lifetimeSaved = Number(
306
+ _runtimeSnapshot?.tokens?.savedTokens
307
+ ?? _runtimeSnapshot?.tokens?.totalSaved
308
+ ?? _runtimeSnapshot?.summary?.savedTokens
309
+ ?? _runtimeSnapshot?.summary?.saved
310
+ ?? 0
311
+ );
312
+ const displaySaved = _totalTokensSaved > 0 ? _totalTokensSaved : lifetimeSaved;
313
+ const ops = normalizeOps(_runtimeSnapshot?.operatives);
314
+ const activeOps = ops.filter(op => ['active','running','wip','claimed'].includes(op.status)).length;
315
+ const jobs = countKanbanJobs(_runtimeSnapshot?.kanban);
316
+ const clients = _runtimeSnapshot?.health?.runtimeEnvelope?.clients ?? _runtimeSnapshot?.health?.clients ?? {};
317
+ const activeClients = Number(clients.active ?? clients.activeCount ?? 0);
318
+ const totalClients = Number(clients.total ?? clients.totalCount ?? 0);
266
319
  if (toolEl) toolEl.textContent = _toolCalls.toLocaleString();
267
- if (savedEl) savedEl.textContent = _totalTokensSaved > 0
268
- ? (_totalTokensSaved >= 1000 ? `${(_totalTokensSaved / 1000).toFixed(1)}k` : String(_totalTokensSaved))
320
+ if (savedEl) savedEl.textContent = displaySaved > 0
321
+ ? (displaySaved >= 1000 ? `${(displaySaved / 1000).toFixed(1)}k` : String(displaySaved))
269
322
  : '0';
270
323
  if (activeEl) activeEl.textContent = String(_activeTools.size);
324
+ if (operativeEl) operativeEl.textContent = ops.length ? `${activeOps}/${ops.length}` : '0';
325
+ if (jobEl) jobEl.textContent = jobs.total ? `${jobs.active}/${jobs.total}` : '0';
326
+ if (clientEl) clientEl.textContent = totalClients ? `${activeClients}/${totalClients}` : '0';
271
327
  const tokenBtn = $('rt-kpi-saved');
272
328
  if (tokenBtn) tokenBtn.setAttribute('aria-expanded', _tokenFlyoutOpen ? 'true' : 'false');
273
329
  }
274
330
 
331
+ async function refreshRuntimeSnapshot() {
332
+ if (_runtimeSnapshotLoading) return;
333
+ _runtimeSnapshotLoading = true;
334
+ try {
335
+ const [summary, lifetimeRaw, health, kanban, operatives] = await Promise.allSettled([
336
+ api('/api/tokens/summary', 0),
337
+ api('/api/tokens/lifetime', 0),
338
+ api('/api/health', 15_000),
339
+ api('/api/workforce/kanban', 0),
340
+ api('/api/synapse/health', 0),
341
+ ]);
342
+ _runtimeSnapshot = {
343
+ summary: summary.status === 'fulfilled' ? summary.value : null,
344
+ tokens: lifetimeRaw.status === 'fulfilled' ? (lifetimeRaw.value?.data ?? lifetimeRaw.value) : null,
345
+ health: health.status === 'fulfilled' ? health.value : null,
346
+ kanban: kanban.status === 'fulfilled' ? kanban.value : null,
347
+ operatives: operatives.status === 'fulfilled' ? operatives.value : null,
348
+ };
349
+ if (!_tokenTelemetry) {
350
+ _tokenTelemetry = {
351
+ summary: _runtimeSnapshot.summary ?? {},
352
+ lifetime: _runtimeSnapshot.tokens ?? {},
353
+ bySource: {},
354
+ timeline: [],
355
+ };
356
+ }
357
+ } finally {
358
+ _runtimeSnapshotLoading = false;
359
+ renderKPIs();
360
+ renderTokenFlyout();
361
+ }
362
+ }
363
+
275
364
  async function loadTokenTelemetry() {
276
365
  _tokenFlyoutLoading = true;
277
366
  _tokenFlyoutError = '';
@@ -637,6 +726,7 @@ export function ingestEvent(evt) {
637
726
  export function load() {
638
727
  if (!_mounted) mount();
639
728
  else renderAll();
729
+ void refreshRuntimeSnapshot();
640
730
  }
641
731
 
642
732
  export function render() {
@@ -46,6 +46,18 @@ function lifecycleLabel(card, payload) {
46
46
  return 'Queued';
47
47
  }
48
48
 
49
+ function normalizeStatus(value) {
50
+ return String(value || 'idle').toLowerCase();
51
+ }
52
+
53
+ function isActiveStatus(value) {
54
+ return ['active','running','wip','claimed'].includes(normalizeStatus(value));
55
+ }
56
+
57
+ function opLabel(op) {
58
+ return firstText(op?.displayName, op?.name, op?.role, op?.roleTitle, op?.specialistId, op?.id, 'agent');
59
+ }
60
+
49
61
  /* ── Active-dispatch state (push-mode) ── */
50
62
  // Keyed by runId. Entries are created on dispatch.started and removed on complete/failed/cancelled.
51
63
  const _dispatches = new Map();
@@ -156,7 +168,7 @@ function _buildDispatchStrip(run) {
156
168
 
157
169
  /* ── Data loader ── */
158
170
  export async function load() {
159
- const [teams, health, disp, appr, assets, healthData, kanban, sortiesRes] = await Promise.all([
171
+ const [teams, health, disp, appr, assets, healthData, kanban, sortiesRes, curated] = await Promise.all([
160
172
  api('/api/synapse/teams', 5000),
161
173
  api('/api/synapse/health', 5000),
162
174
  api('/api/architects/dispatch', 5000),
@@ -165,6 +177,7 @@ export async function load() {
165
177
  api('/api/health', 15000),
166
178
  api('/api/workforce/kanban', 5000),
167
179
  api('/api/synapse/sorties?limit=40', 5000),
180
+ api('/api/specialists/curated', 60000),
168
181
  ]);
169
182
  S.synapseTeams = teams;
170
183
  S.synapseHealthRaw = health;
@@ -175,14 +188,17 @@ export async function load() {
175
188
  S.healthData = healthData;
176
189
  S.wfKanban = kanban;
177
190
  S.sorties = Array.isArray(sortiesRes?.sorties) ? sortiesRes.sorties : [];
191
+ S.curatedSpecialists = Array.isArray(curated) ? curated : S.curatedSpecialists;
178
192
  notifyNotReady([teams, health, disp]);
179
193
  render();
180
194
  }
181
195
 
182
196
  function _norm(op) {
183
197
  if (!op||typeof op!=='object') return op;
184
- return { ...op, role: op.roleTitle||op.role||op.name||null,
185
- status: op.state||op.healthState||op.status||'IDLE',
198
+ const displayName = op.name || op.displayName || op.role || op.roleTitle || op.specialistId || op.id || op.operativeId;
199
+ return { ...op, displayName,
200
+ role: op.role||op.roleTitle||op.specialistId||displayName||null,
201
+ status: normalizeStatus(op.state||op.healthState||op.status||'IDLE'),
186
202
  budget: op.budgetCapUsd??op.budget??null,
187
203
  budgetUsed: op.spentUsd??op.budgetUsed??0,
188
204
  team: op.strikeTeamId||op.team||op.strikeName||null };
@@ -226,7 +242,17 @@ function renderKanban() {
226
242
  return;
227
243
  }
228
244
 
229
- const lanesHtml = KANBAN_LANES.map(lane => {
245
+ const laneOrder = [...KANBAN_LANES].sort((a, b) => {
246
+ const ac = (lanes[a] ?? []).length;
247
+ const bc = (lanes[b] ?? []).length;
248
+ if (Boolean(bc) !== Boolean(ac)) return Boolean(bc) - Boolean(ac);
249
+ return KANBAN_LANES.indexOf(a) - KANBAN_LANES.indexOf(b);
250
+ });
251
+ const laneSummary = laneOrder
252
+ .filter(lane => (lanes[lane] ?? []).length)
253
+ .map(lane => `<span class="kanban-summary-chip">${esc(LANE_LABEL[lane] ?? lane)} ${fmtNum((lanes[lane] ?? []).length)}</span>`)
254
+ .join('');
255
+ const lanesHtml = laneOrder.map(lane => {
230
256
  const cards = lanes[lane] ?? [];
231
257
  if (cards.length === 0 && ['cancelled','failed'].includes(lane)) return '';
232
258
  const cls = LANE_CLASS[lane] ?? '';
@@ -246,8 +272,11 @@ function renderKanban() {
246
272
  </div>`;
247
273
  }).filter(Boolean).join('');
248
274
 
249
- el.innerHTML = `<div class="kanban-meta">Total: ${total} jobs</div>
275
+ el.innerHTML = `<div class="kanban-meta"><span>Total: ${total} jobs</span>${laneSummary ? `<span class="kanban-summary">${laneSummary}</span>` : ''}</div>
250
276
  <div class="kanban-scroll">${lanesHtml || '<div class="kanban-empty">No visible lanes for the selected filters.</div>'}</div>`;
277
+ el.querySelectorAll('[data-jobid]').forEach(card => {
278
+ card.addEventListener('click', () => _openKanbanJobDrawer(card.dataset.jobid));
279
+ });
251
280
  }
252
281
 
253
282
  function _buildKanbanCard(c, lane) {
@@ -266,7 +295,7 @@ function _buildKanbanCard(c, lane) {
266
295
  runId ? `<span>${esc(String(runId).slice(0, 12))}</span>` : '',
267
296
  c.tokensUsed ? `<span>${fmtNum(c.tokensUsed)}t</span>` : '',
268
297
  ].filter(Boolean).join('');
269
- return `<div class="kanban-card" title="${esc([title, goal, actual].filter(Boolean).join(' · '))}">
298
+ return `<div class="kanban-card" data-jobid="${esc(c.id)}" title="${esc([title, goal, actual].filter(Boolean).join(' · '))}">
270
299
  <div class="kc-top">${pri}<span class="kc-id">${shortId}</span></div>
271
300
  <div class="kc-title">${title}</div>
272
301
  ${goal ? `<div class="kc-goal">${esc(goal)}</div>` : ''}
@@ -279,6 +308,43 @@ function _buildKanbanCard(c, lane) {
279
308
  </div>`;
280
309
  }
281
310
 
311
+ function _findKanbanJob(jobId) {
312
+ const lanes = S.wfKanban?.lanes ?? {};
313
+ for (const [lane, cards] of Object.entries(lanes)) {
314
+ const found = (Array.isArray(cards) ? cards : []).find(card => String(card.id) === String(jobId));
315
+ if (found) return { lane, card: found };
316
+ }
317
+ return null;
318
+ }
319
+
320
+ function _openKanbanJobDrawer(jobId) {
321
+ const hit = _findKanbanJob(jobId);
322
+ if (!hit) return;
323
+ const { lane, card } = hit;
324
+ const payload = card.payload && typeof card.payload === 'object' ? card.payload : {};
325
+ const drows=rows=>rows.map(([k,v])=>`<div class="drow"><span class="drow-k">${esc(k)}</span><span class="drow-v">${esc(String(v??'—'))}</span></div>`).join('');
326
+ const lifecycle = Array.isArray(payload.lifecycle) ? payload.lifecycle : [];
327
+ const files = Array.isArray(payload.filesChanged) ? payload.filesChanged : [];
328
+ openDrawer({
329
+ title: card.title || `Job ${String(card.id).slice(0, 8)}`,
330
+ body: `<div class="dsec"><div class="dsec-title">Workforce job</div>${drows([
331
+ ['ID', card.id],
332
+ ['Lane', LANE_LABEL[lane] ?? lane],
333
+ ['Worker', card.workerId || payload.operativeId || '—'],
334
+ ['Mode', payload.mode || card.client || '—'],
335
+ ['Run', payload.runId || payload.completedRunId || '—'],
336
+ ['Tokens', card.tokensUsed || payload.tokensUsed || 0],
337
+ ])}</div>
338
+ <div class="dsec"><div class="dsec-title">Goal</div><div class="dcontent">${esc(payload.goal || card.title || '—')}</div></div>
339
+ <div class="dsec"><div class="dsec-title">Expected vs actual</div>${drows([
340
+ ['Expected', payload.expectedBehavior || 'Worker owns this dashboard job and closes it with proof.'],
341
+ ['Actual', payload.actualStatus || lifecycleLabel({ status: lane }, payload)],
342
+ ])}</div>
343
+ ${lifecycle.length ? `<div class="dsec"><div class="dsec-title">Lifecycle</div><div class="dtags">${lifecycle.map(step => `<span class="chip chip-muted">${esc(step)}</span>`).join('')}</div></div>` : ''}
344
+ ${files.length ? `<div class="dsec"><div class="dsec-title">Files changed</div><div class="dtags">${files.slice(0, 16).map(file => `<span class="chip">${esc(file)}</span>`).join('')}</div></div>` : ''}`,
345
+ });
346
+ }
347
+
282
348
  /* ── Sortie timeline (kanban diff + parallel execution view) ── */
283
349
  function renderSortieTimeline() {
284
350
  const el = document.getElementById('sortie-timeline');
@@ -336,11 +402,15 @@ function renderSortieTimeline() {
336
402
 
337
403
  /* ── Org chart ── */
338
404
  function _renderOpNode(op) {
339
- const sc=op.status==='ACTIVE'||op.status==='active'?'active':op.status==='ZOMBIE'||op.status==='dead'?'dead':'';
405
+ const st = normalizeStatus(op.status);
406
+ const sc=isActiveStatus(st)?'active':st==='zombie'||st==='dead'||st==='failed'?'dead':'';
340
407
  const interval=op.sortieIntervalMs||60000, lastAt=op.lastSortieAt||op.heartbeatAt||Date.now();
341
408
  const pillId=`op-pill-${esc(op.id||op.operativeId||'')}`;
342
- return `<div class="org-box" id="${pillId}" data-opid="${esc(op.id||op.operativeId)}" data-opname="${esc(op.role||op.name||'agent')}" data-interval="${interval}" data-lastat="${lastAt}">
343
- <span class="op-status-dot ${esc(sc)}"></span>${esc(op.role||op.name||'agent')}
409
+ const label = opLabel(op);
410
+ const sub = firstText(st, op.specialistId, op.team);
411
+ return `<div class="org-box" id="${pillId}" data-opid="${esc(op.id||op.operativeId)}" data-opname="${esc(label)}" data-interval="${interval}" data-lastat="${lastAt}">
412
+ <span class="op-status-dot ${esc(sc)}"></span>
413
+ <span class="org-op-text"><span class="org-op-name">${esc(label)}</span><span class="org-op-meta">${esc(sub)}</span></span>
344
414
  </div>`;
345
415
  }
346
416
 
@@ -403,9 +473,39 @@ function renderOrgChart() {
403
473
  }
404
474
 
405
475
  /* ── Missions ── */
476
+ function _jobsFromKanban(limit = 20) {
477
+ const lanes = S.wfKanban?.lanes ?? {};
478
+ return Object.entries(lanes)
479
+ .flatMap(([lane, cards]) => (Array.isArray(cards) ? cards : []).map(card => ({ lane, card })))
480
+ .sort((a, b) => Number(b.card.updatedAt || b.card.claimedAt || b.card.createdAt || 0) - Number(a.card.updatedAt || a.card.claimedAt || a.card.createdAt || 0))
481
+ .slice(0, limit);
482
+ }
483
+
406
484
  function renderMissions() {
407
485
  const el=$('mission-list'); if (!el) return;
408
- if (!S.missions.length) { el.innerHTML=`<div class="empty"><div class="empty-title">No active missions</div><div class="empty-sub">Missions appear after a goal dispatches through Synapse.</div></div>`; return; }
486
+ if (!S.missions.length) {
487
+ const jobs = _jobsFromKanban(8);
488
+ if (!jobs.length) {
489
+ el.innerHTML=`<div class="empty"><div class="empty-title">No active missions</div><div class="empty-sub">Missions appear after a goal dispatches through Synapse.</div></div>`;
490
+ return;
491
+ }
492
+ el.innerHTML=jobs.map(({ lane, card }) => {
493
+ const payload = card.payload && typeof card.payload === 'object' ? card.payload : {};
494
+ const icon=lane==='done'?'✓':lane==='failed'?'!':lane==='wip'||lane==='claimed'?'●':'○';
495
+ return `<div class="mission-row" data-jobid="${esc(card.id)}">
496
+ <span style="color:${lane==='failed'?'var(--warning)':'var(--accent)'}">${icon}</span>
497
+ <div class="mission-body">
498
+ <div class="mission-title">${esc(payload.goal || card.title || 'Workforce job')}</div>
499
+ <div class="mission-meta">${statusChip(lane)} ${esc(card.workerId||payload.operativeId||'unassigned')}</div>
500
+ </div>
501
+ <span class="mission-budget">${esc(payload.mode || card.client || '')}</span>
502
+ </div>`;
503
+ }).join('');
504
+ el.querySelectorAll('[data-jobid]').forEach(row => {
505
+ row.addEventListener('click', () => _openKanbanJobDrawer(row.dataset.jobid));
506
+ });
507
+ return;
508
+ }
409
509
  el.innerHTML=S.missions.slice(0,20).map(m=>{
410
510
  const icon=m.status==='complete'||m.status==='done'?'✓':m.status==='active'||m.status==='running'?'●':'○';
411
511
  const bud=m.budgetUsed!=null&&m.budget!=null?`${fmtNum(m.budgetUsed)}/${fmtNum(m.budget)}t`:'';
@@ -427,7 +527,35 @@ function renderMissions() {
427
527
  function renderDispatch() {
428
528
  const el=$('dispatch-panel'); if (!el) return;
429
529
  const d=S.archDispatch;
430
- if (!d||!d.activeWorklists?.length) { el.innerHTML=`<div class="empty"><div class="empty-title">No active dispatch</div><div class="empty-sub">Architects worklists appear when a mission attaches real work items.</div></div>`; return; }
530
+ if (!d||!d.activeWorklists?.length) {
531
+ const dispatchRuns = [..._dispatches.values()].slice(0, 8);
532
+ const jobs = _jobsFromKanban(6);
533
+ if (!dispatchRuns.length && !jobs.length) {
534
+ el.innerHTML=`<div class="empty"><div class="empty-title">No active dispatch</div><div class="empty-sub">Architects worklists appear when a mission attaches real work items.</div></div>`;
535
+ return;
536
+ }
537
+ const runRows = dispatchRuns.map(run => `<div class="mission-row">
538
+ <div class="mission-body">
539
+ <div class="mission-title">${esc(run.runId)}</div>
540
+ <div class="mission-meta">${statusChip(run.status)} ${esc(run.operativeId || '')}</div>
541
+ </div>
542
+ <span class="mission-budget">${run.tokens ? `${fmtNum(run.tokens)}t` : ''}</span>
543
+ </div>`);
544
+ const jobRows = jobs.map(({ lane, card }) => {
545
+ const payload = card.payload && typeof card.payload === 'object' ? card.payload : {};
546
+ return `<div class="mission-row" data-jobid="${esc(card.id)}">
547
+ <div class="mission-body">
548
+ <div class="mission-title">${esc(payload.runId || payload.completedRunId || card.title || card.id)}</div>
549
+ <div class="mission-meta">${statusChip(lane)} ${esc(payload.actualStatus || lifecycleLabel({ status: lane }, payload))}</div>
550
+ </div>
551
+ </div>`;
552
+ });
553
+ el.innerHTML = [...runRows, ...jobRows].join('');
554
+ el.querySelectorAll('[data-jobid]').forEach(row => {
555
+ row.addEventListener('click', () => _openKanbanJobDrawer(row.dataset.jobid));
556
+ });
557
+ return;
558
+ }
431
559
  el.innerHTML=d.activeWorklists.slice(0,10).map(wl=>{
432
560
  return `<div class="mission-row">
433
561
  <div class="mission-body">
@@ -442,12 +570,22 @@ function renderDispatch() {
442
570
  function renderSpecialistGrid() {
443
571
  const el=$('specialist-grid'); if (!el) return;
444
572
  const surface=S.assetsSurface;
445
- const specs=(surface?.specialists||[]).slice(0,20);
446
- if (!specs.length) { el.innerHTML=`<div class="empty"><div class="empty-title">No specialists available</div></div>`; return; }
573
+ const rawSpecs=(surface?.specialists?.length ? surface.specialists : S.curatedSpecialists || []).slice(0,20);
574
+ const specs=rawSpecs.map(s => ({
575
+ id: s.id || s.specialistId || s.slug,
576
+ name: s.name || s.title || s.id || s.specialistId,
577
+ domains: s.domains || s.tags || (s.description ? [s.description] : []),
578
+ pricing: s.pricing,
579
+ })).filter(s => s.id);
580
+ if (!specs.length) {
581
+ el.innerHTML=`<div class="empty"><div class="empty-title">No specialists available</div><div class="empty-sub">Catalog data did not arrive yet. Hired operatives and jobs still appear above.</div></div>`;
582
+ return;
583
+ }
447
584
  el.innerHTML=specs.map(s=>{
448
585
  return `<div class="spec-card" data-specid="${esc(s.id)}" data-specname="${esc(s.name||s.id)}">
449
586
  <div class="spec-name">${esc(s.name||s.id)}</div>
450
587
  <div class="spec-domain">${esc((s.domains||[]).slice(0,3).join(', ')||'—')}</div>
588
+ ${s.pricing?.typical != null ? `<div class="spec-cost">~$${esc(String(s.pricing.typical))}/sortie</div>` : ''}
451
589
  <button class="btn btn-sm" data-hire="${esc(s.id)}" data-hirename="${esc(s.name||s.id)}">Hire</button>
452
590
  </div>`;
453
591
  }).join('');
@@ -464,7 +602,7 @@ function _buildHireSelectors() {
464
602
  const ops = (S.synapseHealth || []);
465
603
  const teams = [...new Set(ops.map(o => o.team).filter(Boolean))];
466
604
  const opOptions = [`<option value="">— none (team lead) —</option>`,
467
- ...ops.map(o => `<option value="${esc(o.id||o.operativeId)}">${esc(o.role||o.name||o.id)}</option>`)
605
+ ...ops.map(o => `<option value="${esc(o.id||o.operativeId)}">${esc(opLabel(o))}</option>`)
468
606
  ].join('');
469
607
  const teamOptions = [`<option value="">— solo —</option>`,
470
608
  ...teams.map(t => `<option value="${esc(t)}">${esc(t)}</option>`)
@@ -643,9 +781,9 @@ function _openOpDrawer(id, name) {
643
781
  // Render any active push-mode dispatches for this operative
644
782
  const activeRuns = [..._dispatches.values()].filter(r => r.operativeId === opId);
645
783
  const stripInner = activeRuns.map(_buildDispatchStrip).join('');
646
- openDrawer({ title: op.role||op.name||'Operative',
784
+ openDrawer({ title: opLabel(op)||'Operative',
647
785
  body: `<div class="dsec"><div class="dsec-title">Operative</div>${drows([
648
- ['ID',op.id||op.operativeId],['Role',op.role],['Status',op.status],
786
+ ['ID',op.id||op.operativeId],['Name',opLabel(op)],['Role',op.role],['Status',op.status],
649
787
  ['Budget used',op.budgetUsed!=null?fmtNum(op.budgetUsed):'—'],
650
788
  ['Budget alloc',op.budget!=null?fmtNum(op.budget):'—'],
651
789
  ['Team',op.team||op.strikeName||'—']
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexus-prime",
3
- "version": "7.9.30",
3
+ "version": "7.9.31",
4
4
  "description": "Local-first MCP control plane for coding agents with bootstrap-orchestrate execution, memory fabric, token budgeting, and worktree-backed swarms",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",