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,558 @@
1
+ /**
2
+ * story-claiming.js - Inter-session story coordination
3
+ *
4
+ * Prevents multiple Claude Code sessions from working on the same story.
5
+ * Uses PID-based liveness detection to auto-release stale claims.
6
+ *
7
+ * Usage:
8
+ * const { claimStory, releaseStory, isStoryClaimed, getClaimedStories } = require('./lib/story-claiming');
9
+ *
10
+ * // Claim a story for this session
11
+ * const result = claimStory('US-0042');
12
+ * if (!result.ok) {
13
+ * console.log(`Story claimed by session ${result.claimedBy.session_id}`);
14
+ * }
15
+ *
16
+ * // Release when done
17
+ * releaseStory('US-0042');
18
+ */
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+
23
+ // Shared utilities
24
+ const { getProjectRoot, getStatusPath } = require('../../lib/paths');
25
+ const { safeReadJSON, safeWriteJSON } = require('../../lib/errors');
26
+ const { c } = require('../../lib/colors');
27
+
28
+ // Default claim expiration: 4 hours
29
+ const DEFAULT_CLAIM_TTL_HOURS = 4;
30
+
31
+ /**
32
+ * Check if a PID is alive.
33
+ * Uses signal 0 which checks process existence without sending a signal.
34
+ *
35
+ * @param {number} pid - Process ID to check
36
+ * @returns {boolean} True if PID is alive
37
+ */
38
+ function isPidAlive(pid) {
39
+ if (!pid || typeof pid !== 'number') return false;
40
+ try {
41
+ process.kill(pid, 0);
42
+ return true;
43
+ } catch (e) {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Get the current session info from session-manager registry.
50
+ *
51
+ * @param {string} [rootDir] - Project root directory
52
+ * @returns {{ session_id: string, pid: number, path: string } | null}
53
+ */
54
+ function getCurrentSession(rootDir) {
55
+ const root = rootDir || getProjectRoot();
56
+ const registryPath = path.join(root, '.agileflow', 'sessions', 'registry.json');
57
+
58
+ const result = safeReadJSON(registryPath, { defaultValue: null });
59
+ if (!result.ok || !result.data) return null;
60
+
61
+ const cwd = process.cwd();
62
+ const registry = result.data;
63
+
64
+ // Find session by current working directory
65
+ for (const [id, session] of Object.entries(registry.sessions || {})) {
66
+ if (session.path === cwd) {
67
+ // Get PID from lock file
68
+ const lockPath = path.join(root, '.agileflow', 'sessions', `${id}.lock`);
69
+ let pid = process.ppid || process.pid;
70
+
71
+ if (fs.existsSync(lockPath)) {
72
+ try {
73
+ const lockContent = fs.readFileSync(lockPath, 'utf8');
74
+ const pidMatch = lockContent.match(/^pid=(\d+)/m);
75
+ if (pidMatch) {
76
+ pid = parseInt(pidMatch[1], 10);
77
+ }
78
+ } catch (e) {
79
+ // Use default pid
80
+ }
81
+ }
82
+
83
+ return {
84
+ session_id: id,
85
+ pid,
86
+ path: session.path,
87
+ };
88
+ }
89
+ }
90
+
91
+ // No registered session found - return basic info
92
+ return {
93
+ session_id: 'unregistered',
94
+ pid: process.ppid || process.pid,
95
+ path: cwd,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Check if a claim is still valid.
101
+ * Claims are valid if:
102
+ * 1. The claiming session's PID is still alive
103
+ * 2. The claim hasn't exceeded the TTL
104
+ *
105
+ * @param {object} claimedBy - The claimed_by object from a story
106
+ * @returns {boolean} True if claim is still valid
107
+ */
108
+ function isClaimValid(claimedBy) {
109
+ if (!claimedBy) return false;
110
+
111
+ // Check PID liveness
112
+ if (claimedBy.pid && !isPidAlive(claimedBy.pid)) {
113
+ return false;
114
+ }
115
+
116
+ // Check TTL expiration
117
+ if (claimedBy.claimed_at) {
118
+ const claimedAt = new Date(claimedBy.claimed_at);
119
+ const now = new Date();
120
+ const hoursSinceClaim = (now - claimedAt) / (1000 * 60 * 60);
121
+
122
+ if (hoursSinceClaim > DEFAULT_CLAIM_TTL_HOURS) {
123
+ return false;
124
+ }
125
+ }
126
+
127
+ return true;
128
+ }
129
+
130
+ /**
131
+ * Check if a story is claimed by another session.
132
+ *
133
+ * @param {object} story - Story object from status.json
134
+ * @param {string} [currentSessionId] - Current session ID (auto-detected if not provided)
135
+ * @returns {{ claimed: boolean, claimedBy?: object, stale: boolean }}
136
+ */
137
+ function isStoryClaimed(story, currentSessionId) {
138
+ if (!story || !story.claimed_by) {
139
+ return { claimed: false, stale: false };
140
+ }
141
+
142
+ const current = getCurrentSession();
143
+ const mySessionId = currentSessionId || (current ? current.session_id : null);
144
+
145
+ // Check if it's our own claim
146
+ if (story.claimed_by.session_id === mySessionId) {
147
+ return { claimed: false, stale: false }; // Our claim doesn't block us
148
+ }
149
+
150
+ // Check if the claim is still valid
151
+ if (!isClaimValid(story.claimed_by)) {
152
+ return { claimed: false, stale: true, claimedBy: story.claimed_by };
153
+ }
154
+
155
+ return { claimed: true, stale: false, claimedBy: story.claimed_by };
156
+ }
157
+
158
+ /**
159
+ * Claim a story for the current session.
160
+ *
161
+ * @param {string} storyId - Story ID (e.g., 'US-0042')
162
+ * @param {object} [options] - Options
163
+ * @param {boolean} [options.force=false] - Force claim even if already claimed
164
+ * @param {string} [options.rootDir] - Project root directory
165
+ * @returns {{ ok: boolean, claimed?: boolean, claimedBy?: object, error?: string }}
166
+ */
167
+ function claimStory(storyId, options = {}) {
168
+ const { force = false, rootDir } = options;
169
+ const root = rootDir || getProjectRoot();
170
+ const statusPath = getStatusPath(root);
171
+
172
+ // Load status.json
173
+ const result = safeReadJSON(statusPath, { defaultValue: null });
174
+ if (!result.ok || !result.data) {
175
+ return { ok: false, error: result.error || 'Could not load status.json' };
176
+ }
177
+
178
+ const status = result.data;
179
+ const story = status.stories?.[storyId];
180
+
181
+ if (!story) {
182
+ return { ok: false, error: `Story ${storyId} not found` };
183
+ }
184
+
185
+ // Check if already claimed by someone else
186
+ const claimCheck = isStoryClaimed(story);
187
+ if (claimCheck.claimed && !force) {
188
+ return {
189
+ ok: false,
190
+ claimed: true,
191
+ claimedBy: claimCheck.claimedBy,
192
+ error: `Story ${storyId} is claimed by session ${claimCheck.claimedBy.session_id}`,
193
+ };
194
+ }
195
+
196
+ // Get current session info
197
+ const currentSession = getCurrentSession(root);
198
+ if (!currentSession) {
199
+ return { ok: false, error: 'Could not determine current session' };
200
+ }
201
+
202
+ // Set the claim
203
+ story.claimed_by = {
204
+ session_id: currentSession.session_id,
205
+ pid: currentSession.pid,
206
+ path: currentSession.path,
207
+ claimed_at: new Date().toISOString(),
208
+ };
209
+
210
+ // Save status.json
211
+ status.updated = new Date().toISOString();
212
+ const writeResult = safeWriteJSON(statusPath, status);
213
+ if (!writeResult.ok) {
214
+ return { ok: false, error: writeResult.error };
215
+ }
216
+
217
+ return { ok: true, claimed: true };
218
+ }
219
+
220
+ /**
221
+ * Release a story claim for the current session.
222
+ *
223
+ * @param {string} storyId - Story ID (e.g., 'US-0042')
224
+ * @param {object} [options] - Options
225
+ * @param {string} [options.rootDir] - Project root directory
226
+ * @returns {{ ok: boolean, released?: boolean, error?: string }}
227
+ */
228
+ function releaseStory(storyId, options = {}) {
229
+ const { rootDir } = options;
230
+ const root = rootDir || getProjectRoot();
231
+ const statusPath = getStatusPath(root);
232
+
233
+ // Load status.json
234
+ const result = safeReadJSON(statusPath, { defaultValue: null });
235
+ if (!result.ok || !result.data) {
236
+ return { ok: false, error: result.error || 'Could not load status.json' };
237
+ }
238
+
239
+ const status = result.data;
240
+ const story = status.stories?.[storyId];
241
+
242
+ if (!story) {
243
+ return { ok: false, error: `Story ${storyId} not found` };
244
+ }
245
+
246
+ // Get current session info
247
+ const currentSession = getCurrentSession(root);
248
+ const mySessionId = currentSession ? currentSession.session_id : null;
249
+
250
+ // Check if we own this claim
251
+ if (story.claimed_by && story.claimed_by.session_id !== mySessionId) {
252
+ return {
253
+ ok: false,
254
+ error: `Story ${storyId} is claimed by session ${story.claimed_by.session_id}, not you`,
255
+ };
256
+ }
257
+
258
+ // Remove the claim
259
+ delete story.claimed_by;
260
+
261
+ // Save status.json
262
+ status.updated = new Date().toISOString();
263
+ const writeResult = safeWriteJSON(statusPath, status);
264
+ if (!writeResult.ok) {
265
+ return { ok: false, error: writeResult.error };
266
+ }
267
+
268
+ return { ok: true, released: true };
269
+ }
270
+
271
+ /**
272
+ * Get all stories claimed by a specific session.
273
+ *
274
+ * @param {string} [sessionId] - Session ID (current session if not provided)
275
+ * @param {object} [options] - Options
276
+ * @param {string} [options.rootDir] - Project root directory
277
+ * @returns {{ ok: boolean, stories?: Array<{ id: string, title: string }>, error?: string }}
278
+ */
279
+ function getClaimedStoriesForSession(sessionId, options = {}) {
280
+ const { rootDir } = options;
281
+ const root = rootDir || getProjectRoot();
282
+ const statusPath = getStatusPath(root);
283
+
284
+ // Determine session ID
285
+ let targetSessionId = sessionId;
286
+ if (!targetSessionId) {
287
+ const currentSession = getCurrentSession(root);
288
+ targetSessionId = currentSession ? currentSession.session_id : null;
289
+ }
290
+
291
+ if (!targetSessionId) {
292
+ return { ok: false, error: 'Could not determine session ID' };
293
+ }
294
+
295
+ // Load status.json
296
+ const result = safeReadJSON(statusPath, { defaultValue: null });
297
+ if (!result.ok || !result.data) {
298
+ return { ok: false, error: result.error || 'Could not load status.json' };
299
+ }
300
+
301
+ const status = result.data;
302
+ const claimedStories = [];
303
+
304
+ for (const [id, story] of Object.entries(status.stories || {})) {
305
+ if (story.claimed_by && story.claimed_by.session_id === targetSessionId) {
306
+ claimedStories.push({
307
+ id,
308
+ title: story.title,
309
+ claimed_at: story.claimed_by.claimed_at,
310
+ });
311
+ }
312
+ }
313
+
314
+ return { ok: true, stories: claimedStories };
315
+ }
316
+
317
+ /**
318
+ * Get all stories claimed by OTHER sessions (not this one).
319
+ *
320
+ * @param {object} [options] - Options
321
+ * @param {string} [options.rootDir] - Project root directory
322
+ * @returns {{ ok: boolean, stories?: Array<{ id: string, title: string, claimedBy: object }>, error?: string }}
323
+ */
324
+ function getStoriesClaimedByOthers(options = {}) {
325
+ const { rootDir } = options;
326
+ const root = rootDir || getProjectRoot();
327
+ const statusPath = getStatusPath(root);
328
+
329
+ // Get current session
330
+ const currentSession = getCurrentSession(root);
331
+ const mySessionId = currentSession ? currentSession.session_id : null;
332
+
333
+ // Load status.json
334
+ const result = safeReadJSON(statusPath, { defaultValue: null });
335
+ if (!result.ok || !result.data) {
336
+ return { ok: false, error: result.error || 'Could not load status.json' };
337
+ }
338
+
339
+ const status = result.data;
340
+ const claimedStories = [];
341
+
342
+ for (const [id, story] of Object.entries(status.stories || {})) {
343
+ if (!story.claimed_by) continue;
344
+ if (story.claimed_by.session_id === mySessionId) continue;
345
+
346
+ // Check if claim is still valid
347
+ if (isClaimValid(story.claimed_by)) {
348
+ claimedStories.push({
349
+ id,
350
+ title: story.title,
351
+ claimedBy: story.claimed_by,
352
+ });
353
+ }
354
+ }
355
+
356
+ return { ok: true, stories: claimedStories };
357
+ }
358
+
359
+ /**
360
+ * Clean up stale claims (dead PIDs or expired TTL).
361
+ * Should be called on session startup.
362
+ *
363
+ * @param {object} [options] - Options
364
+ * @param {string} [options.rootDir] - Project root directory
365
+ * @returns {{ ok: boolean, cleaned?: number, error?: string }}
366
+ */
367
+ function cleanupStaleClaims(options = {}) {
368
+ const { rootDir } = options;
369
+ const root = rootDir || getProjectRoot();
370
+ const statusPath = getStatusPath(root);
371
+
372
+ // Load status.json
373
+ const result = safeReadJSON(statusPath, { defaultValue: null });
374
+ if (!result.ok || !result.data) {
375
+ return { ok: false, error: result.error || 'Could not load status.json' };
376
+ }
377
+
378
+ const status = result.data;
379
+ let cleanedCount = 0;
380
+
381
+ for (const [id, story] of Object.entries(status.stories || {})) {
382
+ if (!story.claimed_by) continue;
383
+
384
+ // Check if claim is stale
385
+ if (!isClaimValid(story.claimed_by)) {
386
+ delete story.claimed_by;
387
+ cleanedCount++;
388
+ }
389
+ }
390
+
391
+ // Save if anything was cleaned
392
+ if (cleanedCount > 0) {
393
+ status.updated = new Date().toISOString();
394
+ const writeResult = safeWriteJSON(statusPath, status);
395
+ if (!writeResult.ok) {
396
+ return { ok: false, error: writeResult.error };
397
+ }
398
+ }
399
+
400
+ return { ok: true, cleaned: cleanedCount };
401
+ }
402
+
403
+ /**
404
+ * Format claimed stories for display.
405
+ *
406
+ * @param {Array} stories - Array of claimed story objects
407
+ * @returns {string} Formatted display string
408
+ */
409
+ function formatClaimedStories(stories) {
410
+ if (!stories || stories.length === 0) {
411
+ return '';
412
+ }
413
+
414
+ const lines = [];
415
+ lines.push(`${c.amber}Stories claimed by other sessions:${c.reset}`);
416
+
417
+ for (let i = 0; i < stories.length; i++) {
418
+ const story = stories[i];
419
+ const isLast = i === stories.length - 1;
420
+ const prefix = isLast ? '└─' : '├─';
421
+ const sessionPath = story.claimedBy?.path || 'unknown';
422
+ const sessionDir = path.basename(sessionPath);
423
+ const sessionId = story.claimedBy?.session_id || '?';
424
+
425
+ lines.push(
426
+ ` ${prefix} ${c.lavender}${story.id}${c.reset} "${story.title}" ${c.dim}→ Session ${sessionId} (${sessionDir})${c.reset}`
427
+ );
428
+ }
429
+
430
+ return lines.join('\n');
431
+ }
432
+
433
+ // CLI interface
434
+ function main() {
435
+ const args = process.argv.slice(2);
436
+ const command = args[0];
437
+
438
+ switch (command) {
439
+ case 'claim': {
440
+ const storyId = args[1];
441
+ const force = args.includes('--force');
442
+ if (!storyId) {
443
+ console.log(JSON.stringify({ ok: false, error: 'Story ID required' }));
444
+ return;
445
+ }
446
+ const result = claimStory(storyId, { force });
447
+ console.log(JSON.stringify(result));
448
+ break;
449
+ }
450
+
451
+ case 'release': {
452
+ const storyId = args[1];
453
+ if (!storyId) {
454
+ console.log(JSON.stringify({ ok: false, error: 'Story ID required' }));
455
+ return;
456
+ }
457
+ const result = releaseStory(storyId);
458
+ console.log(JSON.stringify(result));
459
+ break;
460
+ }
461
+
462
+ case 'list': {
463
+ const sessionId = args[1] || null;
464
+ const result = getClaimedStoriesForSession(sessionId);
465
+ console.log(JSON.stringify(result));
466
+ break;
467
+ }
468
+
469
+ case 'others': {
470
+ const result = getStoriesClaimedByOthers();
471
+ if (result.ok && result.stories.length > 0) {
472
+ console.log(formatClaimedStories(result.stories));
473
+ } else {
474
+ console.log(JSON.stringify(result));
475
+ }
476
+ break;
477
+ }
478
+
479
+ case 'cleanup': {
480
+ const result = cleanupStaleClaims();
481
+ console.log(JSON.stringify(result));
482
+ break;
483
+ }
484
+
485
+ case 'check': {
486
+ const storyId = args[1];
487
+ if (!storyId) {
488
+ console.log(JSON.stringify({ ok: false, error: 'Story ID required' }));
489
+ return;
490
+ }
491
+
492
+ const statusPath = getStatusPath();
493
+ const result = safeReadJSON(statusPath, { defaultValue: null });
494
+ if (!result.ok || !result.data) {
495
+ console.log(JSON.stringify({ ok: false, error: 'Could not load status.json' }));
496
+ return;
497
+ }
498
+
499
+ const story = result.data.stories?.[storyId];
500
+ if (!story) {
501
+ console.log(JSON.stringify({ ok: false, error: `Story ${storyId} not found` }));
502
+ return;
503
+ }
504
+
505
+ const claimCheck = isStoryClaimed(story);
506
+ console.log(JSON.stringify({ ok: true, ...claimCheck }));
507
+ break;
508
+ }
509
+
510
+ case 'help':
511
+ default:
512
+ console.log(`
513
+ ${c.brand}${c.bold}Story Claiming${c.reset} - Inter-session coordination
514
+
515
+ ${c.cyan}Commands:${c.reset}
516
+ claim <story-id> [--force] Claim a story for this session
517
+ release <story-id> Release a story claim
518
+ list [session-id] List stories claimed by session (current if not specified)
519
+ others List stories claimed by OTHER sessions
520
+ cleanup Clean up stale claims (dead PIDs)
521
+ check <story-id> Check if a story is claimed
522
+ help Show this help
523
+
524
+ ${c.cyan}Examples:${c.reset}
525
+ node story-claiming.js claim US-0042
526
+ node story-claiming.js release US-0042
527
+ node story-claiming.js others
528
+ node story-claiming.js cleanup
529
+ `);
530
+ }
531
+ }
532
+
533
+ // Export for use as module
534
+ module.exports = {
535
+ // Core functions
536
+ claimStory,
537
+ releaseStory,
538
+ isStoryClaimed,
539
+ isClaimValid,
540
+ cleanupStaleClaims,
541
+
542
+ // Query functions
543
+ getClaimedStoriesForSession,
544
+ getStoriesClaimedByOthers,
545
+ getCurrentSession,
546
+
547
+ // Utilities
548
+ isPidAlive,
549
+ formatClaimedStories,
550
+
551
+ // Constants
552
+ DEFAULT_CLAIM_TTL_HOURS,
553
+ };
554
+
555
+ // Run CLI if executed directly
556
+ if (require.main === module) {
557
+ main();
558
+ }
@@ -433,8 +433,124 @@ function generateFullContent() {
433
433
  content += `${C.dim}Multi-session not configured${C.reset}\n`;
434
434
  }
435
435
 
436
- // 5. INTERACTION MODE (AskUserQuestion guidance)
436
+ // 5. STORY CLAIMS (inter-session coordination)
437
+ const storyClaimingPath = path.join(__dirname, 'lib', 'story-claiming.js');
438
+ const altStoryClaimingPath = '.agileflow/scripts/lib/story-claiming.js';
439
+
440
+ if (fs.existsSync(storyClaimingPath) || fs.existsSync(altStoryClaimingPath)) {
441
+ try {
442
+ const claimPath = fs.existsSync(storyClaimingPath)
443
+ ? storyClaimingPath
444
+ : altStoryClaimingPath;
445
+ const storyClaiming = require(claimPath);
446
+
447
+ // Get stories claimed by other sessions
448
+ const othersResult = storyClaiming.getStoriesClaimedByOthers();
449
+ if (othersResult.ok && othersResult.stories && othersResult.stories.length > 0) {
450
+ content += `\n${C.amber}${C.bold}═══ 🔒 Claimed Stories ═══${C.reset}\n`;
451
+ content += `${C.dim}Stories locked by other sessions - pick a different one${C.reset}\n`;
452
+ othersResult.stories.forEach(story => {
453
+ const sessionDir = story.claimedBy?.path ? path.basename(story.claimedBy.path) : 'unknown';
454
+ content += ` ${C.coral}🔒${C.reset} ${C.lavender}${story.id}${C.reset} "${story.title}" ${C.dim}→ Session ${story.claimedBy?.session_id || '?'} (${sessionDir})${C.reset}\n`;
455
+ });
456
+ content += '\n';
457
+ }
458
+
459
+ // Get stories claimed by THIS session
460
+ const myResult = storyClaiming.getClaimedStoriesForSession();
461
+ if (myResult.ok && myResult.stories && myResult.stories.length > 0) {
462
+ content += `\n${C.mintGreen}${C.bold}═══ ✓ Your Claimed Stories ═══${C.reset}\n`;
463
+ myResult.stories.forEach(story => {
464
+ content += ` ${C.mintGreen}✓${C.reset} ${C.lavender}${story.id}${C.reset} "${story.title}"\n`;
465
+ });
466
+ content += '\n';
467
+ }
468
+ } catch (e) {
469
+ // Story claiming not available or error - silently skip
470
+ }
471
+ }
472
+
473
+ // 5b. FILE OVERLAPS (inter-session file awareness)
474
+ const fileTrackingPath = path.join(__dirname, 'lib', 'file-tracking.js');
475
+ const altFileTrackingPath = '.agileflow/scripts/lib/file-tracking.js';
476
+
477
+ if (fs.existsSync(fileTrackingPath) || fs.existsSync(altFileTrackingPath)) {
478
+ try {
479
+ const trackPath = fs.existsSync(fileTrackingPath)
480
+ ? fileTrackingPath
481
+ : altFileTrackingPath;
482
+ const fileTracking = require(trackPath);
483
+
484
+ // Get file overlaps with other sessions
485
+ const overlapsResult = fileTracking.getMyFileOverlaps();
486
+ if (overlapsResult.ok && overlapsResult.overlaps && overlapsResult.overlaps.length > 0) {
487
+ content += `\n${C.amber}${C.bold}═══ ⚠️ File Overlaps ═══${C.reset}\n`;
488
+ content += `${C.dim}Files also edited by other sessions - conflicts auto-resolved during merge${C.reset}\n`;
489
+ overlapsResult.overlaps.forEach(overlap => {
490
+ const sessionInfo = overlap.otherSessions.map(s => {
491
+ const dir = path.basename(s.path);
492
+ return `Session ${s.id} (${dir})`;
493
+ }).join(', ');
494
+ content += ` ${C.amber}⚠${C.reset} ${C.lavender}${overlap.file}${C.reset} ${C.dim}→ ${sessionInfo}${C.reset}\n`;
495
+ });
496
+ content += '\n';
497
+ }
498
+
499
+ // Show files touched by this session
500
+ const { getCurrentSession, getSessionFiles } = fileTracking;
501
+ const currentSession = getCurrentSession();
502
+ if (currentSession) {
503
+ const filesResult = getSessionFiles(currentSession.session_id);
504
+ if (filesResult.ok && filesResult.files && filesResult.files.length > 0) {
505
+ content += `\n${C.skyBlue}${C.bold}═══ 📁 Files Touched This Session ═══${C.reset}\n`;
506
+ content += `${C.dim}${filesResult.files.length} files tracked for conflict detection${C.reset}\n`;
507
+ // Show first 5 files max
508
+ const displayFiles = filesResult.files.slice(0, 5);
509
+ displayFiles.forEach(file => {
510
+ content += ` ${C.dim}•${C.reset} ${file}\n`;
511
+ });
512
+ if (filesResult.files.length > 5) {
513
+ content += ` ${C.dim}... and ${filesResult.files.length - 5} more${C.reset}\n`;
514
+ }
515
+ content += '\n';
516
+ }
517
+ }
518
+ } catch (e) {
519
+ // File tracking not available or error - silently skip
520
+ }
521
+ }
522
+
523
+ // 6. VISUAL E2E STATUS (detect from metadata or filesystem)
437
524
  const metadata = safeReadJSON('docs/00-meta/agileflow-metadata.json');
525
+ const visualE2eConfig = metadata?.features?.visual_e2e;
526
+ const playwrightExists = fs.existsSync('playwright.config.ts') || fs.existsSync('playwright.config.js');
527
+ const screenshotsExists = fs.existsSync('screenshots');
528
+ const testsE2eExists = fs.existsSync('tests/e2e');
529
+
530
+ // Determine visual e2e status
531
+ const visualE2eEnabled = visualE2eConfig?.enabled || (playwrightExists && screenshotsExists);
532
+
533
+ if (visualE2eEnabled) {
534
+ content += `\n${C.brand}${C.bold}═══ 📸 VISUAL E2E TESTING: ENABLED ═══${C.reset}\n`;
535
+ content += `${C.dim}${'─'.repeat(60)}${C.reset}\n`;
536
+ content += `${C.mintGreen}✓ Playwright:${C.reset} ${playwrightExists ? 'configured' : 'not found'}\n`;
537
+ content += `${C.mintGreen}✓ Screenshots:${C.reset} ${screenshotsExists ? 'screenshots/' : 'not found'}\n`;
538
+ content += `${C.mintGreen}✓ E2E Tests:${C.reset} ${testsE2eExists ? 'tests/e2e/' : 'not found'}\n\n`;
539
+ content += `${C.bold}FOR UI WORK:${C.reset} Use ${C.skyBlue}VISUAL=true${C.reset} flag with babysit:\n`;
540
+ content += `${C.dim} /agileflow:babysit EPIC=EP-XXXX MODE=loop VISUAL=true${C.reset}\n\n`;
541
+ content += `${C.lavender}Screenshot Verification Workflow:${C.reset}\n`;
542
+ content += ` 1. E2E tests capture screenshots to ${C.skyBlue}screenshots/${C.reset}\n`;
543
+ content += ` 2. Review each screenshot visually (Claude reads image files)\n`;
544
+ content += ` 3. Rename verified: ${C.dim}mv file.png verified-file.png${C.reset}\n`;
545
+ content += ` 4. All screenshots must have ${C.mintGreen}verified-${C.reset} prefix before completion\n`;
546
+ content += `${C.dim}${'─'.repeat(60)}${C.reset}\n\n`;
547
+ } else {
548
+ content += `\n${C.dim}═══ 📸 VISUAL E2E TESTING: NOT CONFIGURED ═══${C.reset}\n`;
549
+ content += `${C.dim}For UI work with screenshot verification:${C.reset}\n`;
550
+ content += `${C.dim} /agileflow:configure → Visual E2E testing${C.reset}\n\n`;
551
+ }
552
+
553
+ // 6. INTERACTION MODE (AskUserQuestion guidance)
438
554
  const askUserQuestionConfig = metadata?.features?.askUserQuestion;
439
555
 
440
556
  if (askUserQuestionConfig?.enabled) {