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.
- package/CHANGELOG.md +20 -0
- package/README.md +6 -6
- 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 +186 -7
- 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 +677 -11
- package/src/core/agents/council-advocate.md +202 -0
- package/src/core/agents/council-analyst.md +248 -0
- package/src/core/agents/council-optimist.md +166 -0
- 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 +591 -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/help.md +189 -3
- package/src/core/commands/ideate.md +219 -20
- 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 +229 -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/council/sessions/.gitkeep +0 -0
- package/src/core/council/shared_reasoning.template.md +106 -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;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|