agileflow 2.94.1 → 2.95.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 (74) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +3 -3
  3. package/lib/colors.generated.js +117 -0
  4. package/lib/colors.js +59 -109
  5. package/lib/generator-factory.js +333 -0
  6. package/lib/path-utils.js +49 -0
  7. package/lib/session-registry.js +25 -15
  8. package/lib/smart-json-file.js +40 -32
  9. package/lib/state-machine.js +286 -0
  10. package/package.json +1 -1
  11. package/scripts/agileflow-configure.js +7 -6
  12. package/scripts/archive-completed-stories.sh +86 -11
  13. package/scripts/babysit-context-restore.js +89 -0
  14. package/scripts/claude-tmux.sh +111 -5
  15. package/scripts/damage-control/bash-tool-damage-control.js +11 -247
  16. package/scripts/damage-control/edit-tool-damage-control.js +9 -249
  17. package/scripts/damage-control/write-tool-damage-control.js +9 -244
  18. package/scripts/generate-colors.js +314 -0
  19. package/scripts/lib/colors.generated.sh +82 -0
  20. package/scripts/lib/colors.sh +10 -70
  21. package/scripts/lib/configure-features.js +401 -0
  22. package/scripts/lib/context-loader.js +181 -52
  23. package/scripts/precompact-context.sh +54 -17
  24. package/scripts/session-coordinator.sh +2 -2
  25. package/scripts/session-manager.js +653 -10
  26. package/src/core/commands/audit.md +93 -0
  27. package/src/core/commands/auto.md +73 -0
  28. package/src/core/commands/babysit.md +169 -13
  29. package/src/core/commands/baseline.md +73 -0
  30. package/src/core/commands/batch.md +64 -0
  31. package/src/core/commands/blockers.md +60 -0
  32. package/src/core/commands/board.md +66 -0
  33. package/src/core/commands/choose.md +77 -0
  34. package/src/core/commands/ci.md +77 -0
  35. package/src/core/commands/compress.md +27 -1
  36. package/src/core/commands/configure.md +126 -10
  37. package/src/core/commands/council.md +74 -0
  38. package/src/core/commands/debt.md +72 -0
  39. package/src/core/commands/deploy.md +73 -0
  40. package/src/core/commands/deps.md +68 -0
  41. package/src/core/commands/docs.md +60 -0
  42. package/src/core/commands/feedback.md +68 -0
  43. package/src/core/commands/ideate.md +74 -0
  44. package/src/core/commands/impact.md +74 -0
  45. package/src/core/commands/install.md +529 -0
  46. package/src/core/commands/maintain.md +558 -0
  47. package/src/core/commands/metrics.md +75 -0
  48. package/src/core/commands/multi-expert.md +74 -0
  49. package/src/core/commands/packages.md +69 -0
  50. package/src/core/commands/readme-sync.md +64 -0
  51. package/src/core/commands/research/analyze.md +285 -121
  52. package/src/core/commands/research/import.md +281 -109
  53. package/src/core/commands/retro.md +76 -0
  54. package/src/core/commands/review.md +72 -0
  55. package/src/core/commands/rlm.md +83 -0
  56. package/src/core/commands/rpi.md +90 -0
  57. package/src/core/commands/session/cleanup.md +214 -12
  58. package/src/core/commands/session/end.md +155 -17
  59. package/src/core/commands/sprint.md +72 -0
  60. package/src/core/commands/story-validate.md +68 -0
  61. package/src/core/commands/template.md +69 -0
  62. package/src/core/commands/tests.md +83 -0
  63. package/src/core/commands/update.md +59 -0
  64. package/src/core/commands/validate-expertise.md +76 -0
  65. package/src/core/commands/velocity.md +74 -0
  66. package/src/core/commands/verify.md +91 -0
  67. package/src/core/commands/whats-new.md +69 -0
  68. package/src/core/commands/workflow.md +88 -0
  69. package/src/core/templates/command-documentation.md +187 -0
  70. package/tools/cli/commands/session.js +1171 -0
  71. package/tools/cli/commands/setup.js +2 -81
  72. package/tools/cli/installers/core/installer.js +0 -5
  73. package/tools/cli/installers/ide/claude-code.js +6 -0
  74. package/tools/cli/lib/config-manager.js +42 -5
@@ -25,6 +25,7 @@ const { safeReadJSON } = require('../lib/errors');
25
25
  const { isValidBranchName, isValidSessionNickname } = require('../lib/validate');
26
26
 
27
27
  const { SessionRegistry } = require('../lib/session-registry');
28
+ const { sessionThreadMachine } = require('../lib/state-machine');
28
29
 
29
30
  const ROOT = getProjectRoot();
30
31
  const SESSIONS_DIR = path.join(getAgileflowDir(ROOT), 'sessions');
@@ -32,6 +33,8 @@ const REGISTRY_PATH = path.join(SESSIONS_DIR, 'registry.json');
32
33
 
33
34
  // Injectable registry instance for testing
34
35
  let _registryInstance = null;
36
+ // Track whether we've done the one-time initialization (file existence check)
37
+ let _registryInitialized = false;
35
38
 
36
39
  /**
37
40
  * Get the registry instance (singleton, injectable for testing)
@@ -50,6 +53,18 @@ function getRegistryInstance() {
50
53
  */
51
54
  function injectRegistry(registry) {
52
55
  _registryInstance = registry;
56
+ _registryInitialized = false; // Reset initialization state when injecting
57
+ }
58
+
59
+ /**
60
+ * Reset registry cache state (for testing or forced refresh).
61
+ * Clears both the initialization flag and the underlying SessionRegistry cache.
62
+ */
63
+ function resetRegistryCache() {
64
+ _registryInitialized = false;
65
+ if (_registryInstance) {
66
+ _registryInstance.invalidateCache();
67
+ }
53
68
  }
54
69
 
55
70
  // Ensure sessions directory exists
@@ -59,19 +74,36 @@ function ensureSessionsDir() {
59
74
  }
60
75
  }
61
76
 
62
- // Load or create registry (uses injectable SessionRegistry)
63
- // Preserves original behavior: saves default registry if file didn't exist
77
+ /**
78
+ * Load registry with request-level caching.
79
+ *
80
+ * Uses SessionRegistry's built-in 10-second TTL cache for repeated reads.
81
+ * Only performs file existence check once per session-manager lifecycle,
82
+ * avoiding redundant fs.existsSync() calls on every loadRegistry() invocation.
83
+ *
84
+ * @returns {Object} Registry data
85
+ */
64
86
  function loadRegistry() {
65
87
  const registryInstance = getRegistryInstance();
66
- const fileExistedBefore = fs.existsSync(registryInstance.registryPath);
67
- const data = registryInstance.loadSync();
68
88
 
69
- // If file didn't exist, save the default to disk (original behavior)
70
- if (!fileExistedBefore) {
71
- registryInstance.saveSync(data);
89
+ // One-time initialization: check if file exists and create default if needed
90
+ // This avoids calling fs.existsSync() on every loadRegistry() call
91
+ if (!_registryInitialized) {
92
+ const fileExistedBefore = fs.existsSync(registryInstance.registryPath);
93
+ const data = registryInstance.loadSync();
94
+
95
+ // If file didn't exist, save the default to disk (original behavior)
96
+ if (!fileExistedBefore) {
97
+ registryInstance.saveSync(data);
98
+ }
99
+
100
+ _registryInitialized = true;
101
+ return data;
72
102
  }
73
103
 
74
- return data;
104
+ // Subsequent calls: rely on SessionRegistry's TTL cache (10 seconds)
105
+ // This avoids disk I/O for repeated reads within the same command execution
106
+ return registryInstance.loadSync();
75
107
  }
76
108
 
77
109
  // Save registry (uses injectable SessionRegistry)
@@ -152,6 +184,13 @@ function isSessionActive(sessionId) {
152
184
  return isPidAlive(parseInt(lock.pid, 10));
153
185
  }
154
186
 
187
+ // Check if session is active (async version for parallel batch operations)
188
+ async function isSessionActiveAsync(sessionId) {
189
+ const lock = await readLockAsync(sessionId);
190
+ if (!lock || !lock.pid) return false;
191
+ return isPidAlive(parseInt(lock.pid, 10));
192
+ }
193
+
155
194
  // Clean up stale locks (with detailed tracking) - sync version for backward compatibility
156
195
  function cleanupStaleLocks(registry, options = {}) {
157
196
  const { verbose = false, dryRun = false } = options;
@@ -840,6 +879,25 @@ async function createSession(options = {}) {
840
879
  }
841
880
  }
842
881
 
882
+ // Symlink .agileflow/sessions/ to main project (shared session registry across worktrees)
883
+ // This ensures all sessions see the same registry, preventing is_main bugs and sync issues
884
+ const sessionsSymlinkSrc = path.join(ROOT, '.agileflow', 'sessions');
885
+ const sessionsSymlinkDest = path.join(worktreePath, '.agileflow', 'sessions');
886
+ if (fs.existsSync(sessionsSymlinkSrc)) {
887
+ try {
888
+ // Remove the copied sessions directory (it was copied above with .agileflow)
889
+ if (fs.existsSync(sessionsSymlinkDest)) {
890
+ fs.rmSync(sessionsSymlinkDest, { recursive: true, force: true });
891
+ }
892
+ // Create relative symlink to main project's sessions directory
893
+ const relPath = path.relative(path.dirname(sessionsSymlinkDest), sessionsSymlinkSrc);
894
+ fs.symlinkSync(relPath, sessionsSymlinkDest, 'dir');
895
+ } catch (e) {
896
+ // Non-fatal: log but continue - the copied version will work, just won't be synchronized
897
+ console.warn(`Warning: Could not symlink sessions directory: ${e.message}`);
898
+ }
899
+ }
900
+
843
901
  // Symlink docs/ to main project docs (shared state: status.json, session-state.json, bus/)
844
902
  // This enables story claiming, status bus, and session coordination across worktrees
845
903
  const foldersToSymlink = ['docs'];
@@ -925,6 +983,39 @@ function getSessions() {
925
983
  };
926
984
  }
