agileflow 2.94.0 → 2.95.0

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 (80) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +6 -6
  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 +186 -7
  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 +677 -11
  26. package/src/core/agents/council-advocate.md +202 -0
  27. package/src/core/agents/council-analyst.md +248 -0
  28. package/src/core/agents/council-optimist.md +166 -0
  29. package/src/core/commands/audit.md +93 -0
  30. package/src/core/commands/auto.md +73 -0
  31. package/src/core/commands/babysit.md +169 -13
  32. package/src/core/commands/baseline.md +73 -0
  33. package/src/core/commands/batch.md +64 -0
  34. package/src/core/commands/blockers.md +60 -0
  35. package/src/core/commands/board.md +66 -0
  36. package/src/core/commands/choose.md +77 -0
  37. package/src/core/commands/ci.md +77 -0
  38. package/src/core/commands/compress.md +27 -1
  39. package/src/core/commands/configure.md +126 -10
  40. package/src/core/commands/council.md +591 -0
  41. package/src/core/commands/debt.md +72 -0
  42. package/src/core/commands/deploy.md +73 -0
  43. package/src/core/commands/deps.md +68 -0
  44. package/src/core/commands/docs.md +60 -0
  45. package/src/core/commands/feedback.md +68 -0
  46. package/src/core/commands/help.md +189 -3
  47. package/src/core/commands/ideate.md +219 -20
  48. package/src/core/commands/impact.md +74 -0
  49. package/src/core/commands/install.md +529 -0
  50. package/src/core/commands/maintain.md +558 -0
  51. package/src/core/commands/metrics.md +75 -0
  52. package/src/core/commands/multi-expert.md +74 -0
  53. package/src/core/commands/packages.md +69 -0
  54. package/src/core/commands/readme-sync.md +64 -0
  55. package/src/core/commands/research/analyze.md +285 -121
  56. package/src/core/commands/research/import.md +281 -109
  57. package/src/core/commands/retro.md +76 -0
  58. package/src/core/commands/review.md +72 -0
  59. package/src/core/commands/rlm.md +83 -0
  60. package/src/core/commands/rpi.md +90 -0
  61. package/src/core/commands/session/cleanup.md +214 -12
  62. package/src/core/commands/session/end.md +229 -17
  63. package/src/core/commands/sprint.md +72 -0
  64. package/src/core/commands/story-validate.md +68 -0
  65. package/src/core/commands/template.md +69 -0
  66. package/src/core/commands/tests.md +83 -0
  67. package/src/core/commands/update.md +59 -0
  68. package/src/core/commands/validate-expertise.md +76 -0
  69. package/src/core/commands/velocity.md +74 -0
  70. package/src/core/commands/verify.md +91 -0
  71. package/src/core/commands/whats-new.md +69 -0
  72. package/src/core/commands/workflow.md +88 -0
  73. package/src/core/council/sessions/.gitkeep +0 -0
  74. package/src/core/council/shared_reasoning.template.md +106 -0
  75. package/src/core/templates/command-documentation.md +187 -0
  76. package/tools/cli/commands/session.js +1171 -0
  77. package/tools/cli/commands/setup.js +2 -81
  78. package/tools/cli/installers/core/installer.js +0 -5
  79. package/tools/cli/installers/ide/claude-code.js +6 -0
  80. 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;
