agileflow 2.82.5 → 2.84.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 +10 -0
- package/package.json +1 -1
- package/scripts/agileflow-configure.js +2 -4
- package/scripts/agileflow-statusline.sh +50 -3
- package/scripts/agileflow-welcome.js +84 -11
- package/scripts/check-update.js +12 -63
- package/scripts/lib/file-tracking.js +733 -0
- package/scripts/lib/story-claiming.js +558 -0
- package/scripts/obtain-context.js +117 -1
- package/scripts/session-manager.js +519 -1
- package/src/core/agents/configuration-visual-e2e.md +29 -1
- package/src/core/agents/ui.md +50 -0
- package/src/core/commands/babysit.md +118 -0
- package/src/core/commands/session/end.md +44 -2
- package/tools/cli/commands/start.js +0 -178
- package/tools/cli/tui/Dashboard.js +0 -65
- package/tools/cli/tui/StoryList.js +0 -69
- package/tools/cli/tui/index.js +0 -16
|
@@ -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
|
+
}
|