927
985
 
986
+ // Get all sessions with status (async parallel version - faster for 10+ sessions)
987
+ // US-0190: Uses Promise.all() to batch lock file reads instead of sequential
988
+ async function getSessionsAsync() {
989
+ const registry = loadRegistry();
990
+ const cleanupResult = await cleanupStaleLocksAsync(registry);
991
+
992
+ const sessionEntries = Object.entries(registry.sessions);
993
+ const cwd = process.cwd();
994
+
995
+ // Read all locks in parallel using Promise.all()
996
+ const sessionResults = await Promise.all(
997
+ sessionEntries.map(async ([id, session]) => {
998
+ const active = await isSessionActiveAsync(id);
999
+ return {
1000
+ id,
1001
+ ...session,
1002
+ active,
1003
+ current: session.path === cwd,
1004
+ };
1005
+ })
1006
+ );
1007
+
1008
+ // Sort by ID (numeric)
1009
+ sessionResults.sort((a, b) => parseInt(a.id) - parseInt(b.id));
1010
+
1011
+ // Return count for backward compat, plus detailed info
1012
+ return {
1013
+ sessions: sessionResults,
1014
+ cleaned: cleanupResult.count,
1015
+ cleanedSessions: cleanupResult.sessions,
1016
+ };
1017
+ }
1018
+
928
1019
  // Get count of active sessions (excluding current)
