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.
- package/.claude/commands/tlc/bootstrap.md +77 -0
- package/.claude/commands/tlc/build.md +20 -6
- package/.claude/commands/tlc/deploy.md +194 -2
- package/.claude/commands/tlc/e2e-verify.md +214 -0
- package/.claude/commands/tlc/guard.md +191 -0
- package/.claude/commands/tlc/help.md +32 -0
- package/.claude/commands/tlc/init.md +73 -37
- package/.claude/commands/tlc/llm.md +19 -4
- package/.claude/commands/tlc/preflight.md +134 -0
- package/.claude/commands/tlc/recall.md +87 -0
- package/.claude/commands/tlc/remember.md +71 -0
- package/.claude/commands/tlc/review.md +17 -4
- package/.claude/commands/tlc/watchci.md +159 -0
- package/.claude/hooks/tlc-block-tools.sh +41 -0
- package/.claude/hooks/tlc-capture-exchange.sh +50 -0
- package/.claude/hooks/tlc-post-build.sh +38 -0
- package/.claude/hooks/tlc-post-push.sh +22 -0
- package/.claude/hooks/tlc-prompt-guard.sh +69 -0
- package/.claude/hooks/tlc-session-init.sh +123 -0
- package/CLAUDE.md +96 -201
- package/bin/install.js +171 -2
- package/bin/postinstall.js +45 -26
- package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
- package/dashboard-web/dist/index.html +2 -2
- package/docker-compose.dev.yml +18 -12
- package/package.json +3 -1
- package/server/index.js +240 -1
- package/server/lib/bug-writer.js +204 -0
- package/server/lib/bug-writer.test.js +279 -0
- package/server/lib/capture-bridge.js +242 -0
- package/server/lib/capture-bridge.test.js +363 -0
- package/server/lib/capture-guard.js +140 -0
- package/server/lib/capture-guard.test.js +182 -0
- package/server/lib/claude-cascade.js +247 -0
- package/server/lib/claude-cascade.test.js +245 -0
- package/server/lib/command-runner.js +159 -0
- package/server/lib/command-runner.test.js +92 -0
- package/server/lib/context-injection.js +121 -0
- package/server/lib/context-injection.test.js +340 -0
- package/server/lib/conversation-chunker.js +320 -0
- package/server/lib/conversation-chunker.test.js +573 -0
- package/server/lib/deploy/runners/dependency-runner.js +106 -0
- package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
- package/server/lib/deploy/runners/secrets-runner.js +174 -0
- package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
- package/server/lib/deploy/security-gates.js +11 -24
- package/server/lib/deploy/security-gates.test.js +9 -2
- package/server/lib/deploy-engine.js +182 -0
- package/server/lib/deploy-engine.test.js +147 -0
- package/server/lib/docker-api.js +137 -0
- package/server/lib/docker-api.test.js +202 -0
- package/server/lib/docker-client.js +297 -0
- package/server/lib/docker-client.test.js +308 -0
- package/server/lib/embedding-client.js +160 -0
- package/server/lib/embedding-client.test.js +243 -0
- package/server/lib/global-config.js +198 -0
- package/server/lib/global-config.test.js +288 -0
- package/server/lib/inherited-search.js +184 -0
- package/server/lib/inherited-search.test.js +343 -0
- package/server/lib/input-sanitizer.js +86 -0
- package/server/lib/input-sanitizer.test.js +117 -0
- package/server/lib/launchd-agent.js +225 -0
- package/server/lib/launchd-agent.test.js +185 -0
- package/server/lib/memory-api.js +182 -0
- package/server/lib/memory-api.test.js +320 -0
- package/server/lib/memory-bridge-e2e.test.js +160 -0
- package/server/lib/memory-committer.js +18 -4
- package/server/lib/memory-committer.test.js +21 -0
- package/server/lib/memory-hooks-capture.test.js +415 -0
- package/server/lib/memory-hooks-integration.test.js +98 -0
- package/server/lib/memory-hooks.js +139 -0
- package/server/lib/memory-inheritance.js +179 -0
- package/server/lib/memory-inheritance.test.js +360 -0
- package/server/lib/memory-store-adapter.js +105 -0
- package/server/lib/memory-store-adapter.test.js +141 -0
- package/server/lib/memory-wiring-e2e.test.js +93 -0
- package/server/lib/nginx-config.js +114 -0
- package/server/lib/nginx-config.test.js +82 -0
- package/server/lib/ollama-health.js +91 -0
- package/server/lib/ollama-health.test.js +74 -0
- package/server/lib/plan-writer.js +196 -0
- package/server/lib/plan-writer.test.js +298 -0
- package/server/lib/port-guard.js +44 -0
- package/server/lib/port-guard.test.js +65 -0
- package/server/lib/project-scanner.js +302 -0
- package/server/lib/project-scanner.test.js +541 -0
- package/server/lib/project-status.js +302 -0
- package/server/lib/project-status.test.js +470 -0
- package/server/lib/projects-registry.js +237 -0
- package/server/lib/projects-registry.test.js +275 -0
- package/server/lib/recall-command.js +207 -0
- package/server/lib/recall-command.test.js +306 -0
- package/server/lib/remember-command.js +98 -0
- package/server/lib/remember-command.test.js +288 -0
- package/server/lib/rich-capture.js +221 -0
- package/server/lib/rich-capture.test.js +312 -0
- package/server/lib/roadmap-api.js +200 -0
- package/server/lib/roadmap-api.test.js +318 -0
- package/server/lib/security/crypto-utils.test.js +2 -2
- package/server/lib/semantic-recall.js +242 -0
- package/server/lib/semantic-recall.test.js +463 -0
- package/server/lib/setup-generator.js +315 -0
- package/server/lib/setup-generator.test.js +303 -0
- package/server/lib/ssh-client.js +184 -0
- package/server/lib/ssh-client.test.js +127 -0
- package/server/lib/test-inventory.js +112 -0
- package/server/lib/test-inventory.test.js +360 -0
- package/server/lib/vector-indexer.js +246 -0
- package/server/lib/vector-indexer.test.js +459 -0
- package/server/lib/vector-store.js +260 -0
- package/server/lib/vector-store.test.js +706 -0
- package/server/lib/vps-api.js +184 -0
- package/server/lib/vps-api.test.js +208 -0
- package/server/lib/vps-bootstrap.js +124 -0
- package/server/lib/vps-bootstrap.test.js +79 -0
- package/server/lib/vps-monitor.js +126 -0
- package/server/lib/vps-monitor.test.js +98 -0
- package/server/lib/workspace-api.js +992 -0
- package/server/lib/workspace-api.test.js +1217 -0
- package/server/lib/workspace-bootstrap.js +164 -0
- package/server/lib/workspace-bootstrap.test.js +503 -0
- package/server/lib/workspace-context.js +129 -0
- package/server/lib/workspace-context.test.js +214 -0
- package/server/lib/workspace-detector.js +162 -0
- package/server/lib/workspace-detector.test.js +193 -0
- package/server/lib/workspace-init.js +307 -0
- package/server/lib/workspace-init.test.js +244 -0
- package/server/lib/workspace-snapshot.js +236 -0
- package/server/lib/workspace-snapshot.test.js +444 -0
- package/server/lib/workspace-watcher.js +162 -0
- package/server/lib/workspace-watcher.test.js +257 -0
- package/server/package-lock.json +1306 -17
- package/server/package.json +7 -0
- package/dashboard-web/dist/assets/index-B1I_joSL.js +0 -393
- package/dashboard-web/dist/assets/index-B1I_joSL.js.map +0 -1
- 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
|
-
//
|
|
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', () => {
|