tlc-claude-code 1.8.5 → 2.1.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.
Files changed (138) hide show
  1. package/.claude/commands/tlc/bootstrap.md +77 -0
  2. package/.claude/commands/tlc/build.md +20 -6
  3. package/.claude/commands/tlc/deploy.md +194 -2
  4. package/.claude/commands/tlc/e2e-verify.md +214 -0
  5. package/.claude/commands/tlc/guard.md +191 -0
  6. package/.claude/commands/tlc/help.md +32 -0
  7. package/.claude/commands/tlc/init.md +73 -37
  8. package/.claude/commands/tlc/llm.md +19 -4
  9. package/.claude/commands/tlc/preflight.md +134 -0
  10. package/.claude/commands/tlc/recall.md +87 -0
  11. package/.claude/commands/tlc/remember.md +71 -0
  12. package/.claude/commands/tlc/review.md +17 -4
  13. package/.claude/commands/tlc/watchci.md +159 -0
  14. package/.claude/hooks/tlc-block-tools.sh +41 -0
  15. package/.claude/hooks/tlc-capture-exchange.sh +50 -0
  16. package/.claude/hooks/tlc-post-build.sh +38 -0
  17. package/.claude/hooks/tlc-post-push.sh +22 -0
  18. package/.claude/hooks/tlc-prompt-guard.sh +69 -0
  19. package/.claude/hooks/tlc-session-init.sh +123 -0
  20. package/CLAUDE.md +96 -201
  21. package/bin/install.js +171 -2
  22. package/bin/postinstall.js +45 -26
  23. package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
  24. package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
  25. package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
  26. package/dashboard-web/dist/index.html +2 -2
  27. package/docker-compose.dev.yml +18 -12
  28. package/package.json +3 -1
  29. package/server/index.js +240 -1
  30. package/server/lib/bug-writer.js +204 -0
  31. package/server/lib/bug-writer.test.js +279 -0
  32. package/server/lib/capture-bridge.js +242 -0
  33. package/server/lib/capture-bridge.test.js +363 -0
  34. package/server/lib/capture-guard.js +140 -0
  35. package/server/lib/capture-guard.test.js +182 -0
  36. package/server/lib/claude-cascade.js +247 -0
  37. package/server/lib/claude-cascade.test.js +245 -0
  38. package/server/lib/command-runner.js +159 -0
  39. package/server/lib/command-runner.test.js +92 -0
  40. package/server/lib/context-injection.js +121 -0
  41. package/server/lib/context-injection.test.js +340 -0
  42. package/server/lib/conversation-chunker.js +320 -0
  43. package/server/lib/conversation-chunker.test.js +573 -0
  44. package/server/lib/deploy/runners/dependency-runner.js +106 -0
  45. package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
  46. package/server/lib/deploy/runners/secrets-runner.js +174 -0
  47. package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
  48. package/server/lib/deploy/security-gates.js +11 -24
  49. package/server/lib/deploy/security-gates.test.js +9 -2
  50. package/server/lib/deploy-engine.js +182 -0
  51. package/server/lib/deploy-engine.test.js +147 -0
  52. package/server/lib/docker-api.js +137 -0
  53. package/server/lib/docker-api.test.js +202 -0
  54. package/server/lib/docker-client.js +297 -0
  55. package/server/lib/docker-client.test.js +308 -0
  56. package/server/lib/embedding-client.js +160 -0
  57. package/server/lib/embedding-client.test.js +243 -0
  58. package/server/lib/global-config.js +198 -0
  59. package/server/lib/global-config.test.js +288 -0
  60. package/server/lib/inherited-search.js +184 -0
  61. package/server/lib/inherited-search.test.js +343 -0
  62. package/server/lib/input-sanitizer.js +86 -0
  63. package/server/lib/input-sanitizer.test.js +117 -0
  64. package/server/lib/launchd-agent.js +225 -0
  65. package/server/lib/launchd-agent.test.js +185 -0
  66. package/server/lib/memory-api.js +182 -0
  67. package/server/lib/memory-api.test.js +320 -0
  68. package/server/lib/memory-bridge-e2e.test.js +160 -0
  69. package/server/lib/memory-committer.js +18 -4
  70. package/server/lib/memory-committer.test.js +21 -0
  71. package/server/lib/memory-hooks-capture.test.js +415 -0
  72. package/server/lib/memory-hooks-integration.test.js +98 -0
  73. package/server/lib/memory-hooks.js +139 -0
  74. package/server/lib/memory-inheritance.js +179 -0
  75. package/server/lib/memory-inheritance.test.js +360 -0
  76. package/server/lib/memory-store-adapter.js +105 -0
  77. package/server/lib/memory-store-adapter.test.js +141 -0
  78. package/server/lib/memory-wiring-e2e.test.js +93 -0
  79. package/server/lib/nginx-config.js +114 -0
  80. package/server/lib/nginx-config.test.js +82 -0
  81. package/server/lib/ollama-health.js +91 -0
  82. package/server/lib/ollama-health.test.js +74 -0
  83. package/server/lib/plan-writer.js +196 -0
  84. package/server/lib/plan-writer.test.js +298 -0
  85. package/server/lib/port-guard.js +44 -0
  86. package/server/lib/port-guard.test.js +65 -0
  87. package/server/lib/project-scanner.js +302 -0
  88. package/server/lib/project-scanner.test.js +541 -0
  89. package/server/lib/project-status.js +302 -0
  90. package/server/lib/project-status.test.js +470 -0
  91. package/server/lib/projects-registry.js +237 -0
  92. package/server/lib/projects-registry.test.js +275 -0
  93. package/server/lib/recall-command.js +207 -0
  94. package/server/lib/recall-command.test.js +306 -0
  95. package/server/lib/remember-command.js +98 -0
  96. package/server/lib/remember-command.test.js +288 -0
  97. package/server/lib/rich-capture.js +221 -0
  98. package/server/lib/rich-capture.test.js +312 -0
  99. package/server/lib/roadmap-api.js +200 -0
  100. package/server/lib/roadmap-api.test.js +318 -0
  101. package/server/lib/security/crypto-utils.test.js +2 -2
  102. package/server/lib/semantic-recall.js +242 -0
  103. package/server/lib/semantic-recall.test.js +463 -0
  104. package/server/lib/setup-generator.js +315 -0
  105. package/server/lib/setup-generator.test.js +303 -0
  106. package/server/lib/ssh-client.js +184 -0
  107. package/server/lib/ssh-client.test.js +127 -0
  108. package/server/lib/test-inventory.js +112 -0
  109. package/server/lib/test-inventory.test.js +360 -0
  110. package/server/lib/vector-indexer.js +246 -0
  111. package/server/lib/vector-indexer.test.js +459 -0
  112. package/server/lib/vector-store.js +260 -0
  113. package/server/lib/vector-store.test.js +706 -0
  114. package/server/lib/vps-api.js +184 -0
  115. package/server/lib/vps-api.test.js +208 -0
  116. package/server/lib/vps-bootstrap.js +124 -0
  117. package/server/lib/vps-bootstrap.test.js +79 -0
  118. package/server/lib/vps-monitor.js +126 -0
  119. package/server/lib/vps-monitor.test.js +98 -0
  120. package/server/lib/workspace-api.js +992 -0
  121. package/server/lib/workspace-api.test.js +1217 -0
  122. package/server/lib/workspace-bootstrap.js +164 -0
  123. package/server/lib/workspace-bootstrap.test.js +503 -0
  124. package/server/lib/workspace-context.js +129 -0
  125. package/server/lib/workspace-context.test.js +214 -0
  126. package/server/lib/workspace-detector.js +162 -0
  127. package/server/lib/workspace-detector.test.js +193 -0
  128. package/server/lib/workspace-init.js +307 -0
  129. package/server/lib/workspace-init.test.js +244 -0
  130. package/server/lib/workspace-snapshot.js +236 -0
  131. package/server/lib/workspace-snapshot.test.js +444 -0
  132. package/server/lib/workspace-watcher.js +162 -0
  133. package/server/lib/workspace-watcher.test.js +257 -0
  134. package/server/package-lock.json +1306 -17
  135. package/server/package.json +7 -0
  136. package/dashboard-web/dist/assets/index-B1I_joSL.js +0 -393
  137. package/dashboard-web/dist/assets/index-B1I_joSL.js.map +0 -1
  138. package/dashboard-web/dist/assets/index-Trhg1C1Y.css +0 -1
