create-universal-ai-context 2.1.3 → 2.2.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.
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-Tool Sync Manager
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates automatic synchronization between AI tool contexts.
|
|
5
|
+
* Detects changes in one tool's context and propagates to others.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
const { getAdapter, getAllAdapters, getAdapterNames } = require('../adapters');
|
|
12
|
+
const { analyzeProject } = require('../static-analyzer');
|
|
13
|
+
const { generateAll, initialize: initGenerator } = require('../ai-context-generator');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Context file paths for each AI tool
|
|
17
|
+
*/
|
|
18
|
+
const TOOL_CONTEXT_FILES = {
|
|
19
|
+
claude: ['AI_CONTEXT.md', '.claude/'],
|
|
20
|
+
copilot: ['.github/copilot-instructions.md'],
|
|
21
|
+
cline: ['.clinerules'],
|
|
22
|
+
antigravity: ['.agent/']
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Conflict resolution strategies
|
|
27
|
+
*/
|
|
28
|
+
const CONFLICT_STRATEGY = {
|
|
29
|
+
SOURCE_WINS: 'source_wins', // Changed file always wins
|
|
30
|
+
REGENERATE_ALL: 'regenerate_all', // Regenerate all from codebase
|
|
31
|
+
MANUAL: 'manual', // Require manual resolution
|
|
32
|
+
NEWEST: 'newest' // File with newest modification time wins
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Sync state storage path
|
|
37
|
+
*/
|
|
38
|
+
function getSyncStatePath(projectRoot) {
|
|
39
|
+
return path.join(projectRoot, '.ai-context', 'sync-state.json');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Initialize sync state (creates new if doesn't exist, loads existing if present)
|
|
44
|
+
*/
|
|
45
|
+
function initSyncState(projectRoot) {
|
|
46
|
+
const statePath = getSyncStatePath(projectRoot);
|
|
47
|
+
const stateDir = path.dirname(statePath);
|
|
48
|
+
|
|
49
|
+
if (!fs.existsSync(stateDir)) {
|
|
50
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!fs.existsSync(statePath)) {
|
|
54
|
+
const initialState = {
|
|
55
|
+
version: '1.0.0',
|
|
56
|
+
lastSync: null,
|
|
57
|
+
toolHashes: {},
|
|
58
|
+
conflicts: [],
|
|
59
|
+
syncHistory: []
|
|
60
|
+
};
|
|
61
|
+
fs.writeFileSync(statePath, JSON.stringify(initialState, null, 2));
|
|
62
|
+
return initialState;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(fs.readFileSync(statePath, 'utf-8'));
|
|
67
|
+
} catch (error) {
|
|
68
|
+
// File exists but is corrupted, create new state
|
|
69
|
+
const initialState = {
|
|
70
|
+
version: '1.0.0',
|
|
71
|
+
lastSync: null,
|
|
72
|
+
toolHashes: {},
|
|
73
|
+
conflicts: [],
|
|
74
|
+
syncHistory: []
|
|
75
|
+
};
|
|
76
|
+
fs.writeFileSync(statePath, JSON.stringify(initialState, null, 2));
|
|
77
|
+
return initialState;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Load sync state (always loads from disk, doesn't create new)
|
|
83
|
+
*/
|
|
84
|
+
function loadSyncState(projectRoot) {
|
|
85
|
+
const statePath = getSyncStatePath(projectRoot);
|
|
86
|
+
|
|
87
|
+
if (!fs.existsSync(statePath)) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(fs.readFileSync(statePath, 'utf-8'));
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Save sync state
|
|
100
|
+
*/
|
|
101
|
+
function saveSyncState(projectRoot, state) {
|
|
102
|
+
const statePath = getSyncStatePath(projectRoot);
|
|
103
|
+
fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Calculate file hash for change detection
|
|
108
|
+
*/
|
|
109
|
+
function calculateFileHash(filePath) {
|
|
110
|
+
if (!fs.existsSync(filePath)) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
115
|
+
return crypto.createHash('sha256').update(content).digest('hex');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get all context files for a tool
|
|
120
|
+
*/
|
|
121
|
+
function getToolContextFiles(toolName, projectRoot) {
|
|
122
|
+
const files = TOOL_CONTEXT_FILES[toolName] || [];
|
|
123
|
+
const results = [];
|
|
124
|
+
|
|
125
|
+
for (const file of files) {
|
|
126
|
+
const fullPath = path.join(projectRoot, file);
|
|
127
|
+
|
|
128
|
+
if (file.endsWith('/')) {
|
|
129
|
+
// Directory - calculate combined hash of all files
|
|
130
|
+
if (fs.existsSync(fullPath)) {
|
|
131
|
+
const dirHash = hashDirectory(fullPath);
|
|
132
|
+
results.push({ path: file, hash: dirHash, isDirectory: true });
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
// Single file
|
|
136
|
+
if (fs.existsSync(fullPath)) {
|
|
137
|
+
results.push({ path: file, hash: calculateFileHash(fullPath), isDirectory: false });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return results;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Calculate hash for a directory
|
|
147
|
+
*/
|
|
148
|
+
function hashDirectory(dirPath) {
|
|
149
|
+
const hash = crypto.createHash('sha256');
|
|
150
|
+
const files = getAllFiles(dirPath);
|
|
151
|
+
|
|
152
|
+
for (const file of files.sort()) {
|
|
153
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
154
|
+
hash.update(content);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return hash.digest('hex');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Get all files in directory recursively
|
|
162
|
+
*/
|
|
163
|
+
function getAllFiles(dirPath) {
|
|
164
|
+
const files = [];
|
|
165
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
166
|
+
|
|
167
|
+
for (const entry of entries) {
|
|
168
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
169
|
+
if (entry.isDirectory()) {
|
|
170
|
+
files.push(...getAllFiles(fullPath));
|
|
171
|
+
} else {
|
|
172
|
+
files.push(fullPath);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return files;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Detect which tool's context has changed
|
|
181
|
+
*/
|
|
182
|
+
function detectChangedTool(projectRoot, state) {
|
|
183
|
+
const currentHashes = {};
|
|
184
|
+
const changedTools = [];
|
|
185
|
+
|
|
186
|
+
for (const toolName of getAdapterNames()) {
|
|
187
|
+
const files = getToolContextFiles(toolName, projectRoot);
|
|
188
|
+
const toolHash = files.map(f => f.hash).filter(Boolean).join('|');
|
|
189
|
+
|
|
190
|
+
currentHashes[toolName] = toolHash;
|
|
191
|
+
|
|
192
|
+
// Use hasOwnProperty check to handle empty string case
|
|
193
|
+
const hasStoredHash = Object.prototype.hasOwnProperty.call(state.toolHashes, toolName);
|
|
194
|
+
const storedHash = state.toolHashes[toolName];
|
|
195
|
+
|
|
196
|
+
if (hasStoredHash && storedHash !== toolHash) {
|
|
197
|
+
changedTools.push({
|
|
198
|
+
tool: toolName,
|
|
199
|
+
previousHash: storedHash,
|
|
200
|
+
currentHash: toolHash
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { changedTools, currentHashes };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Propagate context change from source tool to all other tools
|
|
210
|
+
*/
|
|
211
|
+
async function propagateContextChange(sourceTool, projectRoot, config, strategy = CONFLICT_STRATEGY.SOURCE_WINS) {
|
|
212
|
+
const results = {
|
|
213
|
+
sourceTool,
|
|
214
|
+
strategy,
|
|
215
|
+
timestamp: new Date().toISOString(),
|
|
216
|
+
propagated: [],
|
|
217
|
+
skipped: [],
|
|
218
|
+
errors: []
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// 1. Re-analyze codebase to get fresh analysis
|
|
222
|
+
let analysis;
|
|
223
|
+
try {
|
|
224
|
+
analysis = await analyzeProject(projectRoot, config);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
results.errors.push({
|
|
227
|
+
message: `Failed to analyze project: ${error.message}`
|
|
228
|
+
});
|
|
229
|
+
return results;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 2. Get all adapters except source
|
|
233
|
+
const allAdapters = getAllAdapters();
|
|
234
|
+
const targetAdapters = allAdapters.filter(a => a.name !== sourceTool);
|
|
235
|
+
|
|
236
|
+
// 3. Generate contexts for all target tools
|
|
237
|
+
initGenerator();
|
|
238
|
+
|
|
239
|
+
for (const adapter of targetAdapters) {
|
|
240
|
+
try {
|
|
241
|
+
const result = await adapter.generate(analysis, config, projectRoot);
|
|
242
|
+
|
|
243
|
+
if (result.success) {
|
|
244
|
+
results.propagated.push({
|
|
245
|
+
tool: adapter.name,
|
|
246
|
+
displayName: adapter.displayName,
|
|
247
|
+
files: result.files
|
|
248
|
+
});
|
|
249
|
+
} else {
|
|
250
|
+
results.errors.push({
|
|
251
|
+
tool: adapter.name,
|
|
252
|
+
errors: result.errors
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
} catch (error) {
|
|
256
|
+
results.errors.push({
|
|
257
|
+
tool: adapter.name,
|
|
258
|
+
message: error.message
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 4. Update sync state
|
|
264
|
+
const state = initSyncState(projectRoot);
|
|
265
|
+
const { currentHashes } = detectChangedTool(projectRoot, state);
|
|
266
|
+
state.toolHashes = currentHashes;
|
|
267
|
+
state.lastSync = new Date().toISOString();
|
|
268
|
+
state.syncHistory.push({
|
|
269
|
+
timestamp: new Date().toISOString(),
|
|
270
|
+
sourceTool,
|
|
271
|
+
strategy,
|
|
272
|
+
propagatedCount: results.propagated.length,
|
|
273
|
+
errorCount: results.errors.length
|
|
274
|
+
});
|
|
275
|
+
saveSyncState(projectRoot, state);
|
|
276
|
+
|
|
277
|
+
return results;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Check if contexts are out of sync
|
|
282
|
+
*/
|
|
283
|
+
function checkSyncStatus(projectRoot) {
|
|
284
|
+
const state = loadSyncState(projectRoot) || initSyncState(projectRoot);
|
|
285
|
+
const status = {
|
|
286
|
+
inSync: true,
|
|
287
|
+
tools: {},
|
|
288
|
+
lastSync: state.lastSync,
|
|
289
|
+
conflicts: []
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const { changedTools, currentHashes } = detectChangedTool(projectRoot, state);
|
|
293
|
+
|
|
294
|
+
for (const toolName of getAdapterNames()) {
|
|
295
|
+
const files = getToolContextFiles(toolName, projectRoot);
|
|
296
|
+
const exists = files.length > 0;
|
|
297
|
+
const hasChanges = changedTools.some(c => c.tool === toolName);
|
|
298
|
+
|
|
299
|
+
status.tools[toolName] = {
|
|
300
|
+
exists,
|
|
301
|
+
hasChanges,
|
|
302
|
+
hash: currentHashes[toolName],
|
|
303
|
+
previousHash: state.toolHashes[toolName]
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
if (hasChanges && !state.lastSync) {
|
|
307
|
+
status.inSync = false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (changedTools.length > 0 && state.lastSync) {
|
|
312
|
+
status.inSync = false;
|
|
313
|
+
status.changedTools = changedTools;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return status;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Sync all tools from codebase (fresh regeneration)
|
|
321
|
+
*/
|
|
322
|
+
async function syncAllFromCodebase(projectRoot, config) {
|
|
323
|
+
const results = {
|
|
324
|
+
timestamp: new Date().toISOString(),
|
|
325
|
+
tools: [],
|
|
326
|
+
errors: []
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
// Analyze project
|
|
331
|
+
const analysis = await analyzeProject(projectRoot, config);
|
|
332
|
+
|
|
333
|
+
// Generate for all tools
|
|
334
|
+
const generateResults = await generateAll(analysis, config, projectRoot, {
|
|
335
|
+
aiTools: getAdapterNames()
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
results.tools = generateResults.generated.map(g => ({
|
|
339
|
+
tool: g.adapter,
|
|
340
|
+
displayName: g.displayName,
|
|
341
|
+
fileCount: g.files.length
|
|
342
|
+
}));
|
|
343
|
+
|
|
344
|
+
results.errors = generateResults.errors;
|
|
345
|
+
|
|
346
|
+
// Update sync state
|
|
347
|
+
const state = initSyncState(projectRoot);
|
|
348
|
+
const { currentHashes } = detectChangedTool(projectRoot, state);
|
|
349
|
+
state.toolHashes = currentHashes;
|
|
350
|
+
state.lastSync = new Date().toISOString();
|
|
351
|
+
state.syncHistory.push({
|
|
352
|
+
timestamp: new Date().toISOString(),
|
|
353
|
+
source: 'codebase',
|
|
354
|
+
strategy: 'regenerate_all',
|
|
355
|
+
propagatedCount: results.tools.length,
|
|
356
|
+
errorCount: results.errors.length
|
|
357
|
+
});
|
|
358
|
+
saveSyncState(projectRoot, state);
|
|
359
|
+
|
|
360
|
+
} catch (error) {
|
|
361
|
+
results.errors.push({
|
|
362
|
+
message: `Sync failed: ${error.message}`,
|
|
363
|
+
stack: error.stack
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return results;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Resolve conflict between tools
|
|
372
|
+
*/
|
|
373
|
+
async function resolveConflict(projectRoot, config, strategy, preferredTool = null) {
|
|
374
|
+
const status = checkSyncStatus(projectRoot);
|
|
375
|
+
|
|
376
|
+
if (status.inSync) {
|
|
377
|
+
return {
|
|
378
|
+
resolved: true,
|
|
379
|
+
message: 'No conflicts to resolve'
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
switch (strategy) {
|
|
384
|
+
case CONFLICT_STRATEGY.SOURCE_WINS:
|
|
385
|
+
if (!preferredTool) {
|
|
386
|
+
return {
|
|
387
|
+
resolved: false,
|
|
388
|
+
message: 'Source strategy requires a preferred tool'
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
return await propagateContextChange(preferredTool, projectRoot, config, strategy);
|
|
392
|
+
|
|
393
|
+
case CONFLICT_STRATEGY.REGENERATE_ALL:
|
|
394
|
+
return await syncAllFromCodebase(projectRoot, config);
|
|
395
|
+
|
|
396
|
+
case CONFLICT_STRATEGY.NEWEST:
|
|
397
|
+
// Find tool with most recent change
|
|
398
|
+
const newestTool = findNewestTool(projectRoot, status);
|
|
399
|
+
return await propagateContextChange(newestTool, projectRoot, config, strategy);
|
|
400
|
+
|
|
401
|
+
case CONFLICT_STRATEGY.MANUAL:
|
|
402
|
+
return {
|
|
403
|
+
resolved: false,
|
|
404
|
+
message: 'Manual resolution required',
|
|
405
|
+
status
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
default:
|
|
409
|
+
return {
|
|
410
|
+
resolved: false,
|
|
411
|
+
message: `Unknown strategy: ${strategy}`
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Find tool with most recently modified context
|
|
418
|
+
*/
|
|
419
|
+
function findNewestTool(projectRoot, status) {
|
|
420
|
+
let newestTool = null;
|
|
421
|
+
let newestTime = 0;
|
|
422
|
+
|
|
423
|
+
for (const [toolName, toolStatus] of Object.entries(status.tools)) {
|
|
424
|
+
const files = getToolContextFiles(toolName, projectRoot);
|
|
425
|
+
|
|
426
|
+
for (const file of files) {
|
|
427
|
+
const fullPath = path.join(projectRoot, file.path);
|
|
428
|
+
|
|
429
|
+
if (file.isDirectory) {
|
|
430
|
+
continue; // Skip directories for mtime check
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const stats = fs.statSync(fullPath);
|
|
434
|
+
if (stats.mtimeMs > newestTime) {
|
|
435
|
+
newestTime = stats.mtimeMs;
|
|
436
|
+
newestTool = toolName;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return newestTool;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Get sync history
|
|
446
|
+
*/
|
|
447
|
+
function getSyncHistory(projectRoot, limit = 10) {
|
|
448
|
+
const state = initSyncState(projectRoot);
|
|
449
|
+
return state.syncHistory.slice(-limit);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Format sync status for display
|
|
454
|
+
*/
|
|
455
|
+
function formatSyncStatus(status) {
|
|
456
|
+
const lines = [];
|
|
457
|
+
|
|
458
|
+
lines.push('');
|
|
459
|
+
lines.push('Cross-Tool Sync Status');
|
|
460
|
+
lines.push('='.repeat(50));
|
|
461
|
+
lines.push('');
|
|
462
|
+
|
|
463
|
+
const statusEmoji = status.inSync ? '✓' : '⚠';
|
|
464
|
+
lines.push(`Overall: ${status.inSync ? 'In Sync' : 'Out of Sync'} ${statusEmoji}`);
|
|
465
|
+
lines.push('');
|
|
466
|
+
|
|
467
|
+
if (status.lastSync) {
|
|
468
|
+
lines.push(`Last Sync: ${new Date(status.lastSync).toLocaleString()}`);
|
|
469
|
+
lines.push('');
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
lines.push('Tools:');
|
|
473
|
+
for (const [toolName, toolStatus] of Object.entries(status.tools)) {
|
|
474
|
+
const existsEmoji = toolStatus.exists ? '✓' : '✗';
|
|
475
|
+
const changesEmoji = toolStatus.hasChanges ? '⚠' : '';
|
|
476
|
+
lines.push(` ${toolName}: ${existsEmoji} ${changesEmoji}`);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (status.changedTools && status.changedTools.length > 0) {
|
|
480
|
+
lines.push('');
|
|
481
|
+
lines.push('Changed Tools:');
|
|
482
|
+
for (const change of status.changedTools) {
|
|
483
|
+
lines.push(` - ${change.tool}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
lines.push('');
|
|
488
|
+
|
|
489
|
+
return lines.join('\n');
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
module.exports = {
|
|
493
|
+
// Core functions
|
|
494
|
+
detectChangedTool,
|
|
495
|
+
propagateContextChange,
|
|
496
|
+
checkSyncStatus,
|
|
497
|
+
syncAllFromCodebase,
|
|
498
|
+
resolveConflict,
|
|
499
|
+
getSyncHistory,
|
|
500
|
+
|
|
501
|
+
// Utilities
|
|
502
|
+
initSyncState,
|
|
503
|
+
loadSyncState,
|
|
504
|
+
saveSyncState,
|
|
505
|
+
calculateFileHash,
|
|
506
|
+
getToolContextFiles,
|
|
507
|
+
formatSyncStatus,
|
|
508
|
+
|
|
509
|
+
// Constants
|
|
510
|
+
CONFLICT_STRATEGY,
|
|
511
|
+
TOOL_CONTEXT_FILES
|
|
512
|
+
};
|