@link-assistant/hive-mind 1.72.4 → 1.72.6

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.72.6
4
+
5
+ ### Patch Changes
6
+
7
+ - 57f15ec: Detect same-account human feedback in auto-restart comment monitoring only when the AI tool is idle, while still filtering hive-mind tool-generated comments by marker and tracked ID.
8
+
9
+ ## 1.72.5
10
+
11
+ ### Patch Changes
12
+
13
+ - c3a89a3: Recover auto-PR creation when a rejected push leaves the remote branch matching local HEAD, and improve push rejection diagnostics with exact branch and compare links.
14
+
3
15
  ## 1.72.4
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.72.4",
3
+ "version": "1.72.6",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -76,7 +76,7 @@ const formatRunLine = run => {
76
76
  // search scope for checkForExistingComment() stays in lock-step with the
77
77
  // markers actually embedded in tool-posted comments.
78
78
  const toolComments = await import('./tool-comments.lib.mjs');
79
- const { SESSION_ENDING_MARKERS } = toolComments;
79
+ const { SESSION_ENDING_MARKERS, isToolGeneratedComment, isToolTrackedCommentId } = toolComments;
80
80
 
81
81
  /**
82
82
  * Issue #1323: Check if a comment with specific content already exists on the PR
@@ -168,14 +168,25 @@ export const checkForExistingComment = async (owner, repo, prNumber, commentSign
168
168
 
169
169
  /**
170
170
  * Check for new comments from non-bot users since last commit
171
+ *
172
+ * Same-account comments are only considered feedback when
173
+ * `trustAuthenticatedUserComments` is true. Keep the default false for callers
174
+ * that may run while an AI tool is still active: those tools can post through
175
+ * the authenticated GitHub account.
176
+ *
177
+ * @param {Function} commandRunner - Tagged-template command runner, injectable for tests
178
+ * @param {Object} options - Comment classification options
179
+ * @param {boolean} options.trustAuthenticatedUserComments - True only when the caller knows the AI tool is not running
171
180
  * @returns {Promise<{hasNewComments: boolean, comments: Array}>}
172
181
  */
173
- export const checkForNonBotComments = async (owner, repo, prNumber, issueNumber, lastCheckTime, verbose = false) => {
182
+ export const checkForNonBotComments = async (owner, repo, prNumber, issueNumber, lastCheckTime, verbose = false, commandRunner = $, options = {}) => {
174
183
  try {
184
+ const { trustAuthenticatedUserComments = false } = options;
185
+
175
186
  // Get current GitHub user to identify which comments are from the bot/hive-mind
176
187
  let currentUser = null;
177
188
  try {
178
- const userResult = await $`gh api user --jq .login`;
189
+ const userResult = await commandRunner`gh api user --jq .login`;
179
190
  if (userResult.code === 0) {
180
191
  currentUser = userResult.stdout.toString().trim();
181
192
  }
@@ -183,7 +194,12 @@ export const checkForNonBotComments = async (owner, repo, prNumber, issueNumber,
183
194
  // If we can't get the current user, continue without filtering
184
195
  }
185
196
 
186
- // Common bot usernames and patterns to filter out
197
+ // Common bot usernames and patterns to filter out.
198
+ // Issue #1821: In same-account operation, humans and AI tools can both
199
+ // post through the authenticated account. The safe default treats that
200
+ // account as tool-owned; auto-restart-until-mergeable opts in to trusting
201
+ // same-account comments only while no AI tool execution is active, and
202
+ // still filters tool-generated comments by tracked IDs and marker strings.
187
203
  // Note: Patterns use word boundaries or end-of-string to avoid false positives
188
204
  // (e.g., "claudeuser" should NOT match as a bot)
189
205
  const botPatterns = [
@@ -201,21 +217,21 @@ export const checkForNonBotComments = async (owner, repo, prNumber, issueNumber,
201
217
 
202
218
  const isBot = login => {
203
219
  if (!login) return false;
204
- // Check if it's the current user (the bot running hive-mind)
205
- if (currentUser && login === currentUser) return true;
206
220
  // Check against known bot patterns
207
221
  return botPatterns.some(pattern => pattern.test(login));
208
222
  };
209
223
 
224
+ const isToolComment = comment => isToolTrackedCommentId(comment.id) || isToolGeneratedComment(comment.body);
225
+
210
226
  // Fetch PR conversation comments
211
- const prCommentsResult = await $`gh api repos/${owner}/${repo}/issues/${prNumber}/comments --paginate`;
227
+ const prCommentsResult = await commandRunner`gh api repos/${owner}/${repo}/issues/${prNumber}/comments --paginate`;
212
228
  let prComments = [];
213
229
  if (prCommentsResult.code === 0 && prCommentsResult.stdout) {
214
230
  prComments = JSON.parse(prCommentsResult.stdout.toString() || '[]');
215
231
  }
216
232
 
217
233
  // Fetch PR review comments (inline code comments)
218
- const prReviewCommentsResult = await $`gh api repos/${owner}/${repo}/pulls/${prNumber}/comments --paginate`;
234
+ const prReviewCommentsResult = await commandRunner`gh api repos/${owner}/${repo}/pulls/${prNumber}/comments --paginate`;
219
235
  let prReviewComments = [];
220
236
  if (prReviewCommentsResult.code === 0 && prReviewCommentsResult.stdout) {
221
237
  prReviewComments = JSON.parse(prReviewCommentsResult.stdout.toString() || '[]');
@@ -224,7 +240,7 @@ export const checkForNonBotComments = async (owner, repo, prNumber, issueNumber,
224
240
  // Fetch issue comments if we have an issue number
225
241
  let issueComments = [];
226
242
  if (issueNumber && issueNumber !== prNumber) {
227
- const issueCommentsResult = await $`gh api repos/${owner}/${repo}/issues/${issueNumber}/comments --paginate`;
243
+ const issueCommentsResult = await commandRunner`gh api repos/${owner}/${repo}/issues/${issueNumber}/comments --paginate`;
228
244
  if (issueCommentsResult.code === 0 && issueCommentsResult.stdout) {
229
245
  issueComments = JSON.parse(issueCommentsResult.stdout.toString() || '[]');
230
246
  }
@@ -233,14 +249,28 @@ export const checkForNonBotComments = async (owner, repo, prNumber, issueNumber,
233
249
  // Combine all comments
234
250
  const allComments = [...prComments, ...prReviewComments, ...issueComments];
235
251
 
236
- // Filter for new comments from non-bot users
252
+ // Filter for new comments from non-bot users. Automated hive-mind/tool
253
+ // comments are excluded by marker/ID, including comments posted by the
254
+ // authenticated user during the current or a previous process.
237
255
  const newNonBotComments = allComments.filter(comment => {
238
256
  const commentTime = new Date(comment.created_at);
239
257
  const isAfterLastCheck = commentTime > lastCheckTime;
240
- const isFromNonBot = !isBot(comment.user?.login);
241
-
242
- if (verbose && isAfterLastCheck && isFromNonBot) {
243
- console.log(`[VERBOSE] New non-bot comment from ${comment.user?.login} at ${comment.created_at}`);
258
+ const login = comment.user?.login;
259
+ const isFromAuthenticatedUser = Boolean(currentUser && login === currentUser);
260
+ const isFromTool = isToolComment(comment);
261
+ const isFromAuthenticatedUserToolContext = isFromAuthenticatedUser && !trustAuthenticatedUserComments;
262
+ const isFromBot = isBot(login) || isFromAuthenticatedUserToolContext;
263
+ const isFromNonBot = !isFromBot && !isFromTool;
264
+
265
+ if (verbose && isAfterLastCheck && isFromTool) {
266
+ console.log(`[VERBOSE] Skipping tool-generated comment from ${login} at ${comment.created_at}`);
267
+ } else if (verbose && isAfterLastCheck && isFromAuthenticatedUserToolContext) {
268
+ console.log(`[VERBOSE] Skipping authenticated-user comment from ${login} at ${comment.created_at} because same-account feedback is not trusted in this context`);
269
+ } else if (verbose && isAfterLastCheck && isFromBot) {
270
+ console.log(`[VERBOSE] Skipping bot comment from ${login} at ${comment.created_at}`);
271
+ } else if (verbose && isAfterLastCheck && isFromNonBot) {
272
+ const sameAccountSuffix = currentUser && login === currentUser ? ' (authenticated user)' : '';
273
+ console.log(`[VERBOSE] New non-bot comment from ${login}${sameAccountSuffix} at ${comment.created_at}`);
244
274
  }
245
275
 
246
276
  return isAfterLastCheck && isFromNonBot;
@@ -206,8 +206,12 @@ export const watchUntilMergeable = async params => {
206
206
  // Keep the counter as-is (it reached the safety valve or wasn't needed).
207
207
  }
208
208
 
209
- // Check for new comments from non-bot users
210
- const { hasNewComments, comments } = await checkForNonBotComments(owner, repo, prNumber, issueNumber, lastCheckTime, argv.verbose);
209
+ // Check for new comments from non-bot users. At this point the AI tool
210
+ // is not executing, so same-account non-tool comments can be trusted as
211
+ // human feedback while known tool comments remain filtered by markers/IDs.
212
+ const { hasNewComments, comments } = await checkForNonBotComments(owner, repo, prNumber, issueNumber, lastCheckTime, argv.verbose, $, {
213
+ trustAuthenticatedUserComments: true,
214
+ });
211
215
 
212
216
  // Check for uncommitted changes using shared utility
213
217
  const hasUncommittedChanges = await checkForUncommittedChanges(tempDir, argv);
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { closingIssueNumbersContain, parseClosingIssueNumbers } from './pr-issue-linking.lib.mjs';
7
- import { buildPushRejectionExplanation, getRemoteBranchDivergenceSnapshot, synchronizeExistingIssueBranchBeforeAutoPrCreation } from './solve.branch-divergence.lib.mjs';
7
+ import { handleRejectedPushForAutoPr, synchronizeExistingIssueBranchBeforeAutoPrCreation } from './solve.branch-divergence.lib.mjs';
8
8
  import { emitForkAwareDiagnostic } from './solve.auto-pr-fork-diagnostic.lib.mjs';
9
9
 
10
10
  import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry, execGhWithRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller. Issue #1756: execGhWithRetry retries on transient 5xx (504) too.
@@ -409,6 +409,9 @@ Proceed.
409
409
  }
410
410
  }
411
411
 
412
+ let branchReadyForPrCreation = pushResult.code === 0;
413
+ let recoveredFromPushRejection = false;
414
+
412
415
  if (pushResult.code !== 0) {
413
416
  const errorOutput = pushResult.stderr ? pushResult.stderr.toString() : pushResult.stdout ? pushResult.stdout.toString() : 'Unknown error';
414
417
 
@@ -539,48 +542,41 @@ Proceed.
539
542
  }
540
543
  await log('');
541
544
  throw new Error('Permission denied - need fork or collaborator access');
542
- } else if (errorOutput.includes('non-fast-forward') || errorOutput.includes('rejected') || errorOutput.includes('! [rejected]')) {
543
- const divergence = await getRemoteBranchDivergenceSnapshot({ $, tempDir, branchName });
544
- // Push rejected due to conflicts or diverged history
545
- await log('');
546
- await log(formatAligned('❌', 'PUSH REJECTED:', 'Branch has diverged from remote'), { level: 'error' });
547
- await log('');
548
- await log(' 🔍 What happened:');
549
- await log(' The remote branch has changes that conflict with your local changes.');
550
- await log(' This typically means someone else has pushed to this branch.');
551
- for (const line of buildPushRejectionExplanation({ branchName, isContinueMode, prNumber, divergence })) {
552
- await log(line);
545
+ } else {
546
+ const rejectedPush = await handleRejectedPushForAutoPr({
547
+ errorOutput,
548
+ $,
549
+ tempDir,
550
+ log,
551
+ formatAligned,
552
+ branchName,
553
+ isContinueMode,
554
+ prNumber,
555
+ owner,
556
+ repo,
557
+ defaultBranch,
558
+ forkedRepo,
559
+ });
560
+
561
+ if (rejectedPush.handled) {
562
+ branchReadyForPrCreation = rejectedPush.branchReadyForPrCreation;
563
+ recoveredFromPushRejection = rejectedPush.recoveredFromPushRejection;
564
+ } else {
565
+ // Other push errors
566
+ await log(`${formatAligned('❌', 'Failed to push:', 'See error below')}`, { level: 'error' });
567
+ await log(` Error: ${errorOutput}`, { level: 'error' });
568
+ throw new Error('Failed to push branch');
553
569
  }
554
- await log('');
555
- await log(' 💡 Why we cannot fix this automatically:');
556
- await log(' • We never use force push to preserve history');
557
- await log(' • We never use rebase or reset to avoid altering git history');
558
- await log(' • Manual conflict resolution is required');
559
- await log('');
560
- await log(' 🔧 How to fix:');
561
- await log(' 1. Clone the repository and checkout the branch:');
562
- await log(` git clone https://github.com/${owner}/${repo}.git`);
563
- await log(` cd ${repo}`);
564
- await log(` git checkout ${branchName}`);
565
- await log('');
566
- await log(' 2. Pull and merge the remote changes:');
567
- await log(` git pull origin ${branchName}`);
568
- await log('');
569
- await log(' 3. Resolve any conflicts manually, then:');
570
- await log(` git push origin ${branchName}`);
571
- await log('');
572
- await log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
573
- await log('');
574
- throw new Error('Push rejected - branch has diverged, manual resolution required');
570
+ }
571
+ }
572
+
573
+ if (branchReadyForPrCreation) {
574
+ if (recoveredFromPushRejection) {
575
+ await log(`${formatAligned('', 'Branch available:', 'Remote branch matches local HEAD')}`);
575
576
  } else {
576
- // Other push errors
577
- await log(`${formatAligned('❌', 'Failed to push:', 'See error below')}`, { level: 'error' });
578
- await log(` Error: ${errorOutput}`, { level: 'error' });
579
- throw new Error('Failed to push branch');
577
+ await log(`${formatAligned('✅', 'Branch pushed:', 'Successfully to remote')}`);
580
578
  }
581
- } else {
582
- await log(`${formatAligned('✅', 'Branch pushed:', 'Successfully to remote')}`);
583
- if (argv.verbose) {
579
+ if (argv.verbose && pushResult.code === 0) {
584
580
  await log(` Push output: ${pushResult.stdout.toString().trim()}`, { verbose: true });
585
581
  }
586
582
 
@@ -1454,6 +1450,10 @@ ${prBody}`,
1454
1450
  operation: 'handle_auto_pr',
1455
1451
  });
1456
1452
 
1453
+ if (prError?.hiveMindUserFacingLogged) {
1454
+ throw prError;
1455
+ }
1456
+
1457
1457
  // Issue #1462: Single consolidated error message for PR creation failure.
1458
1458
  // Previously this was the third of three error blocks, causing confusing output.
1459
1459
  // Now this is the ONLY error block shown for PR creation failures.
@@ -9,18 +9,117 @@ const outputOf = result => {
9
9
  return stdout || stderr;
10
10
  };
11
11
 
12
- export function buildPushRejectionExplanation({ branchName, isContinueMode, prNumber, divergence = null }) {
12
+ const shortSha = sha => (sha ? String(sha).slice(0, 12) : null);
13
+
14
+ const encodeRefForGitHubUrl = ref =>
15
+ encodeURI(String(ref || ''))
16
+ .replaceAll('#', '%23')
17
+ .replaceAll('?', '%3F');
18
+
19
+ export function classifyPushRejection(errorOutput = '') {
20
+ const normalized = String(errorOutput || '').toLowerCase();
21
+
22
+ if (normalized.includes('cannot lock ref') && normalized.includes('reference already exists')) {
23
+ return 'remote-ref-already-exists';
24
+ }
25
+
26
+ if (normalized.includes('non-fast-forward') || normalized.includes('not fast-forward') || normalized.includes('fetch first') || normalized.includes('stale info') || normalized.includes('tip of your current branch is behind') || normalized.includes('updates were rejected')) {
27
+ return 'non-fast-forward';
28
+ }
29
+
30
+ if (normalized.includes('remote rejected') || normalized.includes('[remote rejected]')) {
31
+ return 'remote-rejected';
32
+ }
33
+
34
+ if (normalized.includes('rejected') || normalized.includes('failed to push some refs')) {
35
+ return 'rejected';
36
+ }
37
+
38
+ return 'unknown';
39
+ }
40
+
41
+ export function shouldTreatPushRejectionAsRemoteSynchronized(divergence = null) {
42
+ if (!divergence?.remoteExists || divergence.ahead !== 0 || divergence.behind !== 0) {
43
+ return false;
44
+ }
45
+
46
+ if (divergence.localSha && divergence.remoteSha) {
47
+ return divergence.localSha === divergence.remoteSha;
48
+ }
49
+
50
+ return true;
51
+ }
52
+
53
+ export function buildBranchSubjectLinks({ owner, repo, branchName, defaultBranch, forkedRepo = null }) {
54
+ const repository = `${owner}/${repo}`;
55
+ const headRepository = forkedRepo || repository;
56
+ const headOwner = headRepository.split('/')[0];
57
+ const baseBranch = defaultBranch || 'main';
58
+ const compareHead = forkedRepo ? `${headOwner}:${branchName}` : branchName;
59
+
60
+ return {
61
+ repository,
62
+ headRepository,
63
+ baseBranchRef: `${repository}:${baseBranch}`,
64
+ headBranchRef: `${headRepository}:${branchName}`,
65
+ remoteBranchRef: `origin/${branchName}`,
66
+ repositoryUrl: `https://github.com/${repository}`,
67
+ branchUrl: `https://github.com/${headRepository}/tree/${encodeRefForGitHubUrl(branchName)}`,
68
+ compareUrl: `https://github.com/${repository}/compare/${encodeRefForGitHubUrl(baseBranch)}...${encodeRefForGitHubUrl(compareHead)}`,
69
+ };
70
+ }
71
+
72
+ export function buildPushRejectionFailureActionSection({ owner, repo, branchName, defaultBranch, forkedRepo = null }) {
73
+ if (!owner || !repo || !branchName) {
74
+ return `### What you can do
75
+ - Inspect the remote branch and compare it with the local branch before retrying.
76
+ - If the remote branch already contains the intended commit, rerun the solver.
77
+ - If the histories differ, merge or resolve the branch manually, then rerun the solver.`;
78
+ }
79
+
80
+ const links = buildBranchSubjectLinks({ owner, repo, branchName, defaultBranch, forkedRepo });
81
+
82
+ return `### What you can do
83
+ - Inspect the remote branch: ${links.branchUrl}
84
+ - Compare the base and head branches: ${links.compareUrl}
85
+ - If the remote branch already contains the intended commit, rerun the solver. Matching remote branches are treated as usable after this fix.
86
+ - If the histories differ, merge or resolve \`${links.headBranchRef}\` against \`${links.baseBranchRef}\`, then rerun the solver.
87
+
88
+ Administrator-only CLI details, if any, are printed in the solver terminal log rather than in this GitHub comment.`;
89
+ }
90
+
91
+ export function buildPushRejectionExplanation({ branchName, isContinueMode, prNumber, divergence = null, owner = null, repo = null, defaultBranch = null, forkedRepo = null, classification = 'unknown' }) {
13
92
  const lines = [];
14
93
 
15
94
  if (isContinueMode && !prNumber) {
16
95
  lines.push(' This run reused an existing issue branch because auto-continue found a matching branch with no PR.');
17
96
  lines.push(' It is not a fresh branch created by this run, even though auto-PR creation is running now.');
97
+ } else if (classification === 'remote-ref-already-exists') {
98
+ lines.push(' GitHub rejected the push while creating or updating the remote ref because that ref already exists.');
18
99
  } else {
19
100
  lines.push(' The remote branch changed after the local branch state used for this push.');
20
101
  }
21
102
 
103
+ if (owner && repo) {
104
+ const links = buildBranchSubjectLinks({ owner, repo, branchName, defaultBranch, forkedRepo });
105
+ lines.push(` Repository: ${links.repositoryUrl}`);
106
+ lines.push(` Base branch: ${links.baseBranchRef}`);
107
+ lines.push(` Remote branch: ${links.headBranchRef}`);
108
+ lines.push(` Branch URL: ${links.branchUrl}`);
109
+ lines.push(` Compare URL: ${links.compareUrl}`);
110
+ }
111
+
22
112
  if (divergence?.remoteExists && divergence.ahead !== null && divergence.behind !== null) {
23
113
  lines.push(` Current branch state for ${branchName}: ${divergence.ahead} commit(s) ahead, ${divergence.behind} commit(s) behind origin/${branchName}.`);
114
+ if (divergence.localSha) {
115
+ lines.push(` Local HEAD: ${shortSha(divergence.localSha)}`);
116
+ }
117
+ if (divergence.remoteSha) {
118
+ lines.push(` Remote HEAD: ${shortSha(divergence.remoteSha)}`);
119
+ }
120
+ if (shouldTreatPushRejectionAsRemoteSynchronized(divergence)) {
121
+ lines.push(' The remote branch currently matches local HEAD, so this is not a branch divergence.');
122
+ }
24
123
  } else if (divergence?.fetchError) {
25
124
  lines.push(` Could not inspect origin/${branchName}: ${divergence.fetchError}`);
26
125
  }
@@ -28,6 +127,142 @@ export function buildPushRejectionExplanation({ branchName, isContinueMode, prNu
28
127
  return lines;
29
128
  }
30
129
 
130
+ export async function logRecoverablePushRejection({ log, formatAligned, branchName, isContinueMode, prNumber, divergence, owner, repo, defaultBranch, forkedRepo, classification }) {
131
+ const links = buildBranchSubjectLinks({ owner, repo, branchName, defaultBranch, forkedRepo });
132
+
133
+ await log('');
134
+ await log(formatAligned('⚠️', 'PUSH REPORTED FAILURE:', 'Remote branch already matches local HEAD'), { level: 'warning' });
135
+ await log('');
136
+ await log(' 🔍 What happened:');
137
+ for (const line of buildPushRejectionExplanation({
138
+ branchName,
139
+ isContinueMode,
140
+ prNumber,
141
+ divergence,
142
+ owner,
143
+ repo,
144
+ defaultBranch,
145
+ forkedRepo,
146
+ classification,
147
+ })) {
148
+ await log(line);
149
+ }
150
+ await log('');
151
+ await log(' ✅ Recovery:');
152
+ await log(` The branch is available at ${links.branchUrl}.`);
153
+ await log(' Continuing with PR creation because no local commit would be lost.');
154
+ await log('');
155
+
156
+ return links;
157
+ }
158
+
159
+ export async function logBlockingPushRejection({ log, formatAligned, branchName, isContinueMode, prNumber, divergence, owner, repo, defaultBranch, forkedRepo, classification }) {
160
+ const links = buildBranchSubjectLinks({ owner, repo, branchName, defaultBranch, forkedRepo });
161
+ const isRefCollision = classification === 'remote-ref-already-exists';
162
+
163
+ await log('');
164
+ await log(formatAligned('❌', isRefCollision ? 'REMOTE BRANCH COLLISION:' : 'PUSH REJECTED:', isRefCollision ? 'Remote ref already exists and differs from local branch' : 'Local and remote branch histories differ'), { level: 'error' });
165
+ await log('');
166
+ await log(' 🔍 What happened:');
167
+ if (isRefCollision) {
168
+ await log(` GitHub rejected creation or update of ${links.headBranchRef} because that remote ref already exists.`);
169
+ await log(' The existing remote branch does not match this local branch, so hive-mind cannot assume it is safe to continue.');
170
+ } else {
171
+ await log(` Git rejected updating ${links.headBranchRef} from this local branch.`);
172
+ await log(' The local and remote histories are not in a state that a normal push can update safely.');
173
+ }
174
+ for (const line of buildPushRejectionExplanation({
175
+ branchName,
176
+ isContinueMode,
177
+ prNumber,
178
+ divergence,
179
+ owner,
180
+ repo,
181
+ defaultBranch,
182
+ forkedRepo,
183
+ classification,
184
+ })) {
185
+ await log(line);
186
+ }
187
+ await log('');
188
+ await log(' 💡 Why we cannot fix this automatically:');
189
+ await log(' • We never use force push to preserve history');
190
+ await log(' • We never use rebase or reset to avoid altering git history');
191
+ await log(` • Manual review is required before changing ${links.headBranchRef}`);
192
+ await log('');
193
+ await log(' 🔧 How to fix:');
194
+ await log(` 1. Inspect the remote branch: ${links.branchUrl}`);
195
+ await log(` 2. Compare base and head: ${links.compareUrl}`);
196
+ await log(' 3. Clone the repository and checkout the branch:');
197
+ await log(` git clone https://github.com/${links.headRepository}.git`);
198
+ await log(` cd ${links.headRepository.split('/')[1]}`);
199
+ await log(` git checkout ${branchName}`);
200
+ await log('');
201
+ await log(' 4. Merge the remote branch state, resolve conflicts if any, then push:');
202
+ await log(` git pull origin ${branchName}`);
203
+ await log(` git push origin ${branchName}`);
204
+ await log('');
205
+ await log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
206
+ await log('');
207
+
208
+ return {
209
+ links,
210
+ failureActionSection: buildPushRejectionFailureActionSection({ owner, repo, branchName, defaultBranch, forkedRepo }),
211
+ };
212
+ }
213
+
214
+ export async function handleRejectedPushForAutoPr({ errorOutput, $, tempDir, log, formatAligned, branchName, isContinueMode, prNumber, owner, repo, defaultBranch, forkedRepo }) {
215
+ const classification = classifyPushRejection(errorOutput);
216
+ if (classification === 'unknown') {
217
+ return {
218
+ handled: false,
219
+ branchReadyForPrCreation: false,
220
+ recoveredFromPushRejection: false,
221
+ };
222
+ }
223
+
224
+ const divergence = await getRemoteBranchDivergenceSnapshot({ $, tempDir, branchName });
225
+
226
+ if (shouldTreatPushRejectionAsRemoteSynchronized(divergence)) {
227
+ await logRecoverablePushRejection({
228
+ log,
229
+ formatAligned,
230
+ branchName,
231
+ isContinueMode,
232
+ prNumber,
233
+ divergence,
234
+ owner,
235
+ repo,
236
+ defaultBranch,
237
+ forkedRepo,
238
+ classification,
239
+ });
240
+ return {
241
+ handled: true,
242
+ branchReadyForPrCreation: true,
243
+ recoveredFromPushRejection: true,
244
+ };
245
+ }
246
+
247
+ const { links, failureActionSection } = await logBlockingPushRejection({
248
+ log,
249
+ formatAligned,
250
+ branchName,
251
+ isContinueMode,
252
+ prNumber,
253
+ divergence,
254
+ owner,
255
+ repo,
256
+ defaultBranch,
257
+ forkedRepo,
258
+ classification,
259
+ });
260
+ const error = new Error(`Push rejected for ${links.headBranchRef}; compare ${links.compareUrl} and inspect ${links.branchUrl}`);
261
+ error.hiveMindUserFacingLogged = true;
262
+ error.failureActionSection = failureActionSection;
263
+ throw error;
264
+ }
265
+
31
266
  export async function getRemoteBranchDivergenceSnapshot({ $, tempDir, branchName }) {
32
267
  const fetchResult = await $({ cwd: tempDir, silent: true })`git fetch origin refs/heads/${branchName}:refs/remotes/origin/${branchName} 2>&1`;
33
268
  if (fetchResult.code !== 0) {
@@ -41,11 +276,15 @@ export async function getRemoteBranchDivergenceSnapshot({ $, tempDir, branchName
41
276
 
42
277
  const aheadResult = await $({ cwd: tempDir, silent: true })`git rev-list --count origin/${branchName}..HEAD 2>&1`;
43
278
  const behindResult = await $({ cwd: tempDir, silent: true })`git rev-list --count HEAD..origin/${branchName} 2>&1`;
279
+ const localShaResult = await $({ cwd: tempDir, silent: true })`git rev-parse HEAD 2>&1`;
280
+ const remoteShaResult = await $({ cwd: tempDir, silent: true })`git rev-parse origin/${branchName} 2>&1`;
44
281
 
45
282
  return {
46
283
  remoteExists: aheadResult.code === 0 && behindResult.code === 0,
47
284
  ahead: aheadResult.code === 0 ? toCount(aheadResult.stdout) : null,
48
285
  behind: behindResult.code === 0 ? toCount(behindResult.stdout) : null,
286
+ localSha: localShaResult.code === 0 ? outputOf(localShaResult) : null,
287
+ remoteSha: remoteShaResult.code === 0 ? outputOf(remoteShaResult) : null,
49
288
  fetchError: aheadResult.code === 0 && behindResult.code === 0 ? null : outputOf(aheadResult) || outputOf(behindResult) || 'could not compare local and remote branch',
50
289
  };
51
290
  }
@@ -68,6 +68,7 @@ export const handleFailure = async options => {
68
68
  sanitizeLogContent,
69
69
  verbose: argv.verbose,
70
70
  errorMessage: cleanErrorMessage(error),
71
+ failureActionSection: error?.failureActionSection || null,
71
72
  // Issue #1225: Pass model and tool info for PR comments
72
73
  requestedModel: argv.originalModel || argv.model,
73
74
  tool: argv.tool || 'claude',
@@ -236,7 +237,9 @@ export const handleMainExecutionError = async options => {
236
237
  return;
237
238
  }
238
239
 
239
- await log('Error executing command:', cleanErrorMessage(error));
240
+ if (!error?.hiveMindUserFacingLogged) {
241
+ await log('Error executing command:', cleanErrorMessage(error));
242
+ }
240
243
  await log(`Stack trace: ${error.stack}`, { verbose: true });
241
244
  await log(` 📁 Full log file: ${absoluteLogPath}`, { level: 'error' });
242
245
 
@@ -15,6 +15,16 @@ const isForkDivergenceFailure = reason => {
15
15
  return normalizedReason.includes('fork divergence') || (normalizedReason.includes('fork') && normalizedReason.includes('non-fast-forward')) || normalizedReason.includes('force-with-lease');
16
16
  };
17
17
 
18
+ const extractUrlAfter = (reason, label) => {
19
+ const match = String(reason || '').match(new RegExp(`${label}\\s+(https://github\\.com/[^\\s;]+)`, 'i'));
20
+ return match ? match[1] : null;
21
+ };
22
+
23
+ const isPushRejectionFailure = reason => {
24
+ const normalizedReason = String(reason || '').toLowerCase();
25
+ return normalizedReason.includes('push rejected for ') || (normalizedReason.includes('failed to push') && normalizedReason.includes('github.com'));
26
+ };
27
+
18
28
  export function buildPrePullRequestFailureActionSection(reason = '') {
19
29
  const normalizedReason = String(reason || '').toLowerCase();
20
30
  const isForkOrRecoveryFailure = normalizedReason.includes('fork') || normalizedReason.includes('auto-recovery') || normalizedReason.includes('repository setup');
@@ -25,6 +35,18 @@ export function buildPrePullRequestFailureActionSection(reason = '') {
25
35
  - If the fork has commits you need to preserve, resolve the divergence manually, then rerun the solver.
26
36
  - If this requires elevated Hive Mind access, ask a Hive Mind administrator to handle the affected fork or repository.
27
37
 
38
+ Administrator-only CLI details, if any, are printed in the solver terminal log rather than in this GitHub comment.`;
39
+ }
40
+
41
+ if (isPushRejectionFailure(reason)) {
42
+ const branchUrl = extractUrlAfter(reason, 'inspect');
43
+ const compareUrl = extractUrlAfter(reason, 'compare');
44
+ return `### What you can do
45
+ - Inspect the remote branch${branchUrl ? `: ${branchUrl}` : ' named in the failure'}.
46
+ - Compare the base and head histories${compareUrl ? `: ${compareUrl}` : ' using the compare link named in the failure'}.
47
+ - If the branch belongs to you, merge the remote branch state or choose a new branch name, then rerun the solver.
48
+ - Do not force-push unless you have manually confirmed that overwriting the remote branch is safe.
49
+
28
50
  Administrator-only CLI details, if any, are printed in the solver terminal log rather than in this GitHub comment.`;
29
51
  }
30
52