agileflow 2.85.0 → 2.86.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.
@@ -26,8 +26,67 @@ const { isValidCommandName } = require('../lib/validate');
26
26
  // Summary table should be the LAST thing visible before truncation.
27
27
  const DISPLAY_LIMIT = 29200;
28
28
 
29
- // Optional: Register command for PreCompact context preservation
29
+ // =============================================================================
30
+ // Progressive Disclosure: Section Activation
31
+ // =============================================================================
32
+
33
+ /**
34
+ * Parse command-line arguments and determine which sections to activate.
35
+ * Sections are conditionally loaded based on parameters like MODE=loop.
36
+ *
37
+ * Section mapping:
38
+ * - MODE=loop → activates: loop-mode
39
+ * - Multi-session env → activates: multi-session
40
+ * - (Other triggers detected at runtime by the agent)
41
+ *
42
+ * @param {string[]} args - Command-line arguments after command name
43
+ * @returns {Object} { activeSections: string[], params: Object }
44
+ */
45
+ function parseCommandArgs(args) {
46
+ const activeSections = [];
47
+ const params = {};
48
+
49
+ for (const arg of args) {
50
+ // Parse KEY=VALUE arguments
51
+ const match = arg.match(/^([A-Z_]+)=(.+)$/i);
52
+ if (match) {
53
+ const [, key, value] = match;
54
+ params[key.toUpperCase()] = value;
55
+ }
56
+ }
57
+
58
+ // Activate sections based on parameters
59
+ if (params.MODE === 'loop') {
60
+ activeSections.push('loop-mode');
61
+ }
62
+
63
+ if (params.VISUAL === 'true') {
64
+ activeSections.push('visual-e2e');
65
+ }
66
+
67
+ // Check for multi-session environment
68
+ const registryPath = '.agileflow/sessions/registry.json';
69
+ if (fs.existsSync(registryPath)) {
70
+ try {
71
+ const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
72
+ const sessionCount = Object.keys(registry.sessions || {}).length;
73
+ if (sessionCount > 1) {
74
+ activeSections.push('multi-session');
75
+ }
76
+ } catch {
77
+ // Silently ignore registry read errors
78
+ }
79
+ }
80
+
81
+ return { activeSections, params };
82
+ }
83
+
84
+ // Parse arguments
30
85
  const commandName = process.argv[2];
86
+ const commandArgs = process.argv.slice(3);
87
+ const { activeSections, params: commandParams } = parseCommandArgs(commandArgs);
88
+
89
+ // Register command for PreCompact context preservation
31
90
  if (commandName && isValidCommandName(commandName)) {
32
91
  const sessionStatePath = 'docs/09-agents/session-state.json';
33
92
  if (fs.existsSync(sessionStatePath)) {
@@ -42,11 +101,13 @@ if (commandName && isValidCommandName(commandName)) {
42
101
  // Remove any existing entry for this command (avoid duplicates)
43
102
  state.active_commands = state.active_commands.filter(c => c.name !== commandName);
44
103
 
45
- // Add the new command
104
+ // Add the new command with active sections for progressive disclosure
46
105
  state.active_commands.push({
47
106
  name: commandName,
48
107
  activated_at: new Date().toISOString(),
49
108
  state: {},
109
+ active_sections: activeSections,
110
+ params: commandParams,
50
111
  });
51
112
 
52
113
  // Remove legacy active_command field (only use active_commands array now)
@@ -262,6 +323,13 @@ function generateSummary() {
262
323
  summary += row('⭐ Up Next', readyStories.slice(0, 3).join(', '), C.skyBlue, C.skyBlue);
263
324
  }
264
325
 
326
+ // Progressive disclosure: Show active sections
327
+ if (activeSections.length > 0) {
328
+ summary += divider();
329
+ const sectionList = activeSections.join(', ');
330
+ summary += row('📖 Sections', sectionList, C.cyan, C.mintGreen);
331
+ }
332
+
265
333
  summary += divider();
266
334
 
267
335
  // Key files (using vibrant 256-color palette)
@@ -318,6 +386,35 @@ function generateFullContent() {
318
386
  content += `${C.lavender}${C.bold}${title}${C.reset}\n`;
319
387
  content += `${C.dim}Generated: ${new Date().toISOString()}${C.reset}\n`;
320
388
 
389
+ // 0. PROGRESSIVE DISCLOSURE (section activation)
390
+ if (activeSections.length > 0) {
391
+ content += `\n${C.cyan}${C.bold}═══ 📖 Progressive Disclosure: Active Sections ═══${C.reset}\n`;
392
+ content += `${C.dim}The following sections are activated based on command parameters.${C.reset}\n`;
393
+ content += `${C.dim}Look for <!-- SECTION: name --> markers in the command file.${C.reset}\n\n`;
394
+
395
+ activeSections.forEach(section => {
396
+ content += ` ${C.mintGreen}✓${C.reset} ${C.bold}${section}${C.reset}\n`;
397
+ });
398
+
399
+ // Map sections to their triggers for context
400
+ const sectionDescriptions = {
401
+ 'loop-mode': 'Autonomous epic execution (MODE=loop)',
402
+ 'multi-session': 'Multi-session coordination detected',
403
+ 'visual-e2e': 'Visual screenshot verification (VISUAL=true)',
404
+ 'delegation': 'Expert spawning patterns (load when spawning)',
405
+ 'stuck': 'Research prompt guidance (load after 2 failures)',
406
+ 'plan-mode': 'Planning workflow details (load when entering plan mode)',
407
+ 'tools': 'Tool usage guidance (load when needed)',
408
+ };
409
+
410
+ content += `\n${C.dim}Section meanings:${C.reset}\n`;
411
+ activeSections.forEach(section => {
412
+ const desc = sectionDescriptions[section] || 'Conditional content';
413
+ content += ` ${C.dim}• ${section}: ${desc}${C.reset}\n`;
414
+ });
415
+ content += '\n';
416
+ }
417
+
321
418
  // 1. GIT STATUS (using vibrant 256-color palette)
322
419
  content += `\n${C.skyBlue}${C.bold}═══ Git Status ═══${C.reset}\n`;
323
420
  const branch = safeExec('git branch --show-current') || 'unknown';
@@ -376,6 +473,19 @@ function generateFullContent() {
376
473
  // Backwards compatibility for old format
377
474
  content += `Active command: ${C.skyBlue}${sessionState.active_command.name}${C.reset}\n`;
378
475
  }
476
+
477
+ // Show batch loop status if active
478
+ const batchLoop = sessionState.batch_loop;
479
+ if (batchLoop && batchLoop.enabled) {
480
+ content += `\n${C.skyBlue}${C.bold}── Batch Loop Active ──${C.reset}\n`;
481
+ content += `Pattern: ${C.cyan}${batchLoop.pattern}${C.reset}\n`;
482
+ content += `Action: ${C.cyan}${batchLoop.action}${C.reset}\n`;
483
+ content += `Current: ${C.lightYellow}${batchLoop.current_item || 'none'}${C.reset}\n`;
484
+ const summary = batchLoop.summary || {};
485
+ content += `Progress: ${C.lightGreen}${summary.completed || 0}${C.reset}/${summary.total || 0} `;
486
+ content += `(${C.lightYellow}${summary.in_progress || 0}${C.reset} in progress)\n`;
487
+ content += `Iteration: ${batchLoop.iteration || 0}/${batchLoop.max_iterations || 50}\n`;
488
+ }
379
489
  } else {
380
490
  content += `${C.dim}No session-state.json found${C.reset}\n`;
381
491
  }
@@ -440,9 +550,7 @@ function generateFullContent() {
440
550
 
441
551
  if (fs.existsSync(storyClaimingPath) || fs.existsSync(altStoryClaimingPath)) {
442
552
  try {
443
- const claimPath = fs.existsSync(storyClaimingPath)
444
- ? storyClaimingPath
445
- : altStoryClaimingPath;
553
+ const claimPath = fs.existsSync(storyClaimingPath) ? storyClaimingPath : altStoryClaimingPath;
446
554
  const storyClaiming = require(claimPath);
447
555
 
448
556
  // Get stories claimed by other sessions
@@ -451,7 +559,9 @@ function generateFullContent() {
451
559
  content += `\n${C.amber}${C.bold}═══ 🔒 Claimed Stories ═══${C.reset}\n`;
452
560
  content += `${C.dim}Stories locked by other sessions - pick a different one${C.reset}\n`;
453
561
  othersResult.stories.forEach(story => {
454
- const sessionDir = story.claimedBy?.path ? path.basename(story.claimedBy.path) : 'unknown';
562
+ const sessionDir = story.claimedBy?.path
563
+ ? path.basename(story.claimedBy.path)
564
+ : 'unknown';
455
565
  content += ` ${C.coral}🔒${C.reset} ${C.lavender}${story.id}${C.reset} "${story.title}" ${C.dim}→ Session ${story.claimedBy?.session_id || '?'} (${sessionDir})${C.reset}\n`;
456
566
  });
457
567
  content += '\n';
@@ -477,9 +587,7 @@ function generateFullContent() {
477
587
 
478
588
  if (fs.existsSync(fileTrackingPath) || fs.existsSync(altFileTrackingPath)) {
479
589
  try {
480
- const trackPath = fs.existsSync(fileTrackingPath)
481
- ? fileTrackingPath
482
- : altFileTrackingPath;
590
+ const trackPath = fs.existsSync(fileTrackingPath) ? fileTrackingPath : altFileTrackingPath;
483
591
  const fileTracking = require(trackPath);
484
592
 
485
593
  // Get file overlaps with other sessions
@@ -488,10 +596,12 @@ function generateFullContent() {
488
596
  content += `\n${C.amber}${C.bold}═══ ⚠️ File Overlaps ═══${C.reset}\n`;
489
597
  content += `${C.dim}Files also edited by other sessions - conflicts auto-resolved during merge${C.reset}\n`;
490
598
  overlapsResult.overlaps.forEach(overlap => {
491
- const sessionInfo = overlap.otherSessions.map(s => {
492
- const dir = path.basename(s.path);
493
- return `Session ${s.id} (${dir})`;
494
- }).join(', ');
599
+ const sessionInfo = overlap.otherSessions
600
+ .map(s => {
601
+ const dir = path.basename(s.path);
602
+ return `Session ${s.id} (${dir})`;
603
+ })
604
+ .join(', ');
495
605
  content += ` ${C.amber}⚠${C.reset} ${C.lavender}${overlap.file}${C.reset} ${C.dim}→ ${sessionInfo}${C.reset}\n`;
496
606
  });
497
607
  content += '\n';
@@ -524,7 +634,8 @@ function generateFullContent() {
524
634
  // 6. VISUAL E2E STATUS (detect from metadata or filesystem)
525
635
  const metadata = safeReadJSON('docs/00-meta/agileflow-metadata.json');
526
636
  const visualE2eConfig = metadata?.features?.visual_e2e;
527
- const playwrightExists = fs.existsSync('playwright.config.ts') || fs.existsSync('playwright.config.js');
637
+ const playwrightExists =
638
+ fs.existsSync('playwright.config.ts') || fs.existsSync('playwright.config.js');
528
639
  const screenshotsExists = fs.existsSync('screenshots');
529
640
  const testsE2eExists = fs.existsSync('tests/e2e');
530
641
 
@@ -61,8 +61,9 @@ function isInsideSession(filePath, sessionPath) {
61
61
  const normalizedFile = path.resolve(filePath);
62
62
  const normalizedSession = path.resolve(sessionPath);
63
63
 
64
- return normalizedFile.startsWith(normalizedSession + path.sep) ||
65
- normalizedFile === normalizedSession;
64
+ return (
65
+ normalizedFile.startsWith(normalizedSession + path.sep) || normalizedFile === normalizedSession
66
+ );
66
67
  }
67
68
 
68
69
  // Output blocked message
@@ -117,7 +118,6 @@ function main() {
117
118
  // File is OUTSIDE active session - BLOCK
118
119
  outputBlocked(filePath, activeSession);
119
120
  process.exit(2);
120
-
121
121
  } catch (e) {
122
122
  // Parse error or other issue - fail open
123
123
  process.exit(0);
@@ -156,8 +156,21 @@ function getCurrentStory() {
156
156
  return null;
157
157
  }
158
158
 
159
+ // Thread type enum values
160
+ const THREAD_TYPES = ['base', 'parallel', 'chained', 'fusion', 'big', 'long'];
161
+
162
+ // Auto-detect thread type from context
163
+ function detectThreadType(session, isWorktree = false) {
164
+ // Worktree sessions are parallel threads
165
+ if (isWorktree || (session && !session.is_main)) {
166
+ return 'parallel';
167
+ }
168
+ // Default to base
169
+ return 'base';
170
+ }
171
+
159
172
  // Register current session (called on startup)
160
- function registerSession(nickname = null) {
173
+ function registerSession(nickname = null, threadType = null) {
161
174
  const registry = loadRegistry();
162
175
  const cwd = process.cwd();
163
176
  const branch = getCurrentBranch();
@@ -179,6 +192,10 @@ function registerSession(nickname = null) {
179
192
  registry.sessions[existingId].story = story ? story.id : null;
180
193
  registry.sessions[existingId].last_active = new Date().toISOString();
181
194
  if (nickname) registry.sessions[existingId].nickname = nickname;
195
+ // Update thread_type if explicitly provided
196
+ if (threadType && THREAD_TYPES.includes(threadType)) {
197
+ registry.sessions[existingId].thread_type = threadType;
198
+ }
182
199
 
183
200
  writeLock(existingId, pid);
184
201
  saveRegistry(registry);
@@ -190,6 +207,11 @@ function registerSession(nickname = null) {
190
207
  const sessionId = String(registry.next_id);
191
208
  registry.next_id++;
192
209
 
210
+ const isMain = cwd === ROOT;
211
+ const detectedType = threadType && THREAD_TYPES.includes(threadType)
212
+ ? threadType
213
+ : detectThreadType(null, !isMain);
214
+
193
215
  registry.sessions[sessionId] = {
194
216
  path: cwd,
195
217
  branch,
@@ -197,13 +219,14 @@ function registerSession(nickname = null) {
197
219
  nickname: nickname || null,
198
220
  created: new Date().toISOString(),
199
221
  last_active: new Date().toISOString(),
200
- is_main: cwd === ROOT,
222
+ is_main: isMain,
223
+ thread_type: detectedType,
201
224
  };
202
225
 
203
226
  writeLock(sessionId, pid);
204
227
  saveRegistry(registry);
205
228
 
206
- return { id: sessionId, isNew: true };
229
+ return { id: sessionId, isNew: true, thread_type: detectedType };
207
230
  }
208
231
 
209
232
  // Unregister session (called on exit)
@@ -291,7 +314,7 @@ function createSession(options = {}) {
291
314
  };
292
315
  }
293
316
 
294
- // Register session
317
+ // Register session - worktree sessions are always parallel threads
295
318
  registry.next_id++;
296
319
  registry.sessions[sessionId] = {
297
320
  path: worktreePath,
@@ -301,6 +324,7 @@ function createSession(options = {}) {
301
324
  created: new Date().toISOString(),
302
325
  last_active: new Date().toISOString(),
303
326
  is_main: false,
327
+ thread_type: options.thread_type || 'parallel', // Worktrees default to parallel
304
328
  };
305
329
 
306
330
  saveRegistry(registry);
@@ -310,6 +334,7 @@ function createSession(options = {}) {
310
334
  sessionId,
311
335
  path: worktreePath,
312
336
  branch: branchName,
337
+ thread_type: registry.sessions[sessionId].thread_type,
313
338
  command: `cd "${worktreePath}" && claude`,
314
339
  };
315
340
  }
@@ -671,6 +696,172 @@ function integrateSession(sessionId, options = {}) {
671
696
  return result;
672
697
  }
673
698
 
699
+ // Session phases for Kanban-style visualization
700
+ const SESSION_PHASES = {
701
+ TODO: 'todo',
702
+ CODING: 'coding',
703
+ REVIEW: 'review',
704
+ MERGED: 'merged',
705
+ };
706
+
707
+ // Detect session phase based on git state
708
+ function getSessionPhase(session) {
709
+ // If merged_at field exists, session was merged
710
+ if (session.merged_at) {
711
+ return SESSION_PHASES.MERGED;
712
+ }
713
+
714
+ // If is_main, it's the merged/main column
715
+ if (session.is_main) {
716
+ return SESSION_PHASES.MERGED;
717
+ }
718
+
719
+ // Check git state for the session
720
+ try {
721
+ const sessionPath = session.path;
722
+ if (!fs.existsSync(sessionPath)) {
723
+ return SESSION_PHASES.TODO;
724
+ }
725
+
726
+ // Count commits since branch diverged from main
727
+ const mainBranch = getMainBranch();
728
+ const commitCount = execSync(
729
+ `git rev-list --count ${mainBranch}..HEAD 2>/dev/null || echo 0`,
730
+ { cwd: sessionPath, encoding: 'utf8' }
731
+ ).trim();
732
+
733
+ const commits = parseInt(commitCount, 10);
734
+
735
+ if (commits === 0) {
736
+ return SESSION_PHASES.TODO;
737
+ }
738
+
739
+ // Check for uncommitted changes
740
+ const status = execSync('git status --porcelain 2>/dev/null || echo ""', {
741
+ cwd: sessionPath,
742
+ encoding: 'utf8',
743
+ }).trim();
744
+
745
+ if (status === '') {
746
+ // No uncommitted changes = ready for review
747
+ return SESSION_PHASES.REVIEW;
748
+ }
749
+
750
+ // Has commits but also uncommitted changes = still coding
751
+ return SESSION_PHASES.CODING;
752
+ } catch (e) {
753
+ // On error, assume coding phase
754
+ return SESSION_PHASES.CODING;
755
+ }
756
+ }
757
+
758
+ // Render Kanban-style board visualization
759
+ function renderKanbanBoard(sessions) {
760
+ const lines = [];
761
+
762
+ // Group sessions by phase
763
+ const byPhase = {
764
+ [SESSION_PHASES.TODO]: [],
765
+ [SESSION_PHASES.CODING]: [],
766
+ [SESSION_PHASES.REVIEW]: [],
767
+ [SESSION_PHASES.MERGED]: [],
768
+ };
769
+
770
+ for (const session of sessions) {
771
+ const phase = getSessionPhase(session);
772
+ byPhase[phase].push(session);
773
+ }
774
+
775
+ // Calculate column widths (min 12 chars)
776
+ const colWidth = 14;
777
+ const separator = ' ';
778
+
779
+ // Header
780
+ lines.push(`${c.cyan}Sessions (Kanban View):${c.reset}`);
781
+ lines.push('');
782
+
783
+ // Column headers
784
+ const headers = [
785
+ `${c.dim}TO DO${c.reset}`,
786
+ `${c.yellow}CODING${c.reset}`,
787
+ `${c.blue}REVIEW${c.reset}`,
788
+ `${c.green}MERGED${c.reset}`,
789
+ ];
790
+ lines.push(headers.map(h => h.padEnd(colWidth + 10)).join(separator)); // +10 for ANSI codes
791
+
792
+ // Top borders
793
+ const topBorder = `┌${'─'.repeat(colWidth)}┐`;
794
+ lines.push([topBorder, topBorder, topBorder, topBorder].join(separator));
795
+
796
+ // Find max rows needed
797
+ const maxRows = Math.max(
798
+ 1,
799
+ byPhase[SESSION_PHASES.TODO].length,
800
+ byPhase[SESSION_PHASES.CODING].length,
801
+ byPhase[SESSION_PHASES.REVIEW].length,
802
+ byPhase[SESSION_PHASES.MERGED].length
803
+ );
804
+
805
+ // Render rows
806
+ for (let i = 0; i < maxRows; i++) {
807
+ const cells = [
808
+ SESSION_PHASES.TODO,
809
+ SESSION_PHASES.CODING,
810
+ SESSION_PHASES.REVIEW,
811
+ SESSION_PHASES.MERGED,
812
+ ].map(phase => {
813
+ const session = byPhase[phase][i];
814
+ if (!session) {
815
+ return `│${' '.repeat(colWidth)}│`;
816
+ }
817
+
818
+ // Format session info
819
+ const id = `[${session.id}]`;
820
+ const name = session.nickname || session.branch || '';
821
+ const truncName = name.length > colWidth - 5 ? name.slice(0, colWidth - 8) + '...' : name;
822
+ const content = `${id} ${truncName}`.slice(0, colWidth);
823
+
824
+ return `│${content.padEnd(colWidth)}│`;
825
+ });
826
+ lines.push(cells.join(separator));
827
+
828
+ // Second line with story
829
+ const storyCells = [
830
+ SESSION_PHASES.TODO,
831
+ SESSION_PHASES.CODING,
832
+ SESSION_PHASES.REVIEW,
833
+ SESSION_PHASES.MERGED,
834
+ ].map(phase => {
835
+ const session = byPhase[phase][i];
836
+ if (!session) {
837
+ return `│${' '.repeat(colWidth)}│`;
838
+ }
839
+
840
+ const story = session.story || '-';
841
+ const storyTrunc = story.length > colWidth - 2 ? story.slice(0, colWidth - 5) + '...' : story;
842
+
843
+ return `│${c.dim}${storyTrunc.padEnd(colWidth)}${c.reset}│`;
844
+ });
845
+ lines.push(storyCells.join(separator));
846
+ }
847
+
848
+ // Bottom borders
849
+ const bottomBorder = `└${'─'.repeat(colWidth)}┘`;
850
+ lines.push([bottomBorder, bottomBorder, bottomBorder, bottomBorder].join(separator));
851
+
852
+ // Summary
853
+ lines.push('');
854
+ const summary = [
855
+ `${c.dim}To Do: ${byPhase[SESSION_PHASES.TODO].length}${c.reset}`,
856
+ `${c.yellow}Coding: ${byPhase[SESSION_PHASES.CODING].length}${c.reset}`,
857
+ `${c.blue}Review: ${byPhase[SESSION_PHASES.REVIEW].length}${c.reset}`,
858
+ `${c.green}Merged: ${byPhase[SESSION_PHASES.MERGED].length}${c.reset}`,
859
+ ].join(' │ ');
860
+ lines.push(summary);
861
+
862
+ return lines.join('\n');
863
+ }
864
+
674
865
  // Format sessions for display
675
866
  function formatSessionsTable(sessions) {
676
867
  const lines = [];
@@ -747,6 +938,11 @@ function main() {
747
938
  const { sessions, cleaned } = getSessions();
748
939
  if (args.includes('--json')) {
749
940
  console.log(JSON.stringify({ sessions, cleaned }));
941
+ } else if (args.includes('--kanban')) {
942
+ console.log(renderKanbanBoard(sessions));
943
+ if (cleaned > 0) {
944
+ console.log(`${c.dim}Cleaned ${cleaned} stale lock(s)${c.reset}`);
945
+ }
750
946
  } else {
751
947
  console.log(formatSessionsTable(sessions));
752
948
  if (cleaned > 0) {
@@ -814,11 +1010,16 @@ function main() {
814
1010
  registry.sessions[sessionId].story = story ? story.id : null;
815
1011
  registry.sessions[sessionId].last_active = new Date().toISOString();
816
1012
  if (nickname) registry.sessions[sessionId].nickname = nickname;
1013
+ // Ensure thread_type exists (migration for old sessions)
1014
+ if (!registry.sessions[sessionId].thread_type) {
1015
+ registry.sessions[sessionId].thread_type = registry.sessions[sessionId].is_main ? 'base' : 'parallel';
1016
+ }
817
1017
  writeLock(sessionId, pid);
818
1018
  } else {
819
1019
  // Create new
820
1020
  sessionId = String(registry.next_id);
821
1021
  registry.next_id++;
1022
+ const isMain = cwd === ROOT;
822
1023
  registry.sessions[sessionId] = {
823
1024
  path: cwd,
824
1025
  branch,
@@ -826,7 +1027,8 @@ function main() {
826
1027
  nickname: nickname || null,
827
1028
  created: new Date().toISOString(),
828
1029
  last_active: new Date().toISOString(),
829
- is_main: cwd === ROOT,
1030
+ is_main: isMain,
1031
+ thread_type: isMain ? 'base' : 'parallel',
830
1032
  };
831
1033
  writeLock(sessionId, pid);
832
1034
  isNew = true;
@@ -984,6 +1186,26 @@ function main() {
984
1186
  break;
985
1187
  }
986
1188
 
1189
+ case 'thread-type': {
1190
+ const subCommand = args[1];
1191
+ if (subCommand === 'set') {
1192
+ const sessionId = args[2];
1193
+ const threadType = args[3];
1194
+ if (!sessionId || !threadType) {
1195
+ console.log(JSON.stringify({ success: false, error: 'Usage: thread-type set <sessionId> <type>' }));
1196
+ return;
1197
+ }
1198
+ const result = setSessionThreadType(sessionId, threadType);
1199
+ console.log(JSON.stringify(result));
1200
+ } else {
1201
+ // Default: get thread type
1202
+ const sessionId = args[1] || null;
1203
+ const result = getSessionThreadType(sessionId);
1204
+ console.log(JSON.stringify(result));
1205
+ }
1206
+ break;
1207
+ }
1208
+
987
1209
  case 'help':
988
1210
  default:
989
1211
  console.log(`
@@ -1001,6 +1223,8 @@ ${c.cyan}Commands:${c.reset}
1001
1223
  switch <id|nickname> Switch active session context (for /add-dir)
1002
1224
  active Get currently switched session (if any)
1003
1225
  clear-active Clear switched session (back to main)
1226
+ thread-type [id] Get thread type for session (default: current)
1227
+ thread-type set <id> <type> Set thread type (base|parallel|chained|fusion|big|long)
1004
1228
  check-merge <id> Check if session is mergeable to main
1005
1229
  merge-preview <id> Preview commits/files to be merged
1006
1230
  integrate <id> [opts] Merge session to main and cleanup
@@ -1186,7 +1410,7 @@ function smartMerge(sessionId, options = {}) {
1186
1410
  }
1187
1411
 
1188
1412
  // Categorize and plan resolutions
1189
- const resolutions = conflictFiles.files.map((file) => {
1413
+ const resolutions = conflictFiles.files.map(file => {
1190
1414
  const category = categorizeFile(file);
1191
1415
  const strategyInfo = getMergeStrategy(category);
1192
1416
  return {
@@ -1295,7 +1519,10 @@ function smartMerge(sessionId, options = {}) {
1295
1519
  result.worktreeDeleted = true;
1296
1520
  } catch (e) {
1297
1521
  try {
1298
- execSync(`git worktree remove --force "${session.path}"`, { cwd: ROOT, encoding: 'utf8' });
1522
+ execSync(`git worktree remove --force "${session.path}"`, {
1523
+ cwd: ROOT,
1524
+ encoding: 'utf8',
1525
+ });
1299
1526
  result.worktreeDeleted = true;
1300
1527
  } catch (e2) {
1301
1528
  result.worktreeDeleted = false;
@@ -1395,7 +1622,7 @@ function getConflictingFiles(sessionId) {
1395
1622
  const branchSet = new Set((branchFiles.stdout || '').trim().split('\n').filter(Boolean));
1396
1623
 
1397
1624
  // Find intersection (files changed in both)
1398
- const conflicting = [...mainSet].filter((f) => branchSet.has(f));
1625
+ const conflicting = [...mainSet].filter(f => branchSet.has(f));
1399
1626
 
1400
1627
  return { success: true, files: conflicting };
1401
1628
  }
@@ -1612,6 +1839,65 @@ function getActiveSession() {
1612
1839
  }
1613
1840
  }
1614
1841
 
1842
+ /**
1843
+ * Get thread type for a session.
1844
+ * @param {string} sessionId - Session ID (or null for current session)
1845
+ * @returns {{ success: boolean, thread_type?: string, error?: string }}
1846
+ */
1847
+ function getSessionThreadType(sessionId = null) {
1848
+ const registry = loadRegistry();
1849
+ const cwd = process.cwd();
1850
+
1851
+ // Find session
1852
+ let targetId = sessionId;
1853
+ if (!targetId) {
1854
+ // Find current session by path
1855
+ for (const [id, session] of Object.entries(registry.sessions)) {
1856
+ if (session.path === cwd) {
1857
+ targetId = id;
1858
+ break;
1859
+ }
1860
+ }
1861
+ }
1862
+
1863
+ if (!targetId || !registry.sessions[targetId]) {
1864
+ return { success: false, error: 'Session not found' };
1865
+ }
1866
+
1867
+ const session = registry.sessions[targetId];
1868
+ // Return thread_type or auto-detect for legacy sessions
1869
+ const threadType = session.thread_type || (session.is_main ? 'base' : 'parallel');
1870
+
1871
+ return {
1872
+ success: true,
1873
+ thread_type: threadType,
1874
+ session_id: targetId,
1875
+ is_main: session.is_main,
1876
+ };
1877
+ }
1878
+
1879
+ /**
1880
+ * Update thread type for a session.
1881
+ * @param {string} sessionId - Session ID
1882
+ * @param {string} threadType - New thread type
1883
+ * @returns {{ success: boolean, error?: string }}
1884
+ */
1885
+ function setSessionThreadType(sessionId, threadType) {
1886
+ if (!THREAD_TYPES.includes(threadType)) {
1887
+ return { success: false, error: `Invalid thread type: ${threadType}. Valid: ${THREAD_TYPES.join(', ')}` };
1888
+ }
1889
+
1890
+ const registry = loadRegistry();
1891
+ if (!registry.sessions[sessionId]) {
1892
+ return { success: false, error: `Session ${sessionId} not found` };
1893
+ }
1894
+
1895
+ registry.sessions[sessionId].thread_type = threadType;
1896
+ saveRegistry(registry);
1897
+
1898
+ return { success: true, thread_type: threadType };
1899
+ }
1900
+
1615
1901
  // Export for use as module
1616
1902
  module.exports = {
1617
1903
  loadRegistry,
@@ -1639,6 +1925,15 @@ module.exports = {
1639
1925
  switchSession,
1640
1926
  clearActiveSession,
1641
1927
  getActiveSession,
1928
+ // Thread type tracking
1929
+ THREAD_TYPES,
1930
+ detectThreadType,
1931
+ getSessionThreadType,
1932
+ setSessionThreadType,
1933
+ // Kanban visualization
1934
+ SESSION_PHASES,
1935
+ getSessionPhase,
1936
+ renderKanbanBoard,
1642
1937
  };
1643
1938
 
1644
1939
  // Run CLI if executed directly