@link-assistant/hive-mind 2.11.13 → 2.12.1

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 (41) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/package.json +4 -1
  3. package/src/agent-command.lib.mjs +74 -0
  4. package/src/agent.lib.mjs +59 -34
  5. package/src/agentic-cli-updater.lib.mjs +241 -0
  6. package/src/claude.connection.lib.mjs +209 -0
  7. package/src/claude.lib.mjs +6 -202
  8. package/src/codex.lib.mjs +0 -128
  9. package/src/formal-ai-isolation.lib.mjs +62 -0
  10. package/src/formal-ai-maintenance.lib.mjs +106 -0
  11. package/src/formal-ai-model.lib.mjs +25 -0
  12. package/src/formal-ai-runtime.lib.mjs +10 -0
  13. package/src/formal-ai-sidecar.lib.mjs +565 -0
  14. package/src/formal-ai-updater.lib.mjs +294 -0
  15. package/src/formal-ai-version.lib.mjs +100 -0
  16. package/src/formal-ai.lib.mjs +11 -16
  17. package/src/github-rate-limit.lib.mjs +3 -0
  18. package/src/github-url-parser.lib.mjs +255 -0
  19. package/src/github.lib.mjs +22 -343
  20. package/src/hive.mjs +0 -152
  21. package/src/interactive-mode.lib.mjs +0 -43
  22. package/src/isolation-runner.lib.mjs +44 -173
  23. package/src/limits.lib.mjs +0 -89
  24. package/src/model-args.lib.mjs +32 -0
  25. package/src/models/index.mjs +5 -19
  26. package/src/session-monitor.lib.mjs +14 -172
  27. package/src/solve.auto-merge.lib.mjs +70 -164
  28. package/src/solve.mjs +31 -193
  29. package/src/solve.repository.lib.mjs +0 -83
  30. package/src/solve.results.lib.mjs +2 -92
  31. package/src/solve.session.lib.mjs +52 -19
  32. package/src/solve.tool-uncommitted.lib.mjs +22 -0
  33. package/src/state-lock.lib.mjs +82 -0
  34. package/src/telegram-bot.mjs +17 -65
  35. package/src/telegram-fix-command.lib.mjs +1 -8
  36. package/src/telegram-merge-queue.lib.mjs +3 -155
  37. package/src/telegram-solve-queue.lib.mjs +9 -168
  38. package/src/telegram-task-command.lib.mjs +1 -8
  39. package/src/use-m-bootstrap.lib.mjs +6 -5
  40. package/src/use-with-retry.lib.mjs +128 -2
  41. package/src/working-session-summary.lib.mjs +47 -1
@@ -3,7 +3,6 @@ import { ensureUseM } from './use-m-bootstrap.lib.mjs';
3
3
 
4
4
  // Repository management module for solve command
5
5
  // Extracted from solve.mjs to keep files under 1500 lines
6
-
7
6
  // Use use-m to dynamically import modules for cross-runtime compatibility
8
7
  // Check if use is already defined globally (when imported from solve.mjs)
9
8
  // If not, fetch it (when running standalone)
@@ -19,7 +18,6 @@ const $ = wrapDollarWithGhRetry((await use('command-stream')).$);
19
18
  const os = (await use('os')).default;
20
19
  const path = (await use('path')).default;
21
20
  const fs = (await use('fs')).promises;
22
-
23
21
  // Import shared library functions
24
22
  const lib = await import('./lib.mjs');
25
23
  // Import Sentry integration
@@ -27,7 +25,6 @@ const sentryLib = await import('./sentry.lib.mjs');
27
25
  const { reportError } = sentryLib;
28
26
 
29
27
  const { log, formatAligned } = lib;
30
-
31
28
  // Import exit handler
32
29
  import { safeExit } from './exit-handler.lib.mjs';
33
30
  import { ensureAiToolScratchIgnored } from './ai-tool-scratch.lib.mjs';
@@ -40,7 +37,6 @@ const githubLib = await import('./github.lib.mjs');
40
37
  const { checkRepositoryWritePermission } = githubLib;
41
38
  // Issue #1625: centralized markers + tracked posting.
42
39
  const { REPOSITORY_INITIALIZATION_REQUIRED_MARKER, postTrackedComment } = await import('./tool-comments.lib.mjs');
43
-
44
40
  // Get root repository (fork source or self), or null if inaccessible
