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.
- package/CHANGELOG.md +20 -0
- package/README.md +3 -3
- package/lib/colors.generated.js +117 -0
- package/lib/colors.js +59 -109
- package/lib/generator-factory.js +333 -0
- package/lib/path-utils.js +49 -0
- package/lib/session-registry.js +25 -15
- package/lib/smart-json-file.js +40 -32
- package/lib/state-machine.js +286 -0
- package/package.json +1 -1
- package/scripts/agileflow-configure.js +7 -6
- package/scripts/archive-completed-stories.sh +86 -11
- package/scripts/babysit-context-restore.js +89 -0
- package/scripts/claude-tmux.sh +111 -5
- package/scripts/damage-control/bash-tool-damage-control.js +11 -247
- package/scripts/damage-control/edit-tool-damage-control.js +9 -249
- package/scripts/damage-control/write-tool-damage-control.js +9 -244
- package/scripts/generate-colors.js +314 -0
- package/scripts/lib/colors.generated.sh +82 -0
- package/scripts/lib/colors.sh +10 -70
- package/scripts/lib/configure-features.js +401 -0
- package/scripts/lib/context-loader.js +181 -52
- package/scripts/precompact-context.sh +54 -17
- package/scripts/session-coordinator.sh +2 -2
- package/scripts/session-manager.js +653 -10
- package/src/core/commands/audit.md +93 -0
- package/src/core/commands/auto.md +73 -0
- package/src/core/commands/babysit.md +169 -13
- package/src/core/commands/baseline.md +73 -0
- package/src/core/commands/batch.md +64 -0
- package/src/core/commands/blockers.md +60 -0
- package/src/core/commands/board.md +66 -0
- package/src/core/commands/choose.md +77 -0
- package/src/core/commands/ci.md +77 -0
- package/src/core/commands/compress.md +27 -1
- package/src/core/commands/configure.md +126 -10
- package/src/core/commands/council.md +74 -0
- package/src/core/commands/debt.md +72 -0
- package/src/core/commands/deploy.md +73 -0
- package/src/core/commands/deps.md +68 -0
- package/src/core/commands/docs.md +60 -0
- package/src/core/commands/feedback.md +68 -0
- package/src/core/commands/ideate.md +74 -0
- package/src/core/commands/impact.md +74 -0
- package/src/core/commands/install.md +529 -0
- package/src/core/commands/maintain.md +558 -0
- package/src/core/commands/metrics.md +75 -0
- package/src/core/commands/multi-expert.md +74 -0
- package/src/core/commands/packages.md +69 -0
- package/src/core/commands/readme-sync.md +64 -0
- package/src/core/commands/research/analyze.md +285 -121
- package/src/core/commands/research/import.md +281 -109
- package/src/core/commands/retro.md +76 -0
- package/src/core/commands/review.md +72 -0
- package/src/core/commands/rlm.md +83 -0
- package/src/core/commands/rpi.md +90 -0
- package/src/core/commands/session/cleanup.md +214 -12
- package/src/core/commands/session/end.md +155 -17
- package/src/core/commands/sprint.md +72 -0
- package/src/core/commands/story-validate.md +68 -0
- package/src/core/commands/template.md +69 -0
- package/src/core/commands/tests.md +83 -0
- package/src/core/commands/update.md +59 -0
- package/src/core/commands/validate-expertise.md +76 -0
- package/src/core/commands/velocity.md +74 -0
- package/src/core/commands/verify.md +91 -0
- package/src/core/commands/whats-new.md +69 -0
- package/src/core/commands/workflow.md +88 -0
- package/src/core/templates/command-documentation.md +187 -0
- package/tools/cli/commands/session.js +1171 -0
- package/tools/cli/commands/setup.js +2 -81
- package/tools/cli/installers/core/installer.js +0 -5
- package/tools/cli/installers/ide/claude-code.js +6 -0
- 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
|
-
|
|
63
|
-
|
|
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
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|