tlc-claude-code 2.0.1 → 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 (86) hide show
  1. package/.claude/commands/tlc/deploy.md +194 -2
  2. package/.claude/commands/tlc/e2e-verify.md +214 -0
  3. package/.claude/commands/tlc/guard.md +191 -0
  4. package/.claude/commands/tlc/help.md +32 -0
  5. package/.claude/commands/tlc/init.md +73 -37
  6. package/.claude/commands/tlc/llm.md +19 -4
  7. package/.claude/commands/tlc/preflight.md +134 -0
  8. package/.claude/commands/tlc/review.md +17 -4
  9. package/.claude/commands/tlc/watchci.md +159 -0
  10. package/.claude/hooks/tlc-block-tools.sh +41 -0
  11. package/.claude/hooks/tlc-capture-exchange.sh +50 -0
  12. package/.claude/hooks/tlc-post-build.sh +38 -0
  13. package/.claude/hooks/tlc-post-push.sh +22 -0
  14. package/.claude/hooks/tlc-prompt-guard.sh +69 -0
  15. package/.claude/hooks/tlc-session-init.sh +123 -0
  16. package/CLAUDE.md +12 -0
  17. package/bin/install.js +171 -2
  18. package/bin/postinstall.js +45 -26
  19. package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
  20. package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
  21. package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
  22. package/dashboard-web/dist/index.html +2 -2
  23. package/docker-compose.dev.yml +18 -12
  24. package/package.json +3 -1
  25. package/server/index.js +228 -2
  26. package/server/lib/capture-bridge.js +242 -0
  27. package/server/lib/capture-bridge.test.js +363 -0
  28. package/server/lib/capture-guard.js +140 -0
  29. package/server/lib/capture-guard.test.js +182 -0
  30. package/server/lib/command-runner.js +159 -0
  31. package/server/lib/command-runner.test.js +92 -0
  32. package/server/lib/deploy/runners/dependency-runner.js +106 -0
  33. package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
  34. package/server/lib/deploy/runners/secrets-runner.js +174 -0
  35. package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
  36. package/server/lib/deploy/security-gates.js +11 -24
  37. package/server/lib/deploy/security-gates.test.js +9 -2
  38. package/server/lib/deploy-engine.js +182 -0
  39. package/server/lib/deploy-engine.test.js +147 -0
  40. package/server/lib/docker-api.js +137 -0
  41. package/server/lib/docker-api.test.js +202 -0
  42. package/server/lib/docker-client.js +297 -0
  43. package/server/lib/docker-client.test.js +308 -0
  44. package/server/lib/input-sanitizer.js +86 -0
  45. package/server/lib/input-sanitizer.test.js +117 -0
  46. package/server/lib/launchd-agent.js +225 -0
  47. package/server/lib/launchd-agent.test.js +185 -0
  48. package/server/lib/memory-api.js +3 -1
  49. package/server/lib/memory-api.test.js +3 -5
  50. package/server/lib/memory-bridge-e2e.test.js +160 -0
  51. package/server/lib/memory-committer.js +18 -4
  52. package/server/lib/memory-committer.test.js +21 -0
  53. package/server/lib/memory-hooks-capture.test.js +69 -4
  54. package/server/lib/memory-hooks-integration.test.js +98 -0
  55. package/server/lib/memory-hooks.js +42 -4
  56. package/server/lib/memory-store-adapter.js +105 -0
  57. package/server/lib/memory-store-adapter.test.js +141 -0
  58. package/server/lib/memory-wiring-e2e.test.js +93 -0
  59. package/server/lib/nginx-config.js +114 -0
  60. package/server/lib/nginx-config.test.js +82 -0
  61. package/server/lib/ollama-health.js +91 -0
  62. package/server/lib/ollama-health.test.js +74 -0
  63. package/server/lib/port-guard.js +44 -0
  64. package/server/lib/port-guard.test.js +65 -0
  65. package/server/lib/project-scanner.js +37 -2
  66. package/server/lib/project-scanner.test.js +152 -0
  67. package/server/lib/remember-command.js +2 -0
  68. package/server/lib/remember-command.test.js +23 -0
  69. package/server/lib/security/crypto-utils.test.js +2 -2
  70. package/server/lib/semantic-recall.js +1 -1
  71. package/server/lib/semantic-recall.test.js +17 -0
  72. package/server/lib/ssh-client.js +184 -0
  73. package/server/lib/ssh-client.test.js +127 -0
  74. package/server/lib/vps-api.js +184 -0
  75. package/server/lib/vps-api.test.js +208 -0
  76. package/server/lib/vps-bootstrap.js +124 -0
  77. package/server/lib/vps-bootstrap.test.js +79 -0
  78. package/server/lib/vps-monitor.js +126 -0
  79. package/server/lib/vps-monitor.test.js +98 -0
  80. package/server/lib/workspace-api.js +182 -1
  81. package/server/lib/workspace-api.test.js +474 -0
  82. package/server/package-lock.json +737 -0
  83. package/server/package.json +3 -0
  84. package/dashboard-web/dist/assets/index-Uhc49PE-.css +0 -1
  85. package/dashboard-web/dist/assets/index-W36XHPC5.js +0 -431
  86. package/dashboard-web/dist/assets/index-W36XHPC5.js.map +0 -1
