tlc-claude-code 1.2.26 → 1.2.28

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 (177) hide show
  1. package/dashboard/dist/components/ActivityFeed.d.ts +17 -0
  2. package/dashboard/dist/components/ActivityFeed.js +42 -0
  3. package/dashboard/dist/components/ActivityFeed.test.d.ts +1 -0
  4. package/dashboard/dist/components/ActivityFeed.test.js +162 -0
  5. package/dashboard/dist/components/BranchSelector.d.ts +16 -0
  6. package/dashboard/dist/components/BranchSelector.js +49 -0
  7. package/dashboard/dist/components/BranchSelector.test.d.ts +1 -0
  8. package/dashboard/dist/components/BranchSelector.test.js +166 -0
  9. package/dashboard/dist/components/CommandPalette.d.ts +17 -0
  10. package/dashboard/dist/components/CommandPalette.js +118 -0
  11. package/dashboard/dist/components/CommandPalette.test.d.ts +1 -0
  12. package/dashboard/dist/components/CommandPalette.test.js +181 -0
  13. package/dashboard/dist/components/ConnectionStatus.d.ts +16 -0
  14. package/dashboard/dist/components/ConnectionStatus.js +27 -0
  15. package/dashboard/dist/components/ConnectionStatus.test.d.ts +1 -0
  16. package/dashboard/dist/components/ConnectionStatus.test.js +121 -0
  17. package/dashboard/dist/components/DeviceFrame.d.ts +19 -0
  18. package/dashboard/dist/components/DeviceFrame.js +52 -0
  19. package/dashboard/dist/components/DeviceFrame.test.d.ts +1 -0
  20. package/dashboard/dist/components/DeviceFrame.test.js +118 -0
  21. package/dashboard/dist/components/EnvironmentBadge.d.ts +11 -0
  22. package/dashboard/dist/components/EnvironmentBadge.js +16 -0
  23. package/dashboard/dist/components/EnvironmentBadge.test.d.ts +1 -0
  24. package/dashboard/dist/components/EnvironmentBadge.test.js +102 -0
  25. package/dashboard/dist/components/FocusIndicator.d.ts +19 -0
  26. package/dashboard/dist/components/FocusIndicator.js +47 -0
  27. package/dashboard/dist/components/FocusIndicator.test.d.ts +1 -0
  28. package/dashboard/dist/components/FocusIndicator.test.js +117 -0
  29. package/dashboard/dist/components/KeyboardHelp.d.ts +15 -0
  30. package/dashboard/dist/components/KeyboardHelp.js +61 -0
  31. package/dashboard/dist/components/KeyboardHelp.test.d.ts +1 -0
  32. package/dashboard/dist/components/KeyboardHelp.test.js +131 -0
  33. package/dashboard/dist/components/LogSearch.d.ts +13 -0
  34. package/dashboard/dist/components/LogSearch.js +43 -0
  35. package/dashboard/dist/components/LogSearch.test.d.ts +1 -0
  36. package/dashboard/dist/components/LogSearch.test.js +100 -0
  37. package/dashboard/dist/components/LogStream.d.ts +21 -0
  38. package/dashboard/dist/components/LogStream.js +123 -0
  39. package/dashboard/dist/components/LogStream.test.d.ts +1 -0
  40. package/dashboard/dist/components/LogStream.test.js +159 -0
  41. package/dashboard/dist/components/PlanView.d.ts +7 -0
  42. package/dashboard/dist/components/PlanView.js +74 -2
  43. package/dashboard/dist/components/PlanView.test.js +70 -1
  44. package/dashboard/dist/components/PreviewPanel.d.ts +18 -0
  45. package/dashboard/dist/components/PreviewPanel.js +73 -0
  46. package/dashboard/dist/components/PreviewPanel.test.d.ts +1 -0
  47. package/dashboard/dist/components/PreviewPanel.test.js +124 -0
  48. package/dashboard/dist/components/ProjectCard.d.ts +18 -0
  49. package/dashboard/dist/components/ProjectCard.js +19 -0
  50. package/dashboard/dist/components/ProjectCard.test.d.ts +1 -0
  51. package/dashboard/dist/components/ProjectCard.test.js +53 -0
  52. package/dashboard/dist/components/ProjectDetail.d.ts +44 -0
  53. package/dashboard/dist/components/ProjectDetail.js +65 -0
  54. package/dashboard/dist/components/ProjectDetail.test.d.ts +1 -0
  55. package/dashboard/dist/components/ProjectDetail.test.js +196 -0
  56. package/dashboard/dist/components/ProjectList.d.ts +11 -0
  57. package/dashboard/dist/components/ProjectList.js +62 -0
  58. package/dashboard/dist/components/ProjectList.test.d.ts +1 -0
  59. package/dashboard/dist/components/ProjectList.test.js +93 -0
  60. package/dashboard/dist/components/SettingsPanel.d.ts +32 -0
  61. package/dashboard/dist/components/SettingsPanel.js +154 -0
  62. package/dashboard/dist/components/SettingsPanel.test.d.ts +1 -0
  63. package/dashboard/dist/components/SettingsPanel.test.js +196 -0
  64. package/dashboard/dist/components/StatusBar.d.ts +16 -0
  65. package/dashboard/dist/components/StatusBar.js +47 -0
  66. package/dashboard/dist/components/StatusBar.test.d.ts +1 -0
  67. package/dashboard/dist/components/StatusBar.test.js +123 -0
  68. package/dashboard/dist/components/TaskBoard.d.ts +22 -0
  69. package/dashboard/dist/components/TaskBoard.js +102 -0
  70. package/dashboard/dist/components/TaskBoard.test.d.ts +1 -0
  71. package/dashboard/dist/components/TaskBoard.test.js +113 -0
  72. package/dashboard/dist/components/TaskCard.d.ts +17 -0
  73. package/dashboard/dist/components/TaskCard.js +29 -0
  74. package/dashboard/dist/components/TaskCard.test.d.ts +1 -0
  75. package/dashboard/dist/components/TaskCard.test.js +109 -0
  76. package/dashboard/dist/components/TaskDetail.d.ts +36 -0
  77. package/dashboard/dist/components/TaskDetail.js +41 -0
  78. package/dashboard/dist/components/TaskDetail.test.d.ts +1 -0
  79. package/dashboard/dist/components/TaskDetail.test.js +164 -0
  80. package/dashboard/dist/components/TaskFilter.d.ts +12 -0
  81. package/dashboard/dist/components/TaskFilter.js +138 -0
  82. package/dashboard/dist/components/TaskFilter.test.d.ts +1 -0
  83. package/dashboard/dist/components/TaskFilter.test.js +109 -0
  84. package/dashboard/dist/components/TeamPanel.d.ts +15 -0
  85. package/dashboard/dist/components/TeamPanel.js +24 -0
  86. package/dashboard/dist/components/TeamPanel.test.d.ts +1 -0
  87. package/dashboard/dist/components/TeamPanel.test.js +109 -0
  88. package/dashboard/dist/components/TeamPresence.d.ts +14 -0
  89. package/dashboard/dist/components/TeamPresence.js +31 -0
  90. package/dashboard/dist/components/TeamPresence.test.d.ts +1 -0
  91. package/dashboard/dist/components/TeamPresence.test.js +144 -0
  92. package/dashboard/dist/components/layout/Header.d.ts +9 -0
  93. package/dashboard/dist/components/layout/Header.js +11 -0
  94. package/dashboard/dist/components/layout/Header.test.d.ts +1 -0
  95. package/dashboard/dist/components/layout/Header.test.js +35 -0
  96. package/dashboard/dist/components/layout/Shell.d.ts +10 -0
  97. package/dashboard/dist/components/layout/Shell.js +5 -0
  98. package/dashboard/dist/components/layout/Shell.test.d.ts +1 -0
  99. package/dashboard/dist/components/layout/Shell.test.js +34 -0
  100. package/dashboard/dist/components/layout/Sidebar.d.ts +14 -0
  101. package/dashboard/dist/components/layout/Sidebar.js +8 -0
  102. package/dashboard/dist/components/layout/Sidebar.test.d.ts +1 -0
  103. package/dashboard/dist/components/layout/Sidebar.test.js +40 -0
  104. package/dashboard/dist/components/ui/Badge.d.ts +9 -0
  105. package/dashboard/dist/components/ui/Badge.js +13 -0
  106. package/dashboard/dist/components/ui/Badge.test.d.ts +1 -0
  107. package/dashboard/dist/components/ui/Badge.test.js +69 -0
  108. package/dashboard/dist/components/ui/Button.d.ts +12 -0
  109. package/dashboard/dist/components/ui/Button.js +14 -0
  110. package/dashboard/dist/components/ui/Button.test.d.ts +1 -0
  111. package/dashboard/dist/components/ui/Button.test.js +81 -0
  112. package/dashboard/dist/components/ui/Card.d.ts +21 -0
  113. package/dashboard/dist/components/ui/Card.js +20 -0
  114. package/dashboard/dist/components/ui/Card.test.d.ts +1 -0
  115. package/dashboard/dist/components/ui/Card.test.js +82 -0
  116. package/dashboard/dist/components/ui/Input.d.ts +13 -0
  117. package/dashboard/dist/components/ui/Input.js +8 -0
  118. package/dashboard/dist/components/ui/Input.test.d.ts +1 -0
  119. package/dashboard/dist/components/ui/Input.test.js +68 -0
  120. package/dashboard/dist/styles/tokens.d.ts +150 -0
  121. package/dashboard/dist/styles/tokens.js +184 -0
  122. package/dashboard/dist/styles/tokens.test.d.ts +1 -0
  123. package/dashboard/dist/styles/tokens.test.js +95 -0
  124. package/dashboard/dist/test/setup.d.ts +1 -0
  125. package/dashboard/dist/test/setup.js +1 -0
  126. package/dashboard/package.json +3 -0
  127. package/package.json +1 -1
  128. package/server/dashboard/index.html +157 -2
  129. package/server/index.js +38 -21
  130. package/server/lib/adapters/base-adapter.js +114 -0
  131. package/server/lib/adapters/base-adapter.test.js +90 -0
  132. package/server/lib/adapters/claude-adapter.js +141 -0
  133. package/server/lib/adapters/claude-adapter.test.js +180 -0
  134. package/server/lib/adapters/deepseek-adapter.js +153 -0
  135. package/server/lib/adapters/deepseek-adapter.test.js +193 -0
  136. package/server/lib/adapters/openai-adapter.js +190 -0
  137. package/server/lib/adapters/openai-adapter.test.js +231 -0
  138. package/server/lib/budget-tracker.js +169 -0
  139. package/server/lib/budget-tracker.test.js +165 -0
  140. package/server/lib/claude-injector.js +85 -0
  141. package/server/lib/claude-injector.test.js +161 -0
  142. package/server/lib/consensus-engine.js +135 -0
  143. package/server/lib/consensus-engine.test.js +152 -0
  144. package/server/lib/context-builder.js +112 -0
  145. package/server/lib/context-builder.test.js +120 -0
  146. package/server/lib/file-collector.js +322 -0
  147. package/server/lib/file-collector.test.js +307 -0
  148. package/server/lib/memory-classifier.js +175 -0
  149. package/server/lib/memory-classifier.test.js +169 -0
  150. package/server/lib/memory-committer.js +138 -0
  151. package/server/lib/memory-committer.test.js +136 -0
  152. package/server/lib/memory-hooks.js +127 -0
  153. package/server/lib/memory-hooks.test.js +136 -0
  154. package/server/lib/memory-init.js +104 -0
  155. package/server/lib/memory-init.test.js +119 -0
  156. package/server/lib/memory-observer.js +149 -0
  157. package/server/lib/memory-observer.test.js +158 -0
  158. package/server/lib/memory-reader.js +243 -0
  159. package/server/lib/memory-reader.test.js +216 -0
  160. package/server/lib/memory-storage.js +120 -0
  161. package/server/lib/memory-storage.test.js +136 -0
  162. package/server/lib/memory-writer.js +176 -0
  163. package/server/lib/memory-writer.test.js +231 -0
  164. package/server/lib/overdrive-command.js +30 -6
  165. package/server/lib/overdrive-command.test.js +8 -1
  166. package/server/lib/pattern-detector.js +216 -0
  167. package/server/lib/pattern-detector.test.js +241 -0
  168. package/server/lib/relevance-scorer.js +175 -0
  169. package/server/lib/relevance-scorer.test.js +107 -0
  170. package/server/lib/review-command.js +238 -0
  171. package/server/lib/review-command.test.js +245 -0
  172. package/server/lib/review-orchestrator.js +273 -0
  173. package/server/lib/review-orchestrator.test.js +300 -0
  174. package/server/lib/review-reporter.js +288 -0
  175. package/server/lib/review-reporter.test.js +240 -0
  176. package/server/lib/session-summary.js +90 -0
  177. package/server/lib/session-summary.test.js +156 -0
