agileflow 2.95.2 → 2.96.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +6 -6
  3. package/lib/api-routes.js +605 -0
  4. package/lib/api-server.js +260 -0
  5. package/lib/claude-cli-bridge.js +221 -0
  6. package/lib/dashboard-protocol.js +541 -0
  7. package/lib/dashboard-server.js +1601 -0
  8. package/lib/drivers/claude-driver.ts +310 -0
  9. package/lib/drivers/codex-driver.ts +454 -0
  10. package/lib/drivers/driver-manager.ts +158 -0
  11. package/lib/drivers/gemini-driver.ts +485 -0
  12. package/lib/drivers/index.ts +17 -0
  13. package/lib/flag-detection.js +350 -0
  14. package/lib/git-operations.js +267 -0
  15. package/lib/lock-file.js +144 -0
  16. package/lib/merge-operations.js +959 -0
  17. package/lib/protocol/driver.ts +360 -0
  18. package/lib/protocol/index.ts +12 -0
  19. package/lib/protocol/ir.ts +271 -0
  20. package/lib/session-display.js +330 -0
  21. package/lib/worktree-operations.js +221 -0
  22. package/package.json +2 -2
  23. package/scripts/agileflow-welcome.js +272 -24
  24. package/scripts/api-server-runner.js +177 -0
  25. package/scripts/archive-completed-stories.sh +22 -0
  26. package/scripts/automation-run-due.js +126 -0
  27. package/scripts/backfill-ideation-status.js +124 -0
  28. package/scripts/claude-tmux.sh +62 -1
  29. package/scripts/context-loader.js +292 -0
  30. package/scripts/dashboard-serve.js +323 -0
  31. package/scripts/lib/automation-registry.js +544 -0
  32. package/scripts/lib/automation-runner.js +476 -0
  33. package/scripts/lib/concurrency-limiter.js +513 -0
  34. package/scripts/lib/configure-features.js +46 -0
  35. package/scripts/lib/context-formatter.js +61 -0
  36. package/scripts/lib/damage-control-utils.js +29 -4
  37. package/scripts/lib/hook-metrics.js +324 -0
  38. package/scripts/lib/ideation-index.js +1196 -0
  39. package/scripts/lib/process-cleanup.js +359 -0
  40. package/scripts/lib/quality-gates.js +574 -0
  41. package/scripts/lib/status-task-bridge.js +522 -0
  42. package/scripts/lib/sync-ideation-status.js +292 -0
  43. package/scripts/lib/task-registry-cache.js +490 -0
  44. package/scripts/lib/task-registry.js +1181 -0
  45. package/scripts/migrate-ideation-index.js +515 -0
  46. package/scripts/precompact-context.sh +104 -0
  47. package/scripts/ralph-loop.js +2 -2
  48. package/scripts/session-manager.js +363 -2770
  49. package/scripts/spawn-parallel.js +45 -9
  50. package/src/core/agents/api-validator.md +180 -0
  51. package/src/core/agents/api.md +2 -0
  52. package/src/core/agents/code-reviewer.md +289 -0
  53. package/src/core/agents/configuration/damage-control.md +17 -0
  54. package/src/core/agents/database.md +2 -0
  55. package/src/core/agents/error-analyzer.md +203 -0
  56. package/src/core/agents/logic-analyzer-edge.md +171 -0
  57. package/src/core/agents/logic-analyzer-flow.md +254 -0
  58. package/src/core/agents/logic-analyzer-invariant.md +207 -0
  59. package/src/core/agents/logic-analyzer-race.md +267 -0
  60. package/src/core/agents/logic-analyzer-type.md +218 -0
  61. package/src/core/agents/logic-consensus.md +256 -0
  62. package/src/core/agents/orchestrator.md +89 -1
  63. package/src/core/agents/schema-validator.md +451 -0
  64. package/src/core/agents/team-coordinator.md +328 -0
  65. package/src/core/agents/ui-validator.md +328 -0
  66. package/src/core/agents/ui.md +2 -0
  67. package/src/core/commands/api.md +267 -0
  68. package/src/core/commands/automate.md +415 -0
  69. package/src/core/commands/babysit.md +290 -9
  70. package/src/core/commands/ideate/history.md +403 -0
  71. package/src/core/commands/{ideate.md → ideate/new.md} +244 -34
  72. package/src/core/commands/logic/audit.md +368 -0
  73. package/src/core/commands/roadmap/analyze.md +1 -1
  74. package/src/core/experts/documentation/expertise.yaml +29 -2
  75. package/src/core/templates/CONTEXT.md.example +49 -0
  76. package/src/core/templates/claude-settings.advanced.example.json +4 -0
  77. package/tools/cli/commands/serve.js +456 -0
  78. package/tools/cli/installers/core/installer.js +7 -2
  79. package/tools/cli/installers/ide/claude-code.js +85 -0
  80. package/tools/cli/lib/content-injector.js +27 -1
  81. package/tools/cli/lib/ui.js +26 -57
@@ -0,0 +1,959 @@
1
+ /**
2
+ * merge-operations.js - Session merge and conflict resolution
3
+ *
4
+ * Provides merge checking, smart conflict resolution, and change management operations.
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { execSync, spawnSync } = require('child_process');
10
+
11
+ const { getProjectRoot, getAgileflowDir } = require('./paths');
12
+ const { getMainBranch, getCurrentBranch, gitCache } = require('./git-operations');
13
+
14
+ const ROOT = getProjectRoot();
15
+ const SESSIONS_DIR = path.join(getAgileflowDir(ROOT), 'sessions');
16
+
17
+ /**
18
+ * Check if session branch is mergeable to main
19
+ * @param {string} sessionId - Session ID
20
+ * @param {Function} loadRegistry - Registry loader function
21
+ * @returns {Object} Mergeability result
22
+ */
23
+ function checkMergeability(sessionId, loadRegistry) {
24
+ const registry = loadRegistry();
25
+ const session = registry.sessions[sessionId];
26
+
27
+ if (!session) {
28
+ return { success: false, error: `Session ${sessionId} not found` };
29
+ }
30
+
31
+ if (session.is_main) {
32
+ return { success: false, error: 'Cannot merge main session' };
33
+ }
34
+
35
+ const branchName = session.branch;
36
+ const mainBranch = getMainBranch();
37
+
38
+ // Check for uncommitted changes in the session worktree
39
+ const statusResult = spawnSync('git', ['status', '--porcelain'], {
40
+ cwd: session.path,
41
+ encoding: 'utf8',
42
+ });
43
+
44
+ if (statusResult.stdout && statusResult.stdout.trim()) {
45
+ return {
46
+ success: true,
47
+ mergeable: false,
48
+ reason: 'uncommitted_changes',
49
+ details: statusResult.stdout.trim(),
50
+ branchName,
51
+ mainBranch,
52
+ };
53
+ }
54
+
55
+ // Check if branch has commits ahead of main
56
+ const aheadBehind = spawnSync(
57
+ 'git',
58
+ ['rev-list', '--left-right', '--count', `${mainBranch}...${branchName}`],
59
+ {
60
+ cwd: ROOT,
61
+ encoding: 'utf8',
62
+ }
63
+ );
64
+
65
+ const [behind, ahead] = (aheadBehind.stdout || '0\t0').trim().split('\t').map(Number);
66
+
67
+ if (ahead === 0) {
68
+ return {
69
+ success: true,
70
+ mergeable: false,
71
+ reason: 'no_changes',
72
+ details: 'Branch has no commits ahead of main',
73
+ branchName,
74
+ mainBranch,
75
+ commitsAhead: 0,
76
+ commitsBehind: behind,
77
+ };
78
+ }
79
+
80
+ // Try merge --no-commit --no-ff to check for conflicts (dry run)
81
+ const currentBranch = getCurrentBranch();
82
+
83
+ // Checkout main in ROOT for the test merge
84
+ const checkoutMain = spawnSync('git', ['checkout', mainBranch], {
85
+ cwd: ROOT,
86
+ encoding: 'utf8',
87
+ });
88
+
89
+ if (checkoutMain.status !== 0) {
90
+ return {
91
+ success: false,
92
+ error: `Failed to checkout ${mainBranch}: ${checkoutMain.stderr}`,
93
+ };
94
+ }
95
+
96
+ // Try the merge
97
+ const testMerge = spawnSync('git', ['merge', '--no-commit', '--no-ff', branchName], {
98
+ cwd: ROOT,
99
+ encoding: 'utf8',
100
+ });
101
+
102
+ const hasConflicts = testMerge.status !== 0;
103
+
104
+ // Abort the test merge
105
+ spawnSync('git', ['merge', '--abort'], { cwd: ROOT, encoding: 'utf8' });
106
+
107
+ // Go back to original branch if different
108
+ if (currentBranch && currentBranch !== mainBranch) {
109
+ spawnSync('git', ['checkout', currentBranch], { cwd: ROOT, encoding: 'utf8' });
110
+ }
111
+
112
+ return {
113
+ success: true,
114
+ mergeable: !hasConflicts,
115
+ branchName,
116
+ mainBranch,
117
+ commitsAhead: ahead,
118
+ commitsBehind: behind,
119
+ hasConflicts,
120
+ conflictDetails: hasConflicts ? testMerge.stderr : null,
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Get merge preview (commits and files to be merged)
126
+ * @param {string} sessionId - Session ID
127
+ * @param {Function} loadRegistry - Registry loader function
128
+ * @returns {Object} Preview result
129
+ */
130
+ function getMergePreview(sessionId, loadRegistry) {
131
+ const registry = loadRegistry();
132
+ const session = registry.sessions[sessionId];
133
+
134
+ if (!session) {
135
+ return { success: false, error: `Session ${sessionId} not found` };
136
+ }
137
+
138
+ if (session.is_main) {
139
+ return { success: false, error: 'Cannot preview merge for main session' };
140
+ }
141
+
142
+ const branchName = session.branch;
143
+ const mainBranch = getMainBranch();
144
+
145
+ // Get commits that would be merged
146
+ const logResult = spawnSync('git', ['log', '--oneline', `${mainBranch}..${branchName}`], {
147
+ cwd: ROOT,
148
+ encoding: 'utf8',
149
+ });
150
+
151
+ const commits = (logResult.stdout || '').trim().split('\n').filter(Boolean);
152
+
153
+ // Get files changed
154
+ const diffResult = spawnSync('git', ['diff', '--name-status', `${mainBranch}...${branchName}`], {
155
+ cwd: ROOT,
156
+ encoding: 'utf8',
157
+ });
158
+
159
+ const filesChanged = (diffResult.stdout || '').trim().split('\n').filter(Boolean);
160
+
161
+ return {
162
+ success: true,
163
+ branchName,
164
+ mainBranch,
165
+ nickname: session.nickname,
166
+ commits,
167
+ commitCount: commits.length,
168
+ filesChanged,
169
+ fileCount: filesChanged.length,
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Execute merge operation
175
+ * @param {string} sessionId - Session ID
176
+ * @param {Object} options - Merge options
177
+ * @param {Function} loadRegistry - Registry loader function
178
+ * @param {Function} saveRegistry - Registry saver function
179
+ * @param {Function} removeLock - Lock remover function
180
+ * @returns {Object} Merge result
181
+ */
182
+ function integrateSession(sessionId, options = {}, loadRegistry, saveRegistry, removeLock) {
183
+ const {
184
+ strategy = 'squash',
185
+ deleteBranch = true,
186
+ deleteWorktree = true,
187
+ message = null,
188
+ } = options;
189
+
190
+ const registry = loadRegistry();
191
+ const session = registry.sessions[sessionId];
192
+
193
+ if (!session) {
194
+ return { success: false, error: `Session ${sessionId} not found` };
195
+ }
196
+
197
+ if (session.is_main) {
198
+ return { success: false, error: 'Cannot merge main session' };
199
+ }
200
+
201
+ const branchName = session.branch;
202
+ const mainBranch = getMainBranch();
203
+
204
+ // Ensure we're on main branch in ROOT
205
+ const checkoutMain = spawnSync('git', ['checkout', mainBranch], {
206
+ cwd: ROOT,
207
+ encoding: 'utf8',
208
+ });
209
+
210
+ if (checkoutMain.status !== 0) {
211
+ return { success: false, error: `Failed to checkout ${mainBranch}: ${checkoutMain.stderr}` };
212
+ }
213
+
214
+ // Pull latest main (optional, for safety) - ignore errors for local-only repos
215
+ spawnSync('git', ['pull', '--ff-only'], { cwd: ROOT, encoding: 'utf8' });
216
+
217
+ // Build commit message
218
+ const commitMessage =
219
+ message ||
220
+ `Merge session ${sessionId}${session.nickname ? ` "${session.nickname}"` : ''}: ${branchName}`;
221
+
222
+ // Execute merge based on strategy
223
+ let mergeResult;
224
+
225
+ if (strategy === 'squash') {
226
+ mergeResult = spawnSync('git', ['merge', '--squash', branchName], {
227
+ cwd: ROOT,
228
+ encoding: 'utf8',
229
+ });
230
+
231
+ if (mergeResult.status === 0) {
232
+ // Create the squash commit
233
+ const commitResult = spawnSync('git', ['commit', '-m', commitMessage], {
234
+ cwd: ROOT,
235
+ encoding: 'utf8',
236
+ });
237
+
238
+ if (commitResult.status !== 0) {
239
+ return { success: false, error: `Failed to create squash commit: ${commitResult.stderr}` };
240
+ }
241
+ }
242
+ } else {
243
+ // Regular merge commit
244
+ mergeResult = spawnSync('git', ['merge', '--no-ff', '-m', commitMessage, branchName], {
245
+ cwd: ROOT,
246
+ encoding: 'utf8',
247
+ });
248
+ }
249
+
250
+ if (mergeResult.status !== 0) {
251
+ // Abort if merge failed
252
+ spawnSync('git', ['merge', '--abort'], { cwd: ROOT, encoding: 'utf8' });
253
+ return { success: false, error: `Merge failed: ${mergeResult.stderr}`, hasConflicts: true };
254
+ }
255
+
256
+ const result = {
257
+ success: true,
258
+ merged: true,
259
+ strategy,
260
+ branchName,
261
+ mainBranch,
262
+ commitMessage,
263
+ mainPath: ROOT,
264
+ };
265
+
266
+ // Delete worktree first (before branch, as worktree holds ref)
267
+ if (deleteWorktree && session.path !== ROOT && fs.existsSync(session.path)) {
268
+ try {
269
+ execSync(`git worktree remove "${session.path}"`, { cwd: ROOT, encoding: 'utf8' });
270
+ result.worktreeDeleted = true;
271
+ } catch (e) {
272
+ try {
273
+ execSync(`git worktree remove --force "${session.path}"`, { cwd: ROOT, encoding: 'utf8' });
274
+ result.worktreeDeleted = true;
275
+ } catch (e2) {
276
+ result.worktreeDeleted = false;
277
+ result.worktreeError = e2.message;
278
+ }
279
+ }
280
+ }
281
+
282
+ // Delete branch if requested
283
+ if (deleteBranch) {
284
+ const deleteBranchResult = spawnSync('git', ['branch', '-d', branchName], {
285
+ cwd: ROOT,
286
+ encoding: 'utf8',
287
+ });
288
+ result.branchDeleted = deleteBranchResult.status === 0;
289
+ if (!result.branchDeleted) {
290
+ // Try force delete if normal delete fails
291
+ const forceDelete = spawnSync('git', ['branch', '-D', branchName], {
292
+ cwd: ROOT,
293
+ encoding: 'utf8',
294
+ });
295
+ result.branchDeleted = forceDelete.status === 0;
296
+ }
297
+ }
298
+
299
+ // Remove from registry
300
+ removeLock(sessionId);
301
+ delete registry.sessions[sessionId];
302
+ saveRegistry(registry);
303
+
304
+ return result;
305
+ }
306
+
307
+ /**
308
+ * Generate auto commit message for session
309
+ * @param {Object} session - Session object
310
+ * @returns {string} Generated commit message
311
+ */
312
+ function generateCommitMessage(session) {
313
+ const nickname = session.nickname || `session-${session.id || 'unknown'}`;
314
+ const branch = session.branch || 'unknown';
315
+ return `chore: commit uncommitted changes from ${nickname}\n\nBranch: ${branch}`;
316
+ }
317
+
318
+ /**
319
+ * Commit all changes in session worktree
320
+ * @param {string} sessionId - Session ID
321
+ * @param {Object} options - Options including message
322
+ * @param {Function} loadRegistry - Registry loader function
323
+ * @returns {Object} Commit result
324
+ */
325
+ function commitChanges(sessionId, options = {}, loadRegistry) {
326
+ const registry = loadRegistry();
327
+ const session = registry.sessions[sessionId];
328
+
329
+ if (!session) {
330
+ return { success: false, error: `Session ${sessionId} not found` };
331
+ }
332
+
333
+ if (!fs.existsSync(session.path)) {
334
+ return { success: false, error: `Session directory not found: ${session.path}` };
335
+ }
336
+
337
+ // Stage all changes
338
+ const addResult = spawnSync('git', ['add', '-A'], {
339
+ cwd: session.path,
340
+ encoding: 'utf8',
341
+ });
342
+
343
+ if (addResult.status !== 0) {
344
+ return { success: false, error: `Failed to stage changes: ${addResult.stderr}` };
345
+ }
346
+
347
+ // Generate commit message if not provided
348
+ const message = options.message || generateCommitMessage({ ...session, id: sessionId });
349
+
350
+ // Create commit
351
+ const commitResult = spawnSync('git', ['commit', '-m', message], {
352
+ cwd: session.path,
353
+ encoding: 'utf8',
354
+ });
355
+
356
+ if (commitResult.status !== 0) {
357
+ // Check if nothing to commit (all changes already staged/committed)
358
+ if (commitResult.stdout && commitResult.stdout.includes('nothing to commit')) {
359
+ return { success: true, message: 'No changes to commit', commitHash: null };
360
+ }
361
+ return {
362
+ success: false,
363
+ error: `Failed to commit: ${commitResult.stderr || commitResult.stdout}`,
364
+ };
365
+ }
366
+
367
+ // Get commit hash
368
+ const hashResult = spawnSync('git', ['rev-parse', 'HEAD'], {
369
+ cwd: session.path,
370
+ encoding: 'utf8',
371
+ });
372
+
373
+ return {
374
+ success: true,
375
+ commitHash: hashResult.stdout?.trim(),
376
+ message,
377
+ };
378
+ }
379
+
380
+ /**
381
+ * Stash changes in session worktree
382
+ * @param {string} sessionId - Session ID
383
+ * @param {Function} loadRegistry - Registry loader function
384
+ * @returns {Object} Stash result
385
+ */
386
+ function stashChanges(sessionId, loadRegistry) {
387
+ const registry = loadRegistry();
388
+ const session = registry.sessions[sessionId];
389
+
390
+ if (!session) {
391
+ return { success: false, error: `Session ${sessionId} not found` };
392
+ }
393
+
394
+ if (!fs.existsSync(session.path)) {
395
+ return { success: false, error: `Session directory not found: ${session.path}` };
396
+ }
397
+
398
+ const stashMsg = `AgileFlow: session ${sessionId} merge prep`;
399
+ const result = spawnSync('git', ['stash', 'push', '-m', stashMsg], {
400
+ cwd: session.path,
401
+ encoding: 'utf8',
402
+ });
403
+
404
+ if (result.status !== 0) {
405
+ return { success: false, error: `Failed to stash: ${result.stderr}` };
406
+ }
407
+
408
+ // Check if stash was actually created (might be "No local changes to save")
409
+ if (result.stdout && result.stdout.includes('No local changes to save')) {
410
+ return { success: true, message: 'No changes to stash', stashCreated: false };
411
+ }
412
+
413
+ return { success: true, message: stashMsg, stashCreated: true };
414
+ }
415
+
416
+ /**
417
+ * Unstash changes (pop stash)
418
+ * @param {string} sessionId - Session ID (for error messages)
419
+ * @returns {Object} Unstash result
420
+ */
421
+ function unstashChanges(sessionId) {
422
+ // Note: After merge, the session worktree is deleted. Stash is popped on main.
423
+ const result = spawnSync('git', ['stash', 'pop'], {
424
+ cwd: ROOT,
425
+ encoding: 'utf8',
426
+ });
427
+
428
+ if (result.status !== 0) {
429
+ // Check if no stash exists
430
+ if (result.stderr && result.stderr.includes('No stash entries found')) {
431
+ return { success: true, message: 'No stash to pop' };
432
+ }
433
+ return { success: false, error: `Failed to unstash: ${result.stderr}` };
434
+ }
435
+
436
+ return { success: true };
437
+ }
438
+
439
+ /**
440
+ * Discard all uncommitted changes in session worktree
441
+ * @param {string} sessionId - Session ID
442
+ * @param {Function} loadRegistry - Registry loader function
443
+ * @returns {Object} Discard result
444
+ */
445
+ function discardChanges(sessionId, loadRegistry) {
446
+ const registry = loadRegistry();
447
+ const session = registry.sessions[sessionId];
448
+
449
+ if (!session) {
450
+ return { success: false, error: `Session ${sessionId} not found` };
451
+ }
452
+
453
+ if (!fs.existsSync(session.path)) {
454
+ return { success: false, error: `Session directory not found: ${session.path}` };
455
+ }
456
+
457
+ // Reset staged changes
458
+ spawnSync('git', ['reset', 'HEAD'], {
459
+ cwd: session.path,
460
+ encoding: 'utf8',
461
+ });
462
+
463
+ // Discard working directory changes
464
+ const checkoutResult = spawnSync('git', ['checkout', '--', '.'], {
465
+ cwd: session.path,
466
+ encoding: 'utf8',
467
+ });
468
+
469
+ if (checkoutResult.status !== 0) {
470
+ return { success: false, error: `Failed to discard changes: ${checkoutResult.stderr}` };
471
+ }
472
+
473
+ return { success: true };
474
+ }
475
+
476
+ /**
477
+ * Categorize a file by type for merge strategy selection.
478
+ * @param {string} filePath - File path
479
+ * @returns {string} Category: 'docs', 'test', 'schema', 'config', 'source'
480
+ */
481
+ function categorizeFile(filePath) {
482
+ const ext = path.extname(filePath).toLowerCase();
483
+ const basename = path.basename(filePath).toLowerCase();
484
+ const dirname = path.dirname(filePath).toLowerCase();
485
+
486
+ // Documentation files
487
+ if (ext === '.md' || basename === 'readme' || basename.startsWith('readme.')) {
488
+ return 'docs';
489
+ }
490
+
491
+ // Test files
492
+ if (
493
+ filePath.includes('.test.') ||
494
+ filePath.includes('.spec.') ||
495
+ filePath.includes('__tests__') ||
496
+ dirname.includes('test') ||
497
+ dirname.includes('tests')
498
+ ) {
499
+ return 'test';
500
+ }
501
+
502
+ // Schema/migration files
503
+ if (
504
+ ext === '.sql' ||
505
+ filePath.includes('schema') ||
506
+ filePath.includes('migration') ||
507
+ filePath.includes('prisma')
508
+ ) {
509
+ return 'schema';
510
+ }
511
+
512
+ // Config files
513
+ if (
514
+ ext === '.json' ||
515
+ ext === '.yaml' ||
516
+ ext === '.yml' ||
517
+ ext === '.toml' ||
518
+ basename.includes('config') ||
519
+ basename.startsWith('.') // dotfiles
520
+ ) {
521
+ return 'config';
522
+ }
523
+
524
+ // Default: source code
525
+ return 'source';
526
+ }
527
+
528
+ /**
529
+ * Get merge strategy for a file category.
530
+ * @param {string} category - File category
531
+ * @returns {Object} Strategy info with strategy, gitStrategy, description
532
+ */
533
+ function getMergeStrategy(category) {
534
+ const strategies = {
535
+ docs: {
536
+ strategy: 'accept_both',
537
+ gitStrategy: 'union',
538
+ description: 'Documentation is additive - both changes kept',
539
+ },
540
+ test: {
541
+ strategy: 'accept_both',
542
+ gitStrategy: 'union',
543
+ description: 'Tests are additive - both test files kept',
544
+ },
545
+ schema: {
546
+ strategy: 'take_theirs',
547
+ gitStrategy: 'theirs',
548
+ description: 'Schemas evolve forward - session version used',
549
+ },
550
+ config: {
551
+ strategy: 'merge_keys',
552
+ gitStrategy: 'ours',
553
+ description: 'Config changes need review - main version kept',
554
+ },
555
+ source: {
556
+ strategy: 'intelligent_merge',
557
+ gitStrategy: 'recursive',
558
+ description: 'Source code merged by git recursive strategy',
559
+ },
560
+ };
561
+
562
+ return strategies[category] || strategies.source;
563
+ }
564
+
565
+ /**
566
+ * Get list of files that would conflict during merge.
567
+ * @param {string} sessionId - Session ID
568
+ * @param {Function} loadRegistry - Registry loader function
569
+ * @returns {Object} Conflicting files result
570
+ */
571
+ function getConflictingFiles(sessionId, loadRegistry) {
572
+ const registry = loadRegistry();
573
+ const session = registry.sessions[sessionId];
574
+
575
+ if (!session) {
576
+ return { success: false, error: `Session ${sessionId} not found` };
577
+ }
578
+
579
+ const branchName = session.branch;
580
+ const mainBranch = getMainBranch();
581
+
582
+ // Get files changed in both branches since divergence
583
+ const mergeBase = spawnSync('git', ['merge-base', mainBranch, branchName], {
584
+ cwd: ROOT,
585
+ encoding: 'utf8',
586
+ });
587
+
588
+ if (mergeBase.status !== 0) {
589
+ return { success: false, error: 'Could not find merge base' };
590
+ }
591
+
592
+ const base = mergeBase.stdout.trim();
593
+
594
+ // Files changed in main since base
595
+ const mainFiles = spawnSync('git', ['diff', '--name-only', base, mainBranch], {
596
+ cwd: ROOT,
597
+ encoding: 'utf8',
598
+ });
599
+
600
+ // Files changed in session branch since base
601
+ const branchFiles = spawnSync('git', ['diff', '--name-only', base, branchName], {
602
+ cwd: ROOT,
603
+ encoding: 'utf8',
604
+ });
605
+
606
+ const mainSet = new Set((mainFiles.stdout || '').trim().split('\n').filter(Boolean));
607
+ const branchSet = new Set((branchFiles.stdout || '').trim().split('\n').filter(Boolean));
608
+
609
+ // Find intersection (files changed in both)
610
+ const conflicting = [...mainSet].filter(f => branchSet.has(f));
611
+
612
+ return { success: true, files: conflicting };
613
+ }
614
+
615
+ /**
616
+ * Resolve a single file conflict using the designated strategy.
617
+ * @param {Object} resolution - Resolution info from categorization
618
+ * @returns {Object} Resolution result
619
+ */
620
+ function resolveConflict(resolution) {
621
+ const { file, gitStrategy } = resolution;
622
+
623
+ try {
624
+ switch (gitStrategy) {
625
+ case 'union':
626
+ // Union merge - concatenate both versions
627
+ try {
628
+ const base = spawnSync('git', ['show', `:1:${file}`], { cwd: ROOT, encoding: 'utf8' });
629
+ const ours = spawnSync('git', ['show', `:2:${file}`], { cwd: ROOT, encoding: 'utf8' });
630
+ const theirs = spawnSync('git', ['show', `:3:${file}`], { cwd: ROOT, encoding: 'utf8' });
631
+
632
+ if (base.status === 0 && ours.status === 0 && theirs.status === 0) {
633
+ const tmpBase = path.join(ROOT, '.git', 'MERGE_BASE_TMP');
634
+ const tmpOurs = path.join(ROOT, '.git', 'MERGE_OURS_TMP');
635
+ const tmpTheirs = path.join(ROOT, '.git', 'MERGE_THEIRS_TMP');
636
+
637
+ fs.writeFileSync(tmpBase, base.stdout);
638
+ fs.writeFileSync(tmpOurs, ours.stdout);
639
+ fs.writeFileSync(tmpTheirs, theirs.stdout);
640
+
641
+ spawnSync('git', ['merge-file', '--union', tmpOurs, tmpBase, tmpTheirs], {
642
+ cwd: ROOT,
643
+ encoding: 'utf8',
644
+ });
645
+
646
+ fs.copyFileSync(tmpOurs, path.join(ROOT, file));
647
+
648
+ fs.unlinkSync(tmpBase);
649
+ fs.unlinkSync(tmpOurs);
650
+ fs.unlinkSync(tmpTheirs);
651
+ } else {
652
+ execSync(`git checkout --theirs "${file}"`, { cwd: ROOT, encoding: 'utf8' });
653
+ }
654
+ } catch (unionError) {
655
+ execSync(`git checkout --theirs "${file}"`, { cwd: ROOT, encoding: 'utf8' });
656
+ }
657
+ break;
658
+
659
+ case 'theirs':
660
+ execSync(`git checkout --theirs "${file}"`, { cwd: ROOT, encoding: 'utf8' });
661
+ break;
662
+
663
+ case 'ours':
664
+ execSync(`git checkout --ours "${file}"`, { cwd: ROOT, encoding: 'utf8' });
665
+ break;
666
+
667
+ case 'recursive':
668
+ default:
669
+ execSync(`git checkout --theirs "${file}"`, { cwd: ROOT, encoding: 'utf8' });
670
+ break;
671
+ }
672
+
673
+ execSync(`git add "${file}"`, { cwd: ROOT, encoding: 'utf8' });
674
+ return { success: true };
675
+ } catch (e) {
676
+ return { success: false, error: e.message };
677
+ }
678
+ }
679
+
680
+ /**
681
+ * Save merge log for audit trail.
682
+ * @param {Object} log - Merge log entry
683
+ */
684
+ function saveMergeLog(log) {
685
+ const logPath = path.join(SESSIONS_DIR, 'merge-log.json');
686
+
687
+ let logs = { merges: [] };
688
+ if (fs.existsSync(logPath)) {
689
+ try {
690
+ logs = JSON.parse(fs.readFileSync(logPath, 'utf8'));
691
+ } catch (e) {
692
+ // Start fresh
693
+ }
694
+ }
695
+
696
+ logs.merges.push(log);
697
+
698
+ // Keep only last 50 merges
699
+ if (logs.merges.length > 50) {
700
+ logs.merges = logs.merges.slice(-50);
701
+ }
702
+
703
+ fs.writeFileSync(logPath, JSON.stringify(logs, null, 2));
704
+ }
705
+
706
+ /**
707
+ * Get merge history from audit log.
708
+ * @returns {Object} Merge history result
709
+ */
710
+ function getMergeHistory() {
711
+ const logPath = path.join(SESSIONS_DIR, 'merge-log.json');
712
+
713
+ if (!fs.existsSync(logPath)) {
714
+ return { success: true, merges: [] };
715
+ }
716
+
717
+ try {
718
+ const logs = JSON.parse(fs.readFileSync(logPath, 'utf8'));
719
+ return { success: true, merges: logs.merges || [] };
720
+ } catch (e) {
721
+ return { success: false, error: e.message };
722
+ }
723
+ }
724
+
725
+ /**
726
+ * Smart merge with automatic conflict resolution.
727
+ * @param {string} sessionId - Session ID
728
+ * @param {Object} options - Merge options
729
+ * @param {Function} loadRegistry - Registry loader
730
+ * @param {Function} saveRegistry - Registry saver
731
+ * @param {Function} removeLock - Lock remover
732
+ * @param {Function} unregisterSession - Session unregisterer
733
+ * @returns {Object} Smart merge result
734
+ */
735
+ function smartMerge(sessionId, options = {}, loadRegistry, saveRegistry, removeLock, unregisterSession) {
736
+ const { c } = require('./colors');
737
+ const {
738
+ strategy = 'squash',
739
+ deleteBranch = true,
740
+ deleteWorktree = true,
741
+ message = null,
742
+ } = options;
743
+
744
+ const registry = loadRegistry();
745
+ const session = registry.sessions[sessionId];
746
+
747
+ if (!session) {
748
+ return { success: false, error: `Session ${sessionId} not found` };
749
+ }
750
+
751
+ if (session.is_main) {
752
+ return { success: false, error: 'Cannot merge main session' };
753
+ }
754
+
755
+ const branchName = session.branch;
756
+ const mainBranch = getMainBranch();
757
+
758
+ // First, try normal merge
759
+ const checkResult = checkMergeability(sessionId, loadRegistry);
760
+ if (!checkResult.success) {
761
+ return checkResult;
762
+ }
763
+
764
+ // If no conflicts, use regular merge
765
+ if (!checkResult.hasConflicts) {
766
+ return integrateSession(sessionId, options, loadRegistry, saveRegistry, removeLock);
767
+ }
768
+
769
+ // We have conflicts - try smart resolution
770
+ console.log(`${c.amber}Conflicts detected - attempting auto-resolution...${c.reset}`);
771
+
772
+ // Get list of conflicting files
773
+ const conflictFiles = getConflictingFiles(sessionId, loadRegistry);
774
+ if (!conflictFiles.success) {
775
+ return conflictFiles;
776
+ }
777
+
778
+ // Categorize and plan resolutions
779
+ const resolutions = conflictFiles.files.map(file => {
780
+ const category = categorizeFile(file);
781
+ const strategyInfo = getMergeStrategy(category);
782
+ return {
783
+ file,
784
+ category,
785
+ ...strategyInfo,
786
+ };
787
+ });
788
+
789
+ // Log merge audit
790
+ const mergeLog = {
791
+ session: sessionId,
792
+ started_at: new Date().toISOString(),
793
+ files_to_resolve: resolutions,
794
+ };
795
+
796
+ // Ensure we're on main branch
797
+ const checkoutMain = spawnSync('git', ['checkout', mainBranch], {
798
+ cwd: ROOT,
799
+ encoding: 'utf8',
800
+ });
801
+
802
+ if (checkoutMain.status !== 0) {
803
+ return { success: false, error: `Failed to checkout ${mainBranch}: ${checkoutMain.stderr}` };
804
+ }
805
+
806
+ // Start the merge
807
+ const startMerge = spawnSync('git', ['merge', '--no-commit', '--no-ff', branchName], {
808
+ cwd: ROOT,
809
+ encoding: 'utf8',
810
+ });
811
+
812
+ // If merge started but has conflicts, resolve them
813
+ if (startMerge.status !== 0) {
814
+ const resolvedFiles = [];
815
+ const unresolvedFiles = [];
816
+
817
+ for (const resolution of resolutions) {
818
+ const resolveResult = resolveConflict(resolution);
819
+ if (resolveResult.success) {
820
+ resolvedFiles.push({
821
+ file: resolution.file,
822
+ strategy: resolution.strategy,
823
+ description: resolution.description,
824
+ });
825
+ } else {
826
+ unresolvedFiles.push({
827
+ file: resolution.file,
828
+ error: resolveResult.error,
829
+ });
830
+ }
831
+ }
832
+
833
+ // If any files couldn't be resolved, abort
834
+ if (unresolvedFiles.length > 0) {
835
+ spawnSync('git', ['merge', '--abort'], { cwd: ROOT, encoding: 'utf8' });
836
+ return {
837
+ success: false,
838
+ error: 'Some conflicts could not be auto-resolved',
839
+ autoResolved: resolvedFiles,
840
+ unresolved: unresolvedFiles,
841
+ hasConflicts: true,
842
+ };
843
+ }
844
+
845
+ // All conflicts resolved - commit the merge
846
+ const commitMessage =
847
+ message ||
848
+ `Merge session ${sessionId}${session.nickname ? ` "${session.nickname}"` : ''}: ${branchName} (auto-resolved)`;
849
+
850
+ // Stage all resolved files
851
+ spawnSync('git', ['add', '-A'], { cwd: ROOT, encoding: 'utf8' });
852
+
853
+ // Create commit
854
+ const commitResult = spawnSync('git', ['commit', '-m', commitMessage], {
855
+ cwd: ROOT,
856
+ encoding: 'utf8',
857
+ });
858
+
859
+ if (commitResult.status !== 0) {
860
+ spawnSync('git', ['merge', '--abort'], { cwd: ROOT, encoding: 'utf8' });
861
+ return { success: false, error: `Failed to commit merge: ${commitResult.stderr}` };
862
+ }
863
+
864
+ // Log successful merge
865
+ mergeLog.merged_at = new Date().toISOString();
866
+ mergeLog.files_auto_resolved = resolvedFiles;
867
+ mergeLog.commits_merged = checkResult.commitsAhead;
868
+ saveMergeLog(mergeLog);
869
+
870
+ const result = {
871
+ success: true,
872
+ merged: true,
873
+ autoResolved: resolvedFiles,
874
+ strategy,
875
+ branchName,
876
+ mainBranch,
877
+ commitMessage,
878
+ mainPath: ROOT,
879
+ };
880
+
881
+ // Cleanup worktree and branch
882
+ if (deleteWorktree && session.path !== ROOT && fs.existsSync(session.path)) {
883
+ try {
884
+ execSync(`git worktree remove "${session.path}"`, { cwd: ROOT, encoding: 'utf8' });
885
+ result.worktreeDeleted = true;
886
+ } catch (e) {
887
+ try {
888
+ execSync(`git worktree remove --force "${session.path}"`, {
889
+ cwd: ROOT,
890
+ encoding: 'utf8',
891
+ });
892
+ result.worktreeDeleted = true;
893
+ } catch (e2) {
894
+ result.worktreeDeleted = false;
895
+ }
896
+ }
897
+ }
898
+
899
+ if (deleteBranch) {
900
+ try {
901
+ execSync(`git branch -D "${branchName}"`, { cwd: ROOT, encoding: 'utf8' });
902
+ result.branchDeleted = true;
903
+ } catch (e) {
904
+ result.branchDeleted = false;
905
+ }
906
+ }
907
+
908
+ // Unregister the session
909
+ unregisterSession(sessionId);
910
+
911
+ return result;
912
+ }
913
+
914
+ // Merge succeeded without conflicts
915
+ const commitMessage =
916
+ message ||
917
+ `Merge session ${sessionId}${session.nickname ? ` "${session.nickname}"` : ''}: ${branchName}`;
918
+
919
+ const commitResult = spawnSync('git', ['commit', '-m', commitMessage], {
920
+ cwd: ROOT,
921
+ encoding: 'utf8',
922
+ });
923
+
924
+ if (commitResult.status !== 0) {
925
+ return { success: false, error: `Failed to commit: ${commitResult.stderr}` };
926
+ }
927
+
928
+ return {
929
+ success: true,
930
+ merged: true,
931
+ strategy,
932
+ branchName,
933
+ mainBranch,
934
+ commitMessage,
935
+ };
936
+ }
937
+
938
+ module.exports = {
939
+ // Merge checks
940
+ checkMergeability,
941
+ getMergePreview,
942
+ // Merge execution
943
+ integrateSession,
944
+ generateCommitMessage,
945
+ // Changes handling
946
+ commitChanges,
947
+ stashChanges,
948
+ unstashChanges,
949
+ discardChanges,
950
+ // Smart merge
951
+ categorizeFile,
952
+ getMergeStrategy,
953
+ smartMerge,
954
+ getConflictingFiles,
955
+ resolveConflict,
956
+ // Merge history
957
+ saveMergeLog,
958
+ getMergeHistory,
959
+ };