clearctx 3.0.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/CHANGELOG.md +71 -0
- package/LICENSE +21 -0
- package/README.md +1006 -0
- package/STRATEGY.md +485 -0
- package/bin/cli.js +1756 -0
- package/bin/continuity-hook.js +118 -0
- package/bin/mcp.js +27 -0
- package/bin/setup.js +929 -0
- package/package.json +56 -0
- package/src/artifact-store.js +710 -0
- package/src/atomic-io.js +99 -0
- package/src/briefing-generator.js +451 -0
- package/src/continuity-hooks.js +253 -0
- package/src/contract-store.js +525 -0
- package/src/decision-journal.js +229 -0
- package/src/delegate.js +348 -0
- package/src/dependency-resolver.js +453 -0
- package/src/diff-engine.js +473 -0
- package/src/file-lock.js +161 -0
- package/src/index.js +61 -0
- package/src/lineage-graph.js +402 -0
- package/src/manager.js +510 -0
- package/src/mcp-server.js +3501 -0
- package/src/pattern-registry.js +221 -0
- package/src/pipeline-engine.js +618 -0
- package/src/prompts.js +1217 -0
- package/src/safety-net.js +170 -0
- package/src/session-snapshot.js +508 -0
- package/src/snapshot-engine.js +490 -0
- package/src/stale-detector.js +169 -0
- package/src/store.js +131 -0
- package/src/stream-session.js +463 -0
- package/src/team-hub.js +615 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot & Replay Engine - Layer 3 Time-Travel System
|
|
3
|
+
*
|
|
4
|
+
* This module provides the ability to capture, rollback, and replay the entire
|
|
5
|
+
* coordination state of a team. Think of it as "git for your multi-session workflow."
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Capture complete snapshots of contracts, artifacts, and pipelines
|
|
9
|
+
* - Rollback to any previous snapshot
|
|
10
|
+
* - Replay with modifications (override inputs to test different scenarios)
|
|
11
|
+
* - Preserve artifact versions on disk (immutability)
|
|
12
|
+
* - Atomic operations with proper locking
|
|
13
|
+
*
|
|
14
|
+
* Use cases:
|
|
15
|
+
* - Before risky operations: "Save a snapshot, then try the change"
|
|
16
|
+
* - After major milestones: "Schema design complete, snapshot it"
|
|
17
|
+
* - Debugging: "Rollback to before the bug, replay with different inputs"
|
|
18
|
+
* - Testing: "Replay from checkpoint with modified parameters"
|
|
19
|
+
*
|
|
20
|
+
* @class SnapshotEngine
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
const os = require('os');
|
|
26
|
+
|
|
27
|
+
// Import Layer 1 utilities for atomic operations and locking
|
|
28
|
+
const { atomicWriteJson, readJsonSafe } = require('./atomic-io');
|
|
29
|
+
const { acquireLock, releaseLock } = require('./file-lock');
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* SnapshotEngine class - manages snapshots and replay of team state
|
|
33
|
+
*/
|
|
34
|
+
class SnapshotEngine {
|
|
35
|
+
/**
|
|
36
|
+
* Creates a new SnapshotEngine instance
|
|
37
|
+
*
|
|
38
|
+
* @param {string} [teamName='default'] - Name of the team
|
|
39
|
+
*/
|
|
40
|
+
constructor(teamName = 'default') {
|
|
41
|
+
// Store the team name
|
|
42
|
+
this.teamName = teamName;
|
|
43
|
+
|
|
44
|
+
// Set up the base directory: ~/.clearctx
|
|
45
|
+
const baseDir = path.join(os.homedir(), '.clearctx');
|
|
46
|
+
const teamDir = path.join(baseDir, 'team', teamName);
|
|
47
|
+
|
|
48
|
+
// Define paths for snapshots directory
|
|
49
|
+
this.snapshotsDir = path.join(teamDir, 'snapshots');
|
|
50
|
+
|
|
51
|
+
// Define paths to the index files we need to snapshot
|
|
52
|
+
this.contractsIndexPath = path.join(teamDir, 'contracts', 'index.json');
|
|
53
|
+
this.artifactsIndexPath = path.join(teamDir, 'artifacts', 'index.json');
|
|
54
|
+
this.pipelinesIndexPath = path.join(teamDir, 'pipelines', 'index.json');
|
|
55
|
+
|
|
56
|
+
// Define path to artifacts data directory (for version manifest)
|
|
57
|
+
this.artifactsDataDir = path.join(teamDir, 'artifacts', 'data');
|
|
58
|
+
|
|
59
|
+
// Define locks directory
|
|
60
|
+
this.locksDir = path.join(teamDir, 'locks');
|
|
61
|
+
|
|
62
|
+
// Create the snapshots directory if it doesn't exist
|
|
63
|
+
if (!fs.existsSync(this.snapshotsDir)) {
|
|
64
|
+
fs.mkdirSync(this.snapshotsDir, { recursive: true });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Create a snapshot of the current team state
|
|
70
|
+
*
|
|
71
|
+
* This method:
|
|
72
|
+
* 1. Locks all three index files (contracts, artifacts, pipelines)
|
|
73
|
+
* 2. Reads their current state
|
|
74
|
+
* 3. Scans artifact version files to build a version manifest
|
|
75
|
+
* 4. Creates a snapshot file with deep copies of all state
|
|
76
|
+
* 5. Releases all locks
|
|
77
|
+
*
|
|
78
|
+
* @param {string} snapshotId - Unique identifier for this snapshot
|
|
79
|
+
* @param {Object} [options={}] - Snapshot options
|
|
80
|
+
* @param {string} [options.label=''] - Short label for this snapshot
|
|
81
|
+
* @param {string} [options.description=''] - Longer description
|
|
82
|
+
* @returns {Object} Snapshot summary with id, label, timestamp, counts
|
|
83
|
+
*/
|
|
84
|
+
createSnapshot(snapshotId, { label = '', description = '' } = {}) {
|
|
85
|
+
// Step 1: Acquire all three locks in a consistent order to prevent deadlocks
|
|
86
|
+
// Always lock in the same order: contracts → artifacts → pipelines
|
|
87
|
+
acquireLock(this.locksDir, 'contracts-index');
|
|
88
|
+
acquireLock(this.locksDir, 'artifacts-index');
|
|
89
|
+
acquireLock(this.locksDir, 'pipelines-index');
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
// Step 2: Read all three index files
|
|
93
|
+
// Use readJsonSafe to get empty object if file doesn't exist
|
|
94
|
+
const contractsIndex = readJsonSafe(this.contractsIndexPath, {});
|
|
95
|
+
const artifactsIndex = readJsonSafe(this.artifactsIndexPath, {});
|
|
96
|
+
const pipelinesIndex = readJsonSafe(this.pipelinesIndexPath, {});
|
|
97
|
+
|
|
98
|
+
// Step 3: Build artifact version manifest
|
|
99
|
+
// This tells us which version files existed at snapshot time
|
|
100
|
+
const artifactVersionManifest = this._scanVersionManifest();
|
|
101
|
+
|
|
102
|
+
// Step 4: Create the snapshot object
|
|
103
|
+
const snapshot = {
|
|
104
|
+
snapshotId,
|
|
105
|
+
label,
|
|
106
|
+
description,
|
|
107
|
+
createdAt: new Date().toISOString(),
|
|
108
|
+
state: {
|
|
109
|
+
// Deep copy all indexes to prevent mutation
|
|
110
|
+
contracts: this._deepCopy(contractsIndex),
|
|
111
|
+
artifacts: this._deepCopy(artifactsIndex),
|
|
112
|
+
pipelines: this._deepCopy(pipelinesIndex),
|
|
113
|
+
// Include the version manifest so we know what existed
|
|
114
|
+
artifactVersionManifest
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Step 5: Write snapshot to file using atomic write
|
|
119
|
+
const snapshotPath = path.join(this.snapshotsDir, `snap_${snapshotId}.json`);
|
|
120
|
+
atomicWriteJson(snapshotPath, snapshot);
|
|
121
|
+
|
|
122
|
+
// Step 6: Release all locks in reverse order
|
|
123
|
+
releaseLock(this.locksDir, 'pipelines-index');
|
|
124
|
+
releaseLock(this.locksDir, 'artifacts-index');
|
|
125
|
+
releaseLock(this.locksDir, 'contracts-index');
|
|
126
|
+
|
|
127
|
+
// Step 7: Return summary information
|
|
128
|
+
return {
|
|
129
|
+
snapshotId,
|
|
130
|
+
label,
|
|
131
|
+
createdAt: snapshot.createdAt,
|
|
132
|
+
contractCount: Object.keys(contractsIndex).length,
|
|
133
|
+
artifactCount: Object.keys(artifactsIndex).length,
|
|
134
|
+
pipelineCount: Object.keys(pipelinesIndex).length
|
|
135
|
+
};
|
|
136
|
+
} catch (err) {
|
|
137
|
+
// Always release locks on error (reverse order)
|
|
138
|
+
releaseLock(this.locksDir, 'pipelines-index');
|
|
139
|
+
releaseLock(this.locksDir, 'artifacts-index');
|
|
140
|
+
releaseLock(this.locksDir, 'contracts-index');
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* List all snapshots with summary information
|
|
147
|
+
*
|
|
148
|
+
* @returns {Array} Array of snapshot summaries, sorted newest first
|
|
149
|
+
*/
|
|
150
|
+
listSnapshots() {
|
|
151
|
+
// Read all files in the snapshots directory
|
|
152
|
+
if (!fs.existsSync(this.snapshotsDir)) {
|
|
153
|
+
return []; // No snapshots exist yet
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const files = fs.readdirSync(this.snapshotsDir);
|
|
157
|
+
|
|
158
|
+
// Filter for snapshot files (snap_*.json)
|
|
159
|
+
const snapshotFiles = files.filter(f => f.startsWith('snap_') && f.endsWith('.json'));
|
|
160
|
+
|
|
161
|
+
// Read each snapshot and extract summary information
|
|
162
|
+
const summaries = [];
|
|
163
|
+
for (const file of snapshotFiles) {
|
|
164
|
+
const filePath = path.join(this.snapshotsDir, file);
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const snapshot = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
168
|
+
|
|
169
|
+
// Create a summary object
|
|
170
|
+
summaries.push({
|
|
171
|
+
snapshotId: snapshot.snapshotId,
|
|
172
|
+
label: snapshot.label,
|
|
173
|
+
description: snapshot.description,
|
|
174
|
+
createdAt: snapshot.createdAt,
|
|
175
|
+
contractCount: Object.keys(snapshot.state.contracts || {}).length,
|
|
176
|
+
artifactCount: Object.keys(snapshot.state.artifacts || {}).length,
|
|
177
|
+
pipelineCount: Object.keys(snapshot.state.pipelines || {}).length
|
|
178
|
+
});
|
|
179
|
+
} catch (err) {
|
|
180
|
+
// Skip corrupted snapshot files
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Sort by createdAt descending (newest first)
|
|
186
|
+
summaries.sort((a, b) => {
|
|
187
|
+
const dateA = new Date(a.createdAt);
|
|
188
|
+
const dateB = new Date(b.createdAt);
|
|
189
|
+
return dateB - dateA; // Newest first
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return summaries;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Get the full snapshot data by ID
|
|
197
|
+
*
|
|
198
|
+
* @param {string} snapshotId - The snapshot to retrieve
|
|
199
|
+
* @returns {Object|null} The full snapshot object, or null if not found
|
|
200
|
+
*/
|
|
201
|
+
getSnapshot(snapshotId) {
|
|
202
|
+
const snapshotPath = path.join(this.snapshotsDir, `snap_${snapshotId}.json`);
|
|
203
|
+
|
|
204
|
+
// Check if the snapshot exists
|
|
205
|
+
if (!fs.existsSync(snapshotPath)) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8'));
|
|
211
|
+
return snapshot;
|
|
212
|
+
} catch (err) {
|
|
213
|
+
// File is corrupted or invalid
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Rollback to a previous snapshot
|
|
220
|
+
*
|
|
221
|
+
* This method:
|
|
222
|
+
* 1. Reads the snapshot file
|
|
223
|
+
* 2. Locks all three index files
|
|
224
|
+
* 3. Overwrites contracts and pipelines indexes completely
|
|
225
|
+
* 4. For artifacts: either marks newer ones as rolled back, or overwrites completely
|
|
226
|
+
* 5. Releases all locks
|
|
227
|
+
*
|
|
228
|
+
* Important: This DOES NOT delete artifact version files on disk (they're immutable)
|
|
229
|
+
*
|
|
230
|
+
* @param {string} snapshotId - The snapshot to rollback to
|
|
231
|
+
* @param {Object} [options={}] - Rollback options
|
|
232
|
+
* @param {boolean} [options.preserveArtifacts=true] - Keep newer artifacts but mark them
|
|
233
|
+
* @returns {Object} Summary of rollback operation
|
|
234
|
+
* @throws {Error} If snapshot not found
|
|
235
|
+
*/
|
|
236
|
+
rollback(snapshotId, { preserveArtifacts = true } = {}) {
|
|
237
|
+
// Step 1: Read the snapshot
|
|
238
|
+
const snapshot = this.getSnapshot(snapshotId);
|
|
239
|
+
|
|
240
|
+
if (!snapshot) {
|
|
241
|
+
throw new Error(`Snapshot "${snapshotId}" not found`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Step 2: Acquire all three locks (same order as createSnapshot)
|
|
245
|
+
acquireLock(this.locksDir, 'contracts-index');
|
|
246
|
+
acquireLock(this.locksDir, 'artifacts-index');
|
|
247
|
+
acquireLock(this.locksDir, 'pipelines-index');
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
// Step 3: Restore CONTRACTS - complete overwrite
|
|
251
|
+
// This resets all contract statuses to what they were at snapshot time
|
|
252
|
+
atomicWriteJson(this.contractsIndexPath, snapshot.state.contracts);
|
|
253
|
+
const contractsRestored = Object.keys(snapshot.state.contracts).length;
|
|
254
|
+
|
|
255
|
+
// Step 4: Restore PIPELINES - complete overwrite
|
|
256
|
+
atomicWriteJson(this.pipelinesIndexPath, snapshot.state.pipelines);
|
|
257
|
+
|
|
258
|
+
// Step 5: Restore ARTIFACTS
|
|
259
|
+
let artifactsMarked = 0;
|
|
260
|
+
|
|
261
|
+
if (preserveArtifacts) {
|
|
262
|
+
// Read current artifacts index
|
|
263
|
+
const currentArtifacts = readJsonSafe(this.artifactsIndexPath, {});
|
|
264
|
+
const snapshotArtifacts = snapshot.state.artifacts;
|
|
265
|
+
|
|
266
|
+
// For each artifact in the current index
|
|
267
|
+
for (const [artifactId, currentEntry] of Object.entries(currentArtifacts)) {
|
|
268
|
+
const snapshotEntry = snapshotArtifacts[artifactId];
|
|
269
|
+
|
|
270
|
+
// Check if this artifact didn't exist in the snapshot
|
|
271
|
+
// OR if it has a higher version number now
|
|
272
|
+
if (!snapshotEntry || currentEntry.latestVersion > snapshotEntry.latestVersion) {
|
|
273
|
+
// Mark it as rolled back (but keep it in the index)
|
|
274
|
+
currentEntry.rolledBack = true;
|
|
275
|
+
artifactsMarked++;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Merge: start with snapshot artifacts, then overlay current marked ones
|
|
280
|
+
const mergedArtifacts = { ...snapshotArtifacts };
|
|
281
|
+
for (const [artifactId, entry] of Object.entries(currentArtifacts)) {
|
|
282
|
+
if (entry.rolledBack) {
|
|
283
|
+
mergedArtifacts[artifactId] = entry;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Write the merged index
|
|
288
|
+
atomicWriteJson(this.artifactsIndexPath, mergedArtifacts);
|
|
289
|
+
} else {
|
|
290
|
+
// Complete overwrite - don't preserve newer artifacts
|
|
291
|
+
atomicWriteJson(this.artifactsIndexPath, snapshot.state.artifacts);
|
|
292
|
+
artifactsMarked = 0;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Step 6: Release all locks in reverse order
|
|
296
|
+
releaseLock(this.locksDir, 'pipelines-index');
|
|
297
|
+
releaseLock(this.locksDir, 'artifacts-index');
|
|
298
|
+
releaseLock(this.locksDir, 'contracts-index');
|
|
299
|
+
|
|
300
|
+
// Step 7: Return summary
|
|
301
|
+
return {
|
|
302
|
+
rolledBackTo: snapshotId,
|
|
303
|
+
contractsRestored,
|
|
304
|
+
artifactsMarked,
|
|
305
|
+
message: preserveArtifacts
|
|
306
|
+
? `Rolled back to ${snapshotId}. Newer artifacts marked as rolledBack.`
|
|
307
|
+
: `Rolled back to ${snapshotId}. Artifacts overwritten.`
|
|
308
|
+
};
|
|
309
|
+
} catch (err) {
|
|
310
|
+
// Always release locks on error (reverse order)
|
|
311
|
+
releaseLock(this.locksDir, 'pipelines-index');
|
|
312
|
+
releaseLock(this.locksDir, 'artifacts-index');
|
|
313
|
+
releaseLock(this.locksDir, 'contracts-index');
|
|
314
|
+
throw err;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Replay from a snapshot with optional input overrides
|
|
320
|
+
*
|
|
321
|
+
* This method:
|
|
322
|
+
* 1. Calls rollback() to restore the snapshot state
|
|
323
|
+
* 2. Applies overrides to contract inputs (for testing different scenarios)
|
|
324
|
+
* 3. Returns a message telling the caller to run the dependency resolver
|
|
325
|
+
*
|
|
326
|
+
* Note: This does NOT directly call the dependency resolver (to avoid circular imports)
|
|
327
|
+
* The MCP server handler should call resolver.resolve() after replay returns.
|
|
328
|
+
*
|
|
329
|
+
* @param {string} snapshotId - The snapshot to replay from
|
|
330
|
+
* @param {Object} [options={}] - Replay options
|
|
331
|
+
* @param {Object} [options.overrides={}] - Contract ID → override data map
|
|
332
|
+
* @returns {Object} Summary of replay operation
|
|
333
|
+
* @throws {Error} If snapshot not found
|
|
334
|
+
*
|
|
335
|
+
* @example
|
|
336
|
+
* // Replay with modified context for a specific contract
|
|
337
|
+
* replay('snap_001', {
|
|
338
|
+
* overrides: {
|
|
339
|
+
* 'schema-design': {
|
|
340
|
+
* inputs: {
|
|
341
|
+
* context: 'Use PostgreSQL instead of MongoDB',
|
|
342
|
+
* parameters: { database: 'postgres' }
|
|
343
|
+
* }
|
|
344
|
+
* }
|
|
345
|
+
* }
|
|
346
|
+
* });
|
|
347
|
+
*/
|
|
348
|
+
replay(snapshotId, { overrides = {} } = {}) {
|
|
349
|
+
// Step 1: Rollback to the snapshot first
|
|
350
|
+
const rollbackResult = this.rollback(snapshotId);
|
|
351
|
+
|
|
352
|
+
// Step 2: Apply overrides to contract inputs
|
|
353
|
+
if (Object.keys(overrides).length > 0) {
|
|
354
|
+
// Acquire lock on contracts index
|
|
355
|
+
acquireLock(this.locksDir, 'contracts-index');
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
// Read the restored contracts index
|
|
359
|
+
const contractsIndex = readJsonSafe(this.contractsIndexPath, {});
|
|
360
|
+
|
|
361
|
+
// Apply overrides for each specified contract
|
|
362
|
+
for (const [contractId, override] of Object.entries(overrides)) {
|
|
363
|
+
const contract = contractsIndex[contractId];
|
|
364
|
+
|
|
365
|
+
if (!contract) {
|
|
366
|
+
// Contract doesn't exist - skip this override
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Merge override inputs into the contract
|
|
371
|
+
if (override.inputs) {
|
|
372
|
+
// If override has context, replace it completely
|
|
373
|
+
if (override.inputs.context !== undefined) {
|
|
374
|
+
contract.inputs.context = override.inputs.context;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// If override has artifacts, replace the array
|
|
378
|
+
if (override.inputs.artifacts !== undefined) {
|
|
379
|
+
contract.inputs.artifacts = override.inputs.artifacts;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// If override has parameters, deep merge them
|
|
383
|
+
if (override.inputs.parameters !== undefined) {
|
|
384
|
+
contract.inputs.parameters = {
|
|
385
|
+
...contract.inputs.parameters,
|
|
386
|
+
...override.inputs.parameters
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Update the contract's timestamp
|
|
392
|
+
contract.updatedAt = new Date().toISOString();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Save the updated contracts index
|
|
396
|
+
atomicWriteJson(this.contractsIndexPath, contractsIndex);
|
|
397
|
+
|
|
398
|
+
// Release lock
|
|
399
|
+
releaseLock(this.locksDir, 'contracts-index');
|
|
400
|
+
} catch (err) {
|
|
401
|
+
// Always release lock on error
|
|
402
|
+
releaseLock(this.locksDir, 'contracts-index');
|
|
403
|
+
throw err;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Step 3: Return summary
|
|
408
|
+
return {
|
|
409
|
+
replayedFrom: snapshotId,
|
|
410
|
+
overridesApplied: Object.keys(overrides),
|
|
411
|
+
contractsRestored: rollbackResult.contractsRestored,
|
|
412
|
+
message: 'Replayed from snapshot with overrides. Run dependency resolver to continue execution.'
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Scan the artifacts/data directory to build a version manifest
|
|
418
|
+
*
|
|
419
|
+
* This tells us which version files existed at snapshot time.
|
|
420
|
+
* We scan each artifact's directory and collect version numbers.
|
|
421
|
+
*
|
|
422
|
+
* @private
|
|
423
|
+
* @returns {Object} Map of artifactId → [version numbers]
|
|
424
|
+
*
|
|
425
|
+
* @example
|
|
426
|
+
* {
|
|
427
|
+
* "schema-user-model": [1],
|
|
428
|
+
* "api-contract-auth": [1, 2, 3]
|
|
429
|
+
* }
|
|
430
|
+
*/
|
|
431
|
+
_scanVersionManifest() {
|
|
432
|
+
const manifest = {};
|
|
433
|
+
|
|
434
|
+
// Check if artifacts data directory exists
|
|
435
|
+
if (!fs.existsSync(this.artifactsDataDir)) {
|
|
436
|
+
return manifest; // No artifacts yet
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Read all subdirectories (each is an artifact)
|
|
440
|
+
const artifactDirs = fs.readdirSync(this.artifactsDataDir);
|
|
441
|
+
|
|
442
|
+
for (const artifactId of artifactDirs) {
|
|
443
|
+
const artifactPath = path.join(this.artifactsDataDir, artifactId);
|
|
444
|
+
|
|
445
|
+
// Skip if not a directory
|
|
446
|
+
if (!fs.statSync(artifactPath).isDirectory()) {
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Read all files in this artifact directory
|
|
451
|
+
const files = fs.readdirSync(artifactPath);
|
|
452
|
+
|
|
453
|
+
// Filter for version files (v1.json, v2.json, etc.)
|
|
454
|
+
const versionFiles = files.filter(f => /^v\d+\.json$/.test(f));
|
|
455
|
+
|
|
456
|
+
// Extract version numbers
|
|
457
|
+
const versions = versionFiles.map(f => {
|
|
458
|
+
const match = f.match(/^v(\d+)\.json$/);
|
|
459
|
+
return match ? parseInt(match[1], 10) : null;
|
|
460
|
+
}).filter(v => v !== null); // Remove any nulls
|
|
461
|
+
|
|
462
|
+
// Sort versions numerically
|
|
463
|
+
versions.sort((a, b) => a - b);
|
|
464
|
+
|
|
465
|
+
// Add to manifest
|
|
466
|
+
if (versions.length > 0) {
|
|
467
|
+
manifest[artifactId] = versions;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return manifest;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Deep copy an object using JSON serialization
|
|
476
|
+
*
|
|
477
|
+
* This is a simple way to clone objects. It works for plain objects
|
|
478
|
+
* but won't preserve functions, dates, or circular references.
|
|
479
|
+
*
|
|
480
|
+
* @private
|
|
481
|
+
* @param {*} obj - Object to deep copy
|
|
482
|
+
* @returns {*} Deep copy of the object
|
|
483
|
+
*/
|
|
484
|
+
_deepCopy(obj) {
|
|
485
|
+
return JSON.parse(JSON.stringify(obj));
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Export the SnapshotEngine class
|
|
490
|
+
module.exports = SnapshotEngine;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StaleDetector - Session Continuity Engine (Layer 0)
|
|
3
|
+
*
|
|
4
|
+
* Detects when the codebase has changed significantly since the last session,
|
|
5
|
+
* helping identify when previous context might be outdated or incorrect.
|
|
6
|
+
*
|
|
7
|
+
* This class checks for various staleness indicators:
|
|
8
|
+
* - Files you were working on were modified by someone else
|
|
9
|
+
* - Git branch has changed
|
|
10
|
+
* - Many commits happened since last session
|
|
11
|
+
* - Long time gap since last session
|
|
12
|
+
* - Files you were using were deleted
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const DiffEngine = require('./diff-engine');
|
|
16
|
+
const SessionSnapshot = require('./session-snapshot');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* StaleDetector class
|
|
20
|
+
* Analyzes differences between current state and last session to detect stale context
|
|
21
|
+
*/
|
|
22
|
+
class StaleDetector {
|
|
23
|
+
/**
|
|
24
|
+
* Create a StaleDetector instance
|
|
25
|
+
* @param {string} projectPath - Absolute path to the project directory
|
|
26
|
+
*/
|
|
27
|
+
constructor(projectPath) {
|
|
28
|
+
// Store the project path for reference
|
|
29
|
+
this.projectPath = projectPath;
|
|
30
|
+
|
|
31
|
+
// Create DiffEngine to compute differences from last snapshot
|
|
32
|
+
this.diff = new DiffEngine(projectPath);
|
|
33
|
+
|
|
34
|
+
// Create SessionSnapshot to load the last saved session state
|
|
35
|
+
this.snapshot = new SessionSnapshot(projectPath);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check for stale context indicators
|
|
40
|
+
*
|
|
41
|
+
* This is the main method that analyzes the current state versus the last session.
|
|
42
|
+
* It returns a comprehensive report of all staleness warnings.
|
|
43
|
+
*
|
|
44
|
+
* @returns {Object} Stale context report with the following structure:
|
|
45
|
+
* - isStale: boolean - true if any warnings were found
|
|
46
|
+
* - noSnapshot: boolean - true if there's no previous session to compare against
|
|
47
|
+
* - message: string - human-readable summary message
|
|
48
|
+
* - lastSnapshotAge: string - how long ago the last session was (e.g., "3 days ago")
|
|
49
|
+
* - lastSnapshotId: string - identifier of the last snapshot
|
|
50
|
+
* - changedActiveFiles: string[] - list of files you were working on that changed
|
|
51
|
+
* - totalFileChanges: number - total count of all file changes (added + modified + deleted)
|
|
52
|
+
* - newCommits: number - number of commits since last session
|
|
53
|
+
* - warnings: Array<Object> - detailed list of all warnings, each with:
|
|
54
|
+
* - type: string - category of warning
|
|
55
|
+
* - message: string - human-readable description
|
|
56
|
+
* - (additional fields depending on warning type)
|
|
57
|
+
*/
|
|
58
|
+
check() {
|
|
59
|
+
// Step 1: Get the latest snapshot (the most recent saved session state)
|
|
60
|
+
const snapshot = this.snapshot.getLatest();
|
|
61
|
+
|
|
62
|
+
// Step 2: If there's no previous snapshot, we can't detect staleness
|
|
63
|
+
// This happens on the very first session in a project
|
|
64
|
+
if (!snapshot) {
|
|
65
|
+
return {
|
|
66
|
+
isStale: false,
|
|
67
|
+
noSnapshot: true,
|
|
68
|
+
message: 'No previous session to compare against.',
|
|
69
|
+
warnings: []
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Step 3: Get the diff (differences) between now and the last snapshot
|
|
74
|
+
const diff = this.diff.diffFromLatest();
|
|
75
|
+
|
|
76
|
+
// Step 4: If diff computation failed (also means no snapshot), return non-stale
|
|
77
|
+
if (diff.noSnapshot) {
|
|
78
|
+
return {
|
|
79
|
+
isStale: false,
|
|
80
|
+
noSnapshot: true,
|
|
81
|
+
message: 'No previous session to compare against.',
|
|
82
|
+
warnings: []
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Step 5: Build an array of warnings by checking different staleness indicators
|
|
87
|
+
const warnings = [];
|
|
88
|
+
|
|
89
|
+
// WARNING TYPE 1: Active files modified
|
|
90
|
+
// These are files you were actively working on in your last session
|
|
91
|
+
// If they changed, your mental model might be outdated
|
|
92
|
+
if (diff.contextImpact && diff.contextImpact.activeFilesModified) {
|
|
93
|
+
for (const file of diff.contextImpact.activeFilesModified) {
|
|
94
|
+
warnings.push({
|
|
95
|
+
type: 'active_file_modified',
|
|
96
|
+
file,
|
|
97
|
+
message: `${file} was modified since your last session. Re-read before assuming previous context.`
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// WARNING TYPE 2: Branch changed
|
|
103
|
+
// If you switched branches, you're in a completely different code context
|
|
104
|
+
if (diff.gitChanges && diff.gitChanges.branchChanged) {
|
|
105
|
+
warnings.push({
|
|
106
|
+
type: 'branch_changed',
|
|
107
|
+
oldBranch: diff.gitChanges.oldBranch,
|
|
108
|
+
newBranch: diff.gitChanges.newBranch,
|
|
109
|
+
message: `Branch changed from ${diff.gitChanges.oldBranch} to ${diff.gitChanges.newBranch} since your last session.`
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// WARNING TYPE 3: Many commits since last session
|
|
114
|
+
// If lots of commits happened, the codebase might be significantly different
|
|
115
|
+
if (diff.gitChanges && diff.gitChanges.newCommitCount > 5) {
|
|
116
|
+
warnings.push({
|
|
117
|
+
type: 'many_commits',
|
|
118
|
+
count: diff.gitChanges.newCommitCount,
|
|
119
|
+
message: `${diff.gitChanges.newCommitCount} commits have been made since your last session. Significant changes may have occurred.`
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// WARNING TYPE 4: Long time gap since last session
|
|
124
|
+
// If it's been more than 24 hours (86400000 milliseconds), context is likely stale
|
|
125
|
+
if (diff.timeDelta > 86400000) {
|
|
126
|
+
warnings.push({
|
|
127
|
+
type: 'long_gap',
|
|
128
|
+
timeDelta: diff.timeDeltaHuman,
|
|
129
|
+
message: `Last session was ${diff.timeDeltaHuman}. Codebase may have changed significantly.`
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// WARNING TYPE 5: Active files deleted
|
|
134
|
+
// If files you were working on no longer exist, that's critical information
|
|
135
|
+
if (diff.fileChanges && diff.fileChanges.deleted && snapshot.activeFiles) {
|
|
136
|
+
for (const file of diff.fileChanges.deleted) {
|
|
137
|
+
// Only warn if this deleted file was one you were actively using
|
|
138
|
+
if (snapshot.activeFiles.includes(file)) {
|
|
139
|
+
warnings.push({
|
|
140
|
+
type: 'active_file_deleted',
|
|
141
|
+
file,
|
|
142
|
+
message: `${file} was deleted since your last session.`
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Step 6: Determine overall staleness
|
|
149
|
+
// If we found any warnings, the context is considered stale
|
|
150
|
+
const isStale = warnings.length > 0;
|
|
151
|
+
|
|
152
|
+
// Step 7 & 8: Return comprehensive staleness report
|
|
153
|
+
return {
|
|
154
|
+
isStale,
|
|
155
|
+
lastSnapshotAge: diff.timeDeltaHuman,
|
|
156
|
+
lastSnapshotId: snapshot.snapshotId,
|
|
157
|
+
changedActiveFiles: diff.contextImpact ? diff.contextImpact.activeFilesModified : [],
|
|
158
|
+
totalFileChanges: (diff.fileChanges.added.length +
|
|
159
|
+
diff.fileChanges.modified.length +
|
|
160
|
+
diff.fileChanges.deleted.length),
|
|
161
|
+
newCommits: diff.gitChanges ? diff.gitChanges.newCommitCount : 0,
|
|
162
|
+
warnings
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Export the class so other modules can use it
|
|
168
|
+
// Usage: const StaleDetector = require('./stale-detector');
|
|
169
|
+
module.exports = StaleDetector;
|