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.
- package/CHANGELOG.md +10 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/scripts/agileflow-configure.js +2 -4
- package/scripts/agileflow-statusline.sh +50 -3
- package/scripts/agileflow-welcome.js +103 -19
- 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
|
@@ -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.
|
|
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) {
|