@@ -0,0 +1,161 @@
1
+ import { describe, it, beforeEach, afterEach, expect } from 'vitest';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import { injectMemoryContext, extractMemorySection, MEMORY_SECTION_MARKERS } from './claude-injector.js';
6
+
7
+ describe('claude-injector', () => {
8
+ let testDir;
9
+ let claudeMdPath;
10
+
11
+ beforeEach(() => {
12
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-injector-test-'));
13
+ claudeMdPath = path.join(testDir, 'CLAUDE.md');
14
+ });
15
+
16
+ afterEach(() => {
17
+ fs.rmSync(testDir, { recursive: true, force: true });
18
+ });
19
+
20
+ describe('injectMemoryContext', () => {
21
+ it('creates CLAUDE.md if missing', async () => {
22
+ await injectMemoryContext(testDir, '## Active Memory\n\nTest content');
23
+
24
+ expect(fs.existsSync(claudeMdPath)).toBe(true);
25
+ const content = fs.readFileSync(claudeMdPath, 'utf-8');
26
+ expect(content).toContain('Active Memory');
27
+ });
28
+
29
+ it('appends memory section to existing CLAUDE.md', async () => {
30
+ fs.writeFileSync(claudeMdPath, '# Project\n\nExisting content\n');
31
+
32
+ await injectMemoryContext(testDir, '## Preferences\n\n- style: functional');
33
+
34
+ const content = fs.readFileSync(claudeMdPath, 'utf-8');
35
+ expect(content).toContain('Existing content');
36
+ expect(content).toContain('Preferences');
37
+ expect(content).toContain('functional');
38
+ });
39
+
40
+ it('replaces existing memory section without duplication', async () => {
41
+ const initial = `# Project
42
+
43
+ ${MEMORY_SECTION_MARKERS.START}
44
+ ## Old Memory
45
+
46
+ Old content
47
+ ${MEMORY_SECTION_MARKERS.END}
48
+
49
+ ## Other Section
50
+ `;
51
+ fs.writeFileSync(claudeMdPath, initial);
52
+
53
+ await injectMemoryContext(testDir, '## New Memory\n\nNew content');
54
+
55
+ const content = fs.readFileSync(claudeMdPath, 'utf-8');
56
+ expect(content).toContain('New Memory');
57
+ expect(content).toContain('New content');
58
+ expect(content).not.toContain('Old Memory');
59
+ expect(content).not.toContain('Old content');
60
+ expect(content).toContain('Other Section');
61
+ });
62
+
63
+ it('preserves content before and after memory section', async () => {
64
+ const initial = `# Project Title
65
+
66
+ Introduction paragraph.
67
+
68
+ ${MEMORY_SECTION_MARKERS.START}
69
+ ## Memory
70
+ Content
71
+ ${MEMORY_SECTION_MARKERS.END}
72
+
73
+ ## Commands
74
+
75
+ - /help
76
+ `;
77
+ fs.writeFileSync(claudeMdPath, initial);
78
+
79
+ await injectMemoryContext(testDir, '## Updated Memory\n\nUpdated');
80
+
81
+ const content = fs.readFileSync(claudeMdPath, 'utf-8');
82
+ expect(content).toContain('Project Title');
83
+ expect(content).toContain('Introduction paragraph');
84
+ expect(content).toContain('Commands');
85
+ expect(content).toContain('/help');
86
+ expect(content).toContain('Updated Memory');
87
+ });
88
+
89
+ it('handles empty memory context gracefully', async () => {
90
+ fs.writeFileSync(claudeMdPath, '# Project\n\nContent\n');
91
+
92
+ await injectMemoryContext(testDir, '');
93
+
94
+ const content = fs.readFileSync(claudeMdPath, 'utf-8');
95
+ expect(content).toContain('Project');
96
+ // Should still have markers even if empty
97
+ expect(content).toContain(MEMORY_SECTION_MARKERS.START);
98
+ });
99
+
100
+ it('adds markers around injected content', async () => {
101
+ await injectMemoryContext(testDir, '## Memory\n\nContent');
102
+
103
+ const content = fs.readFileSync(claudeMdPath, 'utf-8');
104
+ expect(content).toContain(MEMORY_SECTION_MARKERS.START);
105
+ expect(content).toContain(MEMORY_SECTION_MARKERS.END);
106
+ });
107
+
108
+ it('handles CLAUDE.md with only whitespace', async () => {
109
+ fs.writeFileSync(claudeMdPath, ' \n\n \n');
110
+
111
+ await injectMemoryContext(testDir, '## Memory\n\nContent');
112
+
113
+ const content = fs.readFileSync(claudeMdPath, 'utf-8');
114
+ expect(content).toContain('Memory');
115
+ });
116
+ });
117
+
118
+ describe('extractMemorySection', () => {
119
+ it('extracts content between markers', () => {
120
+ const content = `Before
121
+ ${MEMORY_SECTION_MARKERS.START}
122
+ ## Memory
123
+ Content here
124
+ ${MEMORY_SECTION_MARKERS.END}
125
+ After`;
126
+
127
+ const extracted = extractMemorySection(content);
128
+
129
+ expect(extracted).toContain('Memory');
130
+ expect(extracted).toContain('Content here');
131
+ expect(extracted).not.toContain('Before');
132
+ expect(extracted).not.toContain('After');
133
+ });
134
+
135
+ it('returns null if no memory section', () => {
136
+ const content = '# Just a normal file\n\nNo memory here';
137
+
138
+ const extracted = extractMemorySection(content);
139
+
140
+ expect(extracted).toBeNull();
141
+ });
142
+
143
+ it('returns null for malformed markers', () => {
144
+ const content = `${MEMORY_SECTION_MARKERS.START}
145
+ No end marker`;
146
+
147
+ const extracted = extractMemorySection(content);
148
+
149
+ expect(extracted).toBeNull();
150
+ });
151
+
152
+ it('handles empty section between markers', () => {
153
+ const content = `${MEMORY_SECTION_MARKERS.START}
154
+ ${MEMORY_SECTION_MARKERS.END}`;
155
+
156
+ const extracted = extractMemorySection(content);
157
+
158
+ expect(extracted).toBe('');
159
+ });
160
+ });
161
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Consensus Engine - Aggregate reviews from multiple models
3
+ */
4
+
5
+ class ConsensusEngine {
6
+ constructor(adapters, config = {}) {
7
+ this.adapters = adapters;
8
+ this.config = {
9
+ consensusType: 'majority',
10
+ requireMinimum: 2,
11
+ budgetAware: true,
12
+ ...config,
13
+ };
14
+ }
15
+
16
+ /**
17
+ * Run review across all adapters
18
+ * @param {string} code - Code to review
19
+ * @param {Object} context - Review context
20
+ * @returns {Promise<Object>} Aggregated review result
21
+ */
22
+ async review(code, context = {}) {
23
+ // Filter adapters by budget if configured
24
+ const availableAdapters = this.config.budgetAware
25
+ ? this.adapters.filter(a => a.canAfford())
26
+ : this.adapters;
27
+
28
+ // Run all reviews in parallel
29
+ const results = await Promise.allSettled(
30
+ availableAdapters.map(async a => {
31
+ const result = await a.review(code, context);
32
+ return result;
33
+ })
34
+ );
35
+
36
+ const reviews = [];
37
+ const warnings = [];
38
+
39
+ results.forEach((result, i) => {
40
+ if (result.status === 'fulfilled') {
41
+ reviews.push(result.value);
42
+ } else {
43
+ warnings.push(`${availableAdapters[i].name} failed: ${result.reason.message}`);
44
+ }
45
+ });
46
+
47
+ // Check minimum requirement
48
+ if (reviews.length < this.config.requireMinimum) {
49
+ throw new Error(`Insufficient reviews: got ${reviews.length}, need ${this.config.requireMinimum}`);
50
+ }
51
+
52
+ // Calculate consensus
53
+ const consensus = ConsensusEngine.calculateConsensus(reviews, this.config.consensusType);
54
+ const costs = ConsensusEngine.summarizeCosts(reviews);
55
+
56
+ return {
57
+ reviews,
58
+ warnings,
59
+ consensus,
60
+ costs,
61
+ consensusType: reviews.length === 1 ? 'single-model' : this.config.consensusType,
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Calculate consensus from reviews
67
+ * @param {Array} reviews - Array of review results
68
+ * @param {string} type - Consensus type ('majority' or 'unanimous')
69
+ * @returns {Object} Consensus result
70
+ */
71
+ static calculateConsensus(reviews, type = 'majority') {
72
+ const issueMap = new Map();
73
+
74
+ // Collect all issues
75
+ for (const review of reviews) {
76
+ for (const issue of (review.issues || [])) {
77
+ const key = issue.id || ConsensusEngine.hashIssue(issue);
78
+ if (!issueMap.has(key)) {
79
+ issueMap.set(key, { ...issue, votes: 0, voters: [] });
80
+ }
81
+ const entry = issueMap.get(key);
82
+ entry.votes++;
83
+ entry.voters.push(review.model);
84
+ }
85
+ }
86
+
87
+ // Filter by consensus type
88
+ const threshold = type === 'unanimous' ? reviews.length : 1;
89
+
90
+ const consensusIssues = Array.from(issueMap.values())
91
+ .filter(issue => issue.votes >= threshold)
92
+ .map(issue => ({
93
+ ...issue,
94
+ confidence: issue.votes / reviews.length,
95
+ }))
96
+ .sort((a, b) => b.confidence - a.confidence);
97
+
98
+ return {
99
+ issues: consensusIssues,
100
+ totalReviews: reviews.length,
101
+ consensusThreshold: threshold,
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Create hash for issue deduplication
107
+ * @param {Object} issue - Issue object
108
+ * @returns {string} Hash
109
+ */
110
+ static hashIssue(issue) {
111
+ return `${issue.line || ''}-${issue.message || ''}-${issue.severity || ''}`.toLowerCase();
112
+ }
113
+
114
+ /**
115
+ * Summarize costs across reviews
116
+ * @param {Array} reviews - Array of review results
117
+ * @returns {Object} Cost summary
118
+ */
119
+ static summarizeCosts(reviews) {
120
+ const byModel = {};
121
+ let total = 0;
122
+
123
+ for (const review of reviews) {
124
+ const cost = review.cost || 0;
125
+ byModel[review.model] = cost;
126
+ total += cost;
127
+ }
128
+
129
+ return { byModel, total };
130
+ }
131
+ }
132
+
133
+ module.exports = {
134
+ ConsensusEngine,
135
+ };
@@ -0,0 +1,152 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { ConsensusEngine } from './consensus-engine.js';
3
+
4
+ // Mock adapters
5
+ const createMockAdapter = (name, issues = []) => ({
6
+ name,
7
+ canAfford: vi.fn(() => true),
8
+ review: vi.fn(() => Promise.resolve({
9
+ issues,
10
+ suggestions: [],
11
+ score: 80,
12
+ model: name,
13
+ tokensUsed: 100,
14
+ cost: 0.01,
15
+ })),
16
+ });
17
+
18
+ describe('ConsensusEngine', () => {
19
+ describe('review', () => {
20
+ it('aggregates reviews from multiple models', async () => {
21
+ const adapters = [
22
+ createMockAdapter('claude'),
23
+ createMockAdapter('openai'),
24
+ createMockAdapter('deepseek'),
25
+ ];
26
+
27
+ const engine = new ConsensusEngine(adapters);
28
+ const result = await engine.review('const x = 1;');
29
+
30
+ expect(result.reviews).toHaveLength(3);
31
+ expect(result.reviews.map(r => r.model)).toEqual(['claude', 'openai', 'deepseek']);
32
+ });
33
+
34
+ it('runs reviews in parallel', async () => {
35
+ const delay = ms => new Promise(r => setTimeout(r, ms));
36
+ const adapters = [
37
+ { name: 'a', canAfford: () => true, review: async () => { await delay(50); return { issues: [], suggestions: [], score: 80, model: 'a', tokensUsed: 0, cost: 0 }; } },
38
+ { name: 'b', canAfford: () => true, review: async () => { await delay(50); return { issues: [], suggestions: [], score: 80, model: 'b', tokensUsed: 0, cost: 0 }; } },
39
+ { name: 'c', canAfford: () => true, review: async () => { await delay(50); return { issues: [], suggestions: [], score: 80, model: 'c', tokensUsed: 0, cost: 0 }; } },
40
+ ];
41
+
42
+ const engine = new ConsensusEngine(adapters);
43
+ const start = Date.now();
44
+ await engine.review('code');
45
+ const elapsed = Date.now() - start;
46
+
47
+ // Should take ~50ms (parallel), not ~150ms (sequential)
48
+ expect(elapsed).toBeLessThan(120);
49
+ });
50
+
51
+ it('handles model failures gracefully', async () => {
52
+ const adapters = [
53
+ createMockAdapter('claude'),
54
+ {
55
+ name: 'failing',
56
+ canAfford: () => true,
57
+ review: () => Promise.reject(new Error('API error')),
58
+ },
59
+ createMockAdapter('deepseek'),
60
+ ];
61
+
62
+ const engine = new ConsensusEngine(adapters);
63
+ const result = await engine.review('code');
64
+
65
+ expect(result.reviews).toHaveLength(2);
66
+ expect(result.warnings).toContain('failing failed: API error');
67
+ });
68
+
69
+ it('skips models over budget', async () => {
70
+ const adapters = [
71
+ createMockAdapter('claude'),
72
+ { ...createMockAdapter('openai'), canAfford: () => false },
73
+ createMockAdapter('deepseek'),
74
+ ];
75
+
76
+ const engine = new ConsensusEngine(adapters, { budgetAware: true });
77
+ const result = await engine.review('code');
78
+
79
+ expect(result.reviews).toHaveLength(2);
80
+ expect(result.reviews.map(r => r.model)).toEqual(['claude', 'deepseek']);
81
+ });
82
+
83
+ it('throws if insufficient reviews', async () => {
84
+ const adapters = [
85
+ { ...createMockAdapter('claude'), review: () => Promise.reject(new Error('fail')) },
86
+ { ...createMockAdapter('openai'), review: () => Promise.reject(new Error('fail')) },
87
+ ];
88
+
89
+ const engine = new ConsensusEngine(adapters, { requireMinimum: 2 });
90
+
91
+ await expect(engine.review('code')).rejects.toThrow('Insufficient reviews');
92
+ });
93
+ });
94
+
95
+ describe('calculateConsensus', () => {
96
+ it('calculates majority consensus', () => {
97
+ const reviews = [
98
+ { model: 'claude', issues: [{ id: 'A', severity: 'high' }] },
99
+ { model: 'openai', issues: [{ id: 'A', severity: 'high' }, { id: 'B', severity: 'low' }] },
100
+ { model: 'deepseek', issues: [{ id: 'A', severity: 'medium' }] },
101
+ ];
102
+
103
+ const consensus = ConsensusEngine.calculateConsensus(reviews, 'majority');
104
+
105
+ // Issue A flagged by all 3 - confidence 1.0
106
+ const issueA = consensus.issues.find(i => i.id === 'A');
107
+ expect(issueA.confidence).toBe(1.0);
108
+
109
+ // Issue B flagged by 1 of 3 - confidence 0.33
110
+ const issueB = consensus.issues.find(i => i.id === 'B');
111
+ expect(issueB.confidence).toBeCloseTo(0.33, 1);
112
+ });
113
+
114
+ it('uses unanimous consensus when configured', () => {
115
+ const reviews = [
116
+ { model: 'claude', issues: [{ id: 'A' }] },
117
+ { model: 'openai', issues: [{ id: 'A' }, { id: 'B' }] },
118
+ { model: 'deepseek', issues: [{ id: 'A' }] },
119
+ ];
120
+
121
+ const consensus = ConsensusEngine.calculateConsensus(reviews, 'unanimous');
122
+
123
+ // Only issue A is unanimous
124
+ expect(consensus.issues).toHaveLength(1);
125
+ expect(consensus.issues[0].id).toBe('A');
126
+ });
127
+
128
+ it('calculates total cost', () => {
129
+ const reviews = [
130
+ { model: 'claude', cost: 0 },
131
+ { model: 'openai', cost: 0.12 },
132
+ { model: 'deepseek', cost: 0.02 },
133
+ ];
134
+
135
+ const result = ConsensusEngine.summarizeCosts(reviews);
136
+
137
+ expect(result.total).toBeCloseTo(0.14, 2);
138
+ expect(result.byModel.openai).toBe(0.12);
139
+ });
140
+ });
141
+
142
+ describe('single model fallback', () => {
143
+ it('returns single-model consensus type', async () => {
144
+ const adapters = [createMockAdapter('claude')];
145
+
146
+ const engine = new ConsensusEngine(adapters, { requireMinimum: 1 });
147
+ const result = await engine.review('code');
148
+
149
+ expect(result.consensusType).toBe('single-model');
150
+ });
151
+ });
152
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Context Builder - Build session context from memory
3
+ */
4
+
5
+ const { loadTeamDecisions, loadTeamGotchas, loadPersonalPreferences, loadRecentSessions } = require('./memory-reader.js');
6
+
7
+ /**
8
+ * Estimate token count from text (rough: ~4 chars per token)
9
+ * @param {string} text - Text to estimate
10
+ * @returns {number} Estimated token count
11
+ */
12
+ function estimateTokens(text) {
13
+ if (!text) return 0;
14
+ return Math.ceil(text.length / 4);
15
+ }
16
+
17
+ /**
18
+ * Build session context from all memory sources
19
+ * @param {string} projectRoot - Project root directory
20
+ * @param {Object} options - Options
21
+ * @param {number} options.maxTokens - Maximum tokens for context
22
+ * @returns {Promise<string>} Formatted context for CLAUDE.md
23
+ */
24
+ async function buildSessionContext(projectRoot, options = {}) {
25
+ const { maxTokens = 2000 } = options;
26
+
27
+ const sections = [];
28
+
29
+ // Load all memory
30
+ const [decisions, gotchas, preferences, sessions] = await Promise.all([
31
+ loadTeamDecisions(projectRoot),
32
+ loadTeamGotchas(projectRoot),
33
+ loadPersonalPreferences(projectRoot),
34
+ loadRecentSessions(projectRoot, 10),
35
+ ]);
36
+
37
+ // Check if any memory exists
38
+ const hasMemory = decisions.length > 0 ||
39
+ gotchas.length > 0 ||
40
+ Object.keys(preferences).length > 0 ||
41
+ sessions.length > 0;
42
+
43
+ if (!hasMemory) {
44
+ return '';
45
+ }
46
+
47
+ // Build preferences section
48
+ if (Object.keys(preferences).length > 0) {
49
+ const prefsLines = ['## Preferences', ''];
50
+ for (const [key, value] of Object.entries(preferences)) {
51
+ if (typeof value === 'object') {
52
+ prefsLines.push(`- **${key}:** ${JSON.stringify(value)}`);
53
+ } else {
54
+ prefsLines.push(`- **${key}:** ${value}`);
55
+ }
56
+ }
57
+ sections.push({ content: prefsLines.join('\n'), priority: 1 });
58
+ }
59
+
60
+ // Build decisions section (most recent first)
61
+ if (decisions.length > 0) {
62
+ const sortedDecisions = [...decisions].reverse();
63
+ const decisionLines = ['## Recent Decisions', ''];
64
+ for (const d of sortedDecisions.slice(0, 5)) {
65
+ decisionLines.push(`- **${d.title}**: ${d.reasoning?.substring(0, 100) || ''}`);
66
+ }
67
+ sections.push({ content: decisionLines.join('\n'), priority: 2 });
68
+ }
69
+
70
+ // Build gotchas section
71
+ if (gotchas.length > 0) {
72
+ const gotchaLines = ['## Gotchas', ''];
73
+ for (const g of gotchas.slice(0, 5)) {
74
+ gotchaLines.push(`- **${g.title}**: ${g.issue?.substring(0, 100) || ''}`);
75
+ }
76
+ sections.push({ content: gotchaLines.join('\n'), priority: 3 });
77
+ }
78
+
79
+ // Build recent activity section
80
+ if (sessions.length > 0) {
81
+ const activityLines = ['## Recent Activity', ''];
82
+ for (const s of sessions.slice(-5)) {
83
+ if (s.content) {
84
+ activityLines.push(`- ${s.type}: ${s.content}`);
85
+ }
86
+ }
87
+ if (activityLines.length > 2) {
88
+ sections.push({ content: activityLines.join('\n'), priority: 4 });
89
+ }
90
+ }
91
+
92
+ // Sort by priority and build final context
93
+ sections.sort((a, b) => a.priority - b.priority);
94
+
95
+ let context = '';
96
+ let currentTokens = 0;
97
+
98
+ for (const section of sections) {
99
+ const sectionTokens = estimateTokens(section.content);
100
+ if (currentTokens + sectionTokens <= maxTokens) {
101
+ context += section.content + '\n\n';
102
+ currentTokens += sectionTokens;
103
+ }
104
+ }
105
+
106
+ return context.trim();
107
+ }
108
+
109
+ module.exports = {
110
+ buildSessionContext,
111
+ estimateTokens,
112
+ };
@@ -0,0 +1,120 @@
1
+ import { describe, it, beforeEach, afterEach, expect } from 'vitest';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import { buildSessionContext, estimateTokens } from './context-builder.js';
6
+ import { initMemoryStructure } from './memory-storage.js';
7
+ import { writeTeamDecision, writeTeamGotcha, writePersonalPreference, appendSessionLog } from './memory-writer.js';
8
+
9
+ describe('context-builder', () => {
10
+ let testDir;
11
+
12
+ beforeEach(async () => {
13
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-context-test-'));
14
+ await initMemoryStructure(testDir);
15
+ });
16
+
17
+ afterEach(() => {
18
+ fs.rmSync(testDir, { recursive: true, force: true });
19
+ });
20
+
21
+ describe('buildSessionContext', () => {
22
+ it('returns empty context when no memory exists', async () => {
23
+ const context = await buildSessionContext(testDir);
24
+ expect(context).toBe('');
25
+ });
26
+
27
+ it('includes personal preferences', async () => {
28
+ await writePersonalPreference(testDir, 'codeStyle', 'functional');
29
+ await writePersonalPreference(testDir, 'exports', 'named');
30
+
31
+ const context = await buildSessionContext(testDir);
32
+
33
+ expect(context).toContain('functional');
34
+ expect(context).toContain('named');
35
+ });
36
+
37
+ it('includes team decisions', async () => {
38
+ await writeTeamDecision(testDir, {
39
+ title: 'Use Postgres',
40
+ reasoning: 'JSONB support',
41
+ });
42
+
43
+ const context = await buildSessionContext(testDir);
44
+
45
+ expect(context).toContain('Postgres');
46
+ });
47
+
48
+ it('includes team gotchas', async () => {
49
+ await writeTeamGotcha(testDir, {
50
+ title: 'Auth Warmup',
51
+ issue: 'Service needs 2 seconds',
52
+ });
53
+
54
+ const context = await buildSessionContext(testDir);
55
+
56
+ expect(context).toContain('Auth Warmup');
57
+ });
58
+
59
+ it('includes recent session activity', async () => {
60
+ await appendSessionLog(testDir, { type: 'decision', content: 'chose REST API' });
61
+
62
+ const context = await buildSessionContext(testDir);
63
+
64
+ expect(context).toContain('REST API');
65
+ });
66
+
67
+ it('limits context to token budget', async () => {
68
+ // Create lots of decisions
69
+ for (let i = 0; i < 50; i++) {
70
+ await writeTeamDecision(testDir, {
71
+ title: `Decision ${i}`,
72
+ reasoning: 'A'.repeat(200), // Long reasoning
73
+ });
74
+ }
75
+
76
+ const context = await buildSessionContext(testDir, { maxTokens: 500 });
77
+ const tokens = estimateTokens(context);
78
+
79
+ expect(tokens).toBeLessThanOrEqual(500);
80
+ });
81
+
82
+ it('formats context as markdown', async () => {
83
+ await writeTeamDecision(testDir, { title: 'Use Postgres', reasoning: 'JSONB' });
84
+ await writePersonalPreference(testDir, 'style', 'functional');
85
+
86
+ const context = await buildSessionContext(testDir);
87
+
88
+ expect(context).toContain('## ');
89
+ expect(context).toContain('- ');
90
+ });
91
+
92
+ it('prioritizes recent items', async () => {
93
+ await writeTeamDecision(testDir, { title: 'Old Decision', reasoning: 'old' });
94
+ // Simulate time passing
95
+ await new Promise(r => setTimeout(r, 10));
96
+ await writeTeamDecision(testDir, { title: 'New Decision', reasoning: 'new' });
97
+
98
+ const context = await buildSessionContext(testDir, { maxTokens: 100 });
99
+
100
+ // New decision should appear if space is limited
101
+ expect(context).toContain('New Decision');
102
+ });
103
+ });
104
+
105
+ describe('estimateTokens', () => {
106
+ it('estimates tokens from text', () => {
107
+ const text = 'Hello world this is a test';
108
+ const tokens = estimateTokens(text);
109
+
110
+ // Rough estimate: ~1 token per 4 chars
111
+ expect(tokens).toBeGreaterThan(0);
112
+ expect(tokens).toBeLessThan(text.length);
113
+ });
114
+
115
+ it('returns 0 for empty text', () => {
116
+ expect(estimateTokens('')).toBe(0);
117
+ expect(estimateTokens(null)).toBe(0);
118
+ });
119
+ });
120
+ });