@@ -297,18 +297,20 @@ describe('memory-hooks capture (auto-capture hooks)', () => {
297
297
  it('buffer resets after chunk written', async () => {
298
298
  const hooks = createCaptureHooks(testDir, makeDeps());
299
299
 
300
- // Fill buffer to threshold
300
+ // Fill buffer to threshold — the 5th exchange triggers processBuffer
301
+ // which atomically swaps the buffer before async processing
301
302
  for (let i = 0; i < 5; i++) {
302
303
  hooks.onExchange(makeExchange(`q${i}`, `a${i}`));
303
304
  }
304
305
 
305
- expect(hooks.getBufferSize()).toBe(5);
306
-
307
306
  // Wait for async processing to complete
308
307
  await new Promise(resolve => setTimeout(resolve, 100));
309
308
 
310
- // Buffer should be reset after chunking
309
+ // Buffer should be empty after processing
311
310
  expect(hooks.getBufferSize()).toBe(0);
311
+ // Chunker should have received the 5 exchanges
312
+ expect(mockChunker.chunkConversation).toHaveBeenCalled();
313
+ expect(mockChunker.chunkConversation.mock.calls[0][0]).toHaveLength(5);
312
314
  });
313
315
 
314
316
  it('flush() forces processing regardless of buffer size', async () => {
@@ -346,5 +348,68 @@ describe('memory-hooks capture (auto-capture hooks)', () => {
346
348
  expect(indexCallArgs).toBeDefined();
347
349
  expect(indexCallArgs[0]).toHaveProperty('id', 'chunk-1');
348
350
  });
351
+
352
+ // Phase 81 Task 3: Buffer race condition tests
353
+ it('exchanges added during async processing are not lost', async () => {
354
+ // Use a synchronous chunker but slow richCapture to simulate the race
355
+ const slowRichCapture = {
356
+ writeConversationChunk: vi.fn().mockImplementation(async () => {
357
+ // Simulate slow async write — yields control back to event loop
358
+ await new Promise(resolve => setTimeout(resolve, 80));
359
+ return '/tmp/slow-write.md';
360
+ }),
361
+ };
362
+
363
+ const hooks = createCaptureHooks(testDir, makeDeps({ richCapture: slowRichCapture }));
364
+
365
+ // Add 5 exchanges to trigger processing
366
+ for (let i = 0; i < 5; i++) {
367
+ hooks.onExchange(makeExchange(`q${i}`, `a${i}`));
368
+ }
369
+
370
+ // Wait briefly for processing to start (microtask scheduled)
371
+ await new Promise(resolve => setTimeout(resolve, 20));
372
+
373
+ // Now add more exchanges DURING the slow processing
374
+ hooks.onExchange(makeExchange('late-q1', 'late-a1'));
375
+ hooks.onExchange(makeExchange('late-q2', 'late-a2'));
376
+
377
+ // Wait for processing to complete
378
+ await new Promise(resolve => setTimeout(resolve, 200));
379
+
380
+ // The late exchanges must NOT have been lost
381
+ // They should be in the buffer (preserved) or processed in a second batch
382
+ const bufferSize = hooks.getBufferSize();
383
+ const chunkCalls = mockChunker.chunkConversation.mock.calls.length;
384
+ // Either late exchanges remain in buffer, or they triggered a second batch
385
+ expect(bufferSize === 2 || chunkCalls > 1).toBe(true);
386
+ });
387
+
388
+ it('error during processing preserves new exchanges', async () => {
389
+ const slowFailRichCapture = {
390
+ writeConversationChunk: vi.fn().mockImplementation(async () => {
391
+ await new Promise(resolve => setTimeout(resolve, 30));
392
+ throw new Error('Write failed');
393
+ }),
394
+ };
395
+
396
+ const hooks = createCaptureHooks(testDir, makeDeps({ richCapture: slowFailRichCapture }));
397
+
398
+ // Trigger processing (will fail during write)
399
+ for (let i = 0; i < 5; i++) {
400
+ hooks.onExchange(makeExchange(`q${i}`, `a${i}`));
401
+ }
402
+
403
+ // Wait for processing to start, then add exchange during failure
404
+ await new Promise(resolve => setTimeout(resolve, 10));
405
+ hooks.onExchange(makeExchange('after-error-q', 'after-error-a'));
406
+
407
+ // Wait for error processing to complete
408
+ await new Promise(resolve => setTimeout(resolve, 100));
409
+
410
+ // The new exchange added during the failing processing must be preserved
411
+ const bufferSize = hooks.getBufferSize();
412
+ expect(bufferSize).toBeGreaterThanOrEqual(1);
413
+ });
349
414
  });
350
415
  });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Memory Hooks Integration Tests - Phase 81 Task 6
