@cleocode/core 2026.4.48 → 2026.4.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ /**
2
+ * SQLite store for telemetry.db via drizzle-orm/node-sqlite + node:sqlite.
3
+ *
4
+ * Stores opt-in command telemetry in ~/.local/share/cleo/telemetry.db.
5
+ * Follows the same singleton + WAL + migration pattern as brain-sqlite.ts.
6
+ * Telemetry is disabled by default — check isTelemetryEnabled() before writing.
7
+ *
8
+ * @task T624
9
+ */
10
+ import type { NodeSQLiteDatabase } from 'drizzle-orm/node-sqlite';
11
+ import * as telemetrySchema from './schema.js';
12
+ /** Schema version. Single source of truth. */
13
+ export declare const TELEMETRY_SCHEMA_VERSION = "1.0.0";
14
+ /**
15
+ * Get the absolute path to telemetry.db in the global CLEO home directory.
16
+ * Linux: ~/.local/share/cleo/telemetry.db
17
+ */
18
+ export declare function getTelemetryDbPath(): string;
19
+ /**
20
+ * Resolve the drizzle-telemetry migrations folder.
21
+ * Handles both src/ (dev via tsx) and dist/ (bundled) layouts.
22
+ */
23
+ export declare function resolveTelemetryMigrationsFolder(): string;
24
+ /**
25
+ * Reset the singleton (used in tests).
26
+ */
27
+ export declare function resetTelemetryDbState(): void;
28
+ /**
29
+ * Initialize telemetry.db (lazy singleton).
30
+ * Creates the file and runs migrations on first call.
31
+ */
32
+ export declare function getTelemetryDb(): Promise<NodeSQLiteDatabase<typeof telemetrySchema>>;
33
+ //# sourceMappingURL=sqlite.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sqlite.d.ts","sourceRoot":"","sources":["../../src/telemetry/sqlite.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAMH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAKlE,OAAO,KAAK,eAAe,MAAM,aAAa,CAAC;AAK/C,8CAA8C;AAC9C,eAAO,MAAM,wBAAwB,UAAU,CAAC;AAQhD;;;GAGG;AACH,wBAAgB,kBAAkB,IAAI,MAAM,CAE3C;AAED;;;GAGG;AACH,wBAAgB,gCAAgC,IAAI,MAAM,CAQzD;AAgCD;;GAEG;AACH,wBAAgB,qBAAqB,IAAI,IAAI,CAU5C;AAED;;;GAGG;AACH,wBAAsB,cAAc,IAAI,OAAO,CAAC,kBAAkB,CAAC,OAAO,eAAe,CAAC,CAAC,CAmC1F"}
@@ -0,0 +1,23 @@
1
+ CREATE TABLE IF NOT EXISTS `telemetry_events` (
2
+ `id` text PRIMARY KEY,
3
+ `anonymous_id` text NOT NULL,
4
+ `domain` text NOT NULL,
5
+ `gateway` text NOT NULL,
6
+ `operation` text NOT NULL,
7
+ `command` text NOT NULL,
8
+ `exit_code` integer NOT NULL DEFAULT 0,
9
+ `duration_ms` integer NOT NULL,
10
+ `error_code` text,
11
+ `timestamp` text NOT NULL DEFAULT (datetime('now'))
12
+ );
13
+ --> statement-breakpoint
14
+ CREATE TABLE IF NOT EXISTS `telemetry_schema_meta` (
15
+ `key` text PRIMARY KEY,
16
+ `value` text NOT NULL
17
+ );
18
+ --> statement-breakpoint
19
+ CREATE INDEX IF NOT EXISTS `idx_telemetry_command` ON `telemetry_events` (`command`);--> statement-breakpoint
20
+ CREATE INDEX IF NOT EXISTS `idx_telemetry_domain` ON `telemetry_events` (`domain`);--> statement-breakpoint
21
+ CREATE INDEX IF NOT EXISTS `idx_telemetry_exit_code` ON `telemetry_events` (`exit_code`);--> statement-breakpoint
22
+ CREATE INDEX IF NOT EXISTS `idx_telemetry_timestamp` ON `telemetry_events` (`timestamp`);--> statement-breakpoint
23
+ CREATE INDEX IF NOT EXISTS `idx_telemetry_duration` ON `telemetry_events` (`duration_ms`);
@@ -0,0 +1,35 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "sqlite",
4
+ "id": "a1b2c3d4-0000-0000-0000-t624telemetry",
5
+ "prevIds": [
6
+ "00000000-0000-0000-0000-000000000000"
7
+ ],
8
+ "ddl": [
9
+ {
10
+ "name": "telemetry_events",
11
+ "columns": {
12
+ "id": { "name": "id", "type": "text", "notNull": true, "primaryKey": true },
13
+ "anonymous_id": { "name": "anonymous_id", "type": "text", "notNull": true },
14
+ "domain": { "name": "domain", "type": "text", "notNull": true },
15
+ "gateway": { "name": "gateway", "type": "text", "notNull": true },
16
+ "operation": { "name": "operation", "type": "text", "notNull": true },
17
+ "command": { "name": "command", "type": "text", "notNull": true },
18
+ "exit_code": { "name": "exit_code", "type": "integer", "notNull": true, "default": 0 },
19
+ "duration_ms": { "name": "duration_ms", "type": "integer", "notNull": true },
20
+ "error_code": { "name": "error_code", "type": "text", "notNull": false },
21
+ "timestamp": { "name": "timestamp", "type": "text", "notNull": true }
22
+ }
23
+ },
24
+ {
25
+ "name": "telemetry_schema_meta",
26
+ "columns": {
27
+ "key": { "name": "key", "type": "text", "notNull": true, "primaryKey": true },
28
+ "value": { "name": "value", "type": "text", "notNull": true }
29
+ }
30
+ }
31
+ ],
32
+ "indexes": {},
33
+ "foreignKeys": {},
34
+ "tables": {}
35
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cleocode/core",
3
- "version": "2026.4.48",
3
+ "version": "2026.4.49",
4
4
  "description": "CLEO core business logic kernel — tasks, sessions, memory, orchestration, lifecycle, with bundled SQLite store",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -63,13 +63,13 @@
63
63
  "write-file-atomic": "^7.0.1",
64
64
  "yaml": "^2.8.3",
65
65
  "zod": "^4.3.6",
66
- "@cleocode/adapters": "2026.4.48",
67
- "@cleocode/agents": "2026.4.48",
68
- "@cleocode/caamp": "2026.4.48",
69
- "@cleocode/contracts": "2026.4.48",
70
- "@cleocode/lafs": "2026.4.48",
71
- "@cleocode/nexus": "2026.4.48",
72
- "@cleocode/skills": "2026.4.48"
66
+ "@cleocode/adapters": "2026.4.49",
67
+ "@cleocode/agents": "2026.4.49",
68
+ "@cleocode/caamp": "2026.4.49",
69
+ "@cleocode/contracts": "2026.4.49",
70
+ "@cleocode/lafs": "2026.4.49",
71
+ "@cleocode/nexus": "2026.4.49",
72
+ "@cleocode/skills": "2026.4.49"
73
73
  },
74
74
  "engines": {
75
75
  "node": ">=24.0.0"
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Tests for the telemetry module (T624 — diagnostic feedback loop).
3
+ *
4
+ * Covers:
5
+ * - Opt-in / opt-out config management
6
+ * - Anonymous ID generation (stable across calls, new on first enable)
7
+ * - recordTelemetryEvent is a no-op when disabled
8
+ * - buildDiagnosticsReport returns null when disabled
9
+ * - buildDiagnosticsReport returns correct aggregates when enabled
10
+ *
11
+ * @task T624
12
+ */
13
+
14
+ import { mkdtemp, rm } from 'node:fs/promises';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
18
+ import {
19
+ buildDiagnosticsReport,
20
+ disableTelemetry,
21
+ enableTelemetry,
22
+ isTelemetryEnabled,
23
+ loadTelemetryConfig,
24
+ recordTelemetryEvent,
25
+ } from '../telemetry/index.js';
26
+ import { resetTelemetryDbState } from '../telemetry/sqlite.js';
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Test setup: redirect CLEO_HOME to a temp directory for isolation
30
+ // ---------------------------------------------------------------------------
31
+
32
+ let tempDir: string;
33
+ const origCleoHome = process.env['CLEO_HOME'];
34
+
35
+ beforeEach(async () => {
36
+ tempDir = await mkdtemp(join(tmpdir(), 'cleo-telemetry-test-'));
37
+ process.env['CLEO_HOME'] = tempDir;
38
+ // Reset the DB singleton so each test starts clean
39
+ resetTelemetryDbState();
40
+ });
41
+
42
+ afterEach(async () => {
43
+ resetTelemetryDbState();
44
+ await rm(tempDir, { recursive: true, force: true });
45
+ if (origCleoHome !== undefined) {
46
+ process.env['CLEO_HOME'] = origCleoHome;
47
+ } else {
48
+ delete process.env['CLEO_HOME'];
49
+ }
50
+ });
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Config management
54
+ // ---------------------------------------------------------------------------
55
+
56
+ describe('loadTelemetryConfig', () => {
57
+ it('returns disabled config when file does not exist', () => {
58
+ const config = loadTelemetryConfig();
59
+ expect(config.enabled).toBe(false);
60
+ expect(config.anonymousId).toBe('');
61
+ });
62
+ });
63
+
64
+ describe('enableTelemetry', () => {
65
+ it('sets enabled=true and generates a non-empty anonymousId', () => {
66
+ const config = enableTelemetry();
67
+ expect(config.enabled).toBe(true);
68
+ expect(config.anonymousId).toMatch(
69
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
70
+ );
71
+ });
72
+
73
+ it('preserves existing anonymousId on subsequent calls', () => {
74
+ const first = enableTelemetry();
75
+ const second = enableTelemetry();
76
+ expect(second.anonymousId).toBe(first.anonymousId);
77
+ });
78
+ });
79
+
80
+ describe('disableTelemetry', () => {
81
+ it('sets enabled=false', () => {
82
+ enableTelemetry();
83
+ const config = disableTelemetry();
84
+ expect(config.enabled).toBe(false);
85
+ });
86
+
87
+ it('preserves anonymousId after disable', () => {
88
+ const { anonymousId } = enableTelemetry();
89
+ const after = disableTelemetry();
90
+ expect(after.anonymousId).toBe(anonymousId);
91
+ });
92
+ });
93
+
94
+ describe('isTelemetryEnabled', () => {
95
+ it('returns false by default', () => {
96
+ expect(isTelemetryEnabled()).toBe(false);
97
+ });
98
+
99
+ it('returns true after enable', () => {
100
+ enableTelemetry();
101
+ expect(isTelemetryEnabled()).toBe(true);
102
+ });
103
+
104
+ it('returns false after disable', () => {
105
+ enableTelemetry();
106
+ disableTelemetry();
107
+ expect(isTelemetryEnabled()).toBe(false);
108
+ });
109
+ });
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Event recording
113
+ // ---------------------------------------------------------------------------
114
+
115
+ describe('recordTelemetryEvent', () => {
116
+ it('is a no-op when telemetry is disabled (no error thrown)', async () => {
117
+ // Ensure disabled
118
+ disableTelemetry();
119
+ await expect(
120
+ recordTelemetryEvent({
121
+ domain: 'tasks',
122
+ gateway: 'query',
123
+ operation: 'show',
124
+ durationMs: 42,
125
+ exitCode: 0,
126
+ }),
127
+ ).resolves.toBeUndefined();
128
+ });
129
+
130
+ it('writes an event to the DB when enabled', async () => {
131
+ enableTelemetry();
132
+ await recordTelemetryEvent({
133
+ domain: 'tasks',
134
+ gateway: 'mutate',
135
+ operation: 'add',
136
+ durationMs: 100,
137
+ exitCode: 0,
138
+ });
139
+ // Verify by building a report
140
+ const report = await buildDiagnosticsReport(1);
141
+ expect(report).not.toBeNull();
142
+ expect(report!.totalEvents).toBeGreaterThanOrEqual(1);
143
+ });
144
+ });
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Diagnostics report
148
+ // ---------------------------------------------------------------------------
149
+
150
+ describe('buildDiagnosticsReport', () => {
151
+ it('returns null when telemetry is disabled', async () => {
152
+ disableTelemetry();
153
+ const report = await buildDiagnosticsReport(30);
154
+ expect(report).toBeNull();
155
+ });
156
+
157
+ it('returns a report with zero events when DB is empty', async () => {
158
+ enableTelemetry();
159
+ const report = await buildDiagnosticsReport(30);
160
+ expect(report).not.toBeNull();
161
+ expect(report!.totalEvents).toBe(0);
162
+ expect(report!.topFailing).toHaveLength(0);
163
+ expect(report!.topSlow).toHaveLength(0);
164
+ });
165
+
166
+ it('surfaces high-failure-rate commands in topFailing', async () => {
167
+ enableTelemetry();
168
+
169
+ // Record 10 failures and 2 successes for tasks.add
170
+ for (let i = 0; i < 10; i++) {
171
+ await recordTelemetryEvent({
172
+ domain: 'tasks',
173
+ gateway: 'mutate',
174
+ operation: 'add',
175
+ durationMs: 50,
176
+ exitCode: 6,
177
+ errorCode: 'E_VALIDATION',
178
+ });
179
+ }
180
+ for (let i = 0; i < 2; i++) {
181
+ await recordTelemetryEvent({
182
+ domain: 'tasks',
183
+ gateway: 'mutate',
184
+ operation: 'add',
185
+ durationMs: 50,
186
+ exitCode: 0,
187
+ });
188
+ }
189
+
190
+ const report = await buildDiagnosticsReport(1);
191
+ expect(report).not.toBeNull();
192
+ const failing = report!.topFailing.find((c) => c.command === 'tasks.add');
193
+ expect(failing).toBeDefined();
194
+ expect(failing!.failureCount).toBe(10);
195
+ expect(failing!.failureRate).toBeCloseTo(10 / 12);
196
+ expect(failing!.topErrorCode).toBe('E_VALIDATION');
197
+ });
198
+
199
+ it('generates BRAIN observation text for failing commands', async () => {
200
+ enableTelemetry();
201
+
202
+ // Need at least 5 invocations to appear in topFailing
203
+ for (let i = 0; i < 8; i++) {
204
+ await recordTelemetryEvent({
205
+ domain: 'session',
206
+ gateway: 'mutate',
207
+ operation: 'start',
208
+ durationMs: 200,
209
+ exitCode: i < 7 ? 1 : 0,
210
+ errorCode: i < 7 ? 'E_GENERAL' : null,
211
+ });
212
+ }
213
+
214
+ const report = await buildDiagnosticsReport(1);
215
+ expect(report).not.toBeNull();
216
+ expect(report!.observations.length).toBeGreaterThan(0);
217
+ const obs = report!.observations[0]!;
218
+ expect(obs).toContain('session.start');
219
+ expect(obs).toContain('%');
220
+ });
221
+ });
package/src/index.ts CHANGED
@@ -72,6 +72,7 @@ export * as sticky from './sticky/index.js';
72
72
  export * as system from './system/index.js';
73
73
  export * as taskWork from './task-work/index.js';
74
74
  export * as tasks from './tasks/index.js';
75
+ export * as telemetry from './telemetry/index.js';
75
76
  export * as templates from './templates/index.js';
76
77
  export * as ui from './ui/index.js';
77
78
  export * as validation from './validation/index.js';
package/src/internal.ts CHANGED
@@ -291,7 +291,7 @@ export {
291
291
  export { searchAcrossProjects } from './nexus/discover.js';
292
292
  export { setPermission } from './nexus/permissions.js';
293
293
  export { resolveTask, validateSyntax } from './nexus/query.js';
294
- export type { NexusPermissionLevel } from './nexus/registry.js';
294
+ export type { NexusPermissionLevel, NexusProject, NexusProjectStats } from './nexus/registry.js';
295
295
  export {
296
296
  nexusGetProject,
297
297
  nexusInit,
@@ -301,6 +301,7 @@ export {
301
301
  nexusSync,
302
302
  nexusSyncAll,
303
303
  nexusUnregister,
304
+ nexusUpdateIndexStats,
304
305
  } from './nexus/registry.js';
305
306
  export { getSharingStatus } from './nexus/sharing/index.js';
306
307
  // Context
@@ -625,6 +626,24 @@ export {
625
626
  coreTaskTree,
626
627
  coreTaskUnarchive,
627
628
  } from './tasks/task-ops.js';
629
+ // Self-improvement telemetry (T624)
630
+ export type {
631
+ CommandStats as TelemetryCommandStats,
632
+ DiagnosticsReport as TelemetryDiagnosticsReport,
633
+ TelemetryConfig,
634
+ TelemetryEvent,
635
+ } from './telemetry/index.js';
636
+ export {
637
+ buildDiagnosticsReport,
638
+ disableTelemetry,
639
+ enableTelemetry,
640
+ exportTelemetryEvents,
641
+ getTelemetryConfigPath,
642
+ getTelemetryDbPath,
643
+ isTelemetryEnabled,
644
+ loadTelemetryConfig,
645
+ recordTelemetryEvent,
646
+ } from './telemetry/index.js';
628
647
  export type { IssueTemplate, TemplateConfig, TemplateSection } from './templates/parser.js';
629
648
  // Templates
630
649
  export {
@@ -74,6 +74,7 @@ export {
74
74
  type NexusPermissionLevel,
75
75
  // Types
76
76
  type NexusProject,
77
+ type NexusProjectStats,
77
78
  type NexusRegistryFile,
78
79
  nexusGetProject,
79
80
  nexusInit,
@@ -85,6 +86,7 @@ export {
85
86
  nexusSync,
86
87
  nexusSyncAll,
87
88
  nexusUnregister,
89
+ nexusUpdateIndexStats,
88
90
  // Operations
89
91
  readRegistry,
90
92
  readRegistryRequired,
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { randomUUID } from 'node:crypto';
15
15
  import { mkdir } from 'node:fs/promises';
16
- import { basename, join } from 'node:path';
16
+ import { basename, join, resolve } from 'node:path';
17
17
  import { ExitCode } from '@cleocode/contracts';
18
18
  import { CleoError } from '../errors.js';
19
19
  import { getLogger } from '../logger.js';
@@ -36,6 +36,13 @@ export type NexusPermissionLevel = 'read' | 'write' | 'execute';
36
36
 
37
37
  export type NexusHealthStatus = 'unknown' | 'healthy' | 'degraded' | 'unreachable';
38
38
 
39
+ /** Per-project code intelligence statistics stored in stats_json. */
40
+ export interface NexusProjectStats {
41
+ nodeCount: number;
42
+ relationCount: number;
43
+ fileCount: number;
44
+ }
45
+
39
46
  /** Domain representation of a registered Nexus project. */
40
47
  export interface NexusProject {
41
48
  hash: string;
@@ -50,6 +57,14 @@ export interface NexusProject {
50
57
  lastSync: string;
51
58
  taskCount: number;
52
59
  labels: string[];
60
+ /** Absolute path to the project's brain.db. Null if not yet populated. */
61
+ brainDbPath: string | null;
62
+ /** Absolute path to the project's tasks.db. Null if not yet populated. */
63
+ tasksDbPath: string | null;
64
+ /** ISO 8601 timestamp of the last code intelligence index run. Null if never indexed. */
65
+ lastIndexed: string | null;
66
+ /** Code intelligence stats from the last index run. */
67
+ stats: NexusProjectStats;
53
68
  }
54
69
 
55
70
  /** Legacy registry file shape (pre-SQLite). Retained for migration compatibility. */
@@ -90,6 +105,17 @@ function rowToProject(row: ProjectRegistryRow): NexusProject {
90
105
  } catch {
91
106
  labels = [];
92
107
  }
108
+ let stats: NexusProjectStats = { nodeCount: 0, relationCount: 0, fileCount: 0 };
109
+ try {
110
+ const parsed = JSON.parse(row.statsJson ?? '{}') as Partial<NexusProjectStats>;
111
+ stats = {
112
+ nodeCount: parsed.nodeCount ?? 0,
113
+ relationCount: parsed.relationCount ?? 0,
114
+ fileCount: parsed.fileCount ?? 0,
115
+ };
116
+ } catch {
117
+ stats = { nodeCount: 0, relationCount: 0, fileCount: 0 };
118
+ }
93
119
  return {
94
120
  hash: row.projectHash,
95
121
  projectId: row.projectId,
@@ -103,6 +129,10 @@ function rowToProject(row: ProjectRegistryRow): NexusProject {
103
129
  lastSync: row.lastSync,
104
130
  taskCount: row.taskCount,
105
131
  labels,
132
+ brainDbPath: row.brainDbPath ?? null,
133
+ tasksDbPath: row.tasksDbPath ?? null,
134
+ lastIndexed: row.lastIndexed ?? null,
135
+ stats,
106
136
  };
107
137
  }
108
138
 
@@ -328,6 +358,9 @@ export async function nexusRegister(
328
358
  const meta = await readProjectMeta(projectPath);
329
359
  const now = new Date().toISOString();
330
360
  let projectId = await readProjectId(projectPath);
361
+ const resolvedPath = resolve(projectPath);
362
+ const brainDbPath = join(resolvedPath, '.cleo', 'brain.db');
363
+ const tasksDbPath = join(resolvedPath, '.cleo', 'tasks.db');
331
364
 
332
365
  if (existing) {
333
366
  // Merge nexus fields into existing entry
@@ -339,6 +372,8 @@ export async function nexusRegister(
339
372
  taskCount: meta.taskCount,
340
373
  labelsJson: JSON.stringify(meta.labels),
341
374
  lastSeen: now,
375
+ brainDbPath,
376
+ tasksDbPath,
342
377
  })
343
378
  .where(eq(projectRegistry.projectHash, projectHash));
344
379
  } else {
@@ -361,6 +396,9 @@ export async function nexusRegister(
361
396
  lastSync: now,
362
397
  taskCount: meta.taskCount,
363
398
  labelsJson: JSON.stringify(meta.labels),
399
+ brainDbPath,
400
+ tasksDbPath,
401
+ statsJson: '{}',
364
402
  });
365
403
  }
366
404
 
@@ -527,6 +565,70 @@ export async function nexusSyncAll(): Promise<{ synced: number; failed: number }
527
565
  return { synced, failed };
528
566
  }
529
567
 
568
+ /**
569
+ * Update code intelligence index stats for a registered project.
570
+ *
571
+ * Called after a successful `cleo nexus analyze` run to record the
572
+ * latest node/relation/file counts and the indexed timestamp.
573
+ *
574
+ * @param projectPath - Absolute path to the project root.
575
+ * @param stats - Results from the pipeline run.
576
+ * @task T622
577
+ */
578
+ export async function nexusUpdateIndexStats(
579
+ projectPath: string,
580
+ stats: NexusProjectStats,
581
+ ): Promise<void> {
582
+ if (!projectPath) return;
583
+
584
+ const projectHash = generateProjectHash(projectPath);
585
+ const now = new Date().toISOString();
586
+
587
+ try {
588
+ const { getNexusDb } = await import('../store/nexus-sqlite.js');
589
+ const { eq } = await import('drizzle-orm');
590
+ const db = await getNexusDb();
591
+
592
+ const rows = await db
593
+ .select()
594
+ .from(projectRegistry)
595
+ .where(eq(projectRegistry.projectHash, projectHash));
596
+
597
+ if (rows.length === 0) {
598
+ // Not yet registered — auto-register first (best effort)
599
+ try {
600
+ await nexusRegister(projectPath);
601
+ } catch {
602
+ // Already registered or cannot register — ignore
603
+ }
604
+ }
605
+
606
+ await db
607
+ .update(projectRegistry)
608
+ .set({
609
+ lastIndexed: now,
610
+ statsJson: JSON.stringify(stats),
611
+ lastSeen: now,
612
+ })
613
+ .where(eq(projectRegistry.projectHash, projectHash));
614
+
615
+ await writeNexusAudit({
616
+ action: 'update-index-stats',
617
+ projectHash,
618
+ operation: 'update-index-stats',
619
+ success: true,
620
+ details: {
621
+ nodeCount: stats.nodeCount,
622
+ relationCount: stats.relationCount,
623
+ fileCount: stats.fileCount,
624
+ },
625
+ });
626
+ } catch (err) {
627
+ // Non-fatal — index stats update must never break the analyze pipeline
628
+ getLogger('nexus').warn({ err }, 'nexus: failed to update index stats');
629
+ }
630
+ }
631
+
530
632
  /**
531
633
  * Update a project's permission level in the registry.
532
634
  * Used by permissions.ts to avoid direct JSON file writes.
@@ -31,11 +31,20 @@ export const projectRegistry = sqliteTable(
31
31
  lastSync: text('last_sync').notNull().default(sql`(datetime('now'))`),
32
32
  taskCount: integer('task_count').notNull().default(0),
33
33
  labelsJson: text('labels_json').notNull().default('[]'),
34
+ /** Absolute path to the project's brain.db file. */
35
+ brainDbPath: text('brain_db_path'),
36
+ /** Absolute path to the project's tasks.db file. */
37
+ tasksDbPath: text('tasks_db_path'),
38
+ /** ISO 8601 timestamp of the last successful code intelligence index run. */
39
+ lastIndexed: text('last_indexed'),
40
+ /** JSON object with per-project code intelligence stats (node_count, relation_count, file_count). */
41
+ statsJson: text('stats_json').notNull().default('{}'),
34
42
  },
35
43
  (table) => [
36
44
  index('idx_project_registry_hash').on(table.projectHash),
37
45
  index('idx_project_registry_health').on(table.healthStatus),
38
46
  index('idx_project_registry_name').on(table.name),
47
+ index('idx_project_registry_last_indexed').on(table.lastIndexed),
39
48
  ],
40
49
  );
41
50