@litmers/cursorflow-orchestrator 0.1.29 → 0.1.30

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.
@@ -86,6 +86,26 @@ interface RunningLaneInfo {
86
86
  continueSignalsSent: number; // Number of continue signals sent
87
87
  }
88
88
 
89
+ /**
90
+ * Log the tail of a file
91
+ */
92
+ function logFileTail(filePath: string, lines: number = 10): void {
93
+ if (!fs.existsSync(filePath)) return;
94
+ try {
95
+ const content = fs.readFileSync(filePath, 'utf8');
96
+ const allLines = content.split('\n');
97
+ const tail = allLines.slice(-lines).filter(l => l.trim());
98
+ if (tail.length > 0) {
99
+ logger.error(` Last ${tail.length} lines of log:`);
100
+ for (const line of tail) {
101
+ logger.error(` ${line}`);
102
+ }
103
+ }
104
+ } catch (e) {
105
+ // Ignore log reading errors
106
+ }
107
+ }
108
+
89
109
  /**
90
110
  * Spawn a lane process
91
111
  */
@@ -651,7 +671,28 @@ export async function orchestrate(tasksDir: string, options: {
651
671
  printLaneStatus(lanes, laneRunDirs);
652
672
  }, options.pollInterval || 60000);
653
673
 
654
- while (completedLanes.size + failedLanes.size + blockedLanes.size < lanes.length || (blockedLanes.size > 0 && running.size === 0)) {
674
+ // Handle process interruption
675
+ const sigIntHandler = () => {
676
+ logger.warn('\n⚠️ Orchestration interrupted! Stopping all lanes...');
677
+ for (const [name, info] of running.entries()) {
678
+ logger.info(`Stopping lane: ${name}`);
679
+ try {
680
+ info.child.kill('SIGTERM');
681
+ } catch {
682
+ // Ignore kill errors
683
+ }
684
+ }
685
+ printLaneStatus(lanes, laneRunDirs);
686
+ process.exit(130);
687
+ };
688
+
689
+ process.on('SIGINT', sigIntHandler);
690
+ process.on('SIGTERM', sigIntHandler);
691
+
692
+ let lastStallCheck = Date.now();
693
+
694
+ try {
695
+ while (completedLanes.size + failedLanes.size + blockedLanes.size < lanes.length || (blockedLanes.size > 0 && running.size === 0)) {
655
696
  // 1. Identify lanes ready to start
656
697
  const readyToStart = lanes.filter(lane => {
657
698
  // Not already running or completed or failed or blocked
@@ -692,6 +733,23 @@ export async function orchestrate(tasksDir: string, options: {
692
733
 
693
734
  logger.info(`Lane started: ${lane.name}${lane.startIndex ? ` (resuming from ${lane.startIndex})` : ''}`);
694
735
 
736
+ const now = Date.now();
737
+ // Pre-register lane in running map so onActivity can find it immediately
738
+ running.set(lane.name, {
739
+ child: {} as any, // Placeholder, will be replaced below
740
+ logManager: undefined,
741
+ logPath: '',
742
+ lastActivity: now,
743
+ lastStateUpdate: now,
744
+ stallPhase: 0,
745
+ taskStartTime: now,
746
+ lastOutput: '',
747
+ statePath: laneStatePath,
748
+ bytesReceived: 0,
749
+ lastBytesCheck: 0,
750
+ continueSignalsSent: 0,
751
+ });
752
+
695
753
  let lastOutput = '';
696
754
  const spawnResult = spawnLane({
697
755
  laneName: lane.name,
@@ -706,44 +764,45 @@ export async function orchestrate(tasksDir: string, options: {
706
764
  onActivity: () => {
707
765
  const info = running.get(lane.name);
708
766
  if (info) {
709
- const now = Date.now();
710
- info.lastActivity = now;
711
- // Also reset progress tracking when there's activity (THNK/TOOL events)
712
- // This prevents STALL_NO_PROGRESS from firing when agent is actively working
713
- info.lastStateUpdate = now;
714
- info.stallPhase = 0; // Reset stall phase since agent is responding
767
+ const actNow = Date.now();
768
+ info.lastActivity = actNow;
769
+ info.lastStateUpdate = actNow;
770
+ info.stallPhase = 0;
715
771
  }
716
772
  }
717
773
  });
718
774
 
775
+ // Update with actual spawn result
776
+ const existingInfo = running.get(lane.name)!;
777
+ Object.assign(existingInfo, spawnResult);
778
+
719
779
  // Track last output and bytes received for long operation and stall detection
720
780
  if (spawnResult.child.stdout) {
721
781
  spawnResult.child.stdout.on('data', (data: Buffer) => {
722
782
  const info = running.get(lane.name);
723
783
  if (info) {
724
- info.lastOutput = data.toString().trim().split('\n').pop() || '';
725
- info.bytesReceived += data.length;
784
+ const output = data.toString();
785
+ const lines = output.split('\n').filter(l => l.trim());
786
+
787
+ // Filter out heartbeats from activity tracking to avoid resetting stall detection
788
+ const realLines = lines.filter(line => !(line.includes('Heartbeat') && line.includes('bytes received')));
726
789
 
727
- // Update auto-recovery manager
728
- autoRecoveryManager.recordActivity(lane.name, data.length, info.lastOutput);
790
+ if (realLines.length > 0) {
791
+ // Real activity detected
792
+ const lastRealLine = realLines[realLines.length - 1]!;
793
+ info.lastOutput = lastRealLine;
794
+ info.bytesReceived += data.length;
795
+
796
+ // Update auto-recovery manager with real activity
797
+ autoRecoveryManager.recordActivity(lane.name, data.length, info.lastOutput);
798
+ } else if (lines.length > 0) {
799
+ // Only heartbeats received - update auto-recovery manager with 0 bytes to avoid resetting idle timer
800
+ autoRecoveryManager.recordActivity(lane.name, 0, info.lastOutput);
801
+ }
729
802
  }
730
803
  });
731
804
  }
732
805
 
733
- const now = Date.now();
734
- running.set(lane.name, {
735
- ...spawnResult,
736
- lastActivity: now,
737
- lastStateUpdate: now,
738
- stallPhase: 0,
739
- taskStartTime: now,
740
- lastOutput: '',
741
- statePath: laneStatePath,
742
- bytesReceived: 0,
743
- lastBytesCheck: 0,
744
- continueSignalsSent: 0,
745
- });
746
-
747
806
  // Register lane with auto-recovery manager
748
807
  autoRecoveryManager.registerLane(lane.name);
749
808
 
@@ -773,13 +832,19 @@ export async function orchestrate(tasksDir: string, options: {
773
832
  const result = await Promise.race([...promises, pollPromise]);
774
833
  if (pollTimeout) clearTimeout(pollTimeout);
775
834
 
776
- if (result.name === '__poll__') {
835
+ const now = Date.now();
836
+ if (result.name === '__poll__' || (now - lastStallCheck >= 10000)) {
837
+ lastStallCheck = now;
838
+
777
839
  // Periodic stall check with multi-layer detection and escalating recovery
778
840
  for (const [laneName, info] of running.entries()) {
779
- const now = Date.now();
780
841
  const idleTime = now - info.lastActivity;
781
842
  const lane = lanes.find(l => l.name === laneName)!;
782
843
 
844
+ if (process.env['DEBUG_STALL']) {
845
+ logger.debug(`[${laneName}] Stall check: idle=${Math.round(idleTime/1000)}s, bytesDelta=${info.bytesReceived - info.lastBytesCheck}, phase=${info.stallPhase}`);
846
+ }
847
+
783
848
  // Check state file for progress updates
784
849
  let progressTime = 0;
785
850
  try {
@@ -1002,74 +1067,98 @@ export async function orchestrate(tasksDir: string, options: {
1002
1067
  }
1003
1068
  }
1004
1069
  continue;
1005
- }
1006
-
1007
- const finished = result;
1008
- const info = running.get(finished.name)!;
1009
- running.delete(finished.name);
1010
- exitCodes[finished.name] = finished.code;
1011
-
1012
- // Unregister from auto-recovery manager
1013
- autoRecoveryManager.unregisterLane(finished.name);
1014
-
1015
- if (finished.code === 0) {
1016
- completedLanes.add(finished.name);
1017
- events.emit('lane.completed', {
1018
- laneName: finished.name,
1019
- exitCode: finished.code,
1020
- });
1021
- } else if (finished.code === 2) {
1022
- // Blocked by dependency
1023
- const statePath = safeJoin(laneRunDirs[finished.name]!, 'state.json');
1024
- const state = loadState<LaneState>(statePath);
1070
+ } else {
1071
+ const finished = result;
1072
+ const info = running.get(finished.name)!;
1073
+ running.delete(finished.name);
1074
+ exitCodes[finished.name] = finished.code;
1025
1075
 
1026
- if (state && state.dependencyRequest) {
1027
- blockedLanes.set(finished.name, state.dependencyRequest);
1028
- const lane = lanes.find(l => l.name === finished.name);
1029
- if (lane) {
1030
- lane.startIndex = Math.max(0, state.currentTaskIndex - 1); // Task was blocked, retry it
1031
- }
1032
-
1033
- events.emit('lane.blocked', {
1076
+ // Unregister from auto-recovery manager
1077
+ autoRecoveryManager.unregisterLane(finished.name);
1078
+
1079
+ if (finished.code === 0) {
1080
+ completedLanes.add(finished.name);
1081
+ events.emit('lane.completed', {
1034
1082
  laneName: finished.name,
1035
- dependencyRequest: state.dependencyRequest,
1083
+ exitCode: finished.code,
1036
1084
  });
1037
- logger.warn(`Lane ${finished.name} is blocked on dependency change request`);
1038
- } else {
1039
- failedLanes.add(finished.name);
1040
- logger.error(`Lane ${finished.name} exited with code 2 but no dependency request found`);
1041
- }
1042
- } else {
1043
- // Check if it was a restart request
1044
- if (info.stallPhase === 2) {
1045
- logger.info(`🔄 Lane ${finished.name} is being restarted due to stall...`);
1046
-
1047
- // Update startIndex from current state to resume from the same task
1085
+ } else if (finished.code === 2) {
1086
+ // Blocked by dependency
1048
1087
  const statePath = safeJoin(laneRunDirs[finished.name]!, 'state.json');
1049
1088
  const state = loadState<LaneState>(statePath);
1050
- if (state) {
1089
+
1090
+ if (state && state.dependencyRequest) {
1091
+ blockedLanes.set(finished.name, state.dependencyRequest);
1051
1092
  const lane = lanes.find(l => l.name === finished.name);
1052
1093
  if (lane) {
1053
- lane.startIndex = state.currentTaskIndex;
1094
+ lane.startIndex = Math.max(0, state.currentTaskIndex - 1); // Task was blocked, retry it
1054
1095
  }
1096
+
1097
+ events.emit('lane.blocked', {
1098
+ laneName: finished.name,
1099
+ dependencyRequest: state.dependencyRequest,
1100
+ });
1101
+ logger.warn(`Lane ${finished.name} is blocked on dependency change request`);
1102
+ } else {
1103
+ failedLanes.add(finished.name);
1104
+ logger.error(`Lane ${finished.name} exited with code 2 but no dependency request found`);
1055
1105
  }
1106
+ } else {
1107
+ // Check if it was a restart request
1108
+ if (info.stallPhase === 2) {
1109
+ logger.info(`🔄 Lane ${finished.name} is being restarted due to stall...`);
1110
+
1111
+ // Update startIndex from current state to resume from the same task
1112
+ const statePath = safeJoin(laneRunDirs[finished.name]!, 'state.json');
1113
+ const state = loadState<LaneState>(statePath);
1114
+ if (state) {
1115
+ const lane = lanes.find(l => l.name === finished.name);
1116
+ if (lane) {
1117
+ lane.startIndex = state.currentTaskIndex;
1118
+ }
1119
+ }
1120
+
1121
+ // Note: we don't add to failedLanes or completedLanes,
1122
+ // so it will be eligible to start again in the next iteration.
1123
+ continue;
1124
+ }
1125
+
1126
+ failedLanes.add(finished.name);
1056
1127
 
1057
- // Note: we don't add to failedLanes or completedLanes,
1058
- // so it will be eligible to start again in the next iteration.
1059
- continue;
1060
- }
1128
+ let errorMsg = 'Process exited with non-zero code';
1129
+ if (info.stallPhase === 3) {
1130
+ errorMsg = 'Stopped due to repeated stall';
1131
+ } else if (info.logManager) {
1132
+ const lastError = info.logManager.getLastError();
1133
+ if (lastError) {
1134
+ errorMsg = `Process failed: ${lastError}`;
1135
+ }
1136
+ }
1061
1137
 
1062
- failedLanes.add(finished.name);
1063
- events.emit('lane.failed', {
1064
- laneName: finished.name,
1065
- exitCode: finished.code,
1066
- error: info.stallPhase === 3 ? 'Stopped due to repeated stall' : 'Process exited with non-zero code',
1067
- });
1138
+ logger.error(`[${finished.name}] Lane failed with exit code ${finished.code}: ${errorMsg}`);
1139
+
1140
+ // Log log tail for visibility
1141
+ if (info.logPath) {
1142
+ logFileTail(info.logPath, 15);
1143
+ }
1144
+
1145
+ events.emit('lane.failed', {
1146
+ laneName: finished.name,
1147
+ exitCode: finished.code,
1148
+ error: errorMsg,
1149
+ });
1150
+ }
1151
+
1152
+ printLaneStatus(lanes, laneRunDirs);
1068
1153
  }
1069
-
1070
- printLaneStatus(lanes, laneRunDirs);
1071
1154
  } else {
1072
1155
  // Nothing running. Are we blocked?
1156
+
1157
+ // Wait a bit to avoid busy-spin while waiting for dependencies or new slots
1158
+ if (completedLanes.size + failedLanes.size + blockedLanes.size < lanes.length) {
1159
+ await new Promise(resolve => setTimeout(resolve, 2000));
1160
+ }
1161
+
1073
1162
  if (blockedLanes.size > 0 && autoResolve) {
1074
1163
  logger.section('🛠 Auto-Resolving Dependencies');
1075
1164
 
@@ -1098,10 +1187,14 @@ export async function orchestrate(tasksDir: string, options: {
1098
1187
  // All finished
1099
1188
  break;
1100
1189
  }
1190
+ }
1101
1191
  }
1192
+ } finally {
1193
+ clearInterval(monitorInterval);
1194
+ process.removeListener('SIGINT', sigIntHandler);
1195
+ process.removeListener('SIGTERM', sigIntHandler);
1102
1196
  }
1103
1197
 
1104
- clearInterval(monitorInterval);
1105
1198
  printLaneStatus(lanes, laneRunDirs);
1106
1199
 
1107
1200
  // Check for failures
@@ -6,6 +6,7 @@
6
6
 
7
7
  import * as path from 'path';
8
8
  import * as fs from 'fs';
9
+ import * as git from './git';
9
10
  import { CursorFlowConfig } from './types';
10
11
  import { safeJoin } from './path';
11
12
  export { CursorFlowConfig };
@@ -167,6 +168,7 @@ export function validateConfig(config: CursorFlowConfig): boolean {
167
168
  */
168
169
  export function createDefaultConfig(projectRoot: string, force = false): string {
169
170
  const configPath = safeJoin(projectRoot, 'cursorflow.config.js');
171
+ const currentBranch = git.getCurrentBranch() || 'main';
170
172
 
171
173
  const template = `module.exports = {
172
174
  // Directory configuration
@@ -175,7 +177,7 @@ export function createDefaultConfig(projectRoot: string, force = false): string
175
177
  pofDir: '_cursorflow/pof',
176
178
 
177
179
  // Git configuration
178
- baseBranch: git.getCurrentBranch() || 'main',
180
+ baseBranch: '${currentBranch}',
179
181
  branchPrefix: 'feature/',
180
182
 
181
183
  // Execution configuration
@@ -1024,7 +1024,7 @@ export class EnhancedLogManager {
1024
1024
  }
1025
1025
 
1026
1026
  /**
1027
- * Close all log files
1027
+ * Close all log files and ensure all data is flushed to disk
1028
1028
  */
1029
1029
  public close(): void {
1030
1030
  // Flush transform stream
@@ -1051,46 +1051,87 @@ export class EnhancedLogManager {
1051
1051
  `;
1052
1052
 
1053
1053
  if (this.cleanLogFd !== null) {
1054
- fs.writeSync(this.cleanLogFd, endMarker);
1055
- fs.closeSync(this.cleanLogFd);
1054
+ try {
1055
+ fs.writeSync(this.cleanLogFd, endMarker);
1056
+ fs.fsyncSync(this.cleanLogFd);
1057
+ fs.closeSync(this.cleanLogFd);
1058
+ } catch {}
1056
1059
  this.cleanLogFd = null;
1057
1060
  }
1058
1061
 
1059
1062
  if (this.rawLogFd !== null) {
1060
- fs.writeSync(this.rawLogFd, endMarker);
1061
- fs.closeSync(this.rawLogFd);
1063
+ try {
1064
+ fs.writeSync(this.rawLogFd, endMarker);
1065
+ fs.fsyncSync(this.rawLogFd);
1066
+ fs.closeSync(this.rawLogFd);
1067
+ } catch {}
1062
1068
  this.rawLogFd = null;
1063
1069
  }
1064
1070
 
1065
1071
  if (this.absoluteRawLogFd !== null) {
1066
- fs.closeSync(this.absoluteRawLogFd);
1072
+ try {
1073
+ fs.fsyncSync(this.absoluteRawLogFd);
1074
+ fs.closeSync(this.absoluteRawLogFd);
1075
+ } catch {}
1067
1076
  this.absoluteRawLogFd = null;
1068
1077
  }
1069
1078
 
1070
1079
  if (this.jsonLogFd !== null) {
1071
- this.writeJsonEntry({
1072
- timestamp: new Date().toISOString(),
1073
- level: 'session',
1074
- source: 'system',
1075
- lane: this.session.laneName,
1076
- message: 'Session ended',
1077
- metadata: {
1078
- sessionId: this.session.id,
1079
- duration: Date.now() - this.session.startTime,
1080
- },
1081
- });
1082
- fs.closeSync(this.jsonLogFd);
1080
+ try {
1081
+ this.writeJsonEntry({
1082
+ timestamp: new Date().toISOString(),
1083
+ level: 'session',
1084
+ source: 'system',
1085
+ lane: this.session.laneName,
1086
+ message: 'Session ended',
1087
+ metadata: {
1088
+ sessionId: this.session.id,
1089
+ duration: Date.now() - this.session.startTime,
1090
+ },
1091
+ });
1092
+ fs.fsyncSync(this.jsonLogFd);
1093
+ fs.closeSync(this.jsonLogFd);
1094
+ } catch {}
1083
1095
  this.jsonLogFd = null;
1084
1096
  }
1085
1097
 
1086
1098
  // Close readable log
1087
1099
  if (this.readableLogFd !== null) {
1088
- fs.writeSync(this.readableLogFd, endMarker);
1089
- fs.closeSync(this.readableLogFd);
1100
+ try {
1101
+ fs.writeSync(this.readableLogFd, endMarker);
1102
+ fs.fsyncSync(this.readableLogFd);
1103
+ fs.closeSync(this.readableLogFd);
1104
+ } catch {}
1090
1105
  this.readableLogFd = null;
1091
1106
  }
1092
1107
  }
1093
1108
 
1109
+ /**
1110
+ * Extract the last error message from the clean log file
1111
+ */
1112
+ public getLastError(): string | null {
1113
+ try {
1114
+ if (!fs.existsSync(this.cleanLogPath)) return null;
1115
+ const content = fs.readFileSync(this.cleanLogPath, 'utf8');
1116
+ // Look for lines containing error markers
1117
+ const lines = content.split('\n').filter(l =>
1118
+ l.includes('[ERROR]') ||
1119
+ l.includes('❌') ||
1120
+ l.includes('error:') ||
1121
+ l.includes('Fatal') ||
1122
+ l.includes('fail')
1123
+ );
1124
+ if (lines.length === 0) {
1125
+ // Fallback to last 5 lines if no specific error marker found
1126
+ const allLines = content.split('\n').filter(l => l.trim());
1127
+ return allLines.slice(-5).join('\n');
1128
+ }
1129
+ return lines[lines.length - 1]!.trim();
1130
+ } catch {
1131
+ return null;
1132
+ }
1133
+ }
1134
+
1094
1135
  /**
1095
1136
  * Format duration for display
1096
1137
  */
package/src/utils/git.ts CHANGED
@@ -132,6 +132,10 @@ function filterGitStderr(stderr: string): string {
132
132
  export function runGit(args: string[], options: GitRunOptions = {}): string {
133
133
  const { cwd, silent = false } = options;
134
134
 
135
+ if (process.env['DEBUG_GIT']) {
136
+ console.log(`[DEBUG_GIT] Running: git ${args.join(' ')} (cwd: ${cwd || process.cwd()})`);
137
+ }
138
+
135
139
  try {
136
140
  const stdioMode = silent ? 'pipe' : ['inherit', 'inherit', 'pipe'];
137
141
 
@@ -141,6 +145,13 @@ export function runGit(args: string[], options: GitRunOptions = {}): string {
141
145
  stdio: stdioMode as any,
142
146
  });
143
147
 
148
+ if (result.error) {
149
+ if (!silent) {
150
+ console.error(`[ERROR_GIT] Failed to execute git command: ${result.error.message}`);
151
+ }
152
+ throw result.error;
153
+ }
154
+
144
155
  if (!silent && result.stderr) {
145
156
  const filteredStderr = filterGitStderr(result.stderr);
146
157
  if (filteredStderr) {
@@ -149,12 +160,16 @@ export function runGit(args: string[], options: GitRunOptions = {}): string {
149
160
  }
150
161
 
151
162
  if (result.status !== 0 && !silent) {
152
- throw new Error(`Git command failed: git ${args.join(' ')}\n${result.stderr || ''}`);
163
+ const errorMsg = `Git command failed (exit code ${result.status}): git ${args.join(' ')}\n${result.stderr || ''}`;
164
+ throw new Error(errorMsg);
153
165
  }
154
166
 
155
167
  return result.stdout ? result.stdout.trim() : '';
156
168
  } catch (error) {
157
169
  if (silent) {
170
+ if (process.env['DEBUG_GIT']) {
171
+ console.error(`[DEBUG_GIT] Command failed (silent mode): ${error instanceof Error ? error.message : String(error)}`);
172
+ }
158
173
  return '';
159
174
  }
160
175
  throw error;
@@ -167,18 +182,28 @@ export function runGit(args: string[], options: GitRunOptions = {}): string {
167
182
  export function runGitResult(args: string[], options: GitRunOptions = {}): GitResult {
168
183
  const { cwd } = options;
169
184
 
185
+ if (process.env['DEBUG_GIT']) {
186
+ console.log(`[DEBUG_GIT] Running: git ${args.join(' ')} (result mode, cwd: ${cwd || process.cwd()})`);
187
+ }
188
+
170
189
  const result = spawnSync('git', args, {
171
190
  cwd: cwd || process.cwd(),
172
191
  encoding: 'utf8',
173
192
  stdio: 'pipe',
174
193
  });
175
194
 
176
- return {
195
+ const gitResult = {
177
196
  exitCode: result.status ?? 1,
178
197
  stdout: (result.stdout || '').toString().trim(),
179
198
  stderr: (result.stderr || '').toString().trim(),
180
199
  success: result.status === 0,
181
200
  };
201
+
202
+ if (process.env['DEBUG_GIT'] && !gitResult.success) {
203
+ console.error(`[DEBUG_GIT] Command failed: git ${args.join(' ')}\nstderr: ${gitResult.stderr}`);
204
+ }
205
+
206
+ return gitResult;
182
207
  }
183
208
 
184
209
  /**
@@ -829,6 +854,94 @@ export function getWorktreeForPath(targetPath: string, cwd?: string): WorktreeIn
829
854
  /**
830
855
  * Sync branch with remote (fetch + merge or rebase)
831
856
  */
857
+ /**
858
+ * Push branch to remote, creating it if it doesn't exist
859
+ * Returns success status and any error message
860
+ */
861
+ export function pushBranchSafe(branchName: string, options: { cwd?: string; force?: boolean } = {}): { success: boolean; error?: string } {
862
+ const { cwd, force = false } = options;
863
+
864
+ // Check if origin exists
865
+ if (!remoteExists('origin', { cwd })) {
866
+ return { success: false, error: 'No remote "origin" configured' };
867
+ }
868
+
869
+ const args = ['push'];
870
+ if (force) {
871
+ args.push('--force');
872
+ }
873
+ args.push('-u', 'origin', branchName);
874
+
875
+ const result = runGitResult(args, { cwd });
876
+
877
+ if (result.success) {
878
+ return { success: true };
879
+ }
880
+
881
+ return { success: false, error: result.stderr };
882
+ }
883
+
884
+ /**
885
+ * Auto-commit any uncommitted changes and push to remote
886
+ * Used for checkpoint before destructive operations
887
+ */
888
+ export function checkpointAndPush(options: {
889
+ cwd?: string;
890
+ message?: string;
891
+ branchName?: string;
892
+ } = {}): { success: boolean; committed: boolean; pushed: boolean; error?: string } {
893
+ const { cwd, message = '[cursorflow] checkpoint before clean' } = options;
894
+
895
+ let committed = false;
896
+ let pushed = false;
897
+
898
+ // Get current branch if not specified
899
+ let branchName = options.branchName;
900
+ if (!branchName) {
901
+ try {
902
+ branchName = getCurrentBranch(cwd);
903
+ } catch {
904
+ return { success: false, committed: false, pushed: false, error: 'Failed to get current branch' };
905
+ }
906
+ }
907
+
908
+ // Check for uncommitted changes
909
+ if (hasUncommittedChanges(cwd)) {
910
+ // Stage all changes
911
+ const addResult = runGitResult(['add', '-A'], { cwd });
912
+ if (!addResult.success) {
913
+ return { success: false, committed: false, pushed: false, error: `Failed to stage changes: ${addResult.stderr}` };
914
+ }
915
+
916
+ // Commit
917
+ const commitResult = runGitResult(['commit', '-m', message], { cwd });
918
+ if (!commitResult.success) {
919
+ return { success: false, committed: false, pushed: false, error: `Failed to commit: ${commitResult.stderr}` };
920
+ }
921
+ committed = true;
922
+ }
923
+
924
+ // Push to remote
925
+ const pushResult = pushBranchSafe(branchName, { cwd });
926
+ if (pushResult.success) {
927
+ pushed = true;
928
+ } else {
929
+ // Push failed but commit succeeded - partial success
930
+ if (committed) {
931
+ return { success: true, committed: true, pushed: false, error: `Commit succeeded but push failed: ${pushResult.error}` };
932
+ }
933
+ // Nothing to commit and push failed - check if there's anything to push
934
+ const localCommits = runGitResult(['rev-list', `origin/${branchName}..HEAD`], { cwd });
935
+ if (localCommits.success && localCommits.stdout.trim()) {
936
+ return { success: false, committed: false, pushed: false, error: `Push failed: ${pushResult.error}` };
937
+ }
938
+ // Nothing to push
939
+ pushed = true;
940
+ }
941
+
942
+ return { success: true, committed, pushed };
943
+ }
944
+
832
945
  export function syncWithRemote(branch: string, options: {
833
946
  cwd?: string;
834
947
  strategy?: 'merge' | 'rebase';