3
+ *
4
+ * Tests for createServerMemoryCapture() which wires memory hooks
5
+ * into the TLC server lifecycle so that conversations are automatically
6
+ * captured without user action.
7
+ */
8
+
9
+ import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
10
+ import fs from 'fs';
11
+ import path from 'path';
12
+ import os from 'os';
13
+
14
+ // The function under test — will be created in implementation
15
+ import { createServerMemoryCapture } from './memory-hooks.js';
16
+
17
+ describe('memory-hooks server integration', () => {
18
+ let testDir;
19
+
20
+ beforeEach(() => {
21
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-hooks-integration-'));
22
+ // Create minimal memory structure
23
+ fs.mkdirSync(path.join(testDir, '.tlc', 'memory', 'team', 'decisions'), { recursive: true });
24
+ fs.mkdirSync(path.join(testDir, '.tlc', 'memory', '.local', 'sessions'), { recursive: true });
25
+ vi.clearAllMocks();
26
+ });
27
+
28
+ afterEach(() => {
29
+ fs.rmSync(testDir, { recursive: true, force: true });
30
+ });
31
+
32
+ it('createServerMemoryCapture returns object with expected methods', () => {
33
+ const capture = createServerMemoryCapture({
34
+ projectRoot: testDir,
35
+ observeAndRemember: vi.fn(),
36
+ });
37
+
38
+ expect(capture).toHaveProperty('onAssistantResponse');
39
+ expect(capture).toHaveProperty('onTlcCommand');
40
+ expect(typeof capture.onAssistantResponse).toBe('function');
41
+ expect(typeof capture.onTlcCommand).toBe('function');
42
+ });
43
+
44
+ it('onAssistantResponse triggers observeAndRemember', async () => {
45
+ const mockObserve = vi.fn();
46
+ const capture = createServerMemoryCapture({
47
+ projectRoot: testDir,
48
+ observeAndRemember: mockObserve,
49
+ });
50
+
51
+ await capture.onAssistantResponse('We should use PostgreSQL for better JSON support');
52
+
53
+ expect(mockObserve).toHaveBeenCalledTimes(1);
54
+ expect(mockObserve).toHaveBeenCalledWith(
55
+ testDir,
56
+ expect.objectContaining({ assistant: 'We should use PostgreSQL for better JSON support' })
57
+ );
58
+ });
59
+
60
+ it('onTlcCommand triggers capture flush', async () => {
61
+ const mockObserve = vi.fn();
62
+ const capture = createServerMemoryCapture({
63
+ projectRoot: testDir,
64
+ observeAndRemember: mockObserve,
65
+ });
66
+
67
+ // Add some responses first
68
+ await capture.onAssistantResponse('first response');
69
+ await capture.onAssistantResponse('second response');
70
+
71
+ // TLC command should work without error
72
+ expect(() => capture.onTlcCommand('build')).not.toThrow();
73
+ });
74
+
75
+ it('capture failure does not throw', async () => {
76
+ const failingObserve = vi.fn().mockRejectedValue(new Error('Observation failed'));
77
+ const capture = createServerMemoryCapture({
78
+ projectRoot: testDir,
79
+ observeAndRemember: failingObserve,
80
+ });
81
+
82
+ // Should not throw despite observer failure
83
+ await expect(
84
+ capture.onAssistantResponse('this will fail to observe')
85
+ ).resolves.not.toThrow();
86
+ });
87
+
88
+ it('works when projectRoot has no memory structure', () => {
89
+ const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-empty-'));
90
+
91
+ expect(() => createServerMemoryCapture({
92
+ projectRoot: emptyDir,
93
+ observeAndRemember: vi.fn(),
94
+ })).not.toThrow();
95
+
96
+ fs.rmSync(emptyDir, { recursive: true, force: true });
97
+ });
98
+ });
@@ -158,19 +158,21 @@ function createCaptureHooks(projectRoot, deps) {
158
158
 
159
159
  processing = true;
160
160
 
161
+ // Snapshot and reset buffer atomically BEFORE async processing.
162
+ // New exchanges arriving during processing go into the fresh buffer.
163
+ const snapshot = buffer;
164
+ buffer = [];
165
+
161
166
  Promise.resolve().then(async () => {
162
167
  try {
163
- const exchanges = buffer.slice();
164
- const chunks = chunker.chunkConversation(exchanges);
168
+ const chunks = chunker.chunkConversation(snapshot);
165
169
  for (const chunk of chunks) {
166
170
  await richCapture.writeConversationChunk(projectRoot, chunk);
167
171
  await vectorIndexer.indexChunk(chunk);
168
172
  }
169
- buffer = [];
170
173
  } catch (_err) {
171
174
  // Error resilience: capture failures must not propagate.
172
175
  // Hooks remain functional after errors.
173
- buffer = [];
174
176
  } finally {
175
177
  processing = false;
176
178
  }
@@ -221,8 +223,44 @@ function createCaptureHooks(projectRoot, deps) {
221
223
  };
222
224
  }
223
225
 
226
+ /**
227
+ * Create server-level memory capture that wires observeAndRemember into
228
+ * the TLC server lifecycle so conversations are automatically captured.
229
+ *
230
+ * @param {Object} opts
231
+ * @param {string} opts.projectRoot - Project root directory
232
+ * @param {Function} opts.observeAndRemember - The observe-and-remember function from memory-observer
233
+ * @returns {{ onAssistantResponse: Function, onTlcCommand: Function }}
234
+ */
235
+ function createServerMemoryCapture({ projectRoot, observeAndRemember }) {
236
+ /**
237
+ * Called after each assistant response — fires observeAndRemember.
238
+ * Errors are swallowed to avoid disrupting the response flow.
239
+ * @param {string} response - The assistant response text
240
+ */
241
+ async function onAssistantResponse(response) {
242
+ try {
243
+ await observeAndRemember(projectRoot, { assistant: response });
244
+ } catch (_err) {
245
+ // Capture failures must not propagate
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Called when a TLC command is invoked — currently a no-op placeholder
251
+ * for future flush/capture logic.
252
+ * @param {string} _command - The TLC command name
253
+ */
254
+ function onTlcCommand(_command) {
255
+ // Placeholder for future capture flush on TLC commands
256
+ }
257
+
258
+ return { onAssistantResponse, onTlcCommand };
259
+ }
260
+
224
261
  module.exports = {
225
262
  createMemoryHooks,
226
263
  MemoryHooks,
227
264
  createCaptureHooks,
265
+ createServerMemoryCapture,
228
266
  };
@@ -0,0 +1,105 @@
1
+ /**
2
+ * File-based memory store adapter.
3
+ *
4
+ * Reads decisions and gotchas from `.tlc/memory/team/` markdown files on disk.
5
+ * No vector DB required — just file-based reading. Returns empty arrays when
6
+ * no files exist.
7
+ *
8
+ * @module memory-store-adapter
9
+ */
10
+
11
+ const path = require('path');
12
+ const realFs = require('fs');
13
+
14
+ /**
15
+ * Parse a markdown file into a memory entry.
16
+ * Extracts the title from the first `# Heading` line.
17
+ *
18
+ * @param {string} content - Raw markdown content
19
+ * @param {string} filename - Original filename
20
+ * @returns {{ title: string, content: string, filename: string }}
21
+ */
22
+ function parseMarkdownEntry(content, filename) {
23
+ const headingMatch = content.match(/^#\s+(.+)$/m);
24
+ const title = headingMatch ? headingMatch[1].trim() : filename.replace(/\.md$/, '');
25
+ return { title, content, filename };
26
+ }
27
+
28
+ /**
29
+ * Read markdown files from a directory and parse them into entries.
30
+ *
31
+ * @param {string} dirPath - Directory to read
32
+ * @param {object} fsImpl - fs implementation (for testing)
33
+ * @returns {Array<{ title: string, content: string, filename: string }>}
34
+ */
35
+ function readMarkdownDir(dirPath, fsImpl) {
36
+ if (!fsImpl.existsSync(dirPath)) {
37
+ return [];
38
+ }
39
+
40
+ const files = fsImpl.readdirSync(dirPath);
41
+ const entries = [];
42
+
43
+ for (const file of files) {
44
+ if (!file.endsWith('.md')) continue;
45
+ try {
46
+ const content = fsImpl.readFileSync(path.join(dirPath, file), 'utf-8');
47
+ entries.push(parseMarkdownEntry(content, file));
48
+ } catch {
49
+ // Skip files that can't be read
50
+ }
51
+ }
52
+
53
+ return entries;
54
+ }
55
+
56
+ /**
57
+ * Create a file-based memory store adapter for a project.
58
+ *
59
+ * @param {string} projectPath - Absolute path to the project root
60
+ * @param {object} [options]
61
+ * @param {object} [options.fs] - fs implementation (for testing)
62
+ * @returns {{ listDecisions: Function, listGotchas: Function, getStats: Function }}
63
+ */
64
+ function createMemoryStoreAdapter(projectPath, options = {}) {
65
+ const fsImpl = options.fs || realFs;
66
+ const decisionsDir = path.join(projectPath, '.tlc', 'memory', 'team', 'decisions');
67
+ const gotchasDir = path.join(projectPath, '.tlc', 'memory', 'team', 'gotchas');
68
+
69
+ /**
70
+ * List all decisions from the project's memory directory.
71
+ * @param {object} [options] - Filter options (unused for file-based)
72
+ * @returns {Promise<Array>}
73
+ */
74
+ async function listDecisions() {
75
+ return readMarkdownDir(decisionsDir, fsImpl);
76
+ }
77
+
78
+ /**
79
+ * List all gotchas from the project's memory directory.
80
+ * @param {object} [options] - Filter options (unused for file-based)
81
+ * @returns {Promise<Array>}
82
+ */
83
+ async function listGotchas() {
84
+ return readMarkdownDir(gotchasDir, fsImpl);
85
+ }
86
+
87
+ /**
88
+ * Get basic stats about the memory store.
89
+ * @returns {Promise<{ decisions: number, gotchas: number, total: number }>}
90
+ */
91
+ async function getStats() {
92
+ const dCount = fsImpl.existsSync(decisionsDir)
93
+ ? fsImpl.readdirSync(decisionsDir).filter(f => f.endsWith('.md')).length
94
+ : 0;
95
+ const gCount = fsImpl.existsSync(gotchasDir)
96
+ ? fsImpl.readdirSync(gotchasDir).filter(f => f.endsWith('.md')).length
97
+ : 0;
98
+
99
+ return { decisions: dCount, gotchas: gCount, total: dCount + gCount };
100
+ }
101
+
102
+ return { listDecisions, listGotchas, getStats };
103
+ }
104
+
105
+ module.exports = { createMemoryStoreAdapter };
@@ -0,0 +1,141 @@
1
+ /**
2
+ * @file memory-store-adapter.test.js
3
+ * @description Tests for the file-based memory store adapter.
4
+ *
5
+ * The adapter reads decisions and gotchas from .tlc/memory/team/ markdown
6
+ * files on disk. Returns empty arrays when directories don't exist.
7
+ */
8
+ import { describe, it, beforeEach, expect, vi } from 'vitest';
9
+
10
+ const { createMemoryStoreAdapter } = await import('./memory-store-adapter.js');
11
+
12
+ describe('memory-store-adapter', () => {
13
+ let mockFs;
14
+
15
+ beforeEach(() => {
16
+ mockFs = {
17
+ existsSync: vi.fn().mockReturnValue(false),
18
+ readdirSync: vi.fn().mockReturnValue([]),
19
+ readFileSync: vi.fn().mockReturnValue(''),
20
+ };
21
+ });
22
+
23
+ describe('createMemoryStoreAdapter', () => {
24
+ it('returns an object with listDecisions, listGotchas, getStats', () => {
25
+ const adapter = createMemoryStoreAdapter('/fake/project', { fs: mockFs });
26
+ expect(adapter).toHaveProperty('listDecisions');
27
+ expect(adapter).toHaveProperty('listGotchas');
28
+ expect(adapter).toHaveProperty('getStats');
29
+ expect(typeof adapter.listDecisions).toBe('function');
30
+ expect(typeof adapter.listGotchas).toBe('function');
31
+ expect(typeof adapter.getStats).toBe('function');
32
+ });
33
+ });
34
+
35
+ describe('listDecisions', () => {
36
+ it('returns empty array when decisions directory does not exist', async () => {
37
+ mockFs.existsSync.mockReturnValue(false);
38
+ const adapter = createMemoryStoreAdapter('/fake/project', { fs: mockFs });
39
+ const result = await adapter.listDecisions();
40
+ expect(result).toEqual([]);
41
+ });
42
+
43
+ it('reads markdown files from decisions directory', async () => {
44
+ mockFs.existsSync.mockReturnValue(true);
45
+ mockFs.readdirSync.mockReturnValue(['001-use-postgres.md', '002-rest-api.md']);
46
+ mockFs.readFileSync
47
+ .mockReturnValueOnce('# Use Postgres\n\nWe chose Postgres for the database.\n\n**Date:** 2026-01-20\n**Status:** accepted')
48
+ .mockReturnValueOnce('# REST over GraphQL\n\nREST is simpler for our use case.\n\n**Date:** 2026-01-22\n**Status:** accepted');
49
+
50
+ const adapter = createMemoryStoreAdapter('/fake/project', { fs: mockFs });
51
+ const result = await adapter.listDecisions();
52
+
53
+ expect(result).toHaveLength(2);
54
+ expect(result[0]).toHaveProperty('title', 'Use Postgres');
55
+ expect(result[0]).toHaveProperty('content');
56
+ expect(result[1]).toHaveProperty('title', 'REST over GraphQL');
57
+ });
58
+
59
+ it('handles malformed markdown gracefully', async () => {
60
+ mockFs.existsSync.mockReturnValue(true);
61
+ mockFs.readdirSync.mockReturnValue(['bad.md']);
62
+ mockFs.readFileSync.mockReturnValue('no heading here just text');
63
+
64
+ const adapter = createMemoryStoreAdapter('/fake/project', { fs: mockFs });
65
+ const result = await adapter.listDecisions();
66
+
67
+ expect(result).toHaveLength(1);
68
+ expect(result[0]).toHaveProperty('title');
69
+ });
70
+
71
+ it('skips non-markdown files', async () => {
72
+ mockFs.existsSync.mockReturnValue(true);
73
+ mockFs.readdirSync.mockReturnValue(['decision.md', 'notes.txt', '.DS_Store']);
74
+ mockFs.readFileSync.mockReturnValue('# A Decision\n\nContent here');
75
+
76
+ const adapter = createMemoryStoreAdapter('/fake/project', { fs: mockFs });
77
+ const result = await adapter.listDecisions();
78
+
79
+ expect(result).toHaveLength(1);
80
+ });
81
+ });
82
+
83
+ describe('listGotchas', () => {
84
+ it('returns empty array when gotchas directory does not exist', async () => {
85
+ mockFs.existsSync.mockReturnValue(false);
86
+ const adapter = createMemoryStoreAdapter('/fake/project', { fs: mockFs });
87
+ const result = await adapter.listGotchas();
88
+ expect(result).toEqual([]);
89
+ });
90
+
91
+ it('reads markdown files from gotchas directory', async () => {
92
+ mockFs.existsSync.mockReturnValue(true);
93
+ mockFs.readdirSync.mockReturnValue(['001-cold-starts.md']);
94
+ mockFs.readFileSync.mockReturnValue('# Cold Start Delay\n\nLambda cold starts cause 2s delay.\n\n**Severity:** high');
95
+
96
+ const adapter = createMemoryStoreAdapter('/fake/project', { fs: mockFs });
97
+ const result = await adapter.listGotchas();
98
+
99
+ expect(result).toHaveLength(1);
100
+ expect(result[0]).toHaveProperty('title', 'Cold Start Delay');
101
+ expect(result[0]).toHaveProperty('content');
102
+ });
103
+
104
+ it('handles read errors gracefully', async () => {
105
+ mockFs.existsSync.mockReturnValue(true);
106
+ mockFs.readdirSync.mockReturnValue(['broken.md']);
107
+ mockFs.readFileSync.mockImplementation(() => { throw new Error('EACCES'); });
108
+
109
+ const adapter = createMemoryStoreAdapter('/fake/project', { fs: mockFs });
110
+ const result = await adapter.listGotchas();
111
+
112
+ expect(result).toEqual([]);
113
+ });
114
+ });
115
+
116
+ describe('getStats', () => {
117
+ it('returns zero counts when no directories exist', async () => {
118
+ mockFs.existsSync.mockReturnValue(false);
119
+ const adapter = createMemoryStoreAdapter('/fake/project', { fs: mockFs });
120
+ const stats = await adapter.getStats();
121
+
122
+ expect(stats).toHaveProperty('decisions', 0);
123
+ expect(stats).toHaveProperty('gotchas', 0);
124
+ expect(stats).toHaveProperty('total', 0);
125
+ });
126
+
127
+ it('returns file counts from both directories', async () => {
128
+ mockFs.existsSync.mockReturnValue(true);
129
+ mockFs.readdirSync
130
+ .mockReturnValueOnce(['d1.md', 'd2.md', 'd3.md'])
131
+ .mockReturnValueOnce(['g1.md']);
132
+
133
+ const adapter = createMemoryStoreAdapter('/fake/project', { fs: mockFs });
134
+ const stats = await adapter.getStats();
135
+
136
+ expect(stats.decisions).toBe(3);
137
+ expect(stats.gotchas).toBe(1);
138
+ expect(stats.total).toBe(4);
139
+ });
140
+ });
141
+ });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Memory wiring E2E tests - Phase 84 Task 4
3
+ *
4
+ * Proves the full memory loop: exchange → observeAndRemember → file written → adapter reads back.
5
+ * This is the definitive test that memory actually works end-to-end.
6
+ */
7
+
8
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import os from 'os';
12
+
13
+ import { observeAndRemember } from './memory-observer.js';
14
+ import { createMemoryStoreAdapter } from './memory-store-adapter.js';
15
+
16
+ describe('memory wiring e2e', () => {
17
+ let testDir;
18
+
19
+ beforeEach(() => {
20
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-wiring-e2e-'));
21
+ // Create full memory directory structure
22
+ fs.mkdirSync(path.join(testDir, '.tlc', 'memory', 'team', 'decisions'), { recursive: true });
23
+ fs.mkdirSync(path.join(testDir, '.tlc', 'memory', 'team', 'gotchas'), { recursive: true });
24
+ fs.mkdirSync(path.join(testDir, '.tlc', 'memory', '.local', 'preferences'), { recursive: true });
25
+ fs.mkdirSync(path.join(testDir, '.tlc', 'memory', '.local', 'sessions'), { recursive: true });
26
+ fs.writeFileSync(path.join(testDir, '.tlc.json'), JSON.stringify({ project: 'wiring-test' }));
27
+ });
28
+
29
+ afterEach(() => {
30
+ fs.rmSync(testDir, { recursive: true, force: true });
31
+ });
32
+
33
+ it('decision exchange → file created → adapter reads it back', async () => {
34
+ const exchange = {
35
+ user: "let's use PostgreSQL instead of MySQL for better JSONB support.",
36
+ assistant: 'Good choice. PostgreSQL has excellent JSONB support.',
37
+ };
38
+
39
+ await observeAndRemember(testDir, exchange);
40
+ await new Promise(resolve => setTimeout(resolve, 500));
41
+
42
+ // Adapter reads it back
43
+ const adapter = createMemoryStoreAdapter(testDir);
44
+ const decisions = await adapter.listDecisions();
45
+ expect(decisions.length).toBeGreaterThanOrEqual(1);
46
+ });
47
+
48
+ it('gotcha exchange → file created → adapter reads it back', async () => {
49
+ const exchange = {
50
+ user: 'watch out for the SQLite WAL mode issue under concurrent writes.',
51
+ assistant: 'Noted. Serialize database operations to avoid corruption.',
52
+ };
53
+
54
+ await observeAndRemember(testDir, exchange);
55
+ await new Promise(resolve => setTimeout(resolve, 500));
56
+
57
+ const adapter = createMemoryStoreAdapter(testDir);
58
+ const gotchas = await adapter.listGotchas();
59
+ expect(gotchas.length).toBeGreaterThanOrEqual(1);
60
+ });
61
+
62
+ it('stats reflect actual file counts', async () => {
63
+ // Write a decision
64
+ const exchange = {
65
+ user: "we decided to use Redis as our caching layer instead of Memcached.",
66
+ assistant: 'Redis is more versatile for caching.',
67
+ };
68
+
69
+ await observeAndRemember(testDir, exchange);
70
+ await new Promise(resolve => setTimeout(resolve, 500));
71
+
72
+ const adapter = createMemoryStoreAdapter(testDir);
73
+ const stats = await adapter.getStats();
74
+ expect(stats.decisions).toBeGreaterThanOrEqual(1);
75
+ expect(stats.total).toBeGreaterThanOrEqual(1);
76
+ });
77
+
78
+ it('empty project returns empty arrays without crashing', async () => {
79
+ const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-empty-'));
80
+ fs.writeFileSync(path.join(emptyDir, '.tlc.json'), JSON.stringify({ project: 'empty' }));
81
+
82
+ const adapter = createMemoryStoreAdapter(emptyDir);
83
+ const decisions = await adapter.listDecisions();
84
+ const gotchas = await adapter.listGotchas();
85
+ const stats = await adapter.getStats();
86
+
87
+ expect(decisions).toEqual([]);
88
+ expect(gotchas).toEqual([]);
89
+ expect(stats.total).toBe(0);
90
+
91
+ fs.rmSync(emptyDir, { recursive: true, force: true });
92
+ });
93
+ });