929
1020
  function getActiveSessionCount() {
930
1021
  const { sessions } = getSessions();
@@ -1272,6 +1363,177 @@ function integrateSession(sessionId, options = {}) {
1272
1363
  return result;
1273
1364
  }
1274
1365
 
1366
+ /**
1367
+ * Generate auto commit message for session
1368
+ * @param {Object} session - Session object
1369
+ * @returns {string} Generated commit message
1370
+ */
1371
+ function generateCommitMessage(session) {
1372
+ const nickname = session.nickname || `session-${session.id || 'unknown'}`;
1373
+ const branch = session.branch || 'unknown';
1374
+ return `chore: commit uncommitted changes from ${nickname}\n\nBranch: ${branch}`;
1375
+ }
1376
+
1377
+ /**
1378
+ * Commit all changes in session worktree
1379
+ * @param {string} sessionId - Session ID
1380
+ * @param {Object} options - { message?: string }
1381
+ * @returns {{ success: boolean, commitHash?: string, message?: string, error?: string }}
1382
+ */
1383
+ function commitChanges(sessionId, options = {}) {
1384
+ const registry = loadRegistry();
1385
+ const session = registry.sessions[sessionId];
1386
+
1387
+ if (!session) {
1388
+ return { success: false, error: `Session ${sessionId} not found` };
1389
+ }
1390
+
1391
+ if (!fs.existsSync(session.path)) {
1392
+ return { success: false, error: `Session directory not found: ${session.path}` };
1393
+ }
1394
+
1395
+ // Stage all changes
1396
+ const addResult = spawnSync('git', ['add', '-A'], {
1397
+ cwd: session.path,
1398
+ encoding: 'utf8',
1399
+ });
1400
+
1401
+ if (addResult.status !== 0) {
1402
+ return { success: false, error: `Failed to stage changes: ${addResult.stderr}` };
1403
+ }
1404
+
1405
+ // Generate commit message if not provided
1406
+ const message = options.message || generateCommitMessage({ ...session, id: sessionId });
1407
+
1408
+ // Create commit
1409
+ const commitResult = spawnSync('git', ['commit', '-m', message], {
1410
+ cwd: session.path,
1411
+ encoding: 'utf8',
1412
+ });
1413
+
1414
+ if (commitResult.status !== 0) {
1415
+ // Check if nothing to commit (all changes already staged/committed)
1416
+ if (commitResult.stdout && commitResult.stdout.includes('nothing to commit')) {
1417
+ return { success: true, message: 'No changes to commit', commitHash: null };
1418
+ }
1419
+ return {
1420
+ success: false,
1421
+ error: `Failed to commit: ${commitResult.stderr || commitResult.stdout}`,
1422
+ };
1423
+ }
1424
+
1425
+ // Get commit hash
1426
+ const hashResult = spawnSync('git', ['rev-parse', 'HEAD'], {
1427
+ cwd: session.path,
1428
+ encoding: 'utf8',
1429
+ });
1430
+
1431
+ return {
1432
+ success: true,
1433
+ commitHash: hashResult.stdout?.trim(),
1434
+ message,
1435
+ };
1436
+ }
1437
+
1438
+ /**
1439
+ * Stash changes in session worktree
1440
+ * @param {string} sessionId - Session ID
1441
+ * @returns {{ success: boolean, message?: string, error?: string }}
1442
+ */
1443
+ function stashChanges(sessionId) {
1444
+ const registry = loadRegistry();
1445
+ const session = registry.sessions[sessionId];
1446
+
1447
+ if (!session) {
1448
+ return { success: false, error: `Session ${sessionId} not found` };
1449
+ }
1450
+
1451
+ if (!fs.existsSync(session.path)) {
1452
+ return { success: false, error: `Session directory not found: ${session.path}` };
1453
+ }
1454
+
1455
+ const stashMsg = `AgileFlow: session ${sessionId} merge prep`;
1456
+ const result = spawnSync('git', ['stash', 'push', '-m', stashMsg], {
1457
+ cwd: session.path,
1458
+ encoding: 'utf8',
1459
+ });
1460
+
1461
+ if (result.status !== 0) {
1462
+ return { success: false, error: `Failed to stash: ${result.stderr}` };
1463
+ }
1464
+
1465
+ // Check if stash was actually created (might be "No local changes to save")
1466
+ if (result.stdout && result.stdout.includes('No local changes to save')) {
1467
+ return { success: true, message: 'No changes to stash', stashCreated: false };
1468
+ }
1469
+
1470
+ return { success: true, message: stashMsg, stashCreated: true };
1471
+ }
1472
+
1473
+ /**
1474
+ * Unstash changes (pop stash)
1475
+ * @param {string} sessionId - Session ID (for error messages, uses current cwd)
1476
+ * @returns {{ success: boolean, error?: string }}
1477
+ */
1478
+ function unstashChanges(sessionId) {
1479
+ // Note: After merge, the session worktree is deleted. Stash is popped on main.
1480
+ // So we use ROOT instead of session.path
1481
+
1482
+ const result = spawnSync('git', ['stash', 'pop'], {
1483
+ cwd: ROOT,
1484
+ encoding: 'utf8',
1485
+ });
1486
+
1487
+ if (result.status !== 0) {
1488
+ // Check if no stash exists
1489
+ if (result.stderr && result.stderr.includes('No stash entries found')) {
1490
+ return { success: true, message: 'No stash to pop' };
1491
+ }
1492
+ return { success: false, error: `Failed to unstash: ${result.stderr}` };
1493
+ }
1494
+
1495
+ return { success: true };
1496
+ }
1497
+
1498
+ /**
1499
+ * Discard all uncommitted changes in session worktree
1500
+ * @param {string} sessionId - Session ID
1501
+ * @returns {{ success: boolean, error?: string }}
1502
+ */
1503
+ function discardChanges(sessionId) {
1504
+ const registry = loadRegistry();
1505
+ const session = registry.sessions[sessionId];
1506
+
1507
+ if (!session) {
1508
+ return { success: false, error: `Session ${sessionId} not found` };
1509
+ }
1510
+
1511
+ if (!fs.existsSync(session.path)) {
1512
+ return { success: false, error: `Session directory not found: ${session.path}` };
1513
+ }
1514
+
1515
+ // Reset staged changes
1516
+ spawnSync('git', ['reset', 'HEAD'], {
1517
+ cwd: session.path,
1518
+ encoding: 'utf8',
1519
+ });
1520
+
1521
+ // Discard working directory changes
1522
+ const checkoutResult = spawnSync('git', ['checkout', '--', '.'], {
1523
+ cwd: session.path,
1524
+ encoding: 'utf8',
1525
+ });
1526
+
1527
+ if (checkoutResult.status !== 0) {
1528
+ return { success: false, error: `Failed to discard changes: ${checkoutResult.stderr}` };
1529
+ }
1530
+
1531
+ // Note: Not cleaning untracked files by default (safety measure)
1532
+ // Users can add --clean-untracked flag if needed
1533
+
1534
+ return { success: true };
1535
+ }
1536
+
1275
1537
  // Session phases for Kanban-style visualization
1276
1538
  const SESSION_PHASES = {
1277
1539
  TODO: 'todo',
@@ -1341,6 +1603,109 @@ function getSessionPhase(session) {
1341
1603
  }
1342
1604
  }
1343
1605
 
1606
+ // Execute git command asynchronously (US-0191: Promise-based, non-blocking)
1607
+ function execGitAsync(args, cwd) {
1608
+ return new Promise((resolve, reject) => {
1609
+ const proc = spawn('git', args, {
1610
+ cwd,
1611
+ encoding: 'utf8',
1612
+ stdio: ['pipe', 'pipe', 'pipe'],
1613
+ });
1614
+
1615
+ let stdout = '';
1616
+ let stderr = '';
1617
+
1618
+ proc.stdout.on('data', data => {
1619
+ stdout += data;
1620
+ });
1621
+ proc.stderr.on('data', data => {
1622
+ stderr += data;
1623
+ });
1624
+
1625
+ proc.on('error', err => {
1626
+ reject(err);
1627
+ });
1628
+
1629
+ proc.on('close', code => {
1630
+ resolve({ stdout: stdout.trim(), stderr: stderr.trim(), code });
1631
+ });
1632
+ });
1633
+ }
1634
+
1635
+ // Detect session phase asynchronously (US-0191: Non-blocking git calls)
1636
+ async function getSessionPhaseAsync(session) {
1637
+ // If merged_at field exists, session was merged
1638
+ if (session.merged_at) {
1639
+ return SESSION_PHASES.MERGED;
1640
+ }
1641
+
1642
+ // If is_main, it's the merged/main column
1643
+ if (session.is_main) {
1644
+ return SESSION_PHASES.MERGED;
1645
+ }
1646
+
1647
+ // Check git state for the session
1648
+ try {
1649
+ const sessionPath = session.path;
1650
+ if (!fs.existsSync(sessionPath)) {
1651
+ return SESSION_PHASES.TODO;
1652
+ }
1653
+
1654
+ // Cache key for this session's git state
1655
+ const cacheKey = `phase:${sessionPath}`;
1656
+ const cached = gitCache.get(cacheKey);
1657
+ if (cached !== null) return cached;
1658
+
1659
+ // Count commits since branch diverged from main
1660
+ const mainBranch = getMainBranch();
1661
+ const commitResult = await execGitAsync(
1662
+ ['rev-list', '--count', `${mainBranch}..HEAD`],
1663
+ sessionPath
1664
+ );
1665
+ const commits = parseInt(commitResult.stdout || '0', 10);
1666
+
1667
+ if (commits === 0) {
1668
+ gitCache.set(cacheKey, SESSION_PHASES.TODO);
1669
+ return SESSION_PHASES.TODO;
1670
+ }
1671
+
1672
+ // Check for uncommitted changes
1673
+ const statusResult = await execGitAsync(['status', '--porcelain'], sessionPath);
1674
+ const status = statusResult.stdout;
1675
+
1676
+ let phase;
1677
+ if (status === '') {
1678
+ // No uncommitted changes = ready for review
1679
+ phase = SESSION_PHASES.REVIEW;
1680
+ } else {
1681
+ // Has commits but also uncommitted changes = still coding
1682
+ phase = SESSION_PHASES.CODING;
1683
+ }
1684
+
1685
+ gitCache.set(cacheKey, phase);
1686
+ return phase;
1687
+ } catch (e) {
1688
+ // On error, assume coding phase
1689
+ return SESSION_PHASES.CODING;
1690
+ }
1691
+ }
1692
+
1693
+ // Get phases for multiple sessions in parallel (US-0191: Promise.all batching)
1694
+ async function getSessionPhasesAsync(sessions) {
1695
+ const phasePromises = sessions.map(async session => {
1696
+ const phase = await getSessionPhaseAsync(session);
1697
+ return { session, phase };
1698
+ });
1699
+
1700
+ const results = await Promise.all(phasePromises);
1701
+
1702
+ // Return as array with phase included
1703
+ return results.map(({ session, phase }) => ({
1704
+ ...session,
1705
+ phase,
1706
+ }));
1707
+ }
1708
+
1344
1709
  // Render Kanban-style board visualization
1345
1710
  function renderKanbanBoard(sessions) {
1346
1711
  const lines = [];
@@ -1448,6 +1813,115 @@ function renderKanbanBoard(sessions) {
1448
1813
  return lines.join('\n');
1449
1814
  }
1450
1815
 
1816
+ // Render Kanban-style board visualization (async parallel version - US-0191)
1817
+ async function renderKanbanBoardAsync(sessions) {
1818
+ const lines = [];
1819
+
1820
+ // Get all phases in parallel using Promise.all
1821
+ const sessionsWithPhases = await getSessionPhasesAsync(sessions);
1822
+
1823
+ // Group sessions by phase
1824
+ const byPhase = {
1825
+ [SESSION_PHASES.TODO]: [],
1826
+ [SESSION_PHASES.CODING]: [],
1827
+ [SESSION_PHASES.REVIEW]: [],
1828
+ [SESSION_PHASES.MERGED]: [],
1829
+ };
1830
+
1831
+ for (const session of sessionsWithPhases) {
1832
+ byPhase[session.phase].push(session);
1833
+ }
1834
+
1835
+ // Calculate column widths (min 12 chars)
1836
+ const colWidth = 14;
1837
+ const separator = ' ';
1838
+
1839
+ // Header
1840
+ lines.push(`${c.cyan}Sessions (Kanban View):${c.reset}`);
1841
+ lines.push('');
1842
+
1843
+ // Column headers
1844
+ const headers = [
1845
+ `${c.dim}TO DO${c.reset}`,
1846
+ `${c.yellow}CODING${c.reset}`,
1847
+ `${c.blue}REVIEW${c.reset}`,
1848
+ `${c.green}MERGED${c.reset}`,
1849
+ ];
1850
+ lines.push(headers.map(h => h.padEnd(colWidth + 10)).join(separator)); // +10 for ANSI codes
1851
+
1852
+ // Top borders
1853
+ const topBorder = `┌${'─'.repeat(colWidth)}┐`;
1854
+ lines.push([topBorder, topBorder, topBorder, topBorder].join(separator));
1855
+
1856
+ // Find max rows needed
1857
+ const maxRows = Math.max(
1858
+ 1,
1859
+ byPhase[SESSION_PHASES.TODO].length,
1860
+ byPhase[SESSION_PHASES.CODING].length,
1861
+ byPhase[SESSION_PHASES.REVIEW].length,
1862
+ byPhase[SESSION_PHASES.MERGED].length
1863
+ );
1864
+
1865
+ // Render rows
1866
+ for (let i = 0; i < maxRows; i++) {
1867
+ const cells = [
1868
+ SESSION_PHASES.TODO,
1869
+ SESSION_PHASES.CODING,
1870
+ SESSION_PHASES.REVIEW,
1871
+ SESSION_PHASES.MERGED,
1872
+ ].map(phase => {
1873
+ const session = byPhase[phase][i];
1874
+ if (!session) {
1875
+ return `│${' '.repeat(colWidth)}│`;
1876
+ }
1877
+
1878
+ // Format session info
1879
+ const id = `[${session.id}]`;
1880
+ const name = session.nickname || session.branch || '';
1881
+ const truncName = name.length > colWidth - 5 ? name.slice(0, colWidth - 8) + '...' : name;
1882
+ const content = `${id} ${truncName}`.slice(0, colWidth);
1883
+
1884
+ return `│${content.padEnd(colWidth)}│`;
1885
+ });
1886
+ lines.push(cells.join(separator));
1887
+
1888
+ // Second line with story
1889
+ const storyCells = [
1890
+ SESSION_PHASES.TODO,
1891
+ SESSION_PHASES.CODING,
1892
+ SESSION_PHASES.REVIEW,
1893
+ SESSION_PHASES.MERGED,
1894
+ ].map(phase => {
1895
+ const session = byPhase[phase][i];
1896
+ if (!session) {
1897
+ return `│${' '.repeat(colWidth)}│`;
1898
+ }
1899
+
1900
+ const story = session.story || '-';
1901
+ const storyTrunc = story.length > colWidth - 2 ? story.slice(0, colWidth - 5) + '...' : story;
1902
+
1903
+ return `│${c.dim}${storyTrunc.padEnd(colWidth)}${c.reset}│`;
1904
+ });
1905
+ lines.push(storyCells.join(separator));
1906
+ }
1907
+
1908
+ // Bottom borders
1909
+ const bottomBorder = `└${'─'.repeat(colWidth)}┘`;
1910
+ lines.push([bottomBorder, bottomBorder, bottomBorder, bottomBorder].join(separator));
1911
+
1912
+ // Summary
1913
+ lines.push('');
1914
+ const summary = [
1915
+ `${c.dim}To Do: ${byPhase[SESSION_PHASES.TODO].length}${c.reset}`,
1916
+ `${c.yellow}Coding: ${byPhase[SESSION_PHASES.CODING].length}${c.reset}`,
1917
+ `${c.blue}Review: ${byPhase[SESSION_PHASES.REVIEW].length}${c.reset}`,
1918
+ `${c.green}Merged: ${byPhase[SESSION_PHASES.MERGED].length}${c.reset}`,
1919
+ ].join(' │ ');
1920
+ lines.push(summary);
1921
+
1922
+ return lines.join('\n');
1923
+ }
1924
+
1451
1925
  // Format sessions for display
1452
1926
  function formatSessionsTable(sessions) {
1453
1927
  const lines = [];
@@ -1652,7 +2126,9 @@ function main() {
1652
2126
  // Create new
1653
2127
  sessionId = String(registry.next_id);
1654
2128
  registry.next_id++;
1655
- const isMain = cwd === ROOT;
2129
+ // A session is "main" only if it's at the project root AND not a git worktree
2130
+ // Worktrees have .git as a file (not directory), pointing to the main repo
2131
+ const isMain = cwd === ROOT && !isGitWorktree(cwd);
1656
2132
  registry.sessions[sessionId] = {
1657
2133
  path: cwd,
1658
2134
  branch,
@@ -1764,6 +2240,60 @@ function main() {
1764
2240
  break;
1765
2241
  }
1766
2242
 
2243
+ case 'commit-changes': {
2244
+ const sessionId = args[1];
2245
+ if (!sessionId) {
2246
+ console.log(JSON.stringify({ success: false, error: 'Session ID required' }));
2247
+ return;
2248
+ }
2249
+ const options = {};
2250
+ // Parse --message="..."
2251
+ for (let i = 2; i < args.length; i++) {
2252
+ const arg = args[i];
2253
+ if (arg.startsWith('--message=')) {
2254
+ options.message = arg.slice(10);
2255
+ } else if (arg === '--message' && args[i + 1]) {
2256
+ options.message = args[++i];
2257
+ }
2258
+ }
2259
+ const result = commitChanges(sessionId, options);
2260
+ console.log(JSON.stringify(result));
2261
+ break;
2262
+ }
2263
+
2264
+ case 'stash': {
2265
+ const sessionId = args[1];
2266
+ if (!sessionId) {
2267
+ console.log(JSON.stringify({ success: false, error: 'Session ID required' }));
2268
+ return;
2269
+ }
2270
+ const result = stashChanges(sessionId);
2271
+ console.log(JSON.stringify(result));
2272
+ break;
2273
+ }
2274
+
2275
+ case 'unstash': {
2276
+ const sessionId = args[1];
2277
+ if (!sessionId) {
2278
+ console.log(JSON.stringify({ success: false, error: 'Session ID required' }));
2279
+ return;
2280
+ }
2281
+ const result = unstashChanges(sessionId);
2282
+ console.log(JSON.stringify(result));
2283
+ break;
2284
+ }
2285
+
2286
+ case 'discard-changes': {
2287
+ const sessionId = args[1];
2288
+ if (!sessionId) {
2289
+ console.log(JSON.stringify({ success: false, error: 'Session ID required' }));
2290
+ return;
2291
+ }
2292
+ const result = discardChanges(sessionId);
2293
+ console.log(JSON.stringify(result));
2294
+ break;
2295
+ }
2296
+
1767
2297
  case 'smart-merge': {
1768
2298
  const sessionId = args[1];
1769
2299
  if (!sessionId) {
@@ -1877,6 +2407,10 @@ ${c.cyan}Commands:${c.reset}
1877
2407
  integrate <id> [opts] Merge session to main and cleanup
1878
2408
  smart-merge <id> [opts] Auto-resolve conflicts and merge
1879
2409
  merge-history View merge audit log
2410
+ commit-changes <id> [--message="..."] Commit all uncommitted changes
2411
+ stash <id> Stash changes in session worktree
2412
+ unstash <id> Pop stash (after merge, on main)
2413
+ discard-changes <id> Discard all uncommitted changes
1880
2414
  help Show this help
1881
2415
 
1882
2416
  ${c.cyan}Merge Options (integrate & smart-merge):${c.reset}
@@ -2556,7 +3090,9 @@ function getSessionThreadType(sessionId = null) {
2556
3090
  }
2557
3091
 
2558
3092
  /**
2559
- * Update thread type for a session.
3093
+ * Update thread type for a session (without transition validation).
3094
+ * For backward compatibility. Prefer transitionThread() for new code.
3095
+ *
2560
3096
  * @param {string} sessionId - Session ID
2561
3097
  * @param {string} threadType - New thread type
2562
3098
  * @returns {{ success: boolean, error?: string }}
@@ -2580,11 +3116,104 @@ function setSessionThreadType(sessionId, threadType) {
2580
3116
  return { success: true, thread_type: threadType };
2581
3117
  }
2582
3118
 
3119
+ /**
3120
+ * Transition session to a new thread type with validation.
3121
+ *
3122
+ * Uses sessionThreadMachine to validate that the transition is allowed.
3123
+ * For example: parallel → fusion is valid, but chained → base is not.
3124
+ *
3125
+ * Thread Type Transitions:
3126
+ * - base → parallel, big, long
3127
+ * - parallel → base, fusion, chained
3128
+ * - chained → parallel, fusion
3129
+ * - fusion → base
3130
+ * - big → parallel, fusion
3131
+ * - long → base, parallel
3132
+ *
3133
+ * @param {string} sessionId - Session ID
3134
+ * @param {string} targetType - Target thread type
3135
+ * @param {Object} [options={}] - Transition options
3136
+ * @param {boolean} [options.force=false] - Force transition even if invalid
3137
+ * @returns {{ success: boolean, from?: string, to?: string, error?: string, forced?: boolean }}
3138
+ */
3139
+ function transitionThread(sessionId, targetType, options = {}) {
3140
+ const { force = false } = options;
3141
+
3142
+ const registry = loadRegistry();
3143
+ const session = registry.sessions[sessionId];
3144
+
3145
+ if (!session) {
3146
+ return { success: false, error: `Session ${sessionId} not found` };
3147
+ }
3148
+
3149
+ // Get current thread type (default to 'base' for legacy sessions)
3150
+ const currentType = session.thread_type || (session.is_main ? 'base' : 'parallel');
3151
+
3152
+ // Validate transition using state machine
3153
+ const result = sessionThreadMachine.transition(currentType, targetType, { force });
3154
+
3155
+ if (!result.success) {
3156
+ return {
3157
+ success: false,
3158
+ from: currentType,
3159
+ to: targetType,
3160
+ error: result.error,
3161
+ };
3162
+ }
3163
+
3164
+ // No-op if same type
3165
+ if (result.noop) {
3166
+ return {
3167
+ success: true,
3168
+ from: currentType,
3169
+ to: targetType,
3170
+ noop: true,
3171
+ };
3172
+ }
3173
+
3174
+ // Update registry
3175
+ registry.sessions[sessionId].thread_type = targetType;
3176
+ registry.sessions[sessionId].thread_transitioned_at = new Date().toISOString();
3177
+ saveRegistry(registry);
3178
+
3179
+ return {
3180
+ success: true,
3181
+ from: currentType,
3182
+ to: targetType,
3183
+ forced: result.forced || false,
3184
+ };
3185
+ }
3186
+
3187
+ /**
3188
+ * Get valid thread type transitions from current state.
3189
+ *
3190
+ * @param {string} sessionId - Session ID
3191
+ * @returns {{ success: boolean, current?: string, validTransitions?: string[], error?: string }}
3192
+ */
3193
+ function getValidThreadTransitions(sessionId) {
3194
+ const registry = loadRegistry();
3195
+ const session = registry.sessions[sessionId];
3196
+
3197
+ if (!session) {
3198
+ return { success: false, error: `Session ${sessionId} not found` };
3199
+ }
3200
+
3201
+ const currentType = session.thread_type || (session.is_main ? 'base' : 'parallel');
3202
+ const validTransitions = sessionThreadMachine.getValidTransitions(currentType);
3203
+
3204
+ return {
3205
+ success: true,
3206
+ current: currentType,
3207
+ validTransitions,
3208
+ };
3209
+ }
3210
+
2583
3211
  // Export for use as module
2584
3212
  module.exports = {
2585
3213
  // Registry injection (for testing)
2586
3214
  injectRegistry,
2587
3215
  getRegistryInstance,
3216
+ resetRegistryCache, // US-0193: Reset initialization state for testing
2588
3217
  // Registry access (backward compatible)
2589
3218
  loadRegistry,
2590
3219
  saveRegistry,
@@ -2594,9 +3223,11 @@ module.exports = {
2594
3223
  getSession,
2595
3224
  createSession,
2596
3225
  getSessions,
3226
+ getSessionsAsync, // US-0190: Parallel lock reads for 10+ sessions
2597
3227
  getActiveSessionCount,
2598
3228
  deleteSession,
2599
3229
  isSessionActive,
3230
+ isSessionActiveAsync, // US-0190: Async version for batch operations
2600
3231
  cleanupStaleLocks,
2601
3232
  cleanupStaleLocksAsync,
2602
3233
  // Merge operations
@@ -2604,6 +3235,11 @@ module.exports = {
2604
3235
  checkMergeability,
2605
3236
  getMergePreview,
2606
3237
  integrateSession,
3238
+ // Uncommitted changes handling (inline options for /session:end)
3239
+ commitChanges,
3240
+ stashChanges,
3241
+ unstashChanges,
3242
+ discardChanges,
2607
3243
  // Smart merge (auto-resolution)
2608
3244
  smartMerge,
2609
3245
  getConflictingFiles,
@@ -2619,10 +3255,17 @@ module.exports = {
2619
3255
  detectThreadType,
2620
3256
  getSessionThreadType,
2621
3257
  setSessionThreadType,
3258
+ transitionThread, // US-0202: Validated thread type transitions
3259
+ getValidThreadTransitions, // US-0202: Get valid transitions from current state
2622
3260
  // Kanban visualization
2623
3261
  SESSION_PHASES,
2624
3262
  getSessionPhase,
3263
+ getSessionPhaseAsync, // US-0191: Async version with non-blocking git
3264
+ getSessionPhasesAsync, // US-0191: Batch version with Promise.all()
2625
3265
  renderKanbanBoard,
3266
+ renderKanbanBoardAsync, // US-0191: Async version using parallel git ops
3267
+ // Internal utilities (for testing)
3268
+ execGitAsync, // US-0191: Promise-based git command execution
2626
3269
  };
2627
3270
 
2628
3271
  // Run CLI if executed directly