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.
Files changed (100) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +6 -6
  3. package/lib/README.md +178 -0
  4. package/lib/codebase-indexer.js +32 -23
  5. package/lib/colors.js +190 -12
  6. package/lib/consent.js +232 -0
  7. package/lib/correlation.js +277 -0
  8. package/lib/error-codes.js +46 -0
  9. package/lib/errors.js +48 -6
  10. package/lib/file-cache.js +182 -0
  11. package/lib/format-error.js +156 -0
  12. package/lib/path-resolver.js +155 -7
  13. package/lib/paths.js +212 -20
  14. package/lib/placeholder-registry.js +205 -0
  15. package/lib/registry-di.js +358 -0
  16. package/lib/result-schema.js +363 -0
  17. package/lib/result.js +210 -0
  18. package/lib/session-registry.js +13 -0
  19. package/lib/session-state-machine.js +465 -0
  20. package/lib/validate-commands.js +308 -0
  21. package/lib/validate.js +116 -52
  22. package/package.json +1 -1
  23. package/scripts/af +34 -0
  24. package/scripts/agent-loop.js +63 -9
  25. package/scripts/agileflow-configure.js +2 -2
  26. package/scripts/agileflow-welcome.js +491 -23
  27. package/scripts/archive-completed-stories.sh +57 -11
  28. package/scripts/claude-tmux.sh +102 -0
  29. package/scripts/damage-control-bash.js +3 -70
  30. package/scripts/damage-control-edit.js +3 -20
  31. package/scripts/damage-control-write.js +3 -20
  32. package/scripts/dependency-check.js +310 -0
  33. package/scripts/get-env.js +11 -4
  34. package/scripts/lib/configure-detect.js +23 -1
  35. package/scripts/lib/configure-features.js +50 -2
  36. package/scripts/lib/context-formatter.js +771 -0
  37. package/scripts/lib/context-loader.js +699 -0
  38. package/scripts/lib/damage-control-utils.js +107 -0
  39. package/scripts/lib/json-utils.sh +162 -0
  40. package/scripts/lib/state-migrator.js +353 -0
  41. package/scripts/lib/story-state-machine.js +437 -0
  42. package/scripts/obtain-context.js +80 -1248
  43. package/scripts/pre-push-check.sh +46 -0
  44. package/scripts/precompact-context.sh +23 -10
  45. package/scripts/query-codebase.js +127 -14
  46. package/scripts/ralph-loop.js +5 -5
  47. package/scripts/session-manager.js +408 -55
  48. package/scripts/spawn-parallel.js +666 -0
  49. package/scripts/tui/blessed/data/watcher.js +20 -15
  50. package/scripts/tui/blessed/index.js +2 -2
  51. package/scripts/tui/blessed/panels/output.js +14 -8
  52. package/scripts/tui/blessed/panels/sessions.js +22 -15
  53. package/scripts/tui/blessed/panels/trace.js +14 -8
  54. package/scripts/tui/blessed/ui/help.js +3 -3
  55. package/scripts/tui/blessed/ui/screen.js +4 -4
  56. package/scripts/tui/blessed/ui/statusbar.js +5 -9
  57. package/scripts/tui/blessed/ui/tabbar.js +11 -11
  58. package/scripts/validators/component-validator.js +41 -14
  59. package/scripts/validators/json-schema-validator.js +11 -4
  60. package/scripts/validators/markdown-validator.js +1 -2
  61. package/scripts/validators/migration-validator.js +17 -5
  62. package/scripts/validators/security-validator.js +137 -33
  63. package/scripts/validators/story-format-validator.js +31 -10
  64. package/scripts/validators/test-result-validator.js +19 -4
  65. package/scripts/validators/workflow-validator.js +12 -5
  66. package/src/core/agents/codebase-query.md +24 -0
  67. package/src/core/commands/adr.md +114 -0
  68. package/src/core/commands/agent.md +120 -0
  69. package/src/core/commands/assign.md +145 -0
  70. package/src/core/commands/babysit.md +32 -5
  71. package/src/core/commands/changelog.md +118 -0
  72. package/src/core/commands/configure.md +42 -6
  73. package/src/core/commands/diagnose.md +114 -0
  74. package/src/core/commands/epic.md +113 -0
  75. package/src/core/commands/handoff.md +128 -0
  76. package/src/core/commands/help.md +75 -0
  77. package/src/core/commands/pr.md +96 -0
  78. package/src/core/commands/roadmap/analyze.md +400 -0
  79. package/src/core/commands/session/new.md +132 -6
  80. package/src/core/commands/session/spawn.md +197 -0
  81. package/src/core/commands/sprint.md +22 -0
  82. package/src/core/commands/status.md +74 -0
  83. package/src/core/commands/story.md +143 -4
  84. package/src/core/templates/agileflow-metadata.json +55 -2
  85. package/src/core/templates/plan-template.md +125 -0
  86. package/src/core/templates/story-lifecycle.md +213 -0
  87. package/src/core/templates/story-template.md +4 -0
  88. package/src/core/templates/tdd-test-template.js +241 -0
  89. package/tools/cli/commands/setup.js +95 -0
  90. package/tools/cli/installers/core/installer.js +94 -0
  91. package/tools/cli/installers/ide/_base-ide.js +20 -11
  92. package/tools/cli/installers/ide/codex.js +29 -47
  93. package/tools/cli/installers/ide/windsurf.js +1 -1
  94. package/tools/cli/lib/config-manager.js +17 -2
  95. package/tools/cli/lib/content-transformer.js +271 -0
  96. package/tools/cli/lib/error-handler.js +14 -22
  97. package/tools/cli/lib/ide-error-factory.js +421 -0
  98. package/tools/cli/lib/ide-health-monitor.js +364 -0
  99. package/tools/cli/lib/ide-registry.js +113 -2
  100. 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 { getProjectRoot } = require('../lib/paths');
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, '.agileflow', 'sessions');
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
- ensureSessionsDir();
65
+ const registryInstance = getRegistryInstance();
66
+ const fileExistedBefore = fs.existsSync(registryInstance.registryPath);
67
+ const data = registryInstance.loadSync();
36
68
 
37
- if (fs.existsSync(REGISTRY_PATH)) {
38
- try {
39
- return JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf8'));
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
- // Create default registry
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(registry) {
59
- ensureSessionsDir();
60
- registry.updated = new Date().toISOString();
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
- // Get current git branch
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
- return execSync('git branch --show-current', { cwd: ROOT, encoding: 'utf8' }).trim();
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 = path.join(ROOT, 'docs', '09-agents', 'status.json');
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
- // Create worktree (using spawnSync for safety)
341
- const createWorktree = spawnSync('git', ['worktree', 'add', worktreePath, branchName], {
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
- if (createWorktree.status !== 0) {
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: `Failed to create worktree: ${createWorktree.stderr || 'unknown 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) return 'main';
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) return 'master';
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
- return SESSION_PHASES.REVIEW;
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
- // Has commits but also uncommitted changes = still coding
792
- return SESSION_PHASES.CODING;
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
- const result = createSession(options);
974
- console.log(JSON.stringify(result));
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: cleanupResult.count,
1118
- cleanedSessions: cleanupResult.sessions,
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] Create new session with git worktree
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 = path.join(ROOT, 'docs', '09-agents', 'session-state.json');
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,