45
41
  export const getRootRepository = async (owner, repo) => {
46
42
  try {
@@ -54,7 +50,6 @@ export const getRootRepository = async (owner, repo) => {
54
50
  return null;
55
51
  }
56
52
  };
57
-
58
53
  // Check if current user has a fork of the given root repository
59
54
  export const checkExistingForkOfRoot = async rootRepo => {
60
55
  try {
@@ -80,7 +75,6 @@ export const checkExistingForkOfRoot = async rootRepo => {
80
75
  return null;
81
76
  }
82
77
  };
83
-
84
78
  /**
85
79
  * Validate that a fork's parent matches the expected upstream repository.
86
80
  * This prevents issues where a fork was created from an intermediate fork (fork of a fork)
@@ -101,7 +95,6 @@ export const validateForkParent = async (forkRepo, expectedUpstream) => {
101
95
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
102
96
  try {
103
97
  const forkInfoResult = await $`gh api repos/${forkRepo} --jq '{fork: .fork, parent: .parent.full_name, source: .source.full_name}'`;
104
-
105
98
  // Check for network errors in non-zero exit code
106
99
  if (forkInfoResult.code !== 0) {
107
100
  const errorOutput = (forkInfoResult.stderr?.toString() || '') + (forkInfoResult.stdout?.toString() || '');
@@ -122,7 +115,6 @@ export const validateForkParent = async (forkRepo, expectedUpstream) => {
122
115
  const isFork = forkInfo.fork === true;
123
116
  const parent = forkInfo.parent || null;
124
117
  const source = forkInfo.source || null;
125
-
126
118
  // If not a fork at all, it's invalid for our purposes
127
119
  if (!isFork) {
128
120
  return { isValid: false, isFork: false, parent: null, source: null, error: `Repository ${forkRepo} is not a GitHub fork` };
@@ -132,7 +124,6 @@ export const validateForkParent = async (forkRepo, expectedUpstream) => {
132
124
  // The SOURCE (ultimate root) is also acceptable as it indicates the fork is part of the correct hierarchy
133
125
  const parentMatches = parent === expectedUpstream;
134
126
  const sourceMatches = source === expectedUpstream;
135
-
136
127
  if (parentMatches) {
137
128
  return { isValid: true, isFork: true, parent, source, error: null };
138
129
  }
@@ -142,7 +133,6 @@ export const validateForkParent = async (forkRepo, expectedUpstream) => {
142
133
  if (sourceMatches && !parentMatches) {
143
134
  return { isValid: false, isFork: true, parent, source, error: `Fork ${forkRepo} was created from ${parent} (intermediate fork), not directly from ${expectedUpstream}. This can cause pull requests to include unexpected commits from the intermediate fork.` };
144
135
  }
145
-
146
136
  // Neither parent nor source matches - completely different repository tree
147
137
  return { isValid: false, isFork: true, parent, source, error: `Fork ${forkRepo} is from a different repository tree (parent: ${parent}, source: ${source}) and cannot be used with ${expectedUpstream}` };
148
138
  } catch (error) {
@@ -163,7 +153,6 @@ export const validateForkParent = async (forkRepo, expectedUpstream) => {
163
153
  }
164
154
  return networkErr(`Failed to validate fork after ${maxAttempts} attempts`);
165
155
  };
166
-
167
156
  /**
168
157
  * Build workspace directory name according to the specification:
169
158
  * /tmp/hive-mind-solve-gh-{owner}/{repo}-issue-{issueNumber}-workspace-{timestamp}
@@ -193,7 +182,6 @@ export const setupTempDirectory = async (argv, workspaceInfo = null) => {
193
182
  // needsClone indicates if the repository needs to be cloned into the directory
194
183
  // This is true when: new directory is created, or existing directory is empty
195
184
  let needsClone = true;
196
-
197
185
  // Check if workspace mode should be enabled (works with all tools)
198
186
  const useWorkspaces = argv.enableWorkspaces;
199
187
 
@@ -202,7 +190,6 @@ export const setupTempDirectory = async (argv, workspaceInfo = null) => {
202
190
  // because Claude Code stores sessions by working directory path, not session ID alone
203
191
  if (argv.workingDirectory) {
204
192
  tempDir = path.resolve(argv.workingDirectory);
205
-
206
193
  // Check if directory exists
207
194
  try {
208
195
  const stat = await fs.stat(tempDir);
@@ -232,7 +219,6 @@ export const setupTempDirectory = async (argv, workspaceInfo = null) => {
232
219
 
233
220
  return { tempDir, workspaceTmpDir, isResuming, needsClone };
234
221
  }
235
-
236
222
  if (isResuming) {
237
223
  // When resuming without --working-directory, create a new temp directory
238
224
  // WARNING: This will NOT work correctly with Claude Code because the session
@@ -244,7 +230,6 @@ export const setupTempDirectory = async (argv, workspaceInfo = null) => {
244
230
  // Check if session log exists to verify session is valid
245
231
  await fs.access(sessionLogPattern);
246
232
  await log(`🔄 Resuming session ${argv.resume} (session log found)`);
247
-
248
233
  // For resumed sessions, create new temp directory since old one may be cleaned up
249
234
  tempDir = path.join(os.tmpdir(), `gh-issue-solver-resume-${argv.resume}-${Date.now()}`);
250
235
  await fs.mkdir(tempDir, { recursive: true });
@@ -273,12 +258,10 @@ export const setupTempDirectory = async (argv, workspaceInfo = null) => {
273
258
  // {workspace}/tmp - for temp files, logs, command outputs
274
259
  const repoDir = path.join(workspaceDir, 'repository');
275
260
  workspaceTmpDir = path.join(workspaceDir, 'tmp');
276
-
277
261
  await fs.mkdir(repoDir, { recursive: true });
278
262
  await fs.mkdir(workspaceTmpDir, { recursive: true });
279
263
 
280
264
  tempDir = repoDir;
281
-
282
265
  await log(`\n${formatAligned('📂', 'Workspace mode:', 'ENABLED')}`);
283
266
  await log(formatAligned('', 'Workspace root:', workspaceDir, 2));
284
267
  await log(formatAligned('', 'Repository dir:', repoDir, 2));
@@ -291,7 +274,6 @@ export const setupTempDirectory = async (argv, workspaceInfo = null) => {
291
274
 
292
275
  return { tempDir, workspaceTmpDir, isResuming, needsClone };
293
276
  };
294
-
295
277
  // Try to initialize an empty repository by creating a simple README.md
296
278
  // This makes the repository forkable and allows branch creation
297
279
  // Exported for use in solve.repo-setup.lib.mjs (direct access path for empty repos)
@@ -302,7 +284,6 @@ export const tryInitializeEmptyRepository = async (owner, repo) => {
302
284
  // Check write access before attempting to create files
303
285
  await log(`${formatAligned('', '', 'Checking repository write access...')}`);
304
286
  const hasWriteAccess = await checkRepositoryWritePermission(owner, repo, { useFork: false });
305
-
306
287
  if (!hasWriteAccess) {
307
288
  await log(`${formatAligned('❌', 'No access:', 'You do not have write access to this repository')}`);
308
289
  await log(`${formatAligned('', '', 'Cannot initialize empty repository without write access')}`);
@@ -310,7 +291,6 @@ export const tryInitializeEmptyRepository = async (owner, repo) => {
310
291
  }
311
292
 
312
293
  await log(`${formatAligned('', '', 'Creating a simple README.md to make repository forkable')}`);
313
-
314
294
  // Get repository description to include in README
315
295
  const repoInfoResult = await $`gh api repos/${owner}/${repo} --jq '{description: .description}'`;
316
296
  let description = '';
@@ -329,7 +309,6 @@ export const tryInitializeEmptyRepository = async (owner, repo) => {
329
309
  readmeContent += `\n${description}\n`;
330
310
  }
331
311
  const base64Content = Buffer.from(readmeContent).toString('base64');
332
-
333
312
  // Try to create README.md using GitHub API
334
313
  // Issue #2119: `--field content="${base64Content}"` would leak literal quotes
335
314
  // into the field value as soon as command-stream decides the value needs
@@ -338,7 +317,6 @@ export const tryInitializeEmptyRepository = async (owner, repo) => {
338
317
  const createResult = await $`gh api repos/${owner}/${repo}/contents/README.md --method PUT --silent \
339
318
  --field message=${'Initialize repository with README'} \
340
319
  --field ${contentField} 2>&1`;
341
-
342
320
  if (createResult.code === 0) {
343
321
  await log(`${formatAligned('✅', 'Success:', 'README.md created successfully')}`);
344
322
  await log(`${formatAligned('', '', 'Repository is now forkable, retrying fork creation...')}`);
@@ -372,7 +350,6 @@ export const setupRepository = async (argv, owner, repo, forkOwner = null, issue
372
350
  let repoToClone = `${owner}/${repo}`;
373
351
  let forkedRepo = null;
374
352
  let upstreamRemote = null;
375
-
376
353
  // Priority 1: Check --fork flag first (user explicitly wants to use their own fork)
377
354
  // This takes precedence over forkOwner to avoid trying to access someone else's fork
378
355
  if (argv.fork) {
@@ -386,7 +363,6 @@ export const setupRepository = async (argv, owner, repo, forkOwner = null, issue
386
363
  await safeExit(1, 'Repository setup failed');
387
364
  }
388
365
  const currentUser = userResult.stdout.toString().trim();
389
-
390
366
  // Check if user owns the repository (Issue #1206)
391
367
  // GitHub doesn't allow forking your own repositories and returns HTTP 403
392
368
  // When --fork is explicitly used, fail with a clear error and suggest --auto-fork
@@ -416,13 +392,11 @@ export const setupRepository = async (argv, owner, repo, forkOwner = null, issue
416
392
  // as an existing fork we already have
417
393
  await log(`${formatAligned('🔍', 'Detecting fork conflicts...', '')}`);
418
394
  const rootRepo = await getRootRepository(owner, repo);
419
-
420
395
  if (rootRepo) {
421
396
  const existingFork = await checkExistingForkOfRoot(rootRepo);
422
397
 
423
398
  if (existingFork) {
424
399
  const existingForkOwner = existingFork.split('/')[0];
425
-
426
400
  if (existingForkOwner === currentUser) {
427
401
  const targetRepo = `${owner}/${repo}`;
428
402
  const targetIsRoot = targetRepo === rootRepo;
@@ -459,7 +433,6 @@ export const setupRepository = async (argv, owner, repo, forkOwner = null, issue
459
433
  }
460
434
  }
461
435
  }
462
-
463
436
  await log(`${formatAligned('✅', 'No fork conflict:', 'Safe to proceed')}`);
464
437
  } else {
465
438
  await log(`${formatAligned('⚠️', 'Warning:', 'Could not determine root repository')}`);
@@ -472,7 +445,6 @@ export const setupRepository = async (argv, owner, repo, forkOwner = null, issue
472
445
  let existingForkName = null;
473
446
  const standardForkName = `${currentUser}/${repo}`;
474
447
  const prefixedForkName = `${currentUser}/${owner}-${repo}`;
475
-
476
448
  // Determine expected fork name based on --prefix-fork-name-with-owner-name option
477
449
  const expectedForkName = argv.prefixForkNameWithOwnerName ? prefixedForkName : standardForkName;
478
450
  const alternateForkName = argv.prefixForkNameWithOwnerName ? standardForkName : prefixedForkName;
@@ -498,14 +470,12 @@ export const setupRepository = async (argv, owner, repo, forkOwner = null, issue
498
470
  await log(` Creating prefixed fork ${expectedForkName} instead (--prefix-fork-name-with-owner-name enabled)`);
499
471
  }
500
472
  }
501
-
502
473
  if (existingForkName) {
503
474
  // Fork exists - validate that its parent matches the expected upstream
504
475
  await log(`${formatAligned('✅', 'Fork exists:', existingForkName)}`);
505
476
  await log(`${formatAligned('🔍', 'Validating fork parent...', '')}`);
506
477
 
507
478
  const forkValidation = await validateForkParent(existingForkName, `${owner}/${repo}`);
508
-
509
479
  if (forkValidation.isValid) {
510
480
  // Fork is valid — use it
511
481
  await log(`${formatAligned('✅', 'Fork parent validated:', `${forkValidation.parent}`)}`);
@@ -581,7 +551,6 @@ export const setupRepository = async (argv, owner, repo, forkOwner = null, issue
581
551
  });
582
552
  safetyCheckDescription = branchSafety.safetyCheckDescription;
583
553
  likelyDetachedFork = Boolean(branchSafety.likelyDetachedFork);
584
-
585
554
  if (likelyDetachedFork) {
586
555
  await log(`${formatAligned('🔗', 'Detached fork:', `${existingForkName} shares history with ${owner}/${repo} but is not a GitHub fork — this matches a fork detached by a private/public visibility change`)}`);
587
556
  }
@@ -634,11 +603,9 @@ export const setupRepository = async (argv, owner, repo, forkOwner = null, issue
634
603
  existingForkName = null; // Fall through to fork creation below
635
604
  }
636
605
  }
637
-
638
606
  if (!existingForkName) {
639
607
  // Need to create fork with retry logic for concurrent scenarios
640
608
  await log(`${formatAligned('🔄', 'Creating fork...', '')}`);
641
-
642
609
  const maxForkRetries = 5;
643
610
  const baseDelay = 2000; // Start with 2 seconds
644
611
  let forkCreated = false;
@@ -647,7 +614,6 @@ export const setupRepository = async (argv, owner, repo, forkOwner = null, issue
647
614
  // Determine the expected fork name based on --prefix-fork-name-with-owner-name option
648
615
  const defaultForkName = argv.prefixForkNameWithOwnerName ? `${owner}-${repo}` : repo;
649
616
  let actualForkName = `${currentUser}/${defaultForkName}`;
650
-
651
617
  for (let attempt = 1; attempt <= maxForkRetries; attempt++) {
652
618
  let forkResult;
653
619
  // Issue #1518: Log the exact fork command for debugging non-fork creation scenarios
@@ -667,7 +633,6 @@ export const setupRepository = async (argv, owner, repo, forkOwner = null, issue
667
633
  if (parsedForkName) {
668
634
  actualForkName = parsedForkName;
669
635
  }
670
-
671
636
  if (forkResult.code === 0) {
672
637
  // Fork successfully created or already exists
673
638
  if (forkOutput.includes('already exists')) {
@@ -724,7 +689,6 @@ export const setupRepository = async (argv, owner, repo, forkOwner = null, issue
724
689
  await log('');
725
690
  await safeExit(1, 'Repository setup failed - repository not accessible (HTTP 404)');
726
691
  }
727
-
728
692
  // Check if it's an empty repository (HTTP 403) - try to auto-fix
729
693
  if (forkOutput.includes('HTTP 403') && (forkOutput.includes('Empty repositories cannot be forked') || forkOutput.includes('contains no Git content'))) {
730
694
  // Empty repository detected - try to initialize it
@@ -735,7 +699,6 @@ export const setupRepository = async (argv, owner, repo, forkOwner = null, issue
735
699
 
736
700
  // Try to initialize the repository by creating a README.md
737
701
  const initialized = await tryInitializeEmptyRepository(owner, repo);
738
-
739
702
  if (initialized) {
740
703
  // Success! Repository is now initialized, retry fork creation
741
704
  await log('');
@@ -772,7 +735,6 @@ export const setupRepository = async (argv, owner, repo, forkOwner = null, issue
772
735
  if (issueMatch) {
773
736
  const issueNumber = issueMatch[1];
774
737
  await log(`${formatAligned('💬', 'Creating comment:', 'Requesting maintainer to initialize repository...')}`);
775
-
776
738
  const commentBody = `## ⚠️ ${REPOSITORY_INITIALIZATION_REQUIRED_MARKER}
777
739
 
778
740
  Hello! I attempted to work on this issue, but encountered a problem:
@@ -791,7 +753,6 @@ Please add initial content to the repository. Even a simple README.md (even if i
791
753
  Once the repository contains at least one commit with any file, I'll be able to fork it and proceed with solving this issue.
792
754
 
793
755
  Thank you!`;
794
-
795
756
  const posted = await postTrackedComment({ $, owner, repo, targetNumber: issueNumber, body: commentBody });
796
757
  if (posted.ok) {
797
758
  await log(`${formatAligned('✅', 'Comment created:', `Posted to issue #${issueNumber}${posted.commentId ? ` (id=${posted.commentId})` : ''}`)}`);
@@ -804,7 +765,6 @@ Thank you!`;
804
765
  await log(`${formatAligned('⚠️', 'Note:', 'Could not post comment to issue (this is not critical)')}`);
805
766
  }
806
767
  }
807
-
808
768
  await safeExit(1, 'Repository setup failed - empty repository');
809
769
  }
810
770
  }
@@ -812,7 +772,6 @@ Thank you!`;
812
772
  // Check if fork was created by another worker even if error message doesn't explicitly say so
813
773
  await log(`${formatAligned('🔍', 'Checking:', 'If fork exists after failed creation attempt...')}`);
814
774
  const checkResult = await $`gh repo view ${actualForkName} --json name 2>/dev/null`;
815
-
816
775
  if (checkResult.code === 0) {
817
776
  // Fork exists now (created by another worker during our attempt)
818
777
  await log(`${formatAligned('✅', 'Fork found:', 'Created by another concurrent worker')}`);
@@ -834,7 +793,6 @@ Thank you!`;
834
793
  }
835
794
  }
836
795
  }
837
-
838
796
  // If fork exists (either created or already existed), verify it's accessible
839
797
  if (forkExists) {
840
798
  await log(`${formatAligned('🔍', 'Verifying fork:', 'Checking accessibility...')}`);
@@ -842,7 +800,6 @@ Thank you!`;
842
800
  // Verify fork with retries (GitHub may need time to propagate)
843
801
  const maxVerifyRetries = 5;
844
802
  let forkVerified = false;
845
-
846
803
  for (let attempt = 1; attempt <= maxVerifyRetries; attempt++) {
847
804
  const delay = baseDelay * Math.pow(2, attempt - 1);
848
805
  if (attempt > 1) {
@@ -857,7 +814,6 @@ Thank you!`;
857
814
  break;
858
815
  }
859
816
  }
860
-
861
817
  if (!forkVerified) {
862
818
  await log(`${formatAligned('❌', 'Error:', 'Fork exists but not accessible after multiple retries')}`);
863
819
  await log(`${formatAligned('', 'Suggestion:', 'GitHub may be experiencing delays - try running the command again in a few minutes')}`);
@@ -879,7 +835,6 @@ Thank you!`;
879
835
  reportError(new Error(`Fork created as non-fork: ${pcv.error}`), { context: 'fork_creation_validation', forkRepo: actualForkName, expectedUpstream: `${owner}/${repo}`, isFork: pcv.isFork, parent: pcv.parent, source: pcv.source });
880
836
  }
881
837
  }
882
-
883
838
  repoToClone = actualForkName;
884
839
  forkedRepo = actualForkName;
885
840
  upstreamRemote = `${owner}/${repo}`;
@@ -896,14 +851,12 @@ Thank you!`;
896
851
  const prefixedForkName = `${forkOwner}/${owner}-${headRepoName}`;
897
852
  const expectedForkName = forkRepoName ? `${forkOwner}/${forkRepoName}` : argv.prefixForkNameWithOwnerName ? prefixedForkName : standardForkName;
898
853
  const alternateForkName = forkRepoName ? null : argv.prefixForkNameWithOwnerName ? standardForkName : prefixedForkName;
899
-
900
854
  await log(`${formatAligned('✅', 'Using fork:', expectedForkName)}\n`);
901
855
 
902
856
  // Verify the fork exists and is accessible - try expected name first, then alternate
903
857
  await log(`${formatAligned('🔍', 'Verifying fork:', 'Checking accessibility...')}`);
904
858
  let forkCheckResult = await $`gh repo view ${expectedForkName} --json name 2>/dev/null`;
905
859
  let actualForkName = expectedForkName;
906
-
907
860
  if (forkCheckResult.code !== 0 && alternateForkName && !argv.prefixForkNameWithOwnerName) {
908
861
  // Only try alternate name if --prefix-fork-name-with-owner-name is off AND we're guessing
909
862
  // (forkRepoName authoritative → alternateForkName is null and no fallback is attempted).
@@ -915,7 +868,6 @@ Thank you!`;
915
868
 
916
869
  if (forkCheckResult.code === 0) {
917
870
  await log(`${formatAligned('✅', 'Fork verified:', `${actualForkName} is accessible`)}`);
918
-
919
871
  // Validate fork parent before using it (prevents issue #967)
920
872
  await log(`${formatAligned('🔍', 'Validating fork parent...', '')}`);
921
873
  const forkValidation = await validateForkParent(actualForkName, `${owner}/${repo}`);
@@ -962,7 +914,6 @@ Thank you!`;
962
914
  } else {
963
915
  await log(`${formatAligned('✅', 'Fork parent validated:', `${forkValidation.parent}`)}`);
964
916
  }
965
-
966
917
  repoToClone = actualForkName;
967
918
  forkedRepo = actualForkName;
968
919
  upstreamRemote = `${owner}/${repo}`;
@@ -977,11 +928,9 @@ Thank you!`;
977
928
 
978
929
  return { repoToClone, forkedRepo, upstreamRemote, prForkOwner: forkOwner };
979
930
  };
980
-
981
931
  // Classify git clone errors to determine if they are retryable
982
932
  export const classifyCloneError = errorOutput => {
983
933
  const output = errorOutput.toLowerCase();
984
-
985
934
  // Issue #1211: ENOSPC (disk full) errors - NOT retryable, requires user action
986
935
  if (lib.isENOSPC(errorOutput) || output.includes('no space left on device') || (output.includes('unable to write file') && output.includes('error')) || output.includes('errno -28')) {
987
936
  return { type: 'ENOSPC', retryable: false, description: 'No space left on device' };
@@ -991,7 +940,6 @@ export const classifyCloneError = errorOutput => {
991
940
  if (output.includes('error: 500') || output.includes('internal server error') || output.includes('error: 502') || output.includes('error: 503') || output.includes('error: 504')) {
992
941
  return { type: 'TRANSIENT', retryable: true, description: 'GitHub server error' };
993
942
  }
994
-
995
943
  // Network-related errors - typically retryable
996
944
  // Issue #1957: git fetch-pack/sideband disconnects (e.g.
997
945
  // "fetch-pack: unexpected disconnect while reading sideband packet",
@@ -1005,7 +953,6 @@ export const classifyCloneError = errorOutput => {
1005
953
  if (output.includes('error: 401') || output.includes('error: 403') || output.includes('authentication failed') || output.includes('permission denied')) {
1006
954
  return { type: 'PERMISSION', retryable: false, description: 'Authentication or permission error' };
1007
955
  }
1008
-
1009
956
  // Repository not found - not retryable
1010
957
  if (output.includes('error: 404') || output.includes('not found') || output.includes('repository not found')) {
1011
958
  return { type: 'NOT_FOUND', retryable: false, description: 'Repository not found' };
@@ -1015,7 +962,6 @@ export const classifyCloneError = errorOutput => {
1015
962
  if (output.includes('rate limit') || output.includes('too many requests') || output.includes('api rate limit exceeded')) {
1016
963
  return { type: 'RATE_LIMIT', retryable: true, description: 'Rate limit exceeded' };
1017
964
  }
1018
-
1019
965
  // Default to retryable for unknown errors
1020
966
  return { type: 'UNKNOWN', retryable: true, description: 'Unknown error' };
1021
967
  };
@@ -1037,14 +983,12 @@ export const cleanPartialClone = async tempDir => {
1037
983
  }
1038
984
  }
1039
985
  };
1040
-
1041
986
  // Clone repository and set up remotes with retry mechanism
1042
987
  export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) => {
1043
988
  const maxRetries = 3;
1044
989
  const baseDelay = 2000; // Start with 2 seconds
1045
990
 
1046
991
  await log(`\n${formatAligned('📥', 'Cloning repository:', repoToClone)}`);
1047
-
1048
992
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
1049
993
  if (attempt > 1) {
1050
994
  await log(`${formatAligned('⏳', 'Clone attempt:', `${attempt}/${maxRetries} (with retry logic)`)}`);
@@ -1053,7 +997,6 @@ export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) =
1053
997
  // Use 2>&1 to capture all output and filter "Cloning into" message
1054
998
  const cloneResult = await $`gh repo clone ${repoToClone} ${tempDir} 2>&1`;
1055
999
  const cloneOutput = (cloneResult.stdout || cloneResult.stderr || '').toString().trim();
1056
-
1057
1000
  // Issue #1957: `gh repo clone` (and the `git clone` it wraps) can exit 0 even when
1058
1001
  // the underlying transfer was interrupted — e.g. "fetch-pack: unexpected disconnect
1059
1002
  // while reading sideband packet" — leaving an incomplete or completely missing
@@ -1072,7 +1015,6 @@ export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) =
1072
1015
  // Verify clone was successful (exit code 0 AND a valid working tree exists)
1073
1016
  if (cloneResult.code === 0 && repoIsValid) {
1074
1017
  await log(`${formatAligned('✅', 'Cloned to:', tempDir)}`);
1075
-
1076
1018
  // Issue #2119: AI tools drop scratch state (`.formal-ai/`, `.playwright-mcp/`)
1077
1019
  // into the workspace. Exclude it here, once, so every later `git status` and
1078
1020
  // `git add -A` agrees instead of reading it as the AI's uncommitted work.
@@ -1087,7 +1029,6 @@ export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) =
1087
1029
  }
1088
1030
  return; // Success - exit function
1089
1031
  }
1090
-
1091
1032
  // Clone failed - analyze error and determine if retry is appropriate
1092
1033
  // Issue #1957: when the wrapper exited 0 but left no valid repo, surface the
1093
1034
  // interrupted-transfer output (which carries the real "unexpected disconnect"
@@ -1098,7 +1039,6 @@ export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) =
1098
1039
  // `gh repo clone <dir>` fail with "directory exists and is not empty". Clean the
1099
1040
  // target directory before classifying/retrying so the next attempt starts fresh.
1100
1041
  await cleanPartialClone(tempDir);
1101
-
1102
1042
  const errorClassification = classifyCloneError(errorOutput);
1103
1043
 
1104
1044
  if (!errorClassification.retryable || attempt === maxRetries) {
@@ -1108,7 +1048,6 @@ export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) =
1108
1048
  await log('');
1109
1049
  await log(' 🔍 What happened:');
1110
1050
  await log(` Failed to clone repository ${repoToClone}`);
1111
-
1112
1051
  if (!errorClassification.retryable) {
1113
1052
  await log(` Error type: ${errorClassification.description} (not retryable)`);
1114
1053
  } else {
@@ -1120,7 +1059,6 @@ export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) =
1120
1059
  if (line.trim()) await log(` ${line}`);
1121
1060
  }
1122
1061
  await log('');
1123
-
1124
1062
  // Issue #1211: ENOSPC-specific guidance
1125
1063
  if (errorClassification.type === 'ENOSPC') {
1126
1064
  await log(' 💡 Cause: Disk is full — not enough space to clone the repository');
@@ -1163,14 +1101,12 @@ export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) =
1163
1101
  const delay = baseDelay * Math.pow(2, attempt - 1); // Exponential backoff
1164
1102
  await log(`${formatAligned('⚠️', 'Clone failed:', errorClassification.description)}`);
1165
1103
  await log(`${formatAligned('⏳', 'Retrying:', `Waiting ${delay / 1000}s before attempt ${attempt + 1}/${maxRetries}...`)}`);
1166
-
1167
1104
  if (errorClassification.type === 'RATE_LIMIT') {
1168
1105
  await log(' 💡 Tip: Rate limiting detected - using longer delay');
1169
1106
  }
1170
1107
 
1171
1108
  await new Promise(resolve => setTimeout(resolve, delay));
1172
1109
  }
1173
-
1174
1110
  // This should never be reached due to the loop logic above
1175
1111
  await log(`${formatAligned('❌', 'UNEXPECTED ERROR:', 'Clone logic failed')}`);
1176
1112
  await safeExit(1, 'Repository setup failed');
@@ -1180,7 +1116,6 @@ export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) =
1180
1116
  // Extracted into solve.fork-sync.lib.mjs (#1893) to keep this file under the
1181
1117
  // 1500-line limit; re-exported here so existing importers keep working.
1182
1118
  export { setupUpstreamAndSync } from './solve.fork-sync.lib.mjs';
1183
-
1184
1119
  // Set up pr-fork remote for continuing someone else's fork PR with --fork flag
1185
1120
  export const setupPrForkRemote = async (tempDir, argv, prForkOwner, repo, isContinueMode, owner = null) => {
1186
1121
  // Only set up pr-fork remote if:
@@ -1198,7 +1133,6 @@ export const setupPrForkRemote = async (tempDir, argv, prForkOwner, repo, isCont
1198
1133
  await log(`${formatAligned('⚠️', 'Warning:', 'Failed to get current user, cannot set up pr-fork remote')}`);
1199
1134
  return null;
1200
1135
  }
1201
-
1202
1136
  const currentUser = userResult.stdout.toString().trim();
1203
1137
 
1204
1138
  // If PR is from current user's fork, no need for pr-fork remote
@@ -1206,7 +1140,6 @@ export const setupPrForkRemote = async (tempDir, argv, prForkOwner, repo, isCont
1206
1140
  await log(`${formatAligned('ℹ️', 'PR fork owner:', 'Same as current user, using origin remote')}`);
1207
1141
  return null;
1208
1142
  }
1209
-
1210
1143
  // This is someone else's fork - add it as pr-fork remote
1211
1144
  // IMPORTANT: The fork owner's repository name is independent of our naming preferences
1212
1145
  // We need to discover the actual fork name, not assume it matches our convention
@@ -1215,7 +1148,6 @@ export const setupPrForkRemote = async (tempDir, argv, prForkOwner, repo, isCont
1215
1148
  await log(`${formatAligned('🔗', 'Setting up pr-fork:', "Branch exists in another user's fork")}`);
1216
1149
  await log(`${formatAligned('', 'PR fork owner:', prForkOwner)}`);
1217
1150
  await log(`${formatAligned('', 'Current user:', currentUser)}`);
1218
-
1219
1151
  // Discover the actual fork repository name by querying GitHub API
1220
1152
  // The fork could have any name (standard, prefixed, or custom renamed)
1221
1153
  let prForkRepoName = null;
@@ -1237,7 +1169,6 @@ export const setupPrForkRemote = async (tempDir, argv, prForkOwner, repo, isCont
1237
1169
  }
1238
1170
  }
1239
1171
  }
1240
-
1241
1172
  // Strategy 2: If not found in forks list, try common naming patterns
1242
1173
  if (!prForkRepoName) {
1243
1174
  const possibleNames = [
@@ -1246,7 +1177,6 @@ export const setupPrForkRemote = async (tempDir, argv, prForkOwner, repo, isCont
1246
1177
  ].filter(Boolean);
1247
1178
 
1248
1179
  await log(`${formatAligned('🔍', 'Trying common names:', possibleNames.join(', '))}`);
1249
-
1250
1180
  for (const candidateName of possibleNames) {
1251
1181
  const checkResult = await $`gh repo view ${prForkOwner}/${candidateName} --json name 2>/dev/null`;
1252
1182
  if (checkResult.code === 0) {
@@ -1265,7 +1195,6 @@ export const setupPrForkRemote = async (tempDir, argv, prForkOwner, repo, isCont
1265
1195
  await log(`${formatAligned('', 'Workaround:', 'Remove --fork flag to continue work in the original fork')}`);
1266
1196
  return null;
1267
1197
  }
1268
-
1269
1198
  await log(`${formatAligned('', 'Action:', `Adding ${prForkOwner}/${prForkRepoName} as pr-fork remote`)}`);
1270
1199
 
1271
1200
  const addRemoteResult = await $({
@@ -1280,9 +1209,7 @@ export const setupPrForkRemote = async (tempDir, argv, prForkOwner, repo, isCont
1280
1209
  await log(`${formatAligned('', 'Workaround:', 'Remove --fork flag to continue work in the original fork')}`);
1281
1210
  return null;
1282
1211
  }
1283
-
1284
1212
  await log(`${formatAligned('✅', 'Remote added:', 'pr-fork')}`);
1285
-
1286
1213
  // Fetch from pr-fork to get the branch
1287
1214
  await log(`${formatAligned('📥', 'Fetching branches:', 'From pr-fork remote...')}`);
1288
1215
  const fetchPrForkResult = await $({ cwd: tempDir })`git fetch pr-fork`;
@@ -1299,7 +1226,6 @@ export const setupPrForkRemote = async (tempDir, argv, prForkOwner, repo, isCont
1299
1226
  await log(`${formatAligned('ℹ️', 'Next step:', 'Will checkout branch from pr-fork remote')}`);
1300
1227
  return 'pr-fork';
1301
1228
  };
1302
-
1303
1229
  // Checkout branch for continue mode (PR branch from remote)
1304
1230
  // prNumber is optional - when provided, enables PR refs fallback (refs/pull/{number}/head)
1305
1231
  export const checkoutPrBranch = async (tempDir, branchName, prForkRemote, prForkOwner, prNumber = null) => {
@@ -1307,7 +1233,6 @@ export const checkoutPrBranch = async (tempDir, branchName, prForkRemote, prFork
1307
1233
 
1308
1234
  // Determine which remote to use for branch checkout
1309
1235
  const remoteName = prForkRemote || 'origin';
1310
-
1311
1236
  // First fetch all branches from remote (if not already fetched from pr-fork)
1312
1237
  if (!prForkRemote) {
1313
1238
  await log(`${formatAligned('📥', 'Fetching branches:', 'From remote...')}`);
@@ -1319,7 +1244,6 @@ export const checkoutPrBranch = async (tempDir, branchName, prForkRemote, prFork
1319
1244
  } else {
1320
1245
  await log(`${formatAligned('ℹ️', 'Using pr-fork remote:', `Branch exists in ${prForkOwner}'s fork`)}`);
1321
1246
  }
1322
-
1323
1247
  // Checkout the PR branch (it might exist locally or remotely)
1324
1248
  const localBranchResult = await $({ cwd: tempDir })`git show-ref --verify --quiet refs/heads/${branchName}`;
1325
1249
 
@@ -1330,7 +1254,6 @@ export const checkoutPrBranch = async (tempDir, branchName, prForkRemote, prFork
1330
1254
  } else {
1331
1255
  // Branch doesn't exist locally, try to checkout from remote
1332
1256
  checkoutResult = await $({ cwd: tempDir })`git checkout -b ${branchName} ${remoteName}/${branchName}`;
1333
-
1334
1257
  // If checkout from origin failed, try upstream remote as fallback
1335
1258
  // This handles the case where we're in fork mode but the PR branch exists in upstream
1336
1259
  // (e.g., a bot created PR in the upstream repo, not a fork PR)
@@ -1343,7 +1266,6 @@ export const checkoutPrBranch = async (tempDir, branchName, prForkRemote, prFork
1343
1266
  // Fetch from upstream to ensure we have the latest branches
1344
1267
  await log(`${formatAligned('📥', 'Fetching from upstream:', 'Looking for PR branch...')}`);
1345
1268
  const fetchUpstreamResult = await $({ cwd: tempDir })`git fetch upstream`;
1346
-
1347
1269
  if (fetchUpstreamResult.code === 0) {
1348
1270
  // Check if branch exists in upstream
1349
1271
  const upstreamBranchCheckResult = await $({ cwd: tempDir })`git show-ref --verify --quiet refs/remotes/upstream/${branchName}`;
@@ -1352,7 +1274,6 @@ export const checkoutPrBranch = async (tempDir, branchName, prForkRemote, prFork
1352
1274
  await log(`${formatAligned('✅', 'Found branch in upstream:', `upstream/${branchName}`)}`);
1353
1275
  // Try to checkout from upstream instead
1354
1276
  checkoutResult = await $({ cwd: tempDir })`git checkout -b ${branchName} upstream/${branchName}`;
1355
-
1356
1277
  if (checkoutResult.code === 0) {
1357
1278
  await log(`${formatAligned('ℹ️', 'Note:', 'PR branch was in upstream repository, not your fork')}`);
1358
1279
  await log(`${formatAligned('', '', 'This can happen when a bot creates a PR directly in the main repository')}`);
@@ -1372,14 +1293,12 @@ export const checkoutPrBranch = async (tempDir, branchName, prForkRemote, prFork
1372
1293
  // See: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/checking-out-pull-requests-locally
1373
1294
  if (checkoutResult.code !== 0 && prNumber) {
1374
1295
  await log(`${formatAligned('🔄', 'Trying PR refs fallback:', `Fetching refs/pull/${prNumber}/head...`)}`);
1375
-
1376
1296
  // Fetch the PR head using GitHub's special refs
1377
1297
  const prRefFetchResult = await $({ cwd: tempDir })`git fetch origin pull/${prNumber}/head:${branchName}`;
1378
1298
 
1379
1299
  if (prRefFetchResult.code === 0) {
1380
1300
  await log(`${formatAligned('✅', 'Fetched PR ref:', `refs/pull/${prNumber}/head`)}`);
1381
1301
  checkoutResult = await $({ cwd: tempDir })`git checkout ${branchName}`;
1382
-
1383
1302
  if (checkoutResult.code === 0) {
1384
1303
  await log(`${formatAligned('ℹ️', 'Note:', 'Checked out using GitHub PR refs (fork access not required)')}`);
1385
1304
  await log(`${formatAligned('', '', 'This is a read-only checkout - you may need to push to a different branch')}`);
@@ -1395,12 +1314,10 @@ export const checkoutPrBranch = async (tempDir, branchName, prForkRemote, prFork
1395
1314
 
1396
1315
  return checkoutResult;
1397
1316
  };
1398
-
1399
1317
  // Cleanup temporary directory
1400
1318
  export const cleanupTempDirectory = async (tempDir, argv, limitReached) => {
1401
1319
  // Determine if we should skip cleanup
1402
1320
  const shouldKeepDirectory = !argv.autoCleanup || argv.resume || limitReached || (argv.autoResumeOnLimitReset && global.limitResetTime);
1403
-
1404
1321
  if (!shouldKeepDirectory) {
1405
1322
  try {
1406
1323
  process.stdout.write('\n🧹 Cleaning up...');