@@ -443,6 +482,27 @@ function getCurrentStory() {
443
482
  // Thread type enum values
444
483
  const THREAD_TYPES = ['base', 'parallel', 'chained', 'fusion', 'big', 'long'];
445
484
 
485
+ /**
486
+ * Check if a directory is a git worktree (not the main repo).
487
+ * In a worktree, .git is a file pointing to the main repo's .git/worktrees/<name>
488
+ * In the main repo, .git is a directory.
489
+ *
490
+ * @param {string} dir - Directory to check
491
+ * @returns {boolean} True if dir is a git worktree
492
+ */
493
+ function isGitWorktree(dir) {
494
+ const gitPath = path.join(dir, '.git');
495
+ try {
496
+ const stat = fs.lstatSync(gitPath);
497
+ // In a worktree, .git is a file containing "gitdir: /path/to/main/.git/worktrees/<name>"
498
+ // In the main repo, .git is a directory
499
+ return stat.isFile();
500
+ } catch (e) {
501
+ // .git doesn't exist - not a git repo at all
502
+ return false;
503
+ }
504
+ }
505
+
446
506
  // Auto-detect thread type from context
447
507
  function detectThreadType(session, isWorktree = false) {
448
508
  // Worktree sessions are parallel threads
@@ -491,7 +551,9 @@ function registerSession(nickname = null, threadType = null) {
491
551
  const sessionId = String(registry.next_id);
492
552
  registry.next_id++;
493
553
 
494
- const isMain = cwd === ROOT;
554
+ // A session is "main" only if it's at the project root AND not a git worktree
555
+ // Worktrees have .git as a file (not directory), pointing to the main repo
556
+ const isMain = cwd === ROOT && !isGitWorktree(cwd);
495
557
  const detectedType =
496
558
  threadType && THREAD_TYPES.includes(threadType) ? threadType : detectThreadType(null, !isMain);
497
559
 
@@ -817,6 +879,25 @@ async function createSession(options = {}) {
817
879
  }
818
880
  }
819
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
+
820
901
  // Symlink docs/ to main project docs (shared state: status.json, session-state.json, bus/)
821
902
  // This enables story claiming, status bus, and session coordination across worktrees
822
903
  const foldersToSymlink = ['docs'];
@@ -902,6 +983,39 @@ function getSessions() {
902
983
  };
903
984
  }
904
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
+
905
1019
  // Get count of active sessions (excluding current)
906
1020
  function getActiveSessionCount() {
907
1021
  const { sessions } = getSessions();
@@ -1249,6 +1363,177 @@ function integrateSession(sessionId, options = {}) {
1249
1363
  return result;
1250
1364
  }
1251
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
+
1252
1537
  // Session phases for Kanban-style visualization
1253
1538
  const SESSION_PHASES = {
1254
1539
  TODO: 'todo',
@@ -1318,6 +1603,109 @@ function getSessionPhase(session) {
1318
1603
  }
1319
1604
  }
1320
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
+
1321
1709
  // Render Kanban-style board visualization
1322
1710
  function renderKanbanBoard(sessions) {
1323
1711
  const lines = [];
@@ -1425,6 +1813,115 @@ function renderKanbanBoard(sessions) {
1425
1813
  return lines.join('\n');
1426
1814
  }
1427
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
+
1428
1925
  // Format sessions for display
1429
1926
  function formatSessionsTable(sessions) {
1430
1927
  const lines = [];
@@ -1629,7 +2126,9 @@ function main() {
1629
2126
  // Create new
1630
2127
  sessionId = String(registry.next_id);
1631
2128
  registry.next_id++;
1632
- 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);
1633
2132
  registry.sessions[sessionId] = {
1634
2133
  path: cwd,
1635
2134
  branch,
@@ -1741,6 +2240,60 @@ function main() {
1741
2240
  break;
1742
2241
  }
1743
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
+
1744
2297
  case 'smart-merge': {
1745
2298
  const sessionId = args[1];
1746
2299
  if (!sessionId) {
@@ -1854,6 +2407,10 @@ ${c.cyan}Commands:${c.reset}
1854
2407
  integrate <id> [opts] Merge session to main and cleanup
1855
2408
  smart-merge <id> [opts] Auto-resolve conflicts and merge
1856
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
1857
2414
  help Show this help
1858
2415
 
1859
2416
  ${c.cyan}Merge Options (integrate & smart-merge):${c.reset}
@@ -2533,7 +3090,9 @@ function getSessionThreadType(sessionId = null) {
2533
3090
  }
2534
3091
 
2535
3092
  /**
2536
- * 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
+ *
2537
3096
  * @param {string} sessionId - Session ID
2538
3097
  * @param {string} threadType - New thread type
2539
3098
  * @returns {{ success: boolean, error?: string }}
@@ -2557,11 +3116,104 @@ function setSessionThreadType(sessionId, threadType) {
2557
3116
  return { success: true, thread_type: threadType };
2558
3117
  }
2559
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
+
2560
3211
  // Export for use as module
2561
3212
  module.exports = {
2562
3213
  // Registry injection (for testing)
2563
3214
  injectRegistry,
2564
3215
  getRegistryInstance,
3216
+ resetRegistryCache, // US-0193: Reset initialization state for testing
2565
3217
  // Registry access (backward compatible)
2566
3218
  loadRegistry,
2567
3219
  saveRegistry,
@@ -2571,9 +3223,11 @@ module.exports = {
2571
3223
  getSession,
2572
3224
  createSession,
2573
3225
  getSessions,
3226
+ getSessionsAsync, // US-0190: Parallel lock reads for 10+ sessions
2574
3227
  getActiveSessionCount,
2575
3228
  deleteSession,
2576
3229
  isSessionActive,
3230
+ isSessionActiveAsync, // US-0190: Async version for batch operations
2577
3231
  cleanupStaleLocks,
2578
3232
  cleanupStaleLocksAsync,
2579
3233
  // Merge operations
@@ -2581,6 +3235,11 @@ module.exports = {
2581
3235
  checkMergeability,
2582
3236
  getMergePreview,
2583
3237
  integrateSession,
3238
+ // Uncommitted changes handling (inline options for /session:end)
3239
+ commitChanges,
3240
+ stashChanges,
3241
+ unstashChanges,
3242
+ discardChanges,
2584
3243
  // Smart merge (auto-resolution)
2585
3244
  smartMerge,
2586
3245
  getConflictingFiles,
@@ -2596,10 +3255,17 @@ module.exports = {
2596
3255
  detectThreadType,
2597
3256
  getSessionThreadType,
2598
3257
  setSessionThreadType,
3258
+ transitionThread, // US-0202: Validated thread type transitions
3259
+ getValidThreadTransitions, // US-0202: Get valid transitions from current state
2599
3260
  // Kanban visualization
2600
3261
  SESSION_PHASES,
2601
3262
  getSessionPhase,
3263
+ getSessionPhaseAsync, // US-0191: Async version with non-blocking git
3264
+ getSessionPhasesAsync, // US-0191: Batch version with Promise.all()
2602
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
2603
3269
  };
2604
3270
 
2605
3271
  // Run CLI if executed directly