agileflow 2.91.0 → 2.92.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 +6 -6
- package/lib/README.md +178 -0
- package/lib/codebase-indexer.js +32 -23
- package/lib/colors.js +190 -12
- package/lib/consent.js +232 -0
- package/lib/correlation.js +277 -0
- package/lib/error-codes.js +46 -0
- package/lib/errors.js +48 -6
- package/lib/file-cache.js +182 -0
- package/lib/format-error.js +156 -0
- package/lib/path-resolver.js +155 -7
- package/lib/paths.js +212 -20
- package/lib/placeholder-registry.js +205 -0
- package/lib/registry-di.js +358 -0
- package/lib/result-schema.js +363 -0
- package/lib/result.js +210 -0
- package/lib/session-registry.js +13 -0
- package/lib/session-state-machine.js +465 -0
- package/lib/validate-commands.js +308 -0
- package/lib/validate.js +116 -52
- package/package.json +1 -1
- package/scripts/af +34 -0
- package/scripts/agent-loop.js +63 -9
- package/scripts/agileflow-configure.js +2 -2
- package/scripts/agileflow-welcome.js +491 -23
- package/scripts/archive-completed-stories.sh +57 -11
- package/scripts/claude-tmux.sh +102 -0
- package/scripts/damage-control-bash.js +3 -70
- package/scripts/damage-control-edit.js +3 -20
- package/scripts/damage-control-write.js +3 -20
- package/scripts/dependency-check.js +310 -0
- package/scripts/get-env.js +11 -4
- package/scripts/lib/configure-detect.js +23 -1
- package/scripts/lib/configure-features.js +50 -2
- package/scripts/lib/context-formatter.js +771 -0
- package/scripts/lib/context-loader.js +699 -0
- package/scripts/lib/damage-control-utils.js +107 -0
- package/scripts/lib/json-utils.sh +162 -0
- package/scripts/lib/state-migrator.js +353 -0
- package/scripts/lib/story-state-machine.js +437 -0
- package/scripts/obtain-context.js +80 -1248
- package/scripts/pre-push-check.sh +46 -0
- package/scripts/precompact-context.sh +23 -10
- package/scripts/query-codebase.js +127 -14
- package/scripts/ralph-loop.js +5 -5
- package/scripts/session-manager.js +408 -55
- package/scripts/spawn-parallel.js +666 -0
- package/scripts/tui/blessed/data/watcher.js +20 -15
- package/scripts/tui/blessed/index.js +2 -2
- package/scripts/tui/blessed/panels/output.js +14 -8
- package/scripts/tui/blessed/panels/sessions.js +22 -15
- package/scripts/tui/blessed/panels/trace.js +14 -8
- package/scripts/tui/blessed/ui/help.js +3 -3
- package/scripts/tui/blessed/ui/screen.js +4 -4
- package/scripts/tui/blessed/ui/statusbar.js +5 -9
- package/scripts/tui/blessed/ui/tabbar.js +11 -11
- package/scripts/validators/component-validator.js +41 -14
- package/scripts/validators/json-schema-validator.js +11 -4
- package/scripts/validators/markdown-validator.js +1 -2
- package/scripts/validators/migration-validator.js +17 -5
- package/scripts/validators/security-validator.js +137 -33
- package/scripts/validators/story-format-validator.js +31 -10
- package/scripts/validators/test-result-validator.js +19 -4
- package/scripts/validators/workflow-validator.js +12 -5
- package/src/core/agents/codebase-query.md +24 -0
- package/src/core/commands/adr.md +114 -0
- package/src/core/commands/agent.md +120 -0
- package/src/core/commands/assign.md +145 -0
- package/src/core/commands/babysit.md +32 -5
- package/src/core/commands/changelog.md +118 -0
- package/src/core/commands/configure.md +42 -6
- package/src/core/commands/diagnose.md +114 -0
- package/src/core/commands/epic.md +113 -0
- package/src/core/commands/handoff.md +128 -0
- package/src/core/commands/help.md +75 -0
- package/src/core/commands/pr.md +96 -0
- package/src/core/commands/roadmap/analyze.md +400 -0
- package/src/core/commands/session/new.md +132 -6
- package/src/core/commands/session/spawn.md +197 -0
- package/src/core/commands/sprint.md +22 -0
- package/src/core/commands/status.md +74 -0
- package/src/core/commands/story.md +143 -4
- package/src/core/templates/agileflow-metadata.json +55 -2
- package/src/core/templates/plan-template.md +125 -0
- package/src/core/templates/story-lifecycle.md +213 -0
- package/src/core/templates/story-template.md +4 -0
- package/src/core/templates/tdd-test-template.js +241 -0
- package/tools/cli/commands/setup.js +95 -0
- package/tools/cli/installers/core/installer.js +94 -0
- package/tools/cli/installers/ide/_base-ide.js +20 -11
- package/tools/cli/installers/ide/codex.js +29 -47
- package/tools/cli/installers/ide/windsurf.js +1 -1
- package/tools/cli/lib/config-manager.js +17 -2
- package/tools/cli/lib/content-transformer.js +271 -0
- package/tools/cli/lib/error-handler.js +14 -22
- package/tools/cli/lib/ide-error-factory.js +421 -0
- package/tools/cli/lib/ide-health-monitor.js +364 -0
- package/tools/cli/lib/ide-registry.js +113 -2
- package/tools/cli/lib/ui.js +15 -25
|
@@ -11,18 +11,47 @@
|
|
|
11
11
|
|
|
12
12
|
const fs = require('fs');
|
|
13
13
|
const path = require('path');
|
|
14
|
-
const { execSync, spawnSync } = require('child_process');
|
|
14
|
+
const { execSync, spawnSync, spawn } = require('child_process');
|
|
15
15
|
|
|
16
16
|
// Shared utilities
|
|
17
17
|
const { c } = require('../lib/colors');
|
|
18
|
-
const {
|
|
18
|
+
const {
|
|
19
|
+
getProjectRoot,
|
|
20
|
+
getStatusPath,
|
|
21
|
+
getSessionStatePath,
|
|
22
|
+
getAgileflowDir,
|
|
23
|
+
} = require('../lib/paths');
|
|
19
24
|
const { safeReadJSON } = require('../lib/errors');
|
|
20
25
|
const { isValidBranchName, isValidSessionNickname } = require('../lib/validate');
|
|
21
26
|
|
|
27
|
+
const { SessionRegistry } = require('../lib/session-registry');
|
|
28
|
+
|
|
22
29
|
const ROOT = getProjectRoot();
|
|
23
|
-
const SESSIONS_DIR = path.join(ROOT, '
|
|
30
|
+
const SESSIONS_DIR = path.join(getAgileflowDir(ROOT), 'sessions');
|
|
24
31
|
const REGISTRY_PATH = path.join(SESSIONS_DIR, 'registry.json');
|
|
25
32
|
|
|
33
|
+
// Injectable registry instance for testing
|
|
34
|
+
let _registryInstance = null;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get the registry instance (singleton, injectable for testing)
|
|
38
|
+
* @returns {SessionRegistry}
|
|
39
|
+
*/
|
|
40
|
+
function getRegistryInstance() {
|
|
41
|
+
if (!_registryInstance) {
|
|
42
|
+
_registryInstance = new SessionRegistry(ROOT);
|
|
43
|
+
}
|
|
44
|
+
return _registryInstance;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Inject a mock registry for testing
|
|
49
|
+
* @param {SessionRegistry|null} registry - Registry to inject, or null to reset
|
|
50
|
+
*/
|
|
51
|
+
function injectRegistry(registry) {
|
|
52
|
+
_registryInstance = registry;
|
|
53
|
+
}
|
|
54
|
+
|
|
26
55
|
// Ensure sessions directory exists
|
|
27
56
|
function ensureSessionsDir() {
|
|
28
57
|
if (!fs.existsSync(SESSIONS_DIR)) {
|
|
@@ -30,35 +59,25 @@ function ensureSessionsDir() {
|
|
|
30
59
|
}
|
|
31
60
|
}
|
|
32
61
|
|
|
33
|
-
// Load or create registry
|
|
62
|
+
// Load or create registry (uses injectable SessionRegistry)
|
|
63
|
+
// Preserves original behavior: saves default registry if file didn't exist
|
|
34
64
|
function loadRegistry() {
|
|
35
|
-
|
|
65
|
+
const registryInstance = getRegistryInstance();
|
|
66
|
+
const fileExistedBefore = fs.existsSync(registryInstance.registryPath);
|
|
67
|
+
const data = registryInstance.loadSync();
|
|
36
68
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
} catch (e) {
|
|
41
|
-
console.error(`${c.red}Error loading registry: ${e.message}${c.reset}`);
|
|
42
|
-
}
|
|
69
|
+
// If file didn't exist, save the default to disk (original behavior)
|
|
70
|
+
if (!fileExistedBefore) {
|
|
71
|
+
registryInstance.saveSync(data);
|
|
43
72
|
}
|
|
44
73
|
|
|
45
|
-
|
|
46
|
-
const registry = {
|
|
47
|
-
schema_version: '1.0.0',
|
|
48
|
-
next_id: 1,
|
|
49
|
-
project_name: path.basename(ROOT),
|
|
50
|
-
sessions: {},
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
saveRegistry(registry);
|
|
54
|
-
return registry;
|
|
74
|
+
return data;
|
|
55
75
|
}
|
|
56
76
|
|
|
57
|
-
// Save registry
|
|
58
|
-
function saveRegistry(
|
|
59
|
-
|
|
60
|
-
registry.
|
|
61
|
-
fs.writeFileSync(REGISTRY_PATH, JSON.stringify(registry, null, 2) + '\n');
|
|
77
|
+
// Save registry (uses injectable SessionRegistry)
|
|
78
|
+
function saveRegistry(registryData) {
|
|
79
|
+
const registry = getRegistryInstance();
|
|
80
|
+
return registry.saveSync(registryData);
|
|
62
81
|
}
|
|
63
82
|
|
|
64
83
|
// Check if PID is alive
|
|
@@ -77,7 +96,7 @@ function getLockPath(sessionId) {
|
|
|
77
96
|
return path.join(SESSIONS_DIR, `${sessionId}.lock`);
|
|
78
97
|
}
|
|
79
98
|
|
|
80
|
-
// Read lock file
|
|
99
|
+
// Read lock file (sync version for backward compatibility)
|
|
81
100
|
function readLock(sessionId) {
|
|
82
101
|
const lockPath = getLockPath(sessionId);
|
|
83
102
|
if (!fs.existsSync(lockPath)) return null;
|
|
@@ -95,6 +114,22 @@ function readLock(sessionId) {
|
|
|
95
114
|
}
|
|
96
115
|
}
|
|
97
116
|
|
|
117
|
+
// Read lock file (async version for parallel operations)
|
|
118
|
+
async function readLockAsync(sessionId) {
|
|
119
|
+
const lockPath = getLockPath(sessionId);
|
|
120
|
+
try {
|
|
121
|
+
const content = await fs.promises.readFile(lockPath, 'utf8');
|
|
122
|
+
const lock = {};
|
|
123
|
+
content.split('\n').forEach(line => {
|
|
124
|
+
const [key, value] = line.split('=');
|
|
125
|
+
if (key && value) lock[key.trim()] = value.trim();
|
|
126
|
+
});
|
|
127
|
+
return lock;
|
|
128
|
+
} catch (e) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
98
133
|
// Write lock file
|
|
99
134
|
function writeLock(sessionId, pid) {
|
|
100
135
|
const lockPath = getLockPath(sessionId);
|
|
@@ -117,7 +152,7 @@ function isSessionActive(sessionId) {
|
|
|
117
152
|
return isPidAlive(parseInt(lock.pid, 10));
|
|
118
153
|
}
|
|
119
154
|
|
|
120
|
-
// Clean up stale locks (with detailed tracking)
|
|
155
|
+
// Clean up stale locks (with detailed tracking) - sync version for backward compatibility
|
|
121
156
|
function cleanupStaleLocks(registry, options = {}) {
|
|
122
157
|
const { verbose = false, dryRun = false } = options;
|
|
123
158
|
let cleaned = 0;
|
|
@@ -152,10 +187,84 @@ function cleanupStaleLocks(registry, options = {}) {
|
|
|
152
187
|
return { count: cleaned, sessions: cleanedSessions };
|
|
153
188
|
}
|
|
154
189
|
|
|
155
|
-
//
|
|
190
|
+
// Clean up stale locks (async parallel version - faster for many sessions)
|
|
191
|
+
async function cleanupStaleLocksAsync(registry, options = {}) {
|
|
192
|
+
const { verbose = false, dryRun = false } = options;
|
|
193
|
+
const cleanedSessions = [];
|
|
194
|
+
|
|
195
|
+
const sessionEntries = Object.entries(registry.sessions);
|
|
196
|
+
if (sessionEntries.length === 0) {
|
|
197
|
+
return { count: 0, sessions: [] };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Read all locks in parallel
|
|
201
|
+
const lockResults = await Promise.all(
|
|
202
|
+
sessionEntries.map(async ([id, session]) => {
|
|
203
|
+
const lock = await readLockAsync(id);
|
|
204
|
+
return { id, session, lock };
|
|
205
|
+
})
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
// Process results (sequential - fast since it's just memory operations)
|
|
209
|
+
for (const { id, session, lock } of lockResults) {
|
|
210
|
+
if (lock) {
|
|
211
|
+
const pid = parseInt(lock.pid, 10);
|
|
212
|
+
const isAlive = isPidAlive(pid);
|
|
213
|
+
|
|
214
|
+
if (!isAlive) {
|
|
215
|
+
cleanedSessions.push({
|
|
216
|
+
id,
|
|
217
|
+
nickname: session.nickname,
|
|
218
|
+
branch: session.branch,
|
|
219
|
+
pid,
|
|
220
|
+
reason: 'pid_dead',
|
|
221
|
+
path: session.path,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
if (!dryRun) {
|
|
225
|
+
removeLock(id);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { count: cleanedSessions.length, sessions: cleanedSessions };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Git command cache (10 second TTL to avoid stale data)
|
|
235
|
+
const gitCache = {
|
|
236
|
+
data: new Map(),
|
|
237
|
+
ttlMs: 10000,
|
|
238
|
+
get(key) {
|
|
239
|
+
const entry = this.data.get(key);
|
|
240
|
+
if (entry && Date.now() - entry.timestamp < this.ttlMs) {
|
|
241
|
+
return entry.value;
|
|
242
|
+
}
|
|
243
|
+
this.data.delete(key);
|
|
244
|
+
return null;
|
|
245
|
+
},
|
|
246
|
+
set(key, value) {
|
|
247
|
+
this.data.set(key, { value, timestamp: Date.now() });
|
|
248
|
+
},
|
|
249
|
+
invalidate(key) {
|
|
250
|
+
if (key) {
|
|
251
|
+
this.data.delete(key);
|
|
252
|
+
} else {
|
|
253
|
+
this.data.clear();
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// Get current git branch (cached for performance)
|
|
156
259
|
function getCurrentBranch() {
|
|
260
|
+
const cacheKey = `branch:${ROOT}`;
|
|
261
|
+
const cached = gitCache.get(cacheKey);
|
|
262
|
+
if (cached !== null) return cached;
|
|
263
|
+
|
|
157
264
|
try {
|
|
158
|
-
|
|
265
|
+
const branch = execSync('git branch --show-current', { cwd: ROOT, encoding: 'utf8' }).trim();
|
|
266
|
+
gitCache.set(cacheKey, branch);
|
|
267
|
+
return branch;
|
|
159
268
|
} catch (e) {
|
|
160
269
|
return 'unknown';
|
|
161
270
|
}
|
|
@@ -163,7 +272,7 @@ function getCurrentBranch() {
|
|
|
163
272
|
|
|
164
273
|
// Get current story from status.json
|
|
165
274
|
function getCurrentStory() {
|
|
166
|
-
const statusPath =
|
|
275
|
+
const statusPath = getStatusPath(ROOT);
|
|
167
276
|
const result = safeReadJSON(statusPath, { defaultValue: null });
|
|
168
277
|
|
|
169
278
|
if (!result.ok || !result.data) return null;
|
|
@@ -276,8 +385,157 @@ function getSession(sessionId) {
|
|
|
276
385
|
};
|
|
277
386
|
}
|
|
278
387
|
|
|
388
|
+
// Default worktree timeout (2 minutes)
|
|
389
|
+
const DEFAULT_WORKTREE_TIMEOUT_MS = 120000;
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Display progress feedback during long operations.
|
|
393
|
+
* Returns a function to stop the progress indicator.
|
|
394
|
+
*
|
|
395
|
+
* @param {string} message - Progress message
|
|
396
|
+
* @returns {function} Stop function
|
|
397
|
+
*/
|
|
398
|
+
function progressIndicator(message) {
|
|
399
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
400
|
+
let frameIndex = 0;
|
|
401
|
+
let elapsed = 0;
|
|
402
|
+
|
|
403
|
+
// For TTY (interactive terminal), show spinner
|
|
404
|
+
if (process.stderr.isTTY) {
|
|
405
|
+
const interval = setInterval(() => {
|
|
406
|
+
process.stderr.write(`\r${frames[frameIndex++ % frames.length]} ${message}`);
|
|
407
|
+
}, 80);
|
|
408
|
+
return () => {
|
|
409
|
+
clearInterval(interval);
|
|
410
|
+
process.stderr.write(`\r${' '.repeat(message.length + 2)}\r`);
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// For non-TTY (Claude Code, piped output), emit periodic updates to stderr
|
|
415
|
+
process.stderr.write(`⏳ ${message}...\n`);
|
|
416
|
+
const interval = setInterval(() => {
|
|
417
|
+
elapsed += 10;
|
|
418
|
+
process.stderr.write(`⏳ Still working... (${elapsed}s elapsed)\n`);
|
|
419
|
+
}, 10000); // Update every 10 seconds
|
|
420
|
+
|
|
421
|
+
return () => {
|
|
422
|
+
clearInterval(interval);
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Create a git worktree with timeout and progress feedback.
|
|
428
|
+
* Uses async spawn instead of spawnSync for timeout support.
|
|
429
|
+
*
|
|
430
|
+
* @param {string} worktreePath - Path for the new worktree
|
|
431
|
+
* @param {string} branchName - Branch name for the worktree
|
|
432
|
+
* @param {number} timeoutMs - Timeout in milliseconds
|
|
433
|
+
* @returns {Promise<{stdout: string, stderr: string}>}
|
|
434
|
+
*/
|
|
435
|
+
function createWorktreeWithTimeout(worktreePath, branchName, timeoutMs = DEFAULT_WORKTREE_TIMEOUT_MS) {
|
|
436
|
+
return new Promise((resolve, reject) => {
|
|
437
|
+
let stdout = '';
|
|
438
|
+
let stderr = '';
|
|
439
|
+
let timedOut = false;
|
|
440
|
+
|
|
441
|
+
const proc = spawn('git', ['worktree', 'add', worktreePath, branchName], {
|
|
442
|
+
cwd: ROOT,
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
const timer = setTimeout(() => {
|
|
446
|
+
timedOut = true;
|
|
447
|
+
proc.kill('SIGTERM');
|
|
448
|
+
// Give it a moment to terminate gracefully, then SIGKILL
|
|
449
|
+
setTimeout(() => {
|
|
450
|
+
try {
|
|
451
|
+
proc.kill('SIGKILL');
|
|
452
|
+
} catch (e) {
|
|
453
|
+
// Process may have already exited
|
|
454
|
+
}
|
|
455
|
+
}, 1000);
|
|
456
|
+
}, timeoutMs);
|
|
457
|
+
|
|
458
|
+
proc.stdout.on('data', (data) => {
|
|
459
|
+
stdout += data.toString();
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
proc.stderr.on('data', (data) => {
|
|
463
|
+
stderr += data.toString();
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
proc.on('error', (err) => {
|
|
467
|
+
clearTimeout(timer);
|
|
468
|
+
reject(new Error(`Failed to spawn git: ${err.message}`));
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
proc.on('close', (code, signal) => {
|
|
472
|
+
clearTimeout(timer);
|
|
473
|
+
|
|
474
|
+
if (timedOut) {
|
|
475
|
+
reject(new Error(`Worktree creation timed out after ${timeoutMs / 1000}s. Try increasing timeout or check disk space.`));
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (signal) {
|
|
480
|
+
reject(new Error(`Worktree creation was terminated by signal: ${signal}`));
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (code === 0) {
|
|
485
|
+
resolve({ stdout, stderr });
|
|
486
|
+
} else {
|
|
487
|
+
reject(new Error(`Failed to create worktree: ${stderr || 'unknown error'}`));
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Clean up partial state after failed worktree creation.
|
|
495
|
+
* Removes partial directory and prunes git worktree registry.
|
|
496
|
+
*
|
|
497
|
+
* @param {string} worktreePath - Path of the failed worktree
|
|
498
|
+
* @param {string} branchName - Branch name that was being used
|
|
499
|
+
* @param {boolean} branchCreatedByUs - Whether we created the branch
|
|
500
|
+
*/
|
|
501
|
+
function cleanupFailedWorktree(worktreePath, branchName, branchCreatedByUs = false) {
|
|
502
|
+
// Remove partial worktree directory if it exists
|
|
503
|
+
if (fs.existsSync(worktreePath)) {
|
|
504
|
+
try {
|
|
505
|
+
fs.rmSync(worktreePath, { recursive: true, force: true });
|
|
506
|
+
process.stderr.write(`🧹 Cleaned up partial worktree directory\n`);
|
|
507
|
+
} catch (e) {
|
|
508
|
+
process.stderr.write(`⚠️ Could not remove partial directory: ${e.message}\n`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Prune git worktree registry to clean up any references
|
|
513
|
+
try {
|
|
514
|
+
spawnSync('git', ['worktree', 'prune'], { cwd: ROOT, encoding: 'utf8' });
|
|
515
|
+
} catch (e) {
|
|
516
|
+
// Non-fatal
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// If we created the branch and the worktree failed, optionally clean up the branch too
|
|
520
|
+
// But only if it has no commits beyond the parent (i.e., we just created it)
|
|
521
|
+
if (branchCreatedByUs) {
|
|
522
|
+
try {
|
|
523
|
+
// Check if branch exists and has no unique commits
|
|
524
|
+
const result = spawnSync('git', ['branch', '-d', branchName], {
|
|
525
|
+
cwd: ROOT,
|
|
526
|
+
encoding: 'utf8',
|
|
527
|
+
});
|
|
528
|
+
if (result.status === 0) {
|
|
529
|
+
process.stderr.write(`🧹 Cleaned up unused branch: ${branchName}\n`);
|
|
530
|
+
}
|
|
531
|
+
} catch (e) {
|
|
532
|
+
// Non-fatal - branch may have commits or not exist
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
279
537
|
// Create new session with worktree
|
|
280
|
-
function createSession(options = {}) {
|
|
538
|
+
async function createSession(options = {}) {
|
|
281
539
|
const registry = loadRegistry();
|
|
282
540
|
const sessionId = String(registry.next_id);
|
|
283
541
|
const projectName = registry.project_name;
|
|
@@ -322,6 +580,7 @@ function createSession(options = {}) {
|
|
|
322
580
|
}
|
|
323
581
|
);
|
|
324
582
|
|
|
583
|
+
let branchCreatedByUs = false;
|
|
325
584
|
if (checkRef.status !== 0) {
|
|
326
585
|
// Branch doesn't exist, create it
|
|
327
586
|
const createBranch = spawnSync('git', ['branch', branchName], {
|
|
@@ -335,21 +594,65 @@ function createSession(options = {}) {
|
|
|
335
594
|
error: `Failed to create branch: ${createBranch.stderr || 'unknown error'}`,
|
|
336
595
|
};
|
|
337
596
|
}
|
|
597
|
+
branchCreatedByUs = true;
|
|
338
598
|
}
|
|
339
599
|
|
|
340
|
-
//
|
|
341
|
-
const
|
|
342
|
-
cwd: ROOT,
|
|
343
|
-
encoding: 'utf8',
|
|
344
|
-
});
|
|
600
|
+
// Get timeout from options (default: 2 minutes)
|
|
601
|
+
const timeoutMs = options.timeout || DEFAULT_WORKTREE_TIMEOUT_MS;
|
|
345
602
|
|
|
346
|
-
|
|
603
|
+
// Create worktree with timeout and progress feedback
|
|
604
|
+
const stopProgress = progressIndicator('Creating worktree (this may take a while for large repos)');
|
|
605
|
+
try {
|
|
606
|
+
await createWorktreeWithTimeout(worktreePath, branchName, timeoutMs);
|
|
607
|
+
stopProgress();
|
|
608
|
+
process.stderr.write(`✓ Worktree created successfully\n`);
|
|
609
|
+
} catch (error) {
|
|
610
|
+
stopProgress();
|
|
611
|
+
// Clean up partial state
|
|
612
|
+
cleanupFailedWorktree(worktreePath, branchName, branchCreatedByUs);
|
|
347
613
|
return {
|
|
348
614
|
success: false,
|
|
349
|
-
error:
|
|
615
|
+
error: error.message,
|
|
350
616
|
};
|
|
351
617
|
}
|
|
352
618
|
|
|
619
|
+
// Copy environment files to new worktree (they don't copy automatically)
|
|
620
|
+
const envFiles = ['.env', '.env.local', '.env.development', '.env.test', '.env.production'];
|
|
621
|
+
const copiedEnvFiles = [];
|
|
622
|
+
for (const envFile of envFiles) {
|
|
623
|
+
const src = path.join(ROOT, envFile);
|
|
624
|
+
const dest = path.join(worktreePath, envFile);
|
|
625
|
+
if (fs.existsSync(src) && !fs.existsSync(dest)) {
|
|
626
|
+
try {
|
|
627
|
+
fs.copyFileSync(src, dest);
|
|
628
|
+
copiedEnvFiles.push(envFile);
|
|
629
|
+
} catch (e) {
|
|
630
|
+
// Non-fatal: log but continue
|
|
631
|
+
console.warn(`Warning: Could not copy ${envFile}: ${e.message}`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Copy Claude Code, AgileFlow config, and docs folders (gitignored contents won't copy with worktree)
|
|
637
|
+
// Note: The folder may exist with some tracked files, but gitignored subfolders (commands/, agents/) won't be there
|
|
638
|
+
// docs/ contains gitignored state files like status.json, session-state.json that need to be shared
|
|
639
|
+
const configFolders = ['.claude', '.agileflow', 'docs'];
|
|
640
|
+
const copiedFolders = [];
|
|
641
|
+
for (const folder of configFolders) {
|
|
642
|
+
const src = path.join(ROOT, folder);
|
|
643
|
+
const dest = path.join(worktreePath, folder);
|
|
644
|
+
if (fs.existsSync(src)) {
|
|
645
|
+
try {
|
|
646
|
+
// Use force to overwrite existing files, recursive for subdirs
|
|
647
|
+
fs.cpSync(src, dest, { recursive: true, force: true });
|
|
648
|
+
copiedFolders.push(folder);
|
|
649
|
+
} catch (e) {
|
|
650
|
+
// Non-fatal: log but continue
|
|
651
|
+
console.warn(`Warning: Could not copy ${folder}: ${e.message}`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
353
656
|
// Register session - worktree sessions are always parallel threads
|
|
354
657
|
registry.next_id++;
|
|
355
658
|
registry.sessions[sessionId] = {
|
|
@@ -372,6 +675,8 @@ function createSession(options = {}) {
|
|
|
372
675
|
branch: branchName,
|
|
373
676
|
thread_type: registry.sessions[sessionId].thread_type,
|
|
374
677
|
command: `cd "${worktreePath}" && claude`,
|
|
678
|
+
envFilesCopied: copiedEnvFiles,
|
|
679
|
+
foldersCopied: copiedFolders,
|
|
375
680
|
};
|
|
376
681
|
}
|
|
377
682
|
|
|
@@ -445,22 +750,33 @@ function deleteSession(sessionId, removeWorktree = false) {
|
|
|
445
750
|
return { success: true };
|
|
446
751
|
}
|
|
447
752
|
|
|
448
|
-
// Get main branch name (main or master)
|
|
753
|
+
// Get main branch name (main or master) - cached since it rarely changes
|
|
449
754
|
function getMainBranch() {
|
|
755
|
+
const cacheKey = `mainBranch:${ROOT}`;
|
|
756
|
+
const cached = gitCache.get(cacheKey);
|
|
757
|
+
if (cached !== null) return cached;
|
|
758
|
+
|
|
450
759
|
const checkMain = spawnSync('git', ['show-ref', '--verify', '--quiet', 'refs/heads/main'], {
|
|
451
760
|
cwd: ROOT,
|
|
452
761
|
encoding: 'utf8',
|
|
453
762
|
});
|
|
454
763
|
|
|
455
|
-
if (checkMain.status === 0)
|
|
764
|
+
if (checkMain.status === 0) {
|
|
765
|
+
gitCache.set(cacheKey, 'main');
|
|
766
|
+
return 'main';
|
|
767
|
+
}
|
|
456
768
|
|
|
457
769
|
const checkMaster = spawnSync('git', ['show-ref', '--verify', '--quiet', 'refs/heads/master'], {
|
|
458
770
|
cwd: ROOT,
|
|
459
771
|
encoding: 'utf8',
|
|
460
772
|
});
|
|
461
773
|
|
|
462
|
-
if (checkMaster.status === 0)
|
|
774
|
+
if (checkMaster.status === 0) {
|
|
775
|
+
gitCache.set(cacheKey, 'master');
|
|
776
|
+
return 'master';
|
|
777
|
+
}
|
|
463
778
|
|
|
779
|
+
gitCache.set(cacheKey, 'main');
|
|
464
780
|
return 'main'; // Default fallback
|
|
465
781
|
}
|
|
466
782
|
|
|
@@ -745,7 +1061,7 @@ const SESSION_PHASES = {
|
|
|
745
1061
|
MERGED: 'merged',
|
|
746
1062
|
};
|
|
747
1063
|
|
|
748
|
-
// Detect session phase based on git state
|
|
1064
|
+
// Detect session phase based on git state (with caching for performance)
|
|
749
1065
|
function getSessionPhase(session) {
|
|
750
1066
|
// If merged_at field exists, session was merged
|
|
751
1067
|
if (session.merged_at) {
|
|
@@ -764,6 +1080,11 @@ function getSessionPhase(session) {
|
|
|
764
1080
|
return SESSION_PHASES.TODO;
|
|
765
1081
|
}
|
|
766
1082
|
|
|
1083
|
+
// Cache key for this session's git state
|
|
1084
|
+
const cacheKey = `phase:${sessionPath}`;
|
|
1085
|
+
const cached = gitCache.get(cacheKey);
|
|
1086
|
+
if (cached !== null) return cached;
|
|
1087
|
+
|
|
767
1088
|
// Count commits since branch diverged from main
|
|
768
1089
|
const mainBranch = getMainBranch();
|
|
769
1090
|
const commitCount = execSync(`git rev-list --count ${mainBranch}..HEAD 2>/dev/null || echo 0`, {
|
|
@@ -774,6 +1095,7 @@ function getSessionPhase(session) {
|
|
|
774
1095
|
const commits = parseInt(commitCount, 10);
|
|
775
1096
|
|
|
776
1097
|
if (commits === 0) {
|
|
1098
|
+
gitCache.set(cacheKey, SESSION_PHASES.TODO);
|
|
777
1099
|
return SESSION_PHASES.TODO;
|
|
778
1100
|
}
|
|
779
1101
|
|
|
@@ -783,13 +1105,17 @@ function getSessionPhase(session) {
|
|
|
783
1105
|
encoding: 'utf8',
|
|
784
1106
|
}).trim();
|
|
785
1107
|
|
|
1108
|
+
let phase;
|
|
786
1109
|
if (status === '') {
|
|
787
1110
|
// No uncommitted changes = ready for review
|
|
788
|
-
|
|
1111
|
+
phase = SESSION_PHASES.REVIEW;
|
|
1112
|
+
} else {
|
|
1113
|
+
// Has commits but also uncommitted changes = still coding
|
|
1114
|
+
phase = SESSION_PHASES.CODING;
|
|
789
1115
|
}
|
|
790
1116
|
|
|
791
|
-
|
|
792
|
-
return
|
|
1117
|
+
gitCache.set(cacheKey, phase);
|
|
1118
|
+
return phase;
|
|
793
1119
|
} catch (e) {
|
|
794
1120
|
// On error, assume coding phase
|
|
795
1121
|
return SESSION_PHASES.CODING;
|
|
@@ -952,7 +1278,7 @@ function main() {
|
|
|
952
1278
|
case 'create': {
|
|
953
1279
|
const options = {};
|
|
954
1280
|
// SECURITY: Only accept whitelisted option keys
|
|
955
|
-
const allowedKeys = ['nickname', 'branch'];
|
|
1281
|
+
const allowedKeys = ['nickname', 'branch', 'timeout'];
|
|
956
1282
|
for (let i = 1; i < args.length; i++) {
|
|
957
1283
|
const arg = args[i];
|
|
958
1284
|
if (arg.startsWith('--')) {
|
|
@@ -970,8 +1296,20 @@ function main() {
|
|
|
970
1296
|
}
|
|
971
1297
|
}
|
|
972
1298
|
}
|
|
973
|
-
|
|
974
|
-
|
|
1299
|
+
// Parse timeout as number (milliseconds)
|
|
1300
|
+
if (options.timeout) {
|
|
1301
|
+
options.timeout = parseInt(options.timeout, 10);
|
|
1302
|
+
if (isNaN(options.timeout) || options.timeout < 1000) {
|
|
1303
|
+
console.log(JSON.stringify({ success: false, error: 'Timeout must be a number >= 1000 (milliseconds)' }));
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
// Handle async createSession
|
|
1308
|
+
createSession(options).then(result => {
|
|
1309
|
+
console.log(JSON.stringify(result));
|
|
1310
|
+
}).catch(err => {
|
|
1311
|
+
console.log(JSON.stringify({ success: false, error: err.message }));
|
|
1312
|
+
});
|
|
975
1313
|
break;
|
|
976
1314
|
}
|
|
977
1315
|
|
|
@@ -1046,12 +1384,11 @@ function main() {
|
|
|
1046
1384
|
|
|
1047
1385
|
// Register in single pass (combines register + count + status)
|
|
1048
1386
|
const registry = loadRegistry();
|
|
1049
|
-
const cleanupResult = cleanupStaleLocks(registry);
|
|
1050
1387
|
const branch = getCurrentBranch();
|
|
1051
1388
|
const story = getCurrentStory();
|
|
1052
1389
|
const pid = process.ppid || process.pid;
|
|
1053
1390
|
|
|
1054
|
-
// Find or create session
|
|
1391
|
+
// Find or create session FIRST (so we don't clean our own stale lock)
|
|
1055
1392
|
let sessionId = null;
|
|
1056
1393
|
let isNew = false;
|
|
1057
1394
|
for (const [id, session] of Object.entries(registry.sessions)) {
|
|
@@ -1094,6 +1431,16 @@ function main() {
|
|
|
1094
1431
|
}
|
|
1095
1432
|
saveRegistry(registry);
|
|
1096
1433
|
|
|
1434
|
+
// Clean up stale locks AFTER registering current session (so we don't clean our own lock)
|
|
1435
|
+
const cleanupResult = cleanupStaleLocks(registry);
|
|
1436
|
+
|
|
1437
|
+
// Filter out the current session from cleanup reports (its lock was just refreshed)
|
|
1438
|
+
// Use String() to ensure consistent comparison (sessionId is string, cleanup.id may vary)
|
|
1439
|
+
const filteredCleanup = {
|
|
1440
|
+
count: cleanupResult.sessions.filter(s => String(s.id) !== String(sessionId)).length,
|
|
1441
|
+
sessions: cleanupResult.sessions.filter(s => String(s.id) !== String(sessionId)),
|
|
1442
|
+
};
|
|
1443
|
+
|
|
1097
1444
|
// Build session list and counts
|
|
1098
1445
|
const sessions = [];
|
|
1099
1446
|
let otherActive = 0;
|
|
@@ -1114,8 +1461,8 @@ function main() {
|
|
|
1114
1461
|
current,
|
|
1115
1462
|
otherActive,
|
|
1116
1463
|
total: sessions.length,
|
|
1117
|
-
cleaned:
|
|
1118
|
-
cleanedSessions:
|
|
1464
|
+
cleaned: filteredCleanup.count,
|
|
1465
|
+
cleanedSessions: filteredCleanup.sessions,
|
|
1119
1466
|
})
|
|
1120
1467
|
);
|
|
1121
1468
|
break;
|
|
@@ -1276,7 +1623,7 @@ ${c.brand}${c.bold}Session Manager${c.reset} - Multi-session coordination for Cl
|
|
|
1276
1623
|
${c.cyan}Commands:${c.reset}
|
|
1277
1624
|
register [nickname] Register current directory as a session
|
|
1278
1625
|
unregister <id> Unregister a session (remove lock)
|
|
1279
|
-
create [--nickname X]
|
|
1626
|
+
create [--nickname X] [--timeout MS] Create session with worktree (default timeout: 120000ms)
|
|
1280
1627
|
list [--json] List all sessions
|
|
1281
1628
|
count Count other active sessions
|
|
1282
1629
|
delete <id> [--remove-worktree] Delete session
|
|
@@ -1815,7 +2162,7 @@ function getMergeHistory() {
|
|
|
1815
2162
|
}
|
|
1816
2163
|
|
|
1817
2164
|
// Session state file path
|
|
1818
|
-
const SESSION_STATE_PATH =
|
|
2165
|
+
const SESSION_STATE_PATH = getSessionStatePath(ROOT);
|
|
1819
2166
|
|
|
1820
2167
|
/**
|
|
1821
2168
|
* Switch active session context (for use with /add-dir).
|
|
@@ -1998,8 +2345,13 @@ function setSessionThreadType(sessionId, threadType) {
|
|
|
1998
2345
|
|
|
1999
2346
|
// Export for use as module
|
|
2000
2347
|
module.exports = {
|
|
2348
|
+
// Registry injection (for testing)
|
|
2349
|
+
injectRegistry,
|
|
2350
|
+
getRegistryInstance,
|
|
2351
|
+
// Registry access (backward compatible)
|
|
2001
2352
|
loadRegistry,
|
|
2002
2353
|
saveRegistry,
|
|
2354
|
+
// Session management
|
|
2003
2355
|
registerSession,
|
|
2004
2356
|
unregisterSession,
|
|
2005
2357
|
getSession,
|
|
@@ -2009,6 +2361,7 @@ module.exports = {
|
|
|
2009
2361
|
deleteSession,
|
|
2010
2362
|
isSessionActive,
|
|
2011
2363
|
cleanupStaleLocks,
|
|
2364
|
+
cleanupStaleLocksAsync,
|
|
2012
2365
|
// Merge operations
|
|
2013
2366
|
getMainBranch,
|
|
2014
2367
|
checkMergeability,
|