@@ -0,0 +1,320 @@
1
+ /**
2
+ * @file memory-api.test.js
3
+ * @description Tests for the Memory API endpoint handlers.
4
+ *
5
+ * Tests the factory function `createMemoryApi(deps)` which accepts injected
6
+ * dependencies (semanticRecall, vectorIndexer, richCapture, embeddingClient,
7
+ * memoryStore) and returns handler functions for memory-related HTTP endpoints.
8
+ *
9
+ * All handlers accept Express-compatible (req, res) objects and are async.
10
+ * Tests mock the dependencies and req/res objects directly — no Express
11
+ * routing is tested here.
12
+ */
13
+ import { describe, it, beforeEach, expect, vi } from 'vitest';
14
+
15
+ const { createMemoryApi } = await import('./memory-api.js');
16
+
17
+ /**
18
+ * Creates a mock Express request object.
19
+ * @param {object} overrides - Properties to merge into the base request
20
+ * @returns {{ query: object, params: object, body: object }}
21
+ */
22
+ function createMockReq(overrides = {}) {
23
+ return { query: {}, params: {}, body: {}, ...overrides };
24
+ }
25
+
26
+ /**
27
+ * Creates a mock Express response object with spy methods.
28
+ * Provides helper accessors `_getJson()` and `_getStatus()` to inspect
29
+ * the first call to `res.json()` and `res.status()` respectively.
30
+ * @returns {object} Mock response with status/json spies
31
+ */
32
+ function createMockRes() {
33
+ const res = {
34
+ status: vi.fn().mockReturnThis(),
35
+ json: vi.fn().mockReturnThis(),
36
+ _getJson() { return res.json.mock.calls[0]?.[0]; },
37
+ _getStatus() { return res.status.mock.calls[0]?.[0]; },
38
+ };
39
+ return res;
40
+ }
41
+
42
+ /**
43
+ * Creates a full set of mock dependencies for createMemoryApi.
44
+ * Each dependency method is a vi.fn() returning a resolved promise.
45
+ * @returns {object} Mock deps: semanticRecall, vectorIndexer, richCapture, embeddingClient, memoryStore
46
+ */
47
+ function createMockDeps() {
48
+ return {
49
+ semanticRecall: {
50
+ recall: vi.fn().mockResolvedValue([]),
51
+ },
52
+ vectorIndexer: {
53
+ indexAll: vi.fn().mockResolvedValue({ indexed: 0 }),
54
+ },
55
+ richCapture: {
56
+ processChunk: vi.fn().mockResolvedValue({ stored: true }),
57
+ },
58
+ embeddingClient: {
59
+ embed: vi.fn().mockResolvedValue([0.1, 0.2, 0.3]),
60
+ },
61
+ memoryStore: {
62
+ listConversations: vi.fn().mockResolvedValue({ items: [], total: 0 }),
63
+ getConversation: vi.fn().mockResolvedValue(null),
64
+ listDecisions: vi.fn().mockResolvedValue([]),
65
+ listGotchas: vi.fn().mockResolvedValue([]),
66
+ getStats: vi.fn().mockResolvedValue({ count: 0, size: 0 }),
67
+ },
68
+ };
69
+ }
70
+
71
+ describe('memory-api', () => {
72
+ let api;
73
+ let deps;
74
+
75
+ beforeEach(() => {
76
+ deps = createMockDeps();
77
+ api = createMemoryApi(deps);
78
+ });
79
+
80
+ describe('handleSearch', () => {
81
+ it('search endpoint returns semantic results', async () => {
82
+ const mockResults = [
83
+ { id: 'r1', text: 'Use JWT for auth', score: 0.92, type: 'decision', date: '2026-01-15', source: 'session', permanent: false },
84
+ { id: 'r2', text: 'Watch for cold starts', score: 0.85, type: 'gotcha', date: '2026-01-14', source: 'session', permanent: true },
85
+ ];
86
+ deps.semanticRecall.recall.mockResolvedValue(mockResults);
87
+
88
+ const req = createMockReq({ query: { q: 'authentication' } });
89
+ const res = createMockRes();
90
+
91
+ await api.handleSearch(req, res);
92
+
93
+ expect(deps.semanticRecall.recall).toHaveBeenCalledWith(
94
+ 'authentication',
95
+ expect.anything(),
96
+ expect.anything()
97
+ );
98
+ expect(res.json).toHaveBeenCalledWith(
99
+ expect.objectContaining({ results: mockResults })
100
+ );
101
+ });
102
+
103
+ it('search with scope parameter filters correctly', async () => {
104
+ deps.semanticRecall.recall.mockResolvedValue([]);
105
+
106
+ const req = createMockReq({ query: { q: 'database', scope: 'project' } });
107
+ const res = createMockRes();
108
+
109
+ await api.handleSearch(req, res);
110
+
111
+ expect(deps.semanticRecall.recall).toHaveBeenCalledWith(
112
+ 'database',
113
+ expect.anything(),
114
+ expect.objectContaining({ scope: 'project' })
115
+ );
116
+ });
117
+
118
+ it('empty search returns empty results', async () => {
119
+ const req = createMockReq({ query: { q: '' } });
120
+ const res = createMockRes();
121
+
122
+ await api.handleSearch(req, res);
123
+
124
+ const body = res._getJson();
125
+ expect(body.results).toEqual([]);
126
+ });
127
+ });
128
+
129
+ describe('handleListConversations', () => {
130
+ it('conversations list returns paginated results', async () => {
131
+ const mockData = {
132
+ items: [
133
+ { id: 'c1', title: 'Session 1', date: '2026-02-01' },
134
+ { id: 'c2', title: 'Session 2', date: '2026-02-02' },
135
+ ],
136
+ total: 25,
137
+ };
138
+ deps.memoryStore.listConversations.mockResolvedValue(mockData);
139
+
140
+ const req = createMockReq({ query: { page: '2', limit: '10' } });
141
+ const res = createMockRes();
142
+
143
+ await api.handleListConversations(req, res);
144
+
145
+ expect(deps.memoryStore.listConversations).toHaveBeenCalledWith(
146
+ expect.objectContaining({ page: 2, limit: 10 })
147
+ );
148
+ expect(res.json).toHaveBeenCalledWith(
149
+ expect.objectContaining({ items: mockData.items, total: 25 })
150
+ );
151
+ });
152
+ });
153
+
154
+ describe('handleGetConversation', () => {
155
+ it('conversation detail returns full content', async () => {
156
+ const mockConversation = {
157
+ id: 'conv-42',
158
+ title: 'Auth Architecture Discussion',
159
+ content: '## Decisions made\n- Use JWT tokens\n- Add refresh flow',
160
+ date: '2026-02-10',
161
+ decisions: ['Use JWT', 'Add refresh'],
162
+ files: ['server/lib/auth.js'],
163
+ };
164
+ deps.memoryStore.getConversation.mockResolvedValue(mockConversation);
165
+
166
+ const req = createMockReq({ params: { id: 'conv-42' } });
167
+ const res = createMockRes();
168
+
169
+ await api.handleGetConversation(req, res);
170
+
171
+ expect(deps.memoryStore.getConversation).toHaveBeenCalledWith('conv-42');
172
+ expect(res.json).toHaveBeenCalledWith(
173
+ expect.objectContaining({ id: 'conv-42', content: expect.any(String) })
174
+ );
175
+ });
176
+
177
+ it('404 for unknown conversation ID', async () => {
178
+ deps.memoryStore.getConversation.mockResolvedValue(null);
179
+
180
+ const req = createMockReq({ params: { id: 'nonexistent-id' } });
181
+ const res = createMockRes();
182
+
183
+ await api.handleGetConversation(req, res);
184
+
185
+ expect(res.status).toHaveBeenCalledWith(404);
186
+ expect(res.json).toHaveBeenCalledWith(
187
+ expect.objectContaining({ error: expect.any(String) })
188
+ );
189
+ });
190
+ });
191
+
192
+ describe('handleListDecisions', () => {
193
+ it('decisions endpoint returns all decisions', async () => {
194
+ const mockDecisions = [
195
+ { id: 'd1', title: 'Use Postgres', date: '2026-01-20' },
196
+ { id: 'd2', title: 'Use REST over GraphQL', date: '2026-01-22' },
197
+ ];
198
+ deps.memoryStore.listDecisions.mockResolvedValue(mockDecisions);
199
+
200
+ const req = createMockReq();
201
+ const res = createMockRes();
202
+
203
+ await api.handleListDecisions(req, res);
204
+
205
+ expect(deps.memoryStore.listDecisions).toHaveBeenCalled();
206
+ expect(res.json).toHaveBeenCalledWith(
207
+ expect.objectContaining({ decisions: mockDecisions })
208
+ );
209
+ });
210
+ });
211
+
212
+ describe('handleListGotchas', () => {
213
+ it('gotchas endpoint returns all gotchas', async () => {
214
+ const mockGotchas = [
215
+ { id: 'g1', title: 'Auth warmup delay', severity: 'high' },
216
+ { id: 'g2', title: 'DB pool exhaustion', severity: 'medium' },
217
+ ];
218
+ deps.memoryStore.listGotchas.mockResolvedValue(mockGotchas);
219
+
220
+ const req = createMockReq();
221
+ const res = createMockRes();
222
+
223
+ await api.handleListGotchas(req, res);
224
+
225
+ expect(deps.memoryStore.listGotchas).toHaveBeenCalled();
226
+ expect(res.json).toHaveBeenCalledWith(
227
+ expect.objectContaining({ gotchas: mockGotchas })
228
+ );
229
+ });
230
+ });
231
+
232
+ describe('handleGetStats', () => {
233
+ it('stats endpoint returns vector DB info', async () => {
234
+ const mockStats = {
235
+ count: 1523,
236
+ size: '45MB',
237
+ lastIndexed: '2026-02-14T10:30:00Z',
238
+ model: 'nomic-embed-text',
239
+ };
240
+ deps.memoryStore.getStats.mockResolvedValue(mockStats);
241
+
242
+ const req = createMockReq();
243
+ const res = createMockRes();
244
+
245
+ await api.handleGetStats(req, res);
246
+
247
+ expect(deps.memoryStore.getStats).toHaveBeenCalled();
248
+ expect(res.json).toHaveBeenCalledWith(
249
+ expect.objectContaining({ count: 1523, model: 'nomic-embed-text' })
250
+ );
251
+ });
252
+ });
253
+
254
+ describe('handleRebuild', () => {
255
+ it('rebuild endpoint triggers re-indexing', async () => {
256
+ deps.vectorIndexer.indexAll.mockResolvedValue({ indexed: 347, duration: 12500 });
257
+
258
+ const req = createMockReq({ body: { projectRoot: '/path/to/project' } });
259
+ const res = createMockRes();
260
+
261
+ await api.handleRebuild(req, res);
262
+
263
+ expect(deps.vectorIndexer.indexAll).toHaveBeenCalledWith('/path/to/project');
264
+ expect(res.json).toHaveBeenCalledWith(
265
+ expect.objectContaining({ indexed: 347 })
266
+ );
267
+ });
268
+ });
269
+
270
+ describe('handleRemember', () => {
271
+ it('remember endpoint stores permanent memory', async () => {
272
+ deps.richCapture.processChunk.mockResolvedValue({ stored: true, id: 'mem-99' });
273
+
274
+ const req = createMockReq({
275
+ body: {
276
+ text: 'Always run migrations before deploying',
277
+ metadata: { type: 'gotcha', project: 'my-app' },
278
+ },
279
+ });
280
+ const res = createMockRes();
281
+
282
+ await api.handleRemember(req, res);
283
+
284
+ expect(deps.richCapture.processChunk).toHaveBeenCalledWith(
285
+ 'Always run migrations before deploying',
286
+ expect.objectContaining({ permanent: true })
287
+ );
288
+ expect(res.json).toHaveBeenCalledWith(
289
+ expect.objectContaining({ stored: true })
290
+ );
291
+ });
292
+ });
293
+
294
+ describe('project filter', () => {
295
+ it('project filter works across endpoints', async () => {
296
+ const projectId = 'proj-abc';
297
+
298
+ const convReq = createMockReq({ query: { project: projectId, page: '1', limit: '10' } });
299
+ const convRes = createMockRes();
300
+ await api.handleListConversations(convReq, convRes);
301
+ expect(deps.memoryStore.listConversations).toHaveBeenCalledWith(
302
+ expect.objectContaining({ project: projectId })
303
+ );
304
+
305
+ const decReq = createMockReq({ query: { project: projectId } });
306
+ const decRes = createMockRes();
307
+ await api.handleListDecisions(decReq, decRes);
308
+ expect(deps.memoryStore.listDecisions).toHaveBeenCalledWith(
309
+ expect.objectContaining({ project: projectId })
310
+ );
311
+
312
+ const gotReq = createMockReq({ query: { project: projectId } });
313
+ const gotRes = createMockRes();
314
+ await api.handleListGotchas(gotReq, gotRes);
315
+ expect(deps.memoryStore.listGotchas).toHaveBeenCalledWith(
316
+ expect.objectContaining({ project: projectId })
317
+ );
318
+ });
319
+ });
320
+ });
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Memory Bridge E2E Tests - Phase 82 Task 5
3
+ *
4
+ * Tests the full pipeline: capture → observeAndRemember → pattern detect → file store.
5
+ * Proves the memory system achieves its original goal.
6
+ *
7
+ * RED: depends on capture-bridge.js (Task 1) and capture-guard.js (Task 4).
8
+ */
9
+
10
+ import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import os from 'os';
14
+
15
+ import { captureExchange, drainSpool, SPOOL_FILENAME } from './capture-bridge.js';
16
+ import { observeAndRemember } from './memory-observer.js';
17
+
18
+ describe('memory-bridge e2e', () => {
19
+ let testDir;
20
+
21
+ beforeEach(() => {
22
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-bridge-e2e-'));
23
+ // Create memory directory structure
24
+ fs.mkdirSync(path.join(testDir, '.tlc', 'memory', 'team', 'decisions'), { recursive: true });
25
+ fs.mkdirSync(path.join(testDir, '.tlc', 'memory', 'team', 'gotchas'), { recursive: true });
26
+ fs.mkdirSync(path.join(testDir, '.tlc', 'memory', '.local'), { recursive: true });
27
+ fs.writeFileSync(path.join(testDir, '.tlc.json'), JSON.stringify({ project: 'e2e-test' }));
28
+ });
29
+
30
+ afterEach(() => {
31
+ fs.rmSync(testDir, { recursive: true, force: true });
32
+ });
33
+
34
+ it('decision in exchange creates decision file', async () => {
35
+ // Pattern detector analyzes the user field for decision patterns
36
+ const exchange = {
37
+ user: "let's use PostgreSQL instead of MySQL because we need JSONB support.",
38
+ assistant: 'Good choice. PostgreSQL has excellent JSONB support.',
39
+ };
40
+
41
+ await observeAndRemember(testDir, exchange);
42
+
43
+ // Wait for setImmediate-based async processing
44
+ await new Promise(resolve => setTimeout(resolve, 500));
45
+
46
+ // Check that a decision file was created
47
+ const decisionsDir = path.join(testDir, '.tlc', 'memory', 'team', 'decisions');
48
+ const files = fs.readdirSync(decisionsDir);
49
+ expect(files.length).toBeGreaterThanOrEqual(1);
50
+ });
51
+
52
+ it('gotcha in exchange creates gotcha file', async () => {
53
+ // Pattern detector looks for "watch out for X" in user field
54
+ const exchange = {
55
+ user: 'watch out for the PGlite WASM driver under concurrent writes.',
56
+ assistant: 'Good catch. Serialize database operations to avoid crashes.',
57
+ };
58
+
59
+ await observeAndRemember(testDir, exchange);
60
+
61
+ await new Promise(resolve => setTimeout(resolve, 500));
62
+
63
+ const gotchasDir = path.join(testDir, '.tlc', 'memory', 'team', 'gotchas');
64
+ const files = fs.readdirSync(gotchasDir);
65
+ expect(files.length).toBeGreaterThanOrEqual(1);
66
+ });
67
+
68
+ it('full pipeline: captureExchange → observe → file stored', async () => {
69
+ // Simulate what the Stop hook does: POST to a mock server that calls observeAndRemember
70
+ let capturedExchange = null;
71
+
72
+ // Mock fetch that simulates the server calling observeAndRemember
73
+ const mockFetch = vi.fn().mockImplementation(async (url, opts) => {
74
+ const body = JSON.parse(opts.body);
75
+ for (const ex of body.exchanges) {
76
+ capturedExchange = ex;
77
+ await observeAndRemember(testDir, ex);
78
+ }
79
+ return { ok: true, json: async () => ({ captured: body.exchanges.length }) };
80
+ });
81
+
82
+ await captureExchange({
83
+ cwd: testDir,
84
+ // Pattern detector analyzes user field — put decision language there
85
+ assistantMessage: 'Good choice, JWT is better for horizontal scaling.',
86
+ userMessage: "let's use JWT tokens instead of sessions for authentication.",
87
+ sessionId: 'e2e-sess-1',
88
+ }, { fetch: mockFetch });
89
+
90
+ await new Promise(resolve => setTimeout(resolve, 300));
91
+
92
+ // Verify the exchange was captured
93
+ expect(capturedExchange).not.toBeNull();
94
+ expect(capturedExchange.user).toContain('JWT tokens');
95
+
96
+ // Verify a decision file was created
97
+ const decisionsDir = path.join(testDir, '.tlc', 'memory', 'team', 'decisions');
98
+ const files = fs.readdirSync(decisionsDir);
99
+ expect(files.length).toBeGreaterThanOrEqual(1);
100
+ });
101
+
102
+ it('spool entry captured after drain', async () => {
103
+ const spoolDir = path.join(testDir, '.tlc', 'memory');
104
+ const spoolPath = path.join(spoolDir, SPOOL_FILENAME);
105
+
106
+ // Write a spooled entry with a decision (pattern in user field)
107
+ const spooledEntry = JSON.stringify({
108
+ projectId: 'e2e-test',
109
+ exchanges: [{
110
+ user: "we decided to use SQLite for the vector store.",
111
+ assistant: 'SQLite embeds directly and needs no separate process.',
112
+ timestamp: Date.now(),
113
+ }],
114
+ });
115
+ fs.writeFileSync(spoolPath, spooledEntry + '\n');
116
+
117
+ // Mock fetch that calls observeAndRemember (like the real server would)
118
+ const mockFetch = vi.fn().mockImplementation(async (url, opts) => {
119
+ const body = JSON.parse(opts.body);
120
+ for (const ex of body.exchanges) {
121
+ await observeAndRemember(testDir, ex);
122
+ }
123
+ return { ok: true, json: async () => ({ captured: body.exchanges.length }) };
124
+ });
125
+
126
+ await drainSpool(spoolDir, { fetch: mockFetch });
127
+
128
+ await new Promise(resolve => setTimeout(resolve, 500));
129
+
130
+ // Spool should be drained
131
+ if (fs.existsSync(spoolPath)) {
132
+ expect(fs.readFileSync(spoolPath, 'utf-8').trim()).toBe('');
133
+ }
134
+
135
+ // Decision file should have been created from the spooled exchange
136
+ const decisionsDir = path.join(testDir, '.tlc', 'memory', 'team', 'decisions');
137
+ const files = fs.readdirSync(decisionsDir);
138
+ expect(files.length).toBeGreaterThanOrEqual(1);
139
+ });
140
+
141
+ it('capture guard deduplicates identical exchanges', async () => {
142
+ // Dedup happens at the capture guard level, not the observer
143
+ const { createCaptureGuard } = await import('./capture-guard.js');
144
+ const guard = createCaptureGuard();
145
+
146
+ const exchange = {
147
+ user: "we decided to use Redis as our caching layer.",
148
+ assistant: 'Redis is great for caching.',
149
+ timestamp: Date.now(),
150
+ };
151
+
152
+ // First call returns the exchange
153
+ const first = guard.deduplicate([exchange], 'e2e-test');
154
+ expect(first).toHaveLength(1);
155
+
156
+ // Same exchange immediately — deduplicated
157
+ const second = guard.deduplicate([exchange], 'e2e-test');
158
+ expect(second).toHaveLength(0);
159
+ });
160
+ });
@@ -23,7 +23,24 @@ async function detectUncommittedMemory(projectRoot) {
23
23
  return [];
24
24
  }
25
25
 
26
- // Get all files in team directory
26
+ // Try git status first only return modified/untracked files
27
+ try {
28
+ const teamRelative = path.relative(projectRoot, teamDir);
29
+ const { stdout } = await execAsync(
30
+ `git status --porcelain -- "${teamRelative}"`,
31
+ { cwd: projectRoot }
32
+ );
33
+ if (stdout.trim().length === 0) return [];
34
+
35
+ return stdout.trim().split('\n')
36
+ .map(line => line.slice(3).trim()) // strip status prefix (e.g. "?? ", " M ")
37
+ .filter(f => f.endsWith('.json') || f.endsWith('.md'))
38
+ .filter(f => !f.endsWith('conventions.md'));
39
+ } catch {
40
+ // Not a git repo or git not available — fall back to walkDir
41
+ }
42
+
43
+ // Fallback: return all files (non-git directory)
27
44
  const files = [];
28
45
 
29
46
  async function walkDir(dir) {
@@ -33,10 +50,7 @@ async function detectUncommittedMemory(projectRoot) {
33
50
  if (entry.isDirectory()) {
34
51
  await walkDir(fullPath);
35
52
  } else if (entry.name.endsWith('.json') || entry.name.endsWith('.md')) {
36
- // Skip template files like conventions.md
37
53
  if (entry.name === 'conventions.md') continue;
38
-
39
- // Get path relative to projectRoot
40
54
  const relativePath = path.relative(projectRoot, fullPath);
41
55
  files.push(relativePath);
42
56
  }
@@ -58,6 +58,27 @@ describe('memory-committer', () => {
58
58
 
59
59
  expect(uncommitted.every(f => !f.includes('.local'))).toBe(true);
60
60
  });
61
+
62
+ // Phase 81 Task 4: detectUncommittedMemory should use git status
63
+ it('returns empty for already-committed files in a git repo', async () => {
64
+ // Create a git repo, add a decision, and commit it
65
+ const { execSync } = await import('child_process');
66
+ execSync('git init', { cwd: testDir, stdio: 'pipe' });
67
+ execSync('git config user.email "test@test.com"', { cwd: testDir, stdio: 'pipe' });
68
+ execSync('git config user.name "Test"', { cwd: testDir, stdio: 'pipe' });
69
+
70
+ await writeTeamDecision(testDir, {
71
+ title: 'Committed Decision',
72
+ reasoning: 'Already committed',
73
+ });
74
+
75
+ execSync('git add -A', { cwd: testDir, stdio: 'pipe' });
76
+ execSync('git commit -m "initial"', { cwd: testDir, stdio: 'pipe' });
77
+
78
+ // Now detect uncommitted — should be empty since everything is committed
79
+ const uncommitted = await detectUncommittedMemory(testDir);
80
+ expect(uncommitted).toHaveLength(0);
81
+ });
61
82
  });
62
83
 
63
84
  describe('generateCommitMessage', () => {