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.
@@ -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;