agileflow 2.83.0 → 2.84.1

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,733 @@
1
+ /**
2
+ * file-tracking.js - Inter-session file awareness
3
+ *
4
+ * Tracks which files each session is editing to warn about potential conflicts.
5
+ * Uses PID-based liveness detection to auto-cleanup stale entries.
6
+ *
7
+ * Key Principle: Warn, don't block. Overlapping files get warnings but work continues.
8
+ *
9
+ * Usage:
10
+ * const { recordFileTouch, getFileOverlaps, getSessionFiles } = require('./lib/file-tracking');
11
+ *
12
+ * // Record that this session touched a file
13
+ * recordFileTouch('src/HomePage.tsx');
14
+ *
15
+ * // Get all file overlaps across sessions
16
+ * const overlaps = getFileOverlaps();
17
+ * // → [{ file: 'src/HomePage.tsx', sessions: ['1', '2'] }]
18
+ */
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+
23
+ // Shared utilities
24
+ const { getProjectRoot } = require('../../lib/paths');
25
+ const { safeReadJSON, safeWriteJSON } = require('../../lib/errors');
26
+ const { c } = require('../../lib/colors');
27
+ const { getCurrentSession, isPidAlive } = require('./story-claiming');
28
+
29
+ // Default touch expiration: 4 hours (matches story claiming)
30
+ const DEFAULT_TOUCH_TTL_HOURS = 4;
31
+
32
+ /**
33
+ * Get the path to the file-touches.json file.
34
+ *
35
+ * @param {string} [rootDir] - Project root directory
36
+ * @returns {string} Path to file-touches.json
37
+ */
38
+ function getFileTouchesPath(rootDir) {
39
+ const root = rootDir || getProjectRoot();
40
+ return path.join(root, '.agileflow', 'sessions', 'file-touches.json');
41
+ }
42
+
43
+ /**
44
+ * Ensure the file-touches.json file exists with default structure.
45
+ *
46
+ * @param {string} [rootDir] - Project root directory
47
+ * @returns {{ ok: boolean, data?: object, error?: string }}
48
+ */
49
+ function ensureFileTouchesFile(rootDir) {
50
+ const filePath = getFileTouchesPath(rootDir);
51
+ const dirPath = path.dirname(filePath);
52
+
53
+ // Ensure directory exists
54
+ if (!fs.existsSync(dirPath)) {
55
+ try {
56
+ fs.mkdirSync(dirPath, { recursive: true });
57
+ } catch (e) {
58
+ return { ok: false, error: `Could not create directory: ${e.message}` };
59
+ }
60
+ }
61
+
62
+ // Check if file exists
63
+ if (!fs.existsSync(filePath)) {
64
+ const defaultData = {
65
+ version: 1,
66
+ sessions: {},
67
+ updated: new Date().toISOString(),
68
+ };
69
+ const writeResult = safeWriteJSON(filePath, defaultData);
70
+ if (!writeResult.ok) {
71
+ return { ok: false, error: writeResult.error };
72
+ }
73
+ return { ok: true, data: defaultData };
74
+ }
75
+
76
+ // Read existing file
77
+ const result = safeReadJSON(filePath, { defaultValue: null });
78
+ if (!result.ok || !result.data) {
79
+ return { ok: false, error: result.error || 'Could not read file-touches.json' };
80
+ }
81
+
82
+ return { ok: true, data: result.data };
83
+ }
84
+
85
+ /**
86
+ * Record that the current session touched a file.
87
+ *
88
+ * @param {string} filePath - Path to the file (relative or absolute)
89
+ * @param {object} [options] - Options
90
+ * @param {string} [options.rootDir] - Project root directory
91
+ * @returns {{ ok: boolean, error?: string }}
92
+ */
93
+ function recordFileTouch(filePath, options = {}) {
94
+ const { rootDir } = options;
95
+ const root = rootDir || getProjectRoot();
96
+
97
+ // Get current session
98
+ const currentSession = getCurrentSession(root);
99
+ if (!currentSession) {
100
+ return { ok: false, error: 'Could not determine current session' };
101
+ }
102
+
103
+ const sessionId = currentSession.session_id;
104
+
105
+ // Normalize file path to be relative to project root
106
+ let normalizedPath = filePath;
107
+ if (path.isAbsolute(filePath)) {
108
+ normalizedPath = path.relative(root, filePath);
109
+ }
110
+
111
+ // Load file-touches.json
112
+ const result = ensureFileTouchesFile(root);
113
+ if (!result.ok) {
114
+ return { ok: false, error: result.error };
115
+ }
116
+
117
+ const data = result.data;
118
+
119
+ // Initialize session entry if needed
120
+ if (!data.sessions[sessionId]) {
121
+ data.sessions[sessionId] = {
122
+ files: [],
123
+ pid: currentSession.pid,
124
+ path: currentSession.path,
125
+ last_updated: new Date().toISOString(),
126
+ };
127
+ }
128
+
129
+ // Add file if not already tracked
130
+ const sessionData = data.sessions[sessionId];
131
+ if (!sessionData.files.includes(normalizedPath)) {
132
+ sessionData.files.push(normalizedPath);
133
+ }
134
+ sessionData.last_updated = new Date().toISOString();
135
+ sessionData.pid = currentSession.pid;
136
+
137
+ // Save
138
+ data.updated = new Date().toISOString();
139
+ const touchesPath = getFileTouchesPath(root);
140
+ const writeResult = safeWriteJSON(touchesPath, data);
141
+ if (!writeResult.ok) {
142
+ return { ok: false, error: writeResult.error };
143
+ }
144
+
145
+ return { ok: true };
146
+ }
147
+
148
+ /**
149
+ * Record multiple files touched by the current session.
150
+ *
151
+ * @param {string[]} filePaths - Array of file paths
152
+ * @param {object} [options] - Options
153
+ * @param {string} [options.rootDir] - Project root directory
154
+ * @returns {{ ok: boolean, error?: string }}
155
+ */
156
+ function recordFileTouches(filePaths, options = {}) {
157
+ const { rootDir } = options;
158
+ const root = rootDir || getProjectRoot();
159
+
160
+ // Get current session
161
+ const currentSession = getCurrentSession(root);
162
+ if (!currentSession) {
163
+ return { ok: false, error: 'Could not determine current session' };
164
+ }
165
+
166
+ const sessionId = currentSession.session_id;
167
+
168
+ // Load file-touches.json
169
+ const result = ensureFileTouchesFile(root);
170
+ if (!result.ok) {
171
+ return { ok: false, error: result.error };
172
+ }
173
+
174
+ const data = result.data;
175
+
176
+ // Initialize session entry if needed
177
+ if (!data.sessions[sessionId]) {
178
+ data.sessions[sessionId] = {
179
+ files: [],
180
+ pid: currentSession.pid,
181
+ path: currentSession.path,
182
+ last_updated: new Date().toISOString(),
183
+ };
184
+ }
185
+
186
+ const sessionData = data.sessions[sessionId];
187
+
188
+ // Normalize and add files
189
+ for (const filePath of filePaths) {
190
+ let normalizedPath = filePath;
191
+ if (path.isAbsolute(filePath)) {
192
+ normalizedPath = path.relative(root, filePath);
193
+ }
194
+ if (!sessionData.files.includes(normalizedPath)) {
195
+ sessionData.files.push(normalizedPath);
196
+ }
197
+ }
198
+
199
+ sessionData.last_updated = new Date().toISOString();
200
+ sessionData.pid = currentSession.pid;
201
+
202
+ // Save
203
+ data.updated = new Date().toISOString();
204
+ const touchesPath = getFileTouchesPath(root);
205
+ const writeResult = safeWriteJSON(touchesPath, data);
206
+ if (!writeResult.ok) {
207
+ return { ok: false, error: writeResult.error };
208
+ }
209
+
210
+ return { ok: true };
211
+ }
212
+
213
+ /**
214
+ * Get files touched by a specific session.
215
+ *
216
+ * @param {string} sessionId - Session ID
217
+ * @param {object} [options] - Options
218
+ * @param {string} [options.rootDir] - Project root directory
219
+ * @returns {{ ok: boolean, files?: string[], error?: string }}
220
+ */
221
+ function getSessionFiles(sessionId, options = {}) {
222
+ const { rootDir } = options;
223
+ const root = rootDir || getProjectRoot();
224
+
225
+ const result = ensureFileTouchesFile(root);
226
+ if (!result.ok) {
227
+ return { ok: false, error: result.error };
228
+ }
229
+
230
+ const data = result.data;
231
+ const sessionData = data.sessions[sessionId];
232
+
233
+ if (!sessionData) {
234
+ return { ok: true, files: [] };
235
+ }
236
+
237
+ return { ok: true, files: sessionData.files || [] };
238
+ }
239
+
240
+ /**
241
+ * Check if a session entry is still valid (PID alive, not expired).
242
+ *
243
+ * @param {object} sessionData - Session data from file-touches.json
244
+ * @returns {boolean}
245
+ */
246
+ function isSessionTouchValid(sessionData) {
247
+ if (!sessionData) return false;
248
+
249
+ // Check PID liveness
250
+ if (sessionData.pid && !isPidAlive(sessionData.pid)) {
251
+ return false;
252
+ }
253
+
254
+ // Check TTL expiration
255
+ if (sessionData.last_updated) {
256
+ const lastUpdated = new Date(sessionData.last_updated);
257
+ const now = new Date();
258
+ const hoursSinceUpdate = (now - lastUpdated) / (1000 * 60 * 60);
259
+
260
+ if (hoursSinceUpdate > DEFAULT_TOUCH_TTL_HOURS) {
261
+ return false;
262
+ }
263
+ }
264
+
265
+ return true;
266
+ }
267
+
268
+ /**
269
+ * Get all file overlaps across active sessions.
270
+ * Returns files that are touched by multiple sessions.
271
+ *
272
+ * @param {object} [options] - Options
273
+ * @param {string} [options.rootDir] - Project root directory
274
+ * @param {boolean} [options.includeCurrentSession=false] - Include overlaps with current session
275
+ * @returns {{ ok: boolean, overlaps?: Array<{ file: string, sessions: Array<{ id: string, path: string }> }>, error?: string }}
276
+ */
277
+ function getFileOverlaps(options = {}) {
278
+ const { rootDir, includeCurrentSession = false } = options;
279
+ const root = rootDir || getProjectRoot();
280
+
281
+ const result = ensureFileTouchesFile(root);
282
+ if (!result.ok) {
283
+ return { ok: false, error: result.error };
284
+ }
285
+
286
+ const data = result.data;
287
+
288
+ // Get current session
289
+ const currentSession = getCurrentSession(root);
290
+ const mySessionId = currentSession ? currentSession.session_id : null;
291
+
292
+ // Build file -> sessions map
293
+ const fileMap = {};
294
+
295
+ for (const [sessionId, sessionData] of Object.entries(data.sessions || {})) {
296
+ // Skip current session if not including
297
+ if (!includeCurrentSession && sessionId === mySessionId) {
298
+ continue;
299
+ }
300
+
301
+ // Skip invalid sessions
302
+ if (!isSessionTouchValid(sessionData)) {
303
+ continue;
304
+ }
305
+
306
+ for (const file of sessionData.files || []) {
307
+ if (!fileMap[file]) {
308
+ fileMap[file] = [];
309
+ }
310
+ fileMap[file].push({
311
+ id: sessionId,
312
+ path: sessionData.path,
313
+ pid: sessionData.pid,
314
+ });
315
+ }
316
+ }
317
+
318
+ // Find overlaps (files touched by multiple sessions)
319
+ const overlaps = [];
320
+
321
+ for (const [file, sessions] of Object.entries(fileMap)) {
322
+ if (sessions.length > 1) {
323
+ overlaps.push({ file, sessions });
324
+ }
325
+ }
326
+
327
+ // Sort by file path
328
+ overlaps.sort((a, b) => a.file.localeCompare(b.file));
329
+
330
+ return { ok: true, overlaps };
331
+ }
332
+
333
+ /**
334
+ * Get files that this session shares with other sessions.
335
+ *
336
+ * @param {object} [options] - Options
337
+ * @param {string} [options.rootDir] - Project root directory
338
+ * @returns {{ ok: boolean, overlaps?: Array<{ file: string, otherSessions: Array<{ id: string, path: string }> }>, error?: string }}
339
+ */
340
+ function getMyFileOverlaps(options = {}) {
341
+ const { rootDir } = options;
342
+ const root = rootDir || getProjectRoot();
343
+
344
+ const result = ensureFileTouchesFile(root);
345
+ if (!result.ok) {
346
+ return { ok: false, error: result.error };
347
+ }
348
+
349
+ const data = result.data;
350
+
351
+ // Get current session
352
+ const currentSession = getCurrentSession(root);
353
+ if (!currentSession) {
354
+ return { ok: true, overlaps: [] };
355
+ }
356
+
357
+ const mySessionId = currentSession.session_id;
358
+ const mySessionData = data.sessions[mySessionId];
359
+
360
+ if (!mySessionData || !mySessionData.files || mySessionData.files.length === 0) {
361
+ return { ok: true, overlaps: [] };
362
+ }
363
+
364
+ const myFiles = new Set(mySessionData.files);
365
+ const overlaps = [];
366
+
367
+ // Check each of my files against other sessions
368
+ for (const [sessionId, sessionData] of Object.entries(data.sessions || {})) {
369
+ // Skip my session
370
+ if (sessionId === mySessionId) continue;
371
+
372
+ // Skip invalid sessions
373
+ if (!isSessionTouchValid(sessionData)) continue;
374
+
375
+ // Find shared files
376
+ for (const file of sessionData.files || []) {
377
+ if (myFiles.has(file)) {
378
+ // Find or create overlap entry
379
+ let overlap = overlaps.find((o) => o.file === file);
380
+ if (!overlap) {
381
+ overlap = { file, otherSessions: [] };
382
+ overlaps.push(overlap);
383
+ }
384
+ overlap.otherSessions.push({
385
+ id: sessionId,
386
+ path: sessionData.path,
387
+ pid: sessionData.pid,
388
+ });
389
+ }
390
+ }
391
+ }
392
+
393
+ // Sort by file path
394
+ overlaps.sort((a, b) => a.file.localeCompare(b.file));
395
+
396
+ return { ok: true, overlaps };
397
+ }
398
+
399
+ /**
400
+ * Clean up stale file touches (dead PIDs or expired TTL).
401
+ * Should be called on session startup.
402
+ *
403
+ * @param {object} [options] - Options
404
+ * @param {string} [options.rootDir] - Project root directory
405
+ * @returns {{ ok: boolean, cleaned?: number, error?: string }}
406
+ */
407
+ function cleanupStaleTouches(options = {}) {
408
+ const { rootDir } = options;
409
+ const root = rootDir || getProjectRoot();
410
+
411
+ const result = ensureFileTouchesFile(root);
412
+ if (!result.ok) {
413
+ return { ok: false, error: result.error };
414
+ }
415
+
416
+ const data = result.data;
417
+ let cleanedCount = 0;
418
+
419
+ for (const [sessionId, sessionData] of Object.entries(data.sessions || {})) {
420
+ if (!isSessionTouchValid(sessionData)) {
421
+ delete data.sessions[sessionId];
422
+ cleanedCount++;
423
+ }
424
+ }
425
+
426
+ // Save if anything was cleaned
427
+ if (cleanedCount > 0) {
428
+ data.updated = new Date().toISOString();
429
+ const touchesPath = getFileTouchesPath(root);
430
+ const writeResult = safeWriteJSON(touchesPath, data);
431
+ if (!writeResult.ok) {
432
+ return { ok: false, error: writeResult.error };
433
+ }
434
+ }
435
+
436
+ return { ok: true, cleaned: cleanedCount };
437
+ }
438
+
439
+ /**
440
+ * Clear all file touches for the current session.
441
+ * Used when ending a session.
442
+ *
443
+ * @param {object} [options] - Options
444
+ * @param {string} [options.rootDir] - Project root directory
445
+ * @returns {{ ok: boolean, error?: string }}
446
+ */
447
+ function clearSessionFiles(options = {}) {
448
+ const { rootDir } = options;
449
+ const root = rootDir || getProjectRoot();
450
+
451
+ // Get current session
452
+ const currentSession = getCurrentSession(root);
453
+ if (!currentSession) {
454
+ return { ok: false, error: 'Could not determine current session' };
455
+ }
456
+
457
+ const sessionId = currentSession.session_id;
458
+
459
+ const result = ensureFileTouchesFile(root);
460
+ if (!result.ok) {
461
+ return { ok: false, error: result.error };
462
+ }
463
+
464
+ const data = result.data;
465
+
466
+ // Remove session entry
467
+ if (data.sessions[sessionId]) {
468
+ delete data.sessions[sessionId];
469
+
470
+ data.updated = new Date().toISOString();
471
+ const touchesPath = getFileTouchesPath(root);
472
+ const writeResult = safeWriteJSON(touchesPath, data);
473
+ if (!writeResult.ok) {
474
+ return { ok: false, error: writeResult.error };
475
+ }
476
+ }
477
+
478
+ return { ok: true };
479
+ }
480
+
481
+ /**
482
+ * Format file overlaps for display.
483
+ *
484
+ * @param {Array} overlaps - Array of overlap objects from getMyFileOverlaps
485
+ * @returns {string} Formatted display string
486
+ */
487
+ function formatFileOverlaps(overlaps) {
488
+ if (!overlaps || overlaps.length === 0) {
489
+ return '';
490
+ }
491
+
492
+ const lines = [];
493
+ lines.push(`${c.amber}⚠️ File overlaps detected:${c.reset}`);
494
+
495
+ for (let i = 0; i < overlaps.length; i++) {
496
+ const overlap = overlaps[i];
497
+ const isLast = i === overlaps.length - 1;
498
+ const prefix = isLast ? '└─' : '├─';
499
+
500
+ // Format session info
501
+ const sessionInfo = overlap.otherSessions
502
+ .map((s) => {
503
+ const dir = path.basename(s.path);
504
+ return `Session ${s.id} (${dir})`;
505
+ })
506
+ .join(', ');
507
+
508
+ lines.push(` ${prefix} ${c.lavender}${overlap.file}${c.reset} ${c.dim}→ Also edited by ${sessionInfo}${c.reset}`);
509
+ }
510
+
511
+ lines.push(` ${c.dim}Tip: Conflicts will be auto-resolved during session merge${c.reset}`);
512
+
513
+ return lines.join('\n');
514
+ }
515
+
516
+ /**
517
+ * Categorize a file by type for merge strategy selection.
518
+ *
519
+ * @param {string} filePath - File path
520
+ * @returns {string} Category: 'docs', 'test', 'schema', 'config', 'source'
521
+ */
522
+ function categorizeFile(filePath) {
523
+ const ext = path.extname(filePath).toLowerCase();
524
+ const basename = path.basename(filePath).toLowerCase();
525
+ const dirname = path.dirname(filePath).toLowerCase();
526
+
527
+ // Documentation files
528
+ if (ext === '.md' || basename === 'readme' || basename.startsWith('readme.')) {
529
+ return 'docs';
530
+ }
531
+
532
+ // Test files
533
+ if (
534
+ filePath.includes('.test.') ||
535
+ filePath.includes('.spec.') ||
536
+ filePath.includes('__tests__') ||
537
+ dirname.includes('test') ||
538
+ dirname.includes('tests')
539
+ ) {
540
+ return 'test';
541
+ }
542
+
543
+ // Schema/migration files
544
+ if (
545
+ ext === '.sql' ||
546
+ filePath.includes('schema') ||
547
+ filePath.includes('migration') ||
548
+ filePath.includes('prisma')
549
+ ) {
550
+ return 'schema';
551
+ }
552
+
553
+ // Config files
554
+ if (
555
+ ext === '.json' ||
556
+ ext === '.yaml' ||
557
+ ext === '.yml' ||
558
+ ext === '.toml' ||
559
+ basename.includes('config') ||
560
+ basename.startsWith('.') // dotfiles
561
+ ) {
562
+ return 'config';
563
+ }
564
+
565
+ // Default: source code
566
+ return 'source';
567
+ }
568
+
569
+ /**
570
+ * Get merge strategy recommendation for a file type.
571
+ *
572
+ * @param {string} category - File category from categorizeFile()
573
+ * @returns {{ strategy: string, description: string }}
574
+ */
575
+ function getMergeStrategy(category) {
576
+ const strategies = {
577
+ docs: {
578
+ strategy: 'accept_both',
579
+ description: 'Documentation is additive - both changes kept',
580
+ },
581
+ test: {
582
+ strategy: 'accept_both',
583
+ description: 'Tests are additive - both test files kept',
584
+ },
585
+ schema: {
586
+ strategy: 'take_newer',
587
+ description: 'Schemas evolve forward - newer version used',
588
+ },
589
+ config: {
590
+ strategy: 'merge_keys',
591
+ description: 'Config changes merged by key',
592
+ },
593
+ source: {
594
+ strategy: 'intelligent_merge',
595
+ description: 'Source code merged intelligently by diff region',
596
+ },
597
+ };
598
+
599
+ return strategies[category] || strategies.source;
600
+ }
601
+
602
+ // CLI interface
603
+ function main() {
604
+ const args = process.argv.slice(2);
605
+ const command = args[0];
606
+
607
+ switch (command) {
608
+ case 'touch': {
609
+ const filePath = args[1];
610
+ if (!filePath) {
611
+ console.log(JSON.stringify({ ok: false, error: 'File path required' }));
612
+ return;
613
+ }
614
+ const result = recordFileTouch(filePath);
615
+ console.log(JSON.stringify(result));
616
+ break;
617
+ }
618
+
619
+ case 'files': {
620
+ const sessionId = args[1];
621
+ if (!sessionId) {
622
+ const currentSession = getCurrentSession();
623
+ if (currentSession) {
624
+ const result = getSessionFiles(currentSession.session_id);
625
+ console.log(JSON.stringify(result));
626
+ } else {
627
+ console.log(JSON.stringify({ ok: false, error: 'Session ID required' }));
628
+ }
629
+ return;
630
+ }
631
+ const result = getSessionFiles(sessionId);
632
+ console.log(JSON.stringify(result));
633
+ break;
634
+ }
635
+
636
+ case 'overlaps': {
637
+ const result = getMyFileOverlaps();
638
+ if (result.ok && result.overlaps.length > 0) {
639
+ console.log(formatFileOverlaps(result.overlaps));
640
+ } else if (result.ok) {
641
+ console.log('No file overlaps detected.');
642
+ } else {
643
+ console.log(JSON.stringify(result));
644
+ }
645
+ break;
646
+ }
647
+
648
+ case 'all-overlaps': {
649
+ const result = getFileOverlaps({ includeCurrentSession: true });
650
+ console.log(JSON.stringify(result, null, 2));
651
+ break;
652
+ }
653
+
654
+ case 'cleanup': {
655
+ const result = cleanupStaleTouches();
656
+ console.log(JSON.stringify(result));
657
+ break;
658
+ }
659
+
660
+ case 'clear': {
661
+ const result = clearSessionFiles();
662
+ console.log(JSON.stringify(result));
663
+ break;
664
+ }
665
+
666
+ case 'categorize': {
667
+ const filePath = args[1];
668
+ if (!filePath) {
669
+ console.log(JSON.stringify({ ok: false, error: 'File path required' }));
670
+ return;
671
+ }
672
+ const category = categorizeFile(filePath);
673
+ const strategy = getMergeStrategy(category);
674
+ console.log(JSON.stringify({ ok: true, file: filePath, category, ...strategy }));
675
+ break;
676
+ }
677
+
678
+ case 'help':
679
+ default:
680
+ console.log(`
681
+ ${c.brand}${c.bold}File Tracking${c.reset} - Inter-session file awareness
682
+
683
+ ${c.cyan}Commands:${c.reset}
684
+ touch <file> Record that this session touched a file
685
+ files [session-id] List files touched by session (current if not specified)
686
+ overlaps Show files shared with other sessions (formatted)
687
+ all-overlaps Show all file overlaps across all sessions (JSON)
688
+ cleanup Remove stale session entries (dead PIDs)
689
+ clear Clear all file touches for current session
690
+ categorize <file> Show merge strategy for a file type
691
+ help Show this help
692
+
693
+ ${c.cyan}Examples:${c.reset}
694
+ node file-tracking.js touch src/HomePage.tsx
695
+ node file-tracking.js overlaps
696
+ node file-tracking.js categorize src/HomePage.tsx
697
+ `);
698
+ }
699
+ }
700
+
701
+ // Export for use as module
702
+ module.exports = {
703
+ // Core functions
704
+ recordFileTouch,
705
+ recordFileTouches,
706
+ getSessionFiles,
707
+ clearSessionFiles,
708
+
709
+ // Overlap detection
710
+ getFileOverlaps,
711
+ getMyFileOverlaps,
712
+
713
+ // Cleanup
714
+ cleanupStaleTouches,
715
+
716
+ // Utilities
717
+ formatFileOverlaps,
718
+ categorizeFile,
719
+ getMergeStrategy,
720
+ isSessionTouchValid,
721
+
722
+ // Path utilities
723
+ getFileTouchesPath,
724
+ ensureFileTouchesFile,
725
+
726
+ // Constants
727
+ DEFAULT_TOUCH_TTL_HOURS,
728
+ };
729
+
730
+ // Run CLI if executed directly
731
+ if (require.main === module) {
732
+ main();
733
+ }