@miller-tech/uap 1.11.0 → 1.12.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.
@@ -4,12 +4,13 @@
4
4
  * Extracts structured data from all UAP subsystems for consumption
5
5
  * by both the CLI dashboard and the web overlay.
6
6
  */
7
- import { existsSync, readFileSync, statSync } from 'fs';
7
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
8
8
  import { loadUapConfig } from '../utils/config-loader.js';
9
9
  import { join } from 'path';
10
10
  import { execSync } from 'child_process';
11
11
  import Database from 'better-sqlite3';
12
12
  import { globalSessionStats } from '../mcp-router/session-stats.js';
13
+ import { getSessionSnapshot } from '../telemetry/session-telemetry.js';
13
14
  import { getPerformanceMonitor } from '../utils/performance-monitor.js';
14
15
  const SUBPROCESS_CACHE_TTL = 30_000; // 30 seconds
15
16
  let cachedGitData = null;
@@ -17,19 +18,108 @@ let cachedQdrantStatus = null;
17
18
  // ── DB Connection Pool for memory database (prevents opening/closing on every refresh) ──
18
19
  const MEMORY_DB_CACHE_TTL = 5_000; // 5 seconds
19
20
  let cachedMemoryDb = null;
21
+ function getTelemetryDb(cwd) {
22
+ const dbPath = join(cwd, 'agents', 'data', 'memory', 'telemetry.db');
23
+ const db = new Database(dbPath);
24
+ db.exec(`
25
+ CREATE TABLE IF NOT EXISTS time_series (
26
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
27
+ timestamp TEXT NOT NULL,
28
+ data TEXT NOT NULL
29
+ )
30
+ `);
31
+ return db;
32
+ }
33
+ function persistTimeSeriesPoint(cwd, point) {
34
+ try {
35
+ const db = getTelemetryDb(cwd);
36
+ db.prepare('INSERT INTO time_series (timestamp, data) VALUES (?, ?)').run(point.timestamp, JSON.stringify(point));
37
+ // Keep only the last 500 points
38
+ db.prepare('DELETE FROM time_series WHERE id NOT IN (SELECT id FROM time_series ORDER BY id DESC LIMIT 500)').run();
39
+ db.close();
40
+ }
41
+ catch {
42
+ /* ignore */
43
+ }
44
+ }
45
+ function getTimeSeriesFromDb(cwd) {
46
+ try {
47
+ const db = getTelemetryDb(cwd);
48
+ const rows = db
49
+ .prepare('SELECT data FROM time_series ORDER BY id DESC LIMIT 120')
50
+ .all();
51
+ db.close();
52
+ return rows
53
+ .reverse()
54
+ .map((r) => {
55
+ try {
56
+ return JSON.parse(r.data);
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ })
62
+ .filter((p) => p !== null);
63
+ }
64
+ catch {
65
+ return [];
66
+ }
67
+ }
68
+ export function getTimeSeriesHistory(cwd) {
69
+ return getTimeSeriesFromDb(cwd);
70
+ }
71
+ export function pushTimeSeriesPoint(cwd, point) {
72
+ persistTimeSeriesPoint(cwd, point);
73
+ }
20
74
  // ── Data Gathering ──
21
75
  export async function getDashboardData() {
22
76
  const cwd = process.cwd();
77
+ const tasks = getTaskData(cwd);
78
+ const coordination = getCoordData(cwd);
79
+ const memory = getMemoryData(cwd);
80
+ const compliance = getComplianceData(cwd);
81
+ const deployBuckets = getDeployBucketData(cwd);
82
+ // Persist time-series point
83
+ const tsPoint = {
84
+ timestamp: new Date().toISOString(),
85
+ tasks: {
86
+ total: tasks.total,
87
+ done: tasks.done,
88
+ inProgress: tasks.inProgress,
89
+ blocked: tasks.blocked,
90
+ open: tasks.open,
91
+ },
92
+ coordination: {
93
+ activeAgents: coordination.activeAgents,
94
+ totalAgents: coordination.totalAgents,
95
+ completedAgents: coordination.completedAgents,
96
+ patternHits: coordination.patternHits,
97
+ activeWorktrees: coordination.activeWorktrees,
98
+ },
99
+ deployBuckets,
100
+ compression: memory.compression,
101
+ memoryHitsMisses: memory.hitsMisses || { hits: 0, misses: 0, hitRate: 'N/A' },
102
+ compliance: {
103
+ totalChecks: compliance.totalChecks,
104
+ totalBlocks: compliance.totalBlocks,
105
+ blockRate: compliance.blockRate,
106
+ },
107
+ };
108
+ pushTimeSeriesPoint(cwd, tsPoint);
23
109
  return {
24
110
  timestamp: new Date().toISOString(),
25
111
  system: getSystemData(cwd),
26
112
  policies: getPolicyData(cwd),
113
+ policyFiles: getPolicyFiles(cwd),
27
114
  auditTrail: getAuditData(cwd),
28
- memory: getMemoryData(cwd),
115
+ memory,
29
116
  models: getModelData(cwd),
30
- tasks: getTaskData(cwd),
31
- coordination: getCoordData(cwd),
117
+ tasks,
118
+ coordination,
32
119
  performance: getPerformanceData(),
120
+ timeSeries: getTimeSeriesHistory(cwd),
121
+ compliance,
122
+ deployBuckets,
33
123
  };
34
124
  }
35
125
  function getSystemData(cwd) {
@@ -100,6 +190,60 @@ function getPolicyData(cwd) {
100
190
  return [];
101
191
  }
102
192
  }
193
+ /**
194
+ * Read policy .md files from the policies/ directory.
195
+ * Returns metadata about each file (excluding README.md).
196
+ */
197
+ export function getPolicyFiles(cwd) {
198
+ const policiesDir = join(cwd, 'policies');
199
+ if (!existsSync(policiesDir))
200
+ return [];
201
+ try {
202
+ const files = readdirSync(policiesDir).filter((f) => f.endsWith('.md') && f.toLowerCase() !== 'readme.md');
203
+ return files.map((f) => {
204
+ const nameWithoutExt = f.replace(/\.md$/, '');
205
+ // Convert kebab-case to Title Case
206
+ const name = nameWithoutExt
207
+ .split('-')
208
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
209
+ .join(' ');
210
+ // Derive category from filename patterns
211
+ let category = 'general';
212
+ if (nameWithoutExt.includes('iac') || nameWithoutExt.includes('pipeline')) {
213
+ category = 'infrastructure';
214
+ }
215
+ else if (nameWithoutExt.includes('worktree') || nameWithoutExt.includes('file')) {
216
+ category = 'workflow';
217
+ }
218
+ else if (nameWithoutExt.includes('gate') ||
219
+ nameWithoutExt.includes('completion') ||
220
+ nameWithoutExt.includes('mandatory')) {
221
+ category = 'quality';
222
+ }
223
+ else if (nameWithoutExt.includes('semver') || nameWithoutExt.includes('version')) {
224
+ category = 'versioning';
225
+ }
226
+ else if (nameWithoutExt.includes('image') || nameWithoutExt.includes('asset')) {
227
+ category = 'assets';
228
+ }
229
+ else if (nameWithoutExt.includes('kubectl') || nameWithoutExt.includes('backport')) {
230
+ category = 'operations';
231
+ }
232
+ else if (nameWithoutExt.includes('backup')) {
233
+ category = 'safety';
234
+ }
235
+ return {
236
+ filename: f,
237
+ name,
238
+ category,
239
+ path: join(policiesDir, f),
240
+ };
241
+ });
242
+ }
243
+ catch {
244
+ return [];
245
+ }
246
+ }
103
247
  function getAuditData(cwd) {
104
248
  const dbPath = join(cwd, 'agents', 'data', 'memory', 'policies.db');
105
249
  if (!existsSync(dbPath))
@@ -137,6 +281,7 @@ function getMemoryData(cwd) {
137
281
  let l2Entries = 0;
138
282
  let l4Entities = 0;
139
283
  let l4Relationships = 0;
284
+ const recentQueries = [];
140
285
  if (existsSync(memDbPath)) {
141
286
  try {
142
287
  l1SizeKB = Math.round(statSync(memDbPath).size / 1024);
@@ -155,6 +300,23 @@ function getMemoryData(cwd) {
155
300
  .all();
156
301
  if (hasMem.length > 0) {
157
302
  l1Entries = db.prepare('SELECT COUNT(*) as c FROM memories').get().c;
303
+ // Recent queries from memories table (last 10)
304
+ try {
305
+ const memRows = db
306
+ .prepare(`SELECT type, substr(content, 1, 80) as snippet, timestamp
307
+ FROM memories ORDER BY id DESC LIMIT 10`)
308
+ .all();
309
+ for (const row of memRows) {
310
+ recentQueries.push({
311
+ query: row.snippet || '',
312
+ type: row.type || 'memory',
313
+ timestamp: row.timestamp || '',
314
+ });
315
+ }
316
+ }
317
+ catch {
318
+ /* table might not have expected columns */
319
+ }
158
320
  }
159
321
  const hasSess = db
160
322
  .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='session_memories'")
@@ -180,12 +342,72 @@ function getMemoryData(cwd) {
180
342
  /* ignore */
181
343
  }
182
344
  }
183
- // Get compression stats first (needed for both cached and fresh paths)
345
+ // Get compression stats from session stats
184
346
  const stats = globalSessionStats.getSummary();
347
+ // Try real compression ratio from model_analytics.db
348
+ let compressionRaw = stats.totalRawBytes;
349
+ let compressionCtx = stats.totalContextBytes;
350
+ let compressionSavings = stats.savingsPercent;
351
+ const compressionCalls = stats.totalCalls;
352
+ const analyticsDbPath = join(cwd, 'agents', 'data', 'memory', 'model_analytics.db');
353
+ if (existsSync(analyticsDbPath) && compressionRaw === 0) {
354
+ try {
355
+ const aDb = new Database(analyticsDbPath, { readonly: true });
356
+ const hasTable = aDb
357
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='task_outcomes'")
358
+ .all();
359
+ if (hasTable.length > 0) {
360
+ const row = aDb
361
+ .prepare('SELECT SUM(tokensIn) as ti, SUM(tokensOut) as to2 FROM task_outcomes')
362
+ .get();
363
+ if (row && row.ti && row.to2 && row.ti + row.to2 > 0) {
364
+ compressionRaw = row.ti + row.to2;
365
+ compressionCtx = row.to2;
366
+ const ratio = row.to2 / (row.ti + row.to2);
367
+ compressionSavings = ((1 - ratio) * 100).toFixed(1) + '%';
368
+ }
369
+ }
370
+ aDb.close();
371
+ }
372
+ catch {
373
+ /* ignore */
374
+ }
375
+ }
185
376
  // Use TTL cache for Qdrant status (Docker doesn't change faster than 30s)
186
377
  let l3Status = 'Stopped';
187
378
  let l3Uptime = '';
188
379
  const now = Date.now();
380
+ // Memory hits/misses: derive from memory types (observations=hits, thoughts=misses)
381
+ // or from session telemetry if available
382
+ const snapshot = getSessionSnapshot();
383
+ let memHits = snapshot?.memoryHits ?? 0;
384
+ let memMisses = snapshot?.memoryMisses ?? 0;
385
+ if (memHits === 0 && memMisses === 0 && existsSync(memDbPath)) {
386
+ try {
387
+ const mdb = cachedMemoryDb?.db || new Database(memDbPath, { readonly: true });
388
+ const typeCounts = mdb
389
+ .prepare('SELECT type, COUNT(*) as c FROM memories GROUP BY type')
390
+ .all();
391
+ for (const tc of typeCounts) {
392
+ if (tc.type === 'observation' || tc.type === 'action')
393
+ memHits += tc.c;
394
+ else if (tc.type === 'thought')
395
+ memMisses += tc.c;
396
+ }
397
+ // Session memories are all hits (successfully stored context)
398
+ const sesCount = mdb.prepare('SELECT COUNT(*) as c FROM session_memories').get();
399
+ memHits += sesCount.c;
400
+ }
401
+ catch {
402
+ /* ignore */
403
+ }
404
+ }
405
+ const memTotal = memHits + memMisses;
406
+ const hitsMisses = {
407
+ hits: memHits,
408
+ misses: memMisses,
409
+ hitRate: memTotal > 0 ? `${Math.round((memHits / memTotal) * 100)}%` : 'N/A',
410
+ };
189
411
  if (cachedQdrantStatus && cachedQdrantStatus.expiresAt > now) {
190
412
  return {
191
413
  l1: { entries: l1Entries, sizeKB: l1SizeKB },
@@ -193,11 +415,13 @@ function getMemoryData(cwd) {
193
415
  l3: { status: cachedQdrantStatus.data.status, uptime: cachedQdrantStatus.data.uptime },
194
416
  l4: { entities: l4Entities, relationships: l4Relationships },
195
417
  compression: {
196
- rawBytes: stats.totalRawBytes,
197
- contextBytes: stats.totalContextBytes,
198
- savingsPercent: stats.savingsPercent,
199
- totalCalls: stats.totalCalls,
418
+ rawBytes: compressionRaw,
419
+ contextBytes: compressionCtx,
420
+ savingsPercent: compressionSavings,
421
+ totalCalls: compressionCalls,
200
422
  },
423
+ hitsMisses,
424
+ recentQueries,
201
425
  };
202
426
  }
203
427
  try {
@@ -223,11 +447,13 @@ function getMemoryData(cwd) {
223
447
  l3: { status: l3Status, uptime: l3Uptime },
224
448
  l4: { entities: l4Entities, relationships: l4Relationships },
225
449
  compression: {
226
- rawBytes: stats.totalRawBytes,
227
- contextBytes: stats.totalContextBytes,
228
- savingsPercent: stats.savingsPercent,
229
- totalCalls: stats.totalCalls,
450
+ rawBytes: compressionRaw,
451
+ contextBytes: compressionCtx,
452
+ savingsPercent: compressionSavings,
453
+ totalCalls: compressionCalls,
230
454
  },
455
+ hitsMisses,
456
+ recentQueries,
231
457
  };
232
458
  }
233
459
  function getModelData(cwd) {
@@ -293,18 +519,22 @@ function getTaskData(cwd) {
293
519
  result.blocked = db.prepare("SELECT COUNT(*) as c FROM tasks WHERE status='blocked'").get().c;
294
520
  result.open = db.prepare("SELECT COUNT(*) as c FROM tasks WHERE status='open'").get().c;
295
521
  // Fetch individual task items for kanban board (most recent 50)
296
- const rows = db.prepare(`SELECT id, title, type, status, priority, assignee, updated_at
522
+ const rows = db
523
+ .prepare(`SELECT id, title, type, status, priority, assignee, updated_at
297
524
  FROM tasks
298
525
  WHERE status NOT IN ('done', 'wont_do')
299
526
  ORDER BY priority ASC, updated_at DESC
300
- LIMIT 50`).all();
527
+ LIMIT 50`)
528
+ .all();
301
529
  // Also fetch recent done/wont_do (last 10)
302
- const doneRows = db.prepare(`SELECT id, title, type, status, priority, assignee, updated_at
530
+ const doneRows = db
531
+ .prepare(`SELECT id, title, type, status, priority, assignee, updated_at
303
532
  FROM tasks
304
533
  WHERE status IN ('done', 'wont_do')
305
534
  ORDER BY updated_at DESC
306
- LIMIT 10`).all();
307
- result.items = [...rows, ...doneRows].map(r => ({
535
+ LIMIT 10`)
536
+ .all();
537
+ result.items = [...rows, ...doneRows].map((r) => ({
308
538
  id: r.id,
309
539
  title: r.title,
310
540
  type: r.type,
@@ -323,33 +553,321 @@ function getTaskData(cwd) {
323
553
  }
324
554
  function getCoordData(cwd) {
325
555
  const coordDbPath = join(cwd, 'agents/data/coordination/coordination.db');
326
- const result = { activeAgents: 0, activeClaims: 0, pendingDeploys: 0 };
556
+ const result = {
557
+ activeAgents: 0,
558
+ activeClaims: 0,
559
+ pendingDeploys: 0,
560
+ totalAgents: 0,
561
+ completedAgents: 0,
562
+ patternHits: 0,
563
+ patternSuccesses: 0,
564
+ activeWorktrees: 0,
565
+ agents: [],
566
+ skillsPerAgent: {},
567
+ patternsPerAgent: {},
568
+ };
327
569
  if (existsSync(coordDbPath)) {
328
570
  try {
329
571
  const db = new Database(coordDbPath, { readonly: true });
330
- const hasAgents = db
331
- .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='agent_registry'")
332
- .all();
333
- if (hasAgents.length > 0) {
334
- result.activeAgents = db.prepare("SELECT COUNT(*) as c FROM agent_registry WHERE status='active'").get().c;
572
+ // Active agents
573
+ try {
574
+ const hasAgents = db
575
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='agent_registry'")
576
+ .all();
577
+ if (hasAgents.length > 0) {
578
+ result.activeAgents = db.prepare("SELECT COUNT(*) as c FROM agent_registry WHERE status='active'").get().c;
579
+ result.totalAgents = db.prepare('SELECT COUNT(*) as c FROM agent_registry').get().c;
580
+ result.completedAgents = db
581
+ .prepare("SELECT COUNT(*) as c FROM agent_registry WHERE status='completed'")
582
+ .get().c;
583
+ // Agent list
584
+ try {
585
+ const agentRows = db
586
+ .prepare('SELECT id, name, status, started_at FROM agent_registry ORDER BY started_at DESC LIMIT 20')
587
+ .all();
588
+ result.agents = agentRows.map((a) => ({
589
+ id: a.id,
590
+ name: a.name,
591
+ status: a.status,
592
+ startedAt: a.started_at,
593
+ }));
594
+ }
595
+ catch {
596
+ /* ignore */
597
+ }
598
+ }
335
599
  }
336
- const hasClaims = db
337
- .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='work_claims'")
338
- .all();
339
- if (hasClaims.length > 0) {
340
- result.activeClaims = db.prepare("SELECT COUNT(*) as c FROM work_claims WHERE status='active'").get().c;
600
+ catch {
601
+ /* ignore */
602
+ }
603
+ // Work claims - NO status column, use COUNT(*)
604
+ try {
605
+ const hasClaims = db
606
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='work_claims'")
607
+ .all();
608
+ if (hasClaims.length > 0) {
609
+ result.activeClaims = db.prepare('SELECT COUNT(*) as c FROM work_claims').get().c;
610
+ }
611
+ }
612
+ catch {
613
+ /* ignore */
341
614
  }
615
+ // Deploy queue
616
+ try {
617
+ const hasDQ = db
618
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='deploy_queue'")
619
+ .all();
620
+ if (hasDQ.length > 0) {
621
+ result.pendingDeploys = db.prepare("SELECT COUNT(*) as c FROM deploy_queue WHERE status='pending'").get().c;
622
+ }
623
+ }
624
+ catch {
625
+ /* ignore */
626
+ }
627
+ // Pattern outcomes
628
+ try {
629
+ const hasPO = db
630
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='pattern_outcomes'")
631
+ .all();
632
+ if (hasPO.length > 0) {
633
+ const poRow = db
634
+ .prepare('SELECT SUM(uses) as u, SUM(successes) as s FROM pattern_outcomes')
635
+ .get();
636
+ result.patternHits = poRow?.u || 0;
637
+ result.patternSuccesses = poRow?.s || 0;
638
+ // Patterns per agent - group by pattern_id
639
+ try {
640
+ const patternRows = db
641
+ .prepare('SELECT pattern_id, task_category, uses FROM pattern_outcomes ORDER BY uses DESC')
642
+ .all();
643
+ // Group patterns by agent (use agent list if available, otherwise use 'all')
644
+ const agentIds = result.agents.length > 0 ? result.agents.map((a) => a.id) : ['all'];
645
+ for (const agentId of agentIds) {
646
+ result.patternsPerAgent[agentId] = patternRows.map((p) => ({
647
+ id: p.pattern_id,
648
+ category: p.task_category,
649
+ uses: p.uses,
650
+ }));
651
+ }
652
+ }
653
+ catch {
654
+ /* ignore */
655
+ }
656
+ }
657
+ }
658
+ catch {
659
+ /* ignore */
660
+ }
661
+ db.close();
662
+ }
663
+ catch {
664
+ /* ignore */
665
+ }
666
+ }
667
+ // Worktree count from worktree registry
668
+ try {
669
+ const wtDbPath = join(cwd, '.uap/worktree_registry.db');
670
+ if (existsSync(wtDbPath)) {
671
+ const wtDb = new Database(wtDbPath, { readonly: true });
672
+ try {
673
+ const hasTable = wtDb
674
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='worktrees'")
675
+ .all();
676
+ if (hasTable.length > 0) {
677
+ result.activeWorktrees = wtDb.prepare("SELECT COUNT(*) as c FROM worktrees WHERE status='active'").get().c;
678
+ }
679
+ }
680
+ catch {
681
+ /* ignore */
682
+ }
683
+ wtDb.close();
684
+ }
685
+ }
686
+ catch {
687
+ /* ignore */
688
+ }
689
+ // Skills per agent: read from .claude/skills/ directory (shared by all agents)
690
+ try {
691
+ const skillsDir = join(cwd, '.claude', 'skills');
692
+ if (existsSync(skillsDir)) {
693
+ const skillDirs = readdirSync(skillsDir).filter((d) => {
694
+ try {
695
+ return statSync(join(skillsDir, d)).isDirectory();
696
+ }
697
+ catch {
698
+ return false;
699
+ }
700
+ });
701
+ const agentIds = result.agents.length > 0 ? result.agents.map((a) => a.id) : ['all'];
702
+ for (const agentId of agentIds) {
703
+ result.skillsPerAgent[agentId] = skillDirs;
704
+ }
705
+ }
706
+ }
707
+ catch {
708
+ /* ignore */
709
+ }
710
+ return result;
711
+ }
712
+ export function getDeployBucketData(cwd) {
713
+ const coordDbPath = join(cwd, 'agents/data/coordination/coordination.db');
714
+ const summary = {
715
+ totalActions: 0,
716
+ queued: 0,
717
+ batched: 0,
718
+ executing: 0,
719
+ done: 0,
720
+ failed: 0,
721
+ batchCount: 0,
722
+ savedOps: 0,
723
+ };
724
+ if (!existsSync(coordDbPath))
725
+ return summary;
726
+ try {
727
+ const db = new Database(coordDbPath, { readonly: true });
728
+ try {
342
729
  const hasDQ = db
343
730
  .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='deploy_queue'")
344
731
  .all();
345
732
  if (hasDQ.length > 0) {
346
- result.pendingDeploys = db.prepare("SELECT COUNT(*) as c FROM deploy_queue WHERE status='pending'").get().c;
733
+ // Use status mapping: 'completed' counts as done
734
+ const rows = db
735
+ .prepare(`SELECT status, COUNT(*) as c FROM deploy_queue GROUP BY status`)
736
+ .all();
737
+ for (const row of rows) {
738
+ summary.totalActions += row.c;
739
+ switch (row.status) {
740
+ case 'pending':
741
+ summary.queued += row.c;
742
+ break;
743
+ case 'batched':
744
+ summary.batched += row.c;
745
+ break;
746
+ case 'executing':
747
+ summary.executing += row.c;
748
+ break;
749
+ case 'completed':
750
+ summary.done += row.c;
751
+ break;
752
+ case 'failed':
753
+ summary.failed += row.c;
754
+ break;
755
+ }
756
+ }
757
+ }
758
+ }
759
+ catch {
760
+ /* ignore */
761
+ }
762
+ try {
763
+ const hasDB = db
764
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='deploy_batches'")
765
+ .all();
766
+ if (hasDB.length > 0) {
767
+ summary.batchCount = db.prepare('SELECT COUNT(*) as c FROM deploy_batches').get().c;
347
768
  }
348
- db.close();
349
769
  }
350
770
  catch {
351
771
  /* ignore */
352
772
  }
773
+ db.close();
774
+ }
775
+ catch {
776
+ /* ignore */
777
+ }
778
+ // Calculate saved ops (batched actions that were squashed)
779
+ if (summary.batchCount > 0 && summary.totalActions > summary.batchCount) {
780
+ summary.savedOps = summary.totalActions - summary.batchCount;
781
+ }
782
+ return summary;
783
+ }
784
+ // ── Compliance Data ──
785
+ function categorizeMechanism(policyId, policyName, operation) {
786
+ const combined = `${policyId} ${policyName} ${operation}`.toLowerCase();
787
+ if (combined.includes('worktree'))
788
+ return 'Worktree Gate';
789
+ if (combined.includes('build'))
790
+ return 'Build Gate';
791
+ if (combined.includes('test'))
792
+ return 'Test Gate';
793
+ if (combined.includes('schema'))
794
+ return 'Schema Diff Gate';
795
+ if (combined.includes('backup'))
796
+ return 'File Backup';
797
+ if (combined.includes('version'))
798
+ return 'Version Gate';
799
+ if (combined.includes('lint'))
800
+ return 'Lint Gate';
801
+ if (combined.includes('deploy'))
802
+ return 'Deploy Gate';
803
+ if (combined.includes('security') || combined.includes('secret'))
804
+ return 'Security Gate';
805
+ return 'Policy Enforcement';
806
+ }
807
+ function getComplianceData(cwd) {
808
+ const dbPath = join(cwd, 'agents', 'data', 'memory', 'policies.db');
809
+ const result = {
810
+ totalChecks: 0,
811
+ totalBlocks: 0,
812
+ blockRate: '0%',
813
+ recentFailures: [],
814
+ failuresByMechanism: {},
815
+ };
816
+ if (!existsSync(dbPath))
817
+ return result;
818
+ try {
819
+ const db = new Database(dbPath, { readonly: true });
820
+ const hasTable = db
821
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='policy_executions'")
822
+ .all();
823
+ if (hasTable.length === 0) {
824
+ db.close();
825
+ return result;
826
+ }
827
+ result.totalChecks = db.prepare('SELECT COUNT(*) as c FROM policy_executions').get().c;
828
+ result.totalBlocks = db.prepare('SELECT COUNT(*) as c FROM policy_executions WHERE allowed = 0').get().c;
829
+ result.blockRate =
830
+ result.totalChecks > 0
831
+ ? `${Math.round((result.totalBlocks / result.totalChecks) * 100)}%`
832
+ : '0%';
833
+ const hasPolicies = db
834
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='policies'")
835
+ .all();
836
+ let failureRows;
837
+ if (hasPolicies.length > 0) {
838
+ failureRows = db
839
+ .prepare(`SELECT pe.policyId, pe.operation, pe.reason, pe.executedAt, COALESCE(p.name, pe.policyId) as policyName
840
+ FROM policy_executions pe LEFT JOIN policies p ON pe.policyId = p.id
841
+ WHERE pe.allowed = 0 ORDER BY pe.executedAt DESC LIMIT 50`)
842
+ .all();
843
+ }
844
+ else {
845
+ failureRows = db
846
+ .prepare(`SELECT policyId, operation, reason, executedAt, policyId as policyName
847
+ FROM policy_executions WHERE allowed = 0 ORDER BY executedAt DESC LIMIT 50`)
848
+ .all();
849
+ }
850
+ const mechanismCounts = {};
851
+ result.recentFailures = failureRows.map((r) => {
852
+ const pid = r.policyId || '';
853
+ const pname = r.policyName || pid;
854
+ const op = r.operation || 'unknown';
855
+ const mech = categorizeMechanism(pid, pname, op);
856
+ mechanismCounts[mech] = (mechanismCounts[mech] || 0) + 1;
857
+ return {
858
+ policyId: pid,
859
+ policyName: pname,
860
+ operation: op,
861
+ reason: r.reason || '',
862
+ executedAt: r.executedAt || '',
863
+ defeatedMechanism: mech,
864
+ };
865
+ });
866
+ result.failuresByMechanism = mechanismCounts;
867
+ db.close();
868
+ }
869
+ catch {
870
+ /* ignore */
353
871
  }
354
872
  return result;
355
873
  }