arkaos 3.25.0 → 3.27.0

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/VERSION CHANGED
@@ -1 +1 @@
1
- 3.25.0
1
+ 3.27.0
@@ -20,6 +20,13 @@ const links = [[{
20
20
  onSelect: () => {
21
21
  open.value = false
22
22
  }
23
+ }, {
24
+ label: 'Departments',
25
+ icon: 'i-lucide-folder-tree',
26
+ to: '/departments',
27
+ onSelect: () => {
28
+ open.value = false
29
+ }
23
30
  }, {
24
31
  label: 'Personas',
25
32
  icon: 'i-lucide-user-plus',
@@ -28,6 +28,35 @@ const deptActivity = computed<ActivityRow | null>(() =>
28
28
  (activityData.value?.by_department?.[agent.value?.department ?? ''] ?? null),
29
29
  )
30
30
 
31
+ // PR88d v3.26.0 — agent history (git log + trash entries)
32
+ interface HistoryEvent {
33
+ kind: string
34
+ ts: string | null
35
+ summary: string
36
+ ref?: string
37
+ author?: string
38
+ }
39
+ const { data: historyData } = fetchApi<{ events: HistoryEvent[] }>(
40
+ `/api/agents/${agentId}/history?limit=20`,
41
+ )
42
+
43
+ const historyEvents = computed<HistoryEvent[]>(() => historyData.value?.events ?? [])
44
+
45
+ function historyKindIcon(kind: string): string {
46
+ return ({
47
+ 'git-commit': 'i-lucide-git-commit',
48
+ 'agent-delete': 'i-lucide-trash-2',
49
+ 'agent-move': 'i-lucide-folder-tree',
50
+ } as Record<string, string>)[kind] ?? 'i-lucide-circle'
51
+ }
52
+ function historyKindColor(kind: string): string {
53
+ return ({
54
+ 'git-commit': 'text-blue-500',
55
+ 'agent-delete': 'text-red-500',
56
+ 'agent-move': 'text-amber-500',
57
+ } as Record<string, string>)[kind] ?? 'text-muted'
58
+ }
59
+
31
60
  // PR83d v3.6.0 + PR86b v3.16.0 — activity strip (30d, agent or dept scope)
32
61
  interface ActivityStrip {
33
62
  period: string
@@ -443,6 +472,43 @@ function formatTokens(n: number): string {
443
472
  />
444
473
  </section>
445
474
 
475
+ <!-- ===== HISTORY TIMELINE (PR88d) ===== -->
476
+ <section
477
+ v-if="historyEvents.length > 0"
478
+ class="rounded-xl border border-default bg-elevated/10 p-5"
479
+ >
480
+ <h3 class="text-sm font-semibold uppercase tracking-wide text-muted mb-4">
481
+ History
482
+ </h3>
483
+ <ol class="relative border-l border-default ml-2 space-y-3">
484
+ <li
485
+ v-for="(ev, idx) in historyEvents"
486
+ :key="idx"
487
+ class="ml-4"
488
+ >
489
+ <span
490
+ class="absolute -left-1.5 size-3 rounded-full bg-elevated border border-default flex items-center justify-center"
491
+ >
492
+ <UIcon :name="historyKindIcon(ev.kind)" :class="['size-2', historyKindColor(ev.kind)]" />
493
+ </span>
494
+ <div class="rounded-lg border border-default p-3 bg-elevated/20">
495
+ <div class="flex items-center gap-2 flex-wrap text-xs">
496
+ <span class="font-mono text-muted">{{ ev.ts ? formatRelative(ev.ts) : '—' }}</span>
497
+ <UBadge
498
+ :label="ev.kind"
499
+ :color="ev.kind === 'git-commit' ? 'primary' : ev.kind === 'agent-move' ? 'warning' : 'error'"
500
+ variant="subtle"
501
+ size="xs"
502
+ />
503
+ <code v-if="ev.ref" class="font-mono text-muted">{{ ev.ref }}</code>
504
+ <span v-if="ev.author" class="text-muted">· {{ ev.author }}</span>
505
+ </div>
506
+ <p class="text-sm mt-1">{{ ev.summary }}</p>
507
+ </div>
508
+ </li>
509
+ </ol>
510
+ </section>
511
+
446
512
  <AgentEditDrawer
447
513
  v-model="editOpen"
448
514
  :agent="agent"
@@ -0,0 +1,158 @@
1
+ <script setup lang="ts">
2
+ // PR89a v3.27.0 — Department detail page.
3
+ //
4
+ // Shows agents in the department + its workflows + 30d cost.
5
+
6
+ const route = useRoute()
7
+ const deptId = computed(() => String(route.params.dept ?? ''))
8
+ const { fetchApi } = useApi()
9
+
10
+ interface AgentLite {
11
+ id: string
12
+ name?: string
13
+ role?: string
14
+ tier?: number
15
+ mbti?: string
16
+ disc?: { primary?: string, secondary?: string }
17
+ }
18
+
19
+ interface WorkflowLite {
20
+ id: string
21
+ name: string
22
+ tier: string
23
+ command: string
24
+ phases_count: number
25
+ }
26
+
27
+ interface DeptDetail {
28
+ department: string
29
+ agents: AgentLite[]
30
+ workflows: WorkflowLite[]
31
+ calls_30d: number
32
+ cost_usd_30d: number | null
33
+ error?: string
34
+ }
35
+
36
+ const { data, status, error, refresh } = await fetchApi<DeptDetail>(
37
+ `/api/departments/${deptId.value}`,
38
+ )
39
+
40
+ const errorMsg = computed(() => data.value?.error || error.value?.message || null)
41
+ const detail = computed<DeptDetail | null>(() => {
42
+ if (!data.value || data.value.error) return null
43
+ return data.value
44
+ })
45
+
46
+ function formatCost(cost: number | null): string {
47
+ if (cost === null || cost === undefined) return '—'
48
+ if (cost < 0.01) return '<$0.01'
49
+ if (cost < 1) return `$${cost.toFixed(3)}`
50
+ return `$${cost.toFixed(2)}`
51
+ }
52
+
53
+ const tierColor = (tier: number | undefined) => {
54
+ const colors: Record<number, 'error' | 'warning' | 'primary' | 'neutral'> = {
55
+ 0: 'error', 1: 'warning', 2: 'primary', 3: 'neutral',
56
+ }
57
+ return colors[tier ?? 99] ?? 'neutral'
58
+ }
59
+ </script>
60
+
61
+ <template>
62
+ <UDashboardPanel :id="`dept-${deptId}`">
63
+ <template #header>
64
+ <UDashboardNavbar :title="`Department · ${deptId}`">
65
+ <template #leading>
66
+ <UButton icon="i-lucide-arrow-left" variant="ghost" size="sm" to="/departments" aria-label="Back" />
67
+ </template>
68
+ </UDashboardNavbar>
69
+ </template>
70
+
71
+ <template #body>
72
+ <DashboardState
73
+ :status="status"
74
+ :error="errorMsg ? new Error(errorMsg) : null"
75
+ :empty="!detail"
76
+ empty-title="Department not found"
77
+ empty-icon="i-lucide-folder-x"
78
+ loading-label="Loading department"
79
+ :on-retry="() => refresh()"
80
+ >
81
+ <div v-if="detail" class="space-y-5 max-w-5xl">
82
+ <!-- Stats row -->
83
+ <section class="grid grid-cols-2 md:grid-cols-4 gap-3">
84
+ <div class="rounded-xl border border-default p-4 bg-elevated/20">
85
+ <p class="text-sm font-semibold text-muted uppercase tracking-wide mb-1">Agents</p>
86
+ <p class="text-2xl font-bold">{{ detail.agents.length }}</p>
87
+ </div>
88
+ <div class="rounded-xl border border-default p-4 bg-elevated/20">
89
+ <p class="text-sm font-semibold text-muted uppercase tracking-wide mb-1">Workflows</p>
90
+ <p class="text-2xl font-bold">{{ detail.workflows.length }}</p>
91
+ </div>
92
+ <div class="rounded-xl border border-default p-4 bg-elevated/20">
93
+ <p class="text-sm font-semibold text-muted uppercase tracking-wide mb-1">Calls (30d)</p>
94
+ <p class="text-2xl font-bold">{{ detail.calls_30d }}</p>
95
+ </div>
96
+ <div class="rounded-xl border border-default p-4 bg-elevated/20">
97
+ <p class="text-sm font-semibold text-muted uppercase tracking-wide mb-1">Cost (30d)</p>
98
+ <p class="text-2xl font-bold">{{ formatCost(detail.cost_usd_30d) }}</p>
99
+ </div>
100
+ </section>
101
+
102
+ <!-- Agents -->
103
+ <section>
104
+ <h2 class="text-sm font-semibold uppercase tracking-wide text-muted mb-3">Agents</h2>
105
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-2">
106
+ <NuxtLink
107
+ v-for="a in detail.agents"
108
+ :key="a.id"
109
+ :to="`/agents/${a.id}`"
110
+ class="block rounded-lg border border-default p-3 hover:border-primary/40 transition-colors"
111
+ >
112
+ <div class="flex items-center justify-between gap-3">
113
+ <div class="min-w-0">
114
+ <p class="font-semibold truncate">{{ a.name }}</p>
115
+ <p class="text-xs text-muted truncate">{{ a.role || '—' }}</p>
116
+ </div>
117
+ <div class="flex items-center gap-2 shrink-0">
118
+ <UBadge
119
+ :label="`T${a.tier}`"
120
+ :color="tierColor(a.tier)"
121
+ variant="subtle"
122
+ size="xs"
123
+ />
124
+ <UBadge v-if="a.mbti" :label="a.mbti" variant="soft" size="xs" />
125
+ </div>
126
+ </div>
127
+ </NuxtLink>
128
+ </div>
129
+ </section>
130
+
131
+ <!-- Workflows -->
132
+ <section v-if="detail.workflows.length > 0">
133
+ <h2 class="text-sm font-semibold uppercase tracking-wide text-muted mb-3">Workflows</h2>
134
+ <div class="space-y-2">
135
+ <NuxtLink
136
+ v-for="w in detail.workflows"
137
+ :key="w.id"
138
+ to="/workflows"
139
+ class="block rounded-lg border border-default p-3 hover:border-primary/40 transition-colors"
140
+ >
141
+ <div class="flex items-center justify-between gap-3">
142
+ <div class="min-w-0">
143
+ <p class="font-semibold truncate">{{ w.name }}</p>
144
+ <p class="text-xs text-muted font-mono truncate">{{ w.command || w.id }}</p>
145
+ </div>
146
+ <div class="flex items-center gap-2 shrink-0 text-xs">
147
+ <UBadge v-if="w.tier" :label="w.tier" variant="subtle" size="xs" />
148
+ <span class="text-muted">{{ w.phases_count }} phase{{ w.phases_count === 1 ? '' : 's' }}</span>
149
+ </div>
150
+ </div>
151
+ </NuxtLink>
152
+ </div>
153
+ </section>
154
+ </div>
155
+ </DashboardState>
156
+ </template>
157
+ </UDashboardPanel>
158
+ </template>
@@ -0,0 +1,127 @@
1
+ <script setup lang="ts">
2
+ // PR89a v3.27.0 — Departments index.
3
+ //
4
+ // Lists every department with agent count, tier distribution, and 30d
5
+ // cost. Click a row to drill into /departments/{dept}.
6
+
7
+ import type { TableColumn } from '@nuxt/ui'
8
+
9
+ interface DeptRow {
10
+ department: string
11
+ agent_count: number
12
+ tier_counts: Record<'0' | '1' | '2' | '3', number>
13
+ calls_30d: number
14
+ cost_usd_30d: number | null
15
+ }
16
+
17
+ const { fetchApi } = useApi()
18
+ const { data, status, error, refresh } = await fetchApi<{
19
+ departments: DeptRow[]
20
+ total: number
21
+ }>('/api/departments')
22
+
23
+ const rows = computed<DeptRow[]>(() => data.value?.departments ?? [])
24
+ const search = ref('')
25
+ const filtered = computed(() => {
26
+ const q = search.value.toLowerCase().trim()
27
+ if (!q) return rows.value
28
+ return rows.value.filter((r) => r.department.toLowerCase().includes(q))
29
+ })
30
+
31
+ const columns: TableColumn<DeptRow>[] = [
32
+ { accessorKey: 'department', header: 'Department' },
33
+ { accessorKey: 'agent_count', header: 'Agents' },
34
+ { id: 'tiers', header: 'Tiers (0/1/2/3)' },
35
+ { accessorKey: 'calls_30d', header: 'Calls (30d)' },
36
+ { accessorKey: 'cost_usd_30d', header: 'Cost (30d)' },
37
+ { id: 'actions', header: '' },
38
+ ]
39
+
40
+ function formatCost(cost: number | null): string {
41
+ if (cost === null || cost === undefined) return '—'
42
+ if (cost < 0.01) return '<$0.01'
43
+ if (cost < 1) return `$${cost.toFixed(3)}`
44
+ return `$${cost.toFixed(2)}`
45
+ }
46
+ </script>
47
+
48
+ <template>
49
+ <UDashboardPanel id="departments">
50
+ <template #header>
51
+ <UDashboardNavbar title="Departments">
52
+ <template #leading>
53
+ <UDashboardSidebarCollapse />
54
+ </template>
55
+ <template #trailing>
56
+ <UBadge v-if="data?.total" :label="String(data.total)" variant="subtle" />
57
+ </template>
58
+ </UDashboardNavbar>
59
+ </template>
60
+
61
+ <template #body>
62
+ <DashboardState
63
+ :status="status"
64
+ :error="error"
65
+ :empty="!rows.length"
66
+ empty-title="No departments yet"
67
+ empty-icon="i-lucide-folder-tree"
68
+ loading-label="Loading departments"
69
+ :on-retry="() => refresh()"
70
+ >
71
+ <div class="flex items-center gap-3 mb-4">
72
+ <UInput
73
+ v-model="search"
74
+ class="max-w-sm"
75
+ icon="i-lucide-search"
76
+ placeholder="Filter departments…"
77
+ />
78
+ <span class="ml-auto text-xs text-muted">
79
+ {{ filtered.length }} dept{{ filtered.length === 1 ? '' : 's' }}
80
+ </span>
81
+ </div>
82
+
83
+ <UTable
84
+ :data="filtered"
85
+ :columns="columns"
86
+ :loading="status === 'pending'"
87
+ :ui="{
88
+ tbody: '[&>tr]:cursor-pointer [&>tr]:hover:bg-elevated/50 [&>tr]:transition-colors',
89
+ th: 'py-2 first:rounded-l-lg last:rounded-r-lg border-y border-default first:border-l last:border-r',
90
+ td: 'border-b border-default',
91
+ }"
92
+ @select="(row: { original: DeptRow }) => navigateTo(`/departments/${row.original.department}`)"
93
+ >
94
+ <template #department-cell="{ row }">
95
+ <span class="font-semibold capitalize">{{ row.original.department }}</span>
96
+ </template>
97
+ <template #agent_count-cell="{ row }">
98
+ <span class="font-mono font-semibold">{{ row.original.agent_count }}</span>
99
+ </template>
100
+ <template #tiers-cell="{ row }">
101
+ <div class="flex items-center gap-1 text-xs font-mono">
102
+ <UBadge v-if="row.original.tier_counts['0'] > 0" :label="`0:${row.original.tier_counts['0']}`" color="error" variant="subtle" size="xs" />
103
+ <UBadge v-if="row.original.tier_counts['1'] > 0" :label="`1:${row.original.tier_counts['1']}`" color="warning" variant="subtle" size="xs" />
104
+ <UBadge v-if="row.original.tier_counts['2'] > 0" :label="`2:${row.original.tier_counts['2']}`" color="primary" variant="subtle" size="xs" />
105
+ <UBadge v-if="row.original.tier_counts['3'] > 0" :label="`3:${row.original.tier_counts['3']}`" color="neutral" variant="subtle" size="xs" />
106
+ </div>
107
+ </template>
108
+ <template #calls_30d-cell="{ row }">
109
+ <span class="font-mono text-sm">{{ row.original.calls_30d }}</span>
110
+ </template>
111
+ <template #cost_usd_30d-cell="{ row }">
112
+ <span class="font-mono text-sm font-semibold">{{ formatCost(row.original.cost_usd_30d) }}</span>
113
+ </template>
114
+ <template #actions-cell="{ row }">
115
+ <UButton
116
+ icon="i-lucide-arrow-right"
117
+ variant="ghost"
118
+ size="xs"
119
+ aria-label="Open department"
120
+ @click.stop="navigateTo(`/departments/${row.original.department}`)"
121
+ />
122
+ </template>
123
+ </UTable>
124
+ </DashboardState>
125
+ </template>
126
+ </UDashboardPanel>
127
+ </template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "3.25.0",
3
+ "version": "3.27.0",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "3.25.0"
3
+ version = "3.27.0"
4
4
  description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -322,6 +322,96 @@ def agent_activity_strip(agent_id: str, period: str = "month"):
322
322
  }
323
323
 
324
324
 
325
+ @app.get("/api/agents/{agent_id}/history")
326
+ def agent_history(agent_id: str, limit: int = 20):
327
+ """PR88d v3.26.0 — combined history for an agent.
328
+
329
+ Sources:
330
+ - git log of the YAML file (commit hash, date, subject, author)
331
+ - trash entries (agent-delete + agent-move) where ``item_id``
332
+ matches
333
+
334
+ Returns ``{events: [{kind, ts, summary, ref?, author?}]}`` sorted
335
+ desc by ts.
336
+ """
337
+ events: list[dict] = []
338
+ yaml_file = _resolve_agent_yaml(agent_id)
339
+ if yaml_file is not None:
340
+ events.extend(_agent_git_log(yaml_file, limit=limit))
341
+ try:
342
+ from core import trash as _trash
343
+ for entry in _trash.list_trash(limit=50):
344
+ if entry.get("item_id") == agent_id and str(entry.get("kind", "")).startswith("agent-"):
345
+ events.append({
346
+ "kind": entry.get("kind"),
347
+ "ts": _trash_ts_to_iso(entry.get("timestamp")),
348
+ "summary": _trash_summary(entry),
349
+ "ref": entry.get("id"),
350
+ })
351
+ except Exception: # noqa: BLE001
352
+ pass
353
+ events.sort(key=lambda e: str(e.get("ts") or ""), reverse=True)
354
+ return {"events": events[: max(0, int(limit))]}
355
+
356
+
357
+ def _agent_git_log(yaml_file: Path, limit: int = 20) -> list[dict]:
358
+ """Run ``git log`` on the YAML file. Best-effort — empty on error."""
359
+ try:
360
+ rel = yaml_file.relative_to(ARKAOS_ROOT).as_posix()
361
+ except ValueError:
362
+ return []
363
+ try:
364
+ result = subprocess.run(
365
+ [
366
+ "git", "log", "--follow", f"-n{int(limit)}",
367
+ "--pretty=format:%H%x09%cI%x09%an%x09%s",
368
+ "--", rel,
369
+ ],
370
+ cwd=str(ARKAOS_ROOT),
371
+ capture_output=True, text=True, timeout=5,
372
+ )
373
+ except (subprocess.SubprocessError, OSError):
374
+ return []
375
+ if result.returncode != 0:
376
+ return []
377
+ rows: list[dict] = []
378
+ for line in result.stdout.strip().split("\n"):
379
+ if not line:
380
+ continue
381
+ parts = line.split("\t", 3)
382
+ if len(parts) < 4:
383
+ continue
384
+ sha, iso, author, subject = parts
385
+ rows.append({
386
+ "kind": "git-commit",
387
+ "ts": iso,
388
+ "summary": subject,
389
+ "ref": sha[:8],
390
+ "author": author,
391
+ })
392
+ return rows
393
+
394
+
395
+ def _trash_ts_to_iso(ts: object) -> str | None:
396
+ if ts is None:
397
+ return None
398
+ try:
399
+ from datetime import datetime, timezone
400
+ return datetime.fromtimestamp(float(ts), tz=timezone.utc).isoformat()
401
+ except (TypeError, ValueError, OSError):
402
+ return None
403
+
404
+
405
+ def _trash_summary(entry: dict) -> str:
406
+ kind = entry.get("kind") or ""
407
+ if kind == "agent-move":
408
+ new_path = entry.get("new_path") or ""
409
+ return f"Moved to {Path(new_path).parent.parent.name if new_path else '?'}"
410
+ if kind == "agent-delete":
411
+ return "Deleted (restorable from /trash)"
412
+ return kind
413
+
414
+
325
415
  @app.get("/api/agents/{agent_id}/activity")
326
416
  def agent_activity_detail(agent_id: str, period: str = "month"):
327
417
  """PR86b v3.16.0 — alias for /activity-strip. Same payload shape.
@@ -1404,6 +1494,127 @@ def agent_export_to_vault(agent_id: str):
1404
1494
  return {"exported": True, "path": str(res.path), "vault_path": str(res.vault_path)}
1405
1495
 
1406
1496
 
1497
+ # --- Departments (PR89a v3.27.0) ---
1498
+
1499
+ @app.get("/api/departments")
1500
+ def departments_list():
1501
+ """List every department with agent count + 30d cost summary."""
1502
+ agents = _load_agents()
1503
+ by_dept: dict[str, dict] = {}
1504
+ for a in agents:
1505
+ dept = a.get("department") or ""
1506
+ if not dept:
1507
+ continue
1508
+ row = by_dept.setdefault(dept, {
1509
+ "department": dept,
1510
+ "agent_count": 0,
1511
+ "tier_counts": {"0": 0, "1": 0, "2": 0, "3": 0},
1512
+ })
1513
+ row["agent_count"] += 1
1514
+ tier_key = str(a.get("tier") or "")
1515
+ if tier_key in row["tier_counts"]:
1516
+ row["tier_counts"][tier_key] += 1
1517
+ cost_map: dict[str, dict] = {}
1518
+ try:
1519
+ from core.runtime.llm_cost_telemetry import summarise
1520
+ s = summarise(period="month")
1521
+ for category, row in (s.by_category or {}).items():
1522
+ if not isinstance(category, str) or not category.startswith("subagent:"):
1523
+ continue
1524
+ parts = category.split(":", 2)
1525
+ dept = parts[1] if len(parts) >= 2 else "unknown"
1526
+ bucket = cost_map.setdefault(dept, {
1527
+ "calls_30d": 0,
1528
+ "cost_usd_30d": 0.0,
1529
+ "any_cost_known": False,
1530
+ })
1531
+ bucket["calls_30d"] += int(row.get("call_count", 0))
1532
+ cost = row.get("total_cost_usd")
1533
+ if isinstance(cost, (int, float)):
1534
+ bucket["cost_usd_30d"] += float(cost)
1535
+ bucket["any_cost_known"] = True
1536
+ except Exception: # noqa: BLE001
1537
+ pass
1538
+ out: list[dict] = []
1539
+ for dept in sorted(by_dept.keys()):
1540
+ row = by_dept[dept]
1541
+ cost = cost_map.get(dept, {})
1542
+ row["calls_30d"] = cost.get("calls_30d", 0)
1543
+ row["cost_usd_30d"] = (
1544
+ round(cost.get("cost_usd_30d", 0.0), 6)
1545
+ if cost.get("any_cost_known") else None
1546
+ )
1547
+ out.append(row)
1548
+ return {"departments": out, "total": len(out)}
1549
+
1550
+
1551
+ @app.get("/api/departments/{dept_id}")
1552
+ def department_detail(dept_id: str):
1553
+ """Full department detail: agents, workflows, 30d cost."""
1554
+ dept_id = dept_id.strip().lower()
1555
+ agents = [a for a in _load_agents() if a.get("department") == dept_id]
1556
+ if not agents:
1557
+ return {"error": "Department not found or has no agents"}
1558
+ workflows: list[dict] = []
1559
+ try:
1560
+ import yaml as _yaml
1561
+ wf_dir = ARKAOS_ROOT / "departments" / dept_id / "workflows"
1562
+ if wf_dir.exists():
1563
+ for path in sorted(wf_dir.glob("*.yaml")):
1564
+ try:
1565
+ raw = _yaml.safe_load(path.read_text(encoding="utf-8")) or {}
1566
+ except Exception: # noqa: BLE001
1567
+ raw = {}
1568
+ if not isinstance(raw, dict):
1569
+ continue
1570
+ workflows.append({
1571
+ "id": str(raw.get("id") or path.stem),
1572
+ "name": str(raw.get("name") or path.stem),
1573
+ "tier": str(raw.get("tier") or ""),
1574
+ "command": str(raw.get("command") or ""),
1575
+ "phases_count": len(raw.get("phases") or []),
1576
+ })
1577
+ except ImportError:
1578
+ pass
1579
+ calls_30d = 0
1580
+ cost_usd_30d: Optional[float] = None
1581
+ try:
1582
+ from core.runtime.llm_cost_telemetry import summarise
1583
+ s = summarise(period="month")
1584
+ total_cost = 0.0
1585
+ any_known = False
1586
+ for category, row in (s.by_category or {}).items():
1587
+ if not isinstance(category, str) or not category.startswith("subagent:"):
1588
+ continue
1589
+ parts = category.split(":", 2)
1590
+ if len(parts) >= 2 and parts[1] == dept_id:
1591
+ calls_30d += int(row.get("call_count", 0))
1592
+ cost = row.get("total_cost_usd")
1593
+ if isinstance(cost, (int, float)):
1594
+ total_cost += float(cost)
1595
+ any_known = True
1596
+ cost_usd_30d = round(total_cost, 6) if any_known else None
1597
+ except Exception: # noqa: BLE001
1598
+ pass
1599
+ return {
1600
+ "department": dept_id,
1601
+ "agents": [
1602
+ {
1603
+ "id": a.get("id"),
1604
+ "name": a.get("name"),
1605
+ "role": a.get("role"),
1606
+ "tier": a.get("tier"),
1607
+ "mbti": a.get("mbti"),
1608
+ "disc": a.get("disc"),
1609
+ }
1610
+ for a in agents
1611
+ ],
1612
+ "workflows": workflows,
1613
+ "calls_30d": calls_30d,
1614
+ "cost_usd_30d": cost_usd_30d,
1615
+ }
1616
+
1617
+
1407
1618
  # --- Workflows (PR88b v3.24.0) ---
1408
1619
 
1409
1620
  @app.get("/api/workflows")