@masslessai/push-todo 3.7.8 → 3.7.9

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 (3) hide show
  1. package/lib/daemon.js +154 -15
  2. package/lib/fetch.js +10 -1
  3. package/package.json +1 -1
package/lib/daemon.js CHANGED
@@ -686,15 +686,13 @@ Automated PR from Push daemon for task #${displayNumber}.
686
686
  }
687
687
 
688
688
  /**
689
- * Merge a PR into main and update local main branch.
690
- * Uses `gh pr merge` for clean remote merge, then pulls locally.
689
+ * Attempt to merge a PR. If merge conflicts, use Claude to resolve them.
691
690
  *
692
691
  * @returns {boolean} True if merge succeeded
693
692
  */
694
693
  function mergePRForTask(displayNumber, prUrl, projectPath) {
695
694
  const gitCwd = projectPath || process.cwd();
696
695
 
697
- // Extract PR number from URL (e.g., https://github.com/user/repo/pull/42)
698
696
  const prMatch = prUrl.match(/\/pull\/(\d+)/);
699
697
  if (!prMatch) {
700
698
  logError(`Could not extract PR number from: ${prUrl}`);
@@ -702,6 +700,26 @@ function mergePRForTask(displayNumber, prUrl, projectPath) {
702
700
  }
703
701
  const prNumber = prMatch[1];
704
702
 
703
+ // First attempt: direct merge
704
+ const firstResult = attemptPRMerge(prNumber, displayNumber, gitCwd);
705
+ if (firstResult === 'success') return true;
706
+ if (firstResult !== 'conflict') return false; // non-conflict failure
707
+
708
+ // Conflict detected — try to resolve with Claude
709
+ const suffix = getWorktreeSuffix();
710
+ const branch = `push-${displayNumber}-${suffix}`;
711
+ const resolved = resolveConflictsWithClaude(displayNumber, branch, gitCwd);
712
+ if (!resolved) return false;
713
+
714
+ // Second attempt after conflict resolution
715
+ log(`Retrying merge for PR #${prNumber} after conflict resolution`);
716
+ return attemptPRMerge(prNumber, displayNumber, gitCwd) === 'success';
717
+ }
718
+
719
+ /**
720
+ * Try gh pr merge. Returns 'success', 'conflict', or 'error'.
721
+ */
722
+ function attemptPRMerge(prNumber, displayNumber, gitCwd) {
705
723
  try {
706
724
  execFileSync('gh', ['pr', 'merge', prNumber, '--merge', '--delete-branch'], {
707
725
  cwd: gitCwd,
@@ -722,16 +740,132 @@ function mergePRForTask(displayNumber, prUrl, projectPath) {
722
740
  log('Could not pull main (may not be checked out), skipping local update');
723
741
  }
724
742
 
725
- return true;
743
+ return 'success';
726
744
  } catch (e) {
727
745
  const stderr = e.stderr?.toString() || e.message || '';
728
- if (stderr.includes('merge conflict') || stderr.includes('conflict')) {
729
- logError(`PR #${prNumber} has merge conflicts, skipping auto-merge`);
730
- } else if (stderr.includes('not found') || stderr.includes('ENOENT')) {
746
+ if (stderr.includes('not found') || stderr.includes('ENOENT')) {
731
747
  log('GitHub CLI (gh) not installed, skipping merge');
732
- } else {
733
- logError(`Failed to merge PR #${prNumber}: ${stderr.slice(0, 200)}`);
748
+ return 'error';
749
+ }
750
+ if (stderr.includes('merge conflict') || stderr.includes('conflict')) {
751
+ log(`PR #${prNumber} has merge conflicts, will attempt resolution`);
752
+ return 'conflict';
753
+ }
754
+ logError(`PR merge failed for #${displayNumber}: ${stderr.slice(0, 200)}`);
755
+ return 'error';
756
+ }
757
+ }
758
+
759
+ /**
760
+ * Resolve merge conflicts by creating a worktree, merging main, using Claude
761
+ * to fix conflicts, then committing and pushing the resolution.
762
+ *
763
+ * @returns {boolean} True if conflicts were resolved and pushed
764
+ */
765
+ function resolveConflictsWithClaude(displayNumber, branch, projectPath) {
766
+ log(`Attempting conflict resolution for task #${displayNumber}`);
767
+
768
+ const worktreePath = getWorktreePath(displayNumber, projectPath);
769
+
770
+ // Recreate worktree from the existing branch
771
+ try {
772
+ if (!existsSync(worktreePath)) {
773
+ execFileSync('git', ['worktree', 'add', worktreePath, branch], {
774
+ cwd: projectPath,
775
+ timeout: 30000,
776
+ stdio: 'pipe'
777
+ });
778
+ }
779
+ } catch (e) {
780
+ logError(`Could not create worktree for conflict resolution: ${e.message}`);
781
+ return false;
782
+ }
783
+
784
+ try {
785
+ // Fetch latest main
786
+ execFileSync('git', ['fetch', 'origin'], {
787
+ cwd: worktreePath,
788
+ timeout: 30000,
789
+ stdio: 'pipe'
790
+ });
791
+
792
+ // Attempt merge — this will create conflict markers if conflicts exist
793
+ try {
794
+ execFileSync('git', ['merge', 'origin/main', '--no-edit'], {
795
+ cwd: worktreePath,
796
+ timeout: 30000,
797
+ stdio: 'pipe'
798
+ });
799
+ // No conflicts — merge succeeded cleanly
800
+ log(`No actual conflicts for #${displayNumber}, merge succeeded locally`);
801
+ execFileSync('git', ['push', 'origin', branch], {
802
+ cwd: worktreePath,
803
+ timeout: 30000,
804
+ stdio: 'pipe'
805
+ });
806
+ cleanupWorktree(displayNumber, projectPath);
807
+ return true;
808
+ } catch {
809
+ // Expected: merge conflicts exist, continue to resolution
810
+ log(`Conflicts detected for #${displayNumber}, invoking Claude to resolve`);
734
811
  }
812
+
813
+ // Use Claude to resolve the conflicts
814
+ const prompt = [
815
+ 'There are merge conflicts in this repository from merging origin/main.',
816
+ 'Run `git diff` to see the conflict markers.',
817
+ 'Resolve ALL conflicts by choosing the correct code for each one.',
818
+ 'After resolving, stage the files with `git add` and commit with:',
819
+ `git commit -m "Resolve merge conflicts for task #${displayNumber}"`,
820
+ 'Do NOT push. Just resolve and commit.'
821
+ ].join(' ');
822
+
823
+ execFileSync('claude', [
824
+ '-p', prompt,
825
+ '--allowedTools', 'Bash(git *),Read,Edit,Write,Glob,Grep',
826
+ '--output-format', 'json',
827
+ '--permission-mode', 'bypassPermissions'
828
+ ], {
829
+ cwd: worktreePath,
830
+ timeout: 120000, // 2 min for conflict resolution
831
+ stdio: 'pipe'
832
+ });
833
+
834
+ // Verify the merge completed (no unmerged files remaining)
835
+ try {
836
+ const status = execFileSync('git', ['status', '--porcelain'], {
837
+ cwd: worktreePath,
838
+ timeout: 10000,
839
+ encoding: 'utf8',
840
+ stdio: ['pipe', 'pipe', 'pipe']
841
+ }).toString();
842
+
843
+ if (status.includes('UU ') || status.includes('AA ') || status.includes('DD ')) {
844
+ logError(`Conflicts not fully resolved for #${displayNumber}`);
845
+ try { execFileSync('git', ['merge', '--abort'], { cwd: worktreePath, stdio: 'pipe' }); } catch {}
846
+ cleanupWorktree(displayNumber, projectPath);
847
+ return false;
848
+ }
849
+ } catch {
850
+ try { execFileSync('git', ['merge', '--abort'], { cwd: worktreePath, stdio: 'pipe' }); } catch {}
851
+ cleanupWorktree(displayNumber, projectPath);
852
+ return false;
853
+ }
854
+
855
+ // Push the resolved branch
856
+ execFileSync('git', ['push', 'origin', branch], {
857
+ cwd: worktreePath,
858
+ timeout: 30000,
859
+ stdio: 'pipe'
860
+ });
861
+
862
+ log(`Conflict resolution pushed for task #${displayNumber}`);
863
+ cleanupWorktree(displayNumber, projectPath);
864
+ return true;
865
+ } catch (e) {
866
+ logError(`Conflict resolution failed for #${displayNumber}: ${e.message}`);
867
+ try { execFileSync('git', ['merge', '--abort'], { cwd: worktreePath, stdio: 'pipe' }); } catch {}
868
+ cleanupWorktree(displayNumber, projectPath);
735
869
  return false;
736
870
  }
737
871
  }
@@ -1243,9 +1377,14 @@ async function handleTaskCompletion(displayNumber, exitCode) {
1243
1377
  // Auto-create PR first so we can include it in the summary
1244
1378
  const prUrl = createPRForTask(displayNumber, summary, projectPath);
1245
1379
 
1246
- // Ask Claude to summarize what it accomplished
1380
+ // Ask Claude to summarize what it accomplished (needs worktree path)
1247
1381
  const semanticSummary = extractSemanticSummary(worktreePath, sessionId);
1248
1382
 
1383
+ // Clean up worktree BEFORE merge — gh pr merge --delete-branch fails if
1384
+ // the local branch is still referenced by a worktree. The branch itself
1385
+ // is preserved (only the worktree checkout is removed).
1386
+ cleanupWorktree(displayNumber, projectPath);
1387
+
1249
1388
  // Combine: semantic summary first (what), then machine metadata (how)
1250
1389
  let executionSummary = '';
1251
1390
  if (semanticSummary) {
@@ -1308,8 +1447,12 @@ async function handleTaskCompletion(displayNumber, exitCode) {
1308
1447
  } else {
1309
1448
  const stderr = taskInfo.process.stderr?.read()?.toString() || '';
1310
1449
 
1311
- // Ask Claude to explain what went wrong (if session exists)
1450
+ // Ask Claude to explain what went wrong (needs worktree path)
1312
1451
  const failureSummary = extractSemanticSummary(worktreePath, sessionId);
1452
+
1453
+ // Clean up worktree after summary extraction
1454
+ cleanupWorktree(displayNumber, projectPath);
1455
+
1313
1456
  const errorMsg = failureSummary
1314
1457
  ? `${failureSummary}\nExit code ${exitCode}. Ran for ${durationStr} on ${machineName}.`
1315
1458
  : `Exit code ${exitCode}: ${stderr.slice(0, 200)}`;
@@ -1338,10 +1481,6 @@ async function handleTaskCompletion(displayNumber, exitCode) {
1338
1481
  taskLastOutput.delete(displayNumber);
1339
1482
  taskStdoutBuffer.delete(displayNumber);
1340
1483
  taskProjectPaths.delete(displayNumber);
1341
-
1342
- // Always clean up worktree — the branch preserves all committed work.
1343
- // On re-run, createWorktree() recreates from the existing branch.
1344
- cleanupWorktree(displayNumber, projectPath);
1345
1484
  updateStatusFile();
1346
1485
  }
1347
1486
 
package/lib/fetch.js CHANGED
@@ -11,7 +11,7 @@ import { getGitRemote, isGitRepo } from './utils/git.js';
11
11
  import { formatTaskForDisplay, formatSearchResult } from './utils/format.js';
12
12
  import { bold, green, yellow, red, cyan, dim, muted } from './utils/colors.js';
13
13
  import { decryptTodoField, isE2EEAvailable } from './encryption.js';
14
- import { getAutoCommitEnabled, getMaxBatchSize } from './config.js';
14
+ import { getAutoCommitEnabled, getAutoMergeEnabled, getAutoCompleteEnabled, getAutoUpdateEnabled, getMaxBatchSize } from './config.js';
15
15
  import { writeFileSync, mkdirSync } from 'fs';
16
16
  import { homedir } from 'os';
17
17
  import { join } from 'path';
@@ -333,6 +333,9 @@ export async function showStatus(options = {}) {
333
333
  const registry = getRegistry();
334
334
  const [e2eeAvailable, e2eeMessage] = isE2EEAvailable();
335
335
  const autoCommit = getAutoCommitEnabled();
336
+ const autoMerge = getAutoMergeEnabled();
337
+ const autoComplete = getAutoCompleteEnabled();
338
+ const autoUpdate = getAutoUpdateEnabled();
336
339
  const maxBatch = getMaxBatchSize();
337
340
 
338
341
  // Validate API key
@@ -358,6 +361,9 @@ export async function showStatus(options = {}) {
358
361
  },
359
362
  settings: {
360
363
  autoCommit,
364
+ autoMerge,
365
+ autoComplete,
366
+ autoUpdate,
361
367
  maxBatchSize: maxBatch
362
368
  },
363
369
  registeredProjects: registry.projectCount()
@@ -401,6 +407,9 @@ export async function showStatus(options = {}) {
401
407
 
402
408
  // Settings
403
409
  console.log(`${bold('Auto-commit:')} ${autoCommit ? 'Enabled' : 'Disabled'}`);
410
+ console.log(`${bold('Auto-merge:')} ${autoMerge ? 'Enabled' : 'Disabled'}`);
411
+ console.log(`${bold('Auto-complete:')} ${autoComplete ? 'Enabled' : 'Disabled'}`);
412
+ console.log(`${bold('Auto-update:')} ${autoUpdate ? 'Enabled' : 'Disabled'}`);
404
413
  console.log(`${bold('Max batch size:')} ${maxBatch}`);
405
414
  console.log(`${bold('Registered projects:')} ${status.registeredProjects}`);
406
415
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@masslessai/push-todo",
3
- "version": "3.7.8",
3
+ "version": "3.7.9",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {