@cleocode/core 2026.4.11 → 2026.4.12

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.
Files changed (169) hide show
  1. package/dist/codebase-map/analyzers/architecture.d.ts.map +1 -1
  2. package/dist/codebase-map/analyzers/architecture.js +0 -1
  3. package/dist/codebase-map/analyzers/architecture.js.map +1 -1
  4. package/dist/conduit/local-transport.d.ts +18 -8
  5. package/dist/conduit/local-transport.d.ts.map +1 -1
  6. package/dist/conduit/local-transport.js +23 -13
  7. package/dist/conduit/local-transport.js.map +1 -1
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +0 -1
  10. package/dist/config.js.map +1 -1
  11. package/dist/errors.d.ts +19 -0
  12. package/dist/errors.d.ts.map +1 -1
  13. package/dist/errors.js +6 -0
  14. package/dist/errors.js.map +1 -1
  15. package/dist/index.js +175 -68950
  16. package/dist/index.js.map +1 -7
  17. package/dist/init.d.ts +1 -2
  18. package/dist/init.d.ts.map +1 -1
  19. package/dist/init.js +1 -2
  20. package/dist/init.js.map +1 -1
  21. package/dist/internal.d.ts +8 -3
  22. package/dist/internal.d.ts.map +1 -1
  23. package/dist/internal.js +13 -6
  24. package/dist/internal.js.map +1 -1
  25. package/dist/memory/learnings.d.ts +2 -2
  26. package/dist/memory/patterns.d.ts +6 -6
  27. package/dist/output.d.ts +32 -11
  28. package/dist/output.d.ts.map +1 -1
  29. package/dist/output.js +67 -67
  30. package/dist/output.js.map +1 -1
  31. package/dist/paths.js +80 -14
  32. package/dist/paths.js.map +1 -1
  33. package/dist/skills/dynamic-skill-generator.d.ts +0 -2
  34. package/dist/skills/dynamic-skill-generator.d.ts.map +1 -1
  35. package/dist/skills/dynamic-skill-generator.js.map +1 -1
  36. package/dist/store/agent-registry-accessor.d.ts +203 -12
  37. package/dist/store/agent-registry-accessor.d.ts.map +1 -1
  38. package/dist/store/agent-registry-accessor.js +618 -100
  39. package/dist/store/agent-registry-accessor.js.map +1 -1
  40. package/dist/store/api-key-kdf.d.ts +73 -0
  41. package/dist/store/api-key-kdf.d.ts.map +1 -0
  42. package/dist/store/api-key-kdf.js +84 -0
  43. package/dist/store/api-key-kdf.js.map +1 -0
  44. package/dist/store/cleanup-legacy.js +171 -0
  45. package/dist/store/cleanup-legacy.js.map +1 -0
  46. package/dist/store/conduit-sqlite.d.ts +184 -0
  47. package/dist/store/conduit-sqlite.d.ts.map +1 -0
  48. package/dist/store/conduit-sqlite.js +570 -0
  49. package/dist/store/conduit-sqlite.js.map +1 -0
  50. package/dist/store/global-salt.d.ts +78 -0
  51. package/dist/store/global-salt.d.ts.map +1 -0
  52. package/dist/store/global-salt.js +147 -0
  53. package/dist/store/global-salt.js.map +1 -0
  54. package/dist/store/migrate-signaldock-to-conduit.d.ts +81 -0
  55. package/dist/store/migrate-signaldock-to-conduit.d.ts.map +1 -0
  56. package/dist/store/migrate-signaldock-to-conduit.js +555 -0
  57. package/dist/store/migrate-signaldock-to-conduit.js.map +1 -0
  58. package/dist/store/nexus-sqlite.js +28 -3
  59. package/dist/store/nexus-sqlite.js.map +1 -1
  60. package/dist/store/signaldock-sqlite.d.ts +122 -19
  61. package/dist/store/signaldock-sqlite.d.ts.map +1 -1
  62. package/dist/store/signaldock-sqlite.js +401 -251
  63. package/dist/store/signaldock-sqlite.js.map +1 -1
  64. package/dist/store/sqlite-backup.js +122 -4
  65. package/dist/store/sqlite-backup.js.map +1 -1
  66. package/dist/system/backup.d.ts +0 -26
  67. package/dist/system/backup.d.ts.map +1 -1
  68. package/dist/system/runtime.d.ts +0 -2
  69. package/dist/system/runtime.d.ts.map +1 -1
  70. package/dist/system/runtime.js +3 -3
  71. package/dist/system/runtime.js.map +1 -1
  72. package/dist/tasks/add.d.ts +1 -1
  73. package/dist/tasks/add.d.ts.map +1 -1
  74. package/dist/tasks/add.js +98 -23
  75. package/dist/tasks/add.js.map +1 -1
  76. package/dist/tasks/complete.d.ts.map +1 -1
  77. package/dist/tasks/complete.js +4 -1
  78. package/dist/tasks/complete.js.map +1 -1
  79. package/dist/tasks/find.d.ts.map +1 -1
  80. package/dist/tasks/find.js +4 -1
  81. package/dist/tasks/find.js.map +1 -1
  82. package/dist/tasks/labels.d.ts.map +1 -1
  83. package/dist/tasks/labels.js +4 -1
  84. package/dist/tasks/labels.js.map +1 -1
  85. package/dist/tasks/relates.d.ts.map +1 -1
  86. package/dist/tasks/relates.js +16 -4
  87. package/dist/tasks/relates.js.map +1 -1
  88. package/dist/tasks/show.d.ts.map +1 -1
  89. package/dist/tasks/show.js +4 -1
  90. package/dist/tasks/show.js.map +1 -1
  91. package/dist/tasks/update.d.ts.map +1 -1
  92. package/dist/tasks/update.js +32 -6
  93. package/dist/tasks/update.js.map +1 -1
  94. package/dist/validation/engine.d.ts.map +1 -1
  95. package/dist/validation/engine.js +16 -4
  96. package/dist/validation/engine.js.map +1 -1
  97. package/dist/validation/param-utils.d.ts +5 -3
  98. package/dist/validation/param-utils.d.ts.map +1 -1
  99. package/dist/validation/param-utils.js +8 -6
  100. package/dist/validation/param-utils.js.map +1 -1
  101. package/dist/validation/protocols/_shared.d.ts.map +1 -1
  102. package/dist/validation/protocols/_shared.js +13 -6
  103. package/dist/validation/protocols/_shared.js.map +1 -1
  104. package/package.json +7 -7
  105. package/src/adapters/__tests__/manager.test.ts +0 -1
  106. package/src/codebase-map/analyzers/architecture.ts +0 -1
  107. package/src/conduit/__tests__/local-credential-flow.test.ts +20 -18
  108. package/src/conduit/__tests__/local-transport.test.ts +14 -12
  109. package/src/conduit/local-transport.ts +23 -13
  110. package/src/config.ts +0 -1
  111. package/src/errors.ts +24 -0
  112. package/src/hooks/handlers/__tests__/hook-automation-e2e.test.ts +2 -5
  113. package/src/init.ts +1 -2
  114. package/src/internal.ts +49 -2
  115. package/src/lifecycle/cant/lifecycle-rcasd.cant +133 -0
  116. package/src/memory/__tests__/engine-compat.test.ts +2 -2
  117. package/src/memory/__tests__/pipeline-manifest-sqlite.test.ts +4 -4
  118. package/src/observability/__tests__/index.test.ts +4 -4
  119. package/src/observability/__tests__/log-filter.test.ts +4 -4
  120. package/src/output.ts +73 -75
  121. package/src/sessions/__tests__/session-grade.integration.test.ts +1 -1
  122. package/src/sessions/__tests__/session-grade.test.ts +2 -2
  123. package/src/skills/__tests__/dynamic-skill-generator.test.ts +0 -2
  124. package/src/skills/dynamic-skill-generator.ts +0 -2
  125. package/src/store/__tests__/agent-registry-accessor.test.ts +807 -0
  126. package/src/store/__tests__/api-key-kdf.test.ts +113 -0
  127. package/src/store/__tests__/conduit-sqlite.test.ts +413 -0
  128. package/src/store/__tests__/global-salt.test.ts +195 -0
  129. package/src/store/__tests__/migrate-signaldock-to-conduit.test.ts +715 -0
  130. package/src/store/__tests__/signaldock-sqlite.test.ts +652 -0
  131. package/src/store/__tests__/sqlite-backup-global.test.ts +307 -3
  132. package/src/store/__tests__/sqlite-backup.test.ts +5 -1
  133. package/src/store/__tests__/t310-integration.test.ts +1150 -0
  134. package/src/store/agent-registry-accessor.ts +847 -140
  135. package/src/store/api-key-kdf.ts +104 -0
  136. package/src/store/conduit-sqlite.ts +655 -0
  137. package/src/store/global-salt.ts +175 -0
  138. package/src/store/migrate-signaldock-to-conduit.ts +669 -0
  139. package/src/store/signaldock-sqlite.ts +431 -254
  140. package/src/store/sqlite-backup.ts +185 -10
  141. package/src/system/backup.ts +2 -62
  142. package/src/system/runtime.ts +4 -6
  143. package/src/tasks/__tests__/error-hints.test.ts +256 -0
  144. package/src/tasks/add.ts +99 -9
  145. package/src/tasks/complete.ts +4 -1
  146. package/src/tasks/find.ts +4 -1
  147. package/src/tasks/labels.ts +4 -1
  148. package/src/tasks/relates.ts +16 -4
  149. package/src/tasks/show.ts +4 -1
  150. package/src/tasks/update.ts +32 -3
  151. package/src/validation/__tests__/error-hints.test.ts +97 -0
  152. package/src/validation/engine.ts +16 -1
  153. package/src/validation/param-utils.ts +10 -7
  154. package/src/validation/protocols/_shared.ts +14 -6
  155. package/src/validation/protocols/cant/architecture-decision.cant +80 -0
  156. package/src/validation/protocols/cant/artifact-publish.cant +95 -0
  157. package/src/validation/protocols/cant/consensus.cant +74 -0
  158. package/src/validation/protocols/cant/contribution.cant +82 -0
  159. package/src/validation/protocols/cant/decomposition.cant +92 -0
  160. package/src/validation/protocols/cant/implementation.cant +67 -0
  161. package/src/validation/protocols/cant/provenance.cant +88 -0
  162. package/src/validation/protocols/cant/release.cant +96 -0
  163. package/src/validation/protocols/cant/research.cant +66 -0
  164. package/src/validation/protocols/cant/specification.cant +67 -0
  165. package/src/validation/protocols/cant/testing.cant +88 -0
  166. package/src/validation/protocols/cant/validation.cant +65 -0
  167. package/src/validation/protocols/protocols-markdown/decomposition.md +0 -4
  168. package/templates/config.template.json +0 -1
  169. package/templates/global-config.template.json +0 -1
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Unit tests for api-key-kdf.ts (T349).
3
+ *
4
+ * @task T349
5
+ * @epic T310
6
+ */
7
+
8
+ import { createHmac, randomBytes } from 'node:crypto';
9
+ import { describe, expect, it } from 'vitest';
10
+ import { deriveApiKey, deriveLegacyProjectKey } from '../api-key-kdf.js';
11
+
12
+ describe('api-key-kdf', () => {
13
+ const machineKey = Buffer.from(
14
+ '0000000000000000000000000000000000000000000000000000000000000001',
15
+ 'hex',
16
+ );
17
+ const globalSalt = Buffer.from(
18
+ '1111111111111111111111111111111111111111111111111111111111111111',
19
+ 'hex',
20
+ );
21
+
22
+ describe('deriveApiKey', () => {
23
+ it('returns a 32-byte Buffer', () => {
24
+ const key = deriveApiKey({ machineKey, globalSalt, agentId: 'agent-1' });
25
+ expect(key).toBeInstanceOf(Buffer);
26
+ expect(key.length).toBe(32);
27
+ });
28
+
29
+ it('is deterministic for identical inputs', () => {
30
+ const k1 = deriveApiKey({ machineKey, globalSalt, agentId: 'agent-1' });
31
+ const k2 = deriveApiKey({ machineKey, globalSalt, agentId: 'agent-1' });
32
+ expect(k2.equals(k1)).toBe(true);
33
+ });
34
+
35
+ it('produces different keys for different agentIds', () => {
36
+ const k1 = deriveApiKey({ machineKey, globalSalt, agentId: 'agent-1' });
37
+ const k2 = deriveApiKey({ machineKey, globalSalt, agentId: 'agent-2' });
38
+ expect(k2.equals(k1)).toBe(false);
39
+ });
40
+
41
+ it('produces different keys for different machineKeys', () => {
42
+ const otherMachine = randomBytes(32);
43
+ const k1 = deriveApiKey({ machineKey, globalSalt, agentId: 'agent-1' });
44
+ const k2 = deriveApiKey({ machineKey: otherMachine, globalSalt, agentId: 'agent-1' });
45
+ expect(k2.equals(k1)).toBe(false);
46
+ });
47
+
48
+ it('produces different keys for different globalSalts', () => {
49
+ const otherSalt = randomBytes(32);
50
+ const k1 = deriveApiKey({ machineKey, globalSalt, agentId: 'agent-1' });
51
+ const k2 = deriveApiKey({ machineKey, globalSalt: otherSalt, agentId: 'agent-1' });
52
+ expect(k2.equals(k1)).toBe(false);
53
+ });
54
+
55
+ it('throws on empty machineKey', () => {
56
+ expect(() =>
57
+ deriveApiKey({ machineKey: Buffer.alloc(0), globalSalt, agentId: 'agent-1' }),
58
+ ).toThrow(/machineKey/);
59
+ });
60
+
61
+ it('throws on globalSalt of wrong size', () => {
62
+ expect(() =>
63
+ deriveApiKey({ machineKey, globalSalt: Buffer.alloc(16), agentId: 'agent-1' }),
64
+ ).toThrow(/globalSalt.*32/);
65
+ });
66
+
67
+ it('throws on empty agentId', () => {
68
+ expect(() => deriveApiKey({ machineKey, globalSalt, agentId: '' })).toThrow(/agentId/);
69
+ });
70
+
71
+ it('uses HMAC-SHA256 primitive (compatibility with external crypto libs)', () => {
72
+ const key = Buffer.concat([machineKey, globalSalt]);
73
+ const expected = createHmac('sha256', key).update('agent-1', 'utf8').digest();
74
+ const actual = deriveApiKey({ machineKey, globalSalt, agentId: 'agent-1' });
75
+ expect(actual.equals(expected)).toBe(true);
76
+ });
77
+ });
78
+
79
+ describe('deriveLegacyProjectKey', () => {
80
+ it('returns a 32-byte Buffer', () => {
81
+ const key = deriveLegacyProjectKey(machineKey, '/home/user/project');
82
+ expect(key).toBeInstanceOf(Buffer);
83
+ expect(key.length).toBe(32);
84
+ });
85
+
86
+ it('is deterministic', () => {
87
+ const k1 = deriveLegacyProjectKey(machineKey, '/p');
88
+ const k2 = deriveLegacyProjectKey(machineKey, '/p');
89
+ expect(k2.equals(k1)).toBe(true);
90
+ });
91
+
92
+ it('produces different keys for different project paths', () => {
93
+ const k1 = deriveLegacyProjectKey(machineKey, '/project-a');
94
+ const k2 = deriveLegacyProjectKey(machineKey, '/project-b');
95
+ expect(k2.equals(k1)).toBe(false);
96
+ });
97
+
98
+ it('differs from new KDF for same machineKey + agentId', () => {
99
+ const legacy = deriveLegacyProjectKey(machineKey, 'agent-1');
100
+ const modern = deriveApiKey({ machineKey, globalSalt, agentId: 'agent-1' });
101
+ // Would be a bug if identical — legacy has no salt
102
+ expect(modern.equals(legacy)).toBe(false);
103
+ });
104
+
105
+ it('throws on empty machineKey', () => {
106
+ expect(() => deriveLegacyProjectKey(Buffer.alloc(0), '/p')).toThrow(/machineKey/);
107
+ });
108
+
109
+ it('throws on empty projectPath', () => {
110
+ expect(() => deriveLegacyProjectKey(machineKey, '')).toThrow(/projectPath/);
111
+ });
112
+ });
113
+ });
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Tests for conduit-sqlite.ts — project-tier conduit.db module.
3
+ *
4
+ * Covers path resolution, DDL creation, idempotent re-open, FTS5 triggers,
5
+ * getConduitNativeDb, and integrity_check.
6
+ *
7
+ * Uses real node:sqlite DatabaseSync (genuine SQLite operations, not mocks).
8
+ * All filesystem interactions occur in tmp directories; the real user's
9
+ * project root is never touched.
10
+ *
11
+ * @task T344
12
+ * @epic T310
13
+ */
14
+
15
+ import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+ import { DatabaseSync } from 'node:sqlite';
19
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
20
+ import {
21
+ applyConduitSchema,
22
+ attachAgentToProject,
23
+ CONDUIT_DB_FILENAME,
24
+ CONDUIT_SCHEMA_VERSION,
25
+ checkConduitDbHealth,
26
+ closeConduitDb,
27
+ detachAgentFromProject,
28
+ ensureConduitDb,
29
+ getConduitDbPath,
30
+ getConduitNativeDb,
31
+ getProjectAgentRef,
32
+ listProjectAgentRefs,
33
+ updateProjectAgentLastUsed,
34
+ } from '../conduit-sqlite.js';
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Helpers
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /** Expected project-local messaging + tracking table names in conduit.db. */
41
+ const EXPECTED_TABLES = [
42
+ '_conduit_meta',
43
+ '_conduit_migrations',
44
+ 'attachment_approvals',
45
+ 'attachment_contributors',
46
+ 'attachment_versions',
47
+ 'attachments',
48
+ 'conversations',
49
+ 'dead_letters',
50
+ 'delivery_jobs',
51
+ 'message_pins',
52
+ 'messages',
53
+ 'project_agent_refs',
54
+ ];
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Suite
58
+ // ---------------------------------------------------------------------------
59
+
60
+ describe('conduit-sqlite', () => {
61
+ let tmpRoot: string;
62
+
63
+ beforeEach(() => {
64
+ tmpRoot = mkdtempSync(join(tmpdir(), 'cleo-t344-'));
65
+ // Ensure each test starts with a clean singleton.
66
+ closeConduitDb();
67
+ });
68
+
69
+ afterEach(() => {
70
+ closeConduitDb();
71
+ rmSync(tmpRoot, { recursive: true, force: true });
72
+ });
73
+
74
+ // TC-001 — path resolution
75
+ it('getConduitDbPath resolves to <projectRoot>/.cleo/conduit.db', () => {
76
+ const expected = join(tmpRoot, '.cleo', CONDUIT_DB_FILENAME);
77
+ expect(getConduitDbPath(tmpRoot)).toBe(expected);
78
+ expect(getConduitDbPath(tmpRoot)).toMatch(/\.cleo[/\\]conduit\.db$/);
79
+ });
80
+
81
+ // TC-002 — fresh install creates file + directory
82
+ it('ensureConduitDb creates .cleo/ dir and conduit.db on a fresh project root', () => {
83
+ expect(existsSync(join(tmpRoot, '.cleo'))).toBe(false);
84
+
85
+ const result = ensureConduitDb(tmpRoot);
86
+
87
+ expect(result.path).toBe(getConduitDbPath(tmpRoot));
88
+ expect(result.action).toBe('created');
89
+ expect(existsSync(join(tmpRoot, '.cleo'))).toBe(true);
90
+ expect(existsSync(join(tmpRoot, '.cleo', 'conduit.db'))).toBe(true);
91
+ });
92
+
93
+ // TC-002 continued — all 11 messaging tables + 2 tracking tables created
94
+ it('ensureConduitDb creates all expected tables on fresh install', () => {
95
+ ensureConduitDb(tmpRoot);
96
+ const db = getConduitNativeDb();
97
+ expect(db).not.toBeNull();
98
+
99
+ const rows = db!
100
+ .prepare(
101
+ "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
102
+ )
103
+ .all() as Array<{ name: string }>;
104
+
105
+ const tableNames = rows.map((r) => r.name).sort();
106
+ // messages_fts appears as a virtual table with shadow tables; the main
107
+ // virtual table itself is type='table' in sqlite_master.
108
+ const nonFts = tableNames.filter((n) => !n.startsWith('messages_fts'));
109
+ expect(nonFts).toEqual(EXPECTED_TABLES);
110
+ });
111
+
112
+ // TC-011 — messages_fts virtual table created
113
+ it('ensureConduitDb creates messages_fts virtual table', () => {
114
+ ensureConduitDb(tmpRoot);
115
+ const db = getConduitNativeDb();
116
+ expect(db).not.toBeNull();
117
+
118
+ const row = db!
119
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='messages_fts'")
120
+ .get() as { name: string } | undefined;
121
+ expect(row?.name).toBe('messages_fts');
122
+ });
123
+
124
+ // TC-012 — FTS5 triggers created and functional
125
+ it('messages_fts triggers are created and FTS search works after insert', () => {
126
+ ensureConduitDb(tmpRoot);
127
+ const db = getConduitNativeDb();
128
+ expect(db).not.toBeNull();
129
+
130
+ // Verify all three triggers exist.
131
+ const triggers = db!
132
+ .prepare("SELECT name FROM sqlite_master WHERE type='trigger' ORDER BY name")
133
+ .all() as Array<{ name: string }>;
134
+ const triggerNames = triggers.map((t) => t.name);
135
+ expect(triggerNames).toContain('messages_ai');
136
+ expect(triggerNames).toContain('messages_ad');
137
+ expect(triggerNames).toContain('messages_au');
138
+
139
+ // Insert a conversation first (FK constraint on messages).
140
+ db!.exec(`INSERT INTO conversations (id, participants, created_at, updated_at)
141
+ VALUES ('conv-1', '["a","b"]', 1000, 1000)`);
142
+
143
+ // Insert a message — messages_ai trigger should populate messages_fts.
144
+ db!.exec(`INSERT INTO messages
145
+ (id, conversation_id, from_agent_id, to_agent_id, content, created_at)
146
+ VALUES ('msg-1', 'conv-1', 'agent-a', 'agent-b', 'hello world', 1000)`);
147
+
148
+ const ftsRow = db!
149
+ .prepare("SELECT * FROM messages_fts WHERE messages_fts MATCH 'hello'")
150
+ .get() as Record<string, unknown> | undefined;
151
+ expect(ftsRow).toBeDefined();
152
+ });
153
+
154
+ // TC-003 — idempotent re-open
155
+ it('ensureConduitDb returns action=exists on second call for same project root', () => {
156
+ const first = ensureConduitDb(tmpRoot);
157
+ expect(first.action).toBe('created');
158
+
159
+ // Close the singleton to simulate a second process open.
160
+ closeConduitDb();
161
+
162
+ const second = ensureConduitDb(tmpRoot);
163
+ expect(second.action).toBe('exists');
164
+ expect(second.path).toBe(first.path);
165
+ });
166
+
167
+ it('ensureConduitDb is idempotent: data survives across two opens', () => {
168
+ ensureConduitDb(tmpRoot);
169
+ const db1 = getConduitNativeDb()!;
170
+ db1.exec(`INSERT INTO project_agent_refs (agent_id, attached_at)
171
+ VALUES ('test-agent', '2026-04-12T00:00:00Z')`);
172
+ closeConduitDb();
173
+
174
+ ensureConduitDb(tmpRoot);
175
+ const db2 = getConduitNativeDb()!;
176
+ const row = db2
177
+ .prepare('SELECT agent_id FROM project_agent_refs WHERE agent_id = ?')
178
+ .get('test-agent') as { agent_id: string } | undefined;
179
+ expect(row?.agent_id).toBe('test-agent');
180
+ });
181
+
182
+ // getConduitNativeDb — returns null before init
183
+ it('getConduitNativeDb returns null before ensureConduitDb is called', () => {
184
+ expect(getConduitNativeDb()).toBeNull();
185
+ });
186
+
187
+ // getConduitNativeDb — returns live handle after init
188
+ it('getConduitNativeDb returns the live DatabaseSync handle after ensureConduitDb', () => {
189
+ ensureConduitDb(tmpRoot);
190
+ const db = getConduitNativeDb();
191
+ expect(db).not.toBeNull();
192
+ expect(db!.isOpen).toBe(true);
193
+ });
194
+
195
+ // getConduitNativeDb — returns null after close
196
+ it('getConduitNativeDb returns null after closeConduitDb', () => {
197
+ ensureConduitDb(tmpRoot);
198
+ closeConduitDb();
199
+ expect(getConduitNativeDb()).toBeNull();
200
+ });
201
+
202
+ // integrity_check
203
+ it('conduit.db passes PRAGMA integrity_check after creation', () => {
204
+ ensureConduitDb(tmpRoot);
205
+ const db = getConduitNativeDb()!;
206
+ const result = db.prepare('PRAGMA integrity_check').get() as { integrity_check: string };
207
+ expect(result.integrity_check).toBe('ok');
208
+ });
209
+
210
+ // project_agent_refs schema
211
+ it('project_agent_refs has expected columns with correct constraints', () => {
212
+ ensureConduitDb(tmpRoot);
213
+ const db = getConduitNativeDb()!;
214
+
215
+ const cols = db.prepare('PRAGMA table_info(project_agent_refs)').all() as Array<{
216
+ cid: number;
217
+ name: string;
218
+ type: string;
219
+ notnull: number;
220
+ dflt_value: unknown;
221
+ pk: number;
222
+ }>;
223
+
224
+ const colNames = cols.map((c) => c.name);
225
+ expect(colNames).toEqual([
226
+ 'agent_id',
227
+ 'attached_at',
228
+ 'role',
229
+ 'capabilities_override',
230
+ 'last_used_at',
231
+ 'enabled',
232
+ ]);
233
+
234
+ const agentIdCol = cols.find((c) => c.name === 'agent_id');
235
+ expect(agentIdCol?.pk).toBe(1);
236
+
237
+ const enabledCol = cols.find((c) => c.name === 'enabled');
238
+ expect(enabledCol?.notnull).toBe(1);
239
+ expect(enabledCol?.dflt_value).toBe('1');
240
+ });
241
+
242
+ // partial index on project_agent_refs
243
+ it('idx_project_agent_refs_enabled partial index exists on project_agent_refs', () => {
244
+ ensureConduitDb(tmpRoot);
245
+ const db = getConduitNativeDb()!;
246
+
247
+ const indices = db.prepare('PRAGMA index_list(project_agent_refs)').all() as Array<{
248
+ seq: number;
249
+ name: string;
250
+ unique: number;
251
+ origin: string;
252
+ partial: number;
253
+ }>;
254
+ const enabledIdx = indices.find((i) => i.name === 'idx_project_agent_refs_enabled');
255
+ expect(enabledIdx).toBeDefined();
256
+ expect(enabledIdx?.partial).toBe(1);
257
+ });
258
+
259
+ // applyConduitSchema — idempotent on existing db
260
+ it('applyConduitSchema is idempotent when called twice on the same db', () => {
261
+ const dbPath = join(tmpRoot, 'manual.db');
262
+ mkdirSync(tmpRoot, { recursive: true });
263
+ const db = new DatabaseSync(dbPath);
264
+ expect(() => applyConduitSchema(db)).not.toThrow();
265
+ expect(() => applyConduitSchema(db)).not.toThrow();
266
+ db.close();
267
+ });
268
+
269
+ // schema version recorded in _conduit_meta
270
+ it('ensureConduitDb records CONDUIT_SCHEMA_VERSION in _conduit_meta', () => {
271
+ ensureConduitDb(tmpRoot);
272
+ const db = getConduitNativeDb()!;
273
+ const meta = db.prepare("SELECT value FROM _conduit_meta WHERE key = 'schema_version'").get() as
274
+ | { value: string }
275
+ | undefined;
276
+ expect(meta?.value).toBe(CONDUIT_SCHEMA_VERSION);
277
+ });
278
+
279
+ // migration row recorded in _conduit_migrations
280
+ it('ensureConduitDb records initial migration in _conduit_migrations', () => {
281
+ ensureConduitDb(tmpRoot);
282
+ const db = getConduitNativeDb()!;
283
+ const mig = db
284
+ .prepare(
285
+ "SELECT name FROM _conduit_migrations WHERE name = '2026-04-12-000000_initial_conduit'",
286
+ )
287
+ .get() as { name: string } | undefined;
288
+ expect(mig?.name).toBe('2026-04-12-000000_initial_conduit');
289
+ });
290
+
291
+ // checkConduitDbHealth — db absent
292
+ it('checkConduitDbHealth returns exists=false when conduit.db does not exist', () => {
293
+ const health = checkConduitDbHealth(tmpRoot);
294
+ expect(health.exists).toBe(false);
295
+ expect(health.tableCount).toBe(0);
296
+ expect(health.walMode).toBe(false);
297
+ expect(health.schemaVersion).toBeNull();
298
+ expect(health.foreignKeysEnabled).toBe(false);
299
+ });
300
+
301
+ // checkConduitDbHealth — after creation
302
+ it('checkConduitDbHealth returns correct health after ensureConduitDb', () => {
303
+ ensureConduitDb(tmpRoot);
304
+ closeConduitDb();
305
+
306
+ const health = checkConduitDbHealth(tmpRoot);
307
+ expect(health.exists).toBe(true);
308
+ expect(health.walMode).toBe(true);
309
+ expect(health.schemaVersion).toBe(CONDUIT_SCHEMA_VERSION);
310
+ expect(health.foreignKeysEnabled).toBe(true);
311
+ // At minimum all non-FTS tables + FTS main table + meta tables.
312
+ expect(health.tableCount).toBeGreaterThanOrEqual(EXPECTED_TABLES.length);
313
+ });
314
+
315
+ // ensureConduitDb with pre-existing .cleo/ dir
316
+ it('ensureConduitDb works when .cleo/ dir already exists', () => {
317
+ mkdirSync(join(tmpRoot, '.cleo'), { recursive: true });
318
+ expect(() => ensureConduitDb(tmpRoot)).not.toThrow();
319
+ expect(existsSync(join(tmpRoot, '.cleo', 'conduit.db'))).toBe(true);
320
+ });
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // project_agent_refs CRUD (T353)
324
+ // ---------------------------------------------------------------------------
325
+
326
+ describe('project_agent_refs CRUD (T353)', () => {
327
+ it('TC-004: attachAgentToProject inserts a new row with enabled=1', () => {
328
+ ensureConduitDb(tmpRoot);
329
+ const db = getConduitNativeDb()!;
330
+ attachAgentToProject(db, 'agent-1');
331
+ const ref = getProjectAgentRef(db, 'agent-1');
332
+ expect(ref).not.toBeNull();
333
+ expect(ref?.agentId).toBe('agent-1');
334
+ expect(ref?.enabled).toBe(1);
335
+ expect(ref?.role).toBeNull();
336
+ expect(ref?.capabilitiesOverride).toBeNull();
337
+ expect(ref?.lastUsedAt).toBeNull();
338
+ });
339
+
340
+ it('TC-005: attachAgentToProject re-enables an existing enabled=0 row without duplicate', () => {
341
+ ensureConduitDb(tmpRoot);
342
+ const db = getConduitNativeDb()!;
343
+ attachAgentToProject(db, 'agent-1');
344
+ detachAgentFromProject(db, 'agent-1');
345
+ const detached = getProjectAgentRef(db, 'agent-1');
346
+ expect(detached?.enabled).toBe(0);
347
+ attachAgentToProject(db, 'agent-1', { role: 'reviewer' });
348
+ const reattached = getProjectAgentRef(db, 'agent-1');
349
+ expect(reattached?.enabled).toBe(1);
350
+ expect(reattached?.role).toBe('reviewer');
351
+ const count = (
352
+ db
353
+ .prepare('SELECT COUNT(*) AS c FROM project_agent_refs WHERE agent_id = ?')
354
+ .get('agent-1') as {
355
+ c: number;
356
+ }
357
+ ).c;
358
+ expect(count).toBe(1);
359
+ });
360
+
361
+ it('TC-006: detachAgentFromProject sets enabled=0 without deleting', () => {
362
+ ensureConduitDb(tmpRoot);
363
+ const db = getConduitNativeDb()!;
364
+ attachAgentToProject(db, 'agent-1');
365
+ detachAgentFromProject(db, 'agent-1');
366
+ const ref = getProjectAgentRef(db, 'agent-1');
367
+ expect(ref).not.toBeNull();
368
+ expect(ref?.enabled).toBe(0);
369
+ });
370
+
371
+ it('TC-007: listProjectAgentRefs returns only enabled=1 rows by default', () => {
372
+ ensureConduitDb(tmpRoot);
373
+ const db = getConduitNativeDb()!;
374
+ attachAgentToProject(db, 'agent-1');
375
+ attachAgentToProject(db, 'agent-2');
376
+ attachAgentToProject(db, 'agent-3');
377
+ detachAgentFromProject(db, 'agent-2');
378
+ const enabled = listProjectAgentRefs(db);
379
+ expect(enabled.length).toBe(2);
380
+ expect(enabled.map((r) => r.agentId).sort()).toEqual(['agent-1', 'agent-3']);
381
+ });
382
+
383
+ it('TC-008: listProjectAgentRefs returns all rows when enabledOnly=false', () => {
384
+ ensureConduitDb(tmpRoot);
385
+ const db = getConduitNativeDb()!;
386
+ attachAgentToProject(db, 'agent-1');
387
+ attachAgentToProject(db, 'agent-2');
388
+ detachAgentFromProject(db, 'agent-2');
389
+ const all = listProjectAgentRefs(db, { enabledOnly: false });
390
+ expect(all.length).toBe(2);
391
+ });
392
+
393
+ it('TC-009: getProjectAgentRef returns null for unknown agent', () => {
394
+ ensureConduitDb(tmpRoot);
395
+ const db = getConduitNativeDb()!;
396
+ const ref = getProjectAgentRef(db, 'nonexistent');
397
+ expect(ref).toBeNull();
398
+ });
399
+
400
+ it('TC-010: updateProjectAgentLastUsed sets last_used_at to current ISO timestamp', () => {
401
+ ensureConduitDb(tmpRoot);
402
+ const db = getConduitNativeDb()!;
403
+ attachAgentToProject(db, 'agent-1');
404
+ const before = new Date().toISOString();
405
+ updateProjectAgentLastUsed(db, 'agent-1');
406
+ const after = new Date().toISOString();
407
+ const ref = getProjectAgentRef(db, 'agent-1');
408
+ expect(ref?.lastUsedAt).not.toBeNull();
409
+ expect(ref!.lastUsedAt! >= before).toBe(true);
410
+ expect(ref!.lastUsedAt! <= after).toBe(true);
411
+ });
412
+ });
413
+ });