@link-assistant/hive-mind 2.1.1 → 2.1.2

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,11 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.1.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 39164e5: Improve fork-divergence failure comments and logs with inspected branch state, actor-aware admin guidance, exact fork-only commit lists, and less generic repository setup wording.
8
+
3
9
  ## 2.1.1
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.1.1",
3
+ "version": "2.1.2",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -235,14 +235,16 @@ export const logActiveHandles = async (log = null) => {
235
235
  * @param {object} [options]
236
236
  * @param {boolean} [options.skipPreExit=false] - Issue #1823: skip the pre-exit failure notifier
237
237
  * (e.g. on graceful shutdown, which is NOT a failure and must not post a "solver failed" comment).
238
+ * @param {string|null} [options.failureActionSection=null] - Optional prebuilt user-facing recovery
239
+ * guidance for the pre-exit notifier.
238
240
  */
239
- export const safeExit = async (code = 0, reason = 'Process completed', { skipPreExit = false } = {}) => {
241
+ export const safeExit = async (code = 0, reason = 'Process completed', { skipPreExit = false, failureActionSection = null } = {}) => {
240
242
  await showExitMessage(reason, code);
241
243
 
242
244
  if (!skipPreExit && code !== 0 && preExitFunction && !preExitHandlerRan) {
243
245
  preExitHandlerRan = true;
244
246
  try {
245
- await preExitFunction({ code, reason });
247
+ await preExitFunction({ code, reason, failureActionSection });
246
248
  } catch (error) {
247
249
  const message = error && error.message ? error.message : String(error);
248
250
  if (logFunction) {
@@ -31,10 +31,8 @@ const buildIssueFailureActionSection = targetType => {
31
31
 
32
32
  ### What you can do
33
33
  - Resolve the repository, account, permissions, or environment problem described above, then rerun the solver.
34
- - If this requires elevated Hive Mind access, ask a Hive Mind administrator to handle the specific failure described above.
35
- - Repository deletion can require a separate GitHub account or token with repository deletion permission; Hive Mind does not rely on that permission by default.
36
-
37
- Administrator-only CLI details, if any, are printed in the solver terminal log rather than in this issue comment.`;
34
+ - Repository owner or Hive Mind administrator path: handle manual recreation or fix of the repository when the required action is outside the requester access.
35
+ - Repository deletion can require a separate GitHub account or token with repository deletion permission; Hive Mind does not rely on that permission by default.`;
38
36
  };
39
37
  const normalizeFailureActionSection = section => {
40
38
  const text = section || '';
@@ -11,11 +11,243 @@ const outputOf = result => {
11
11
 
12
12
  const shortSha = sha => (sha ? String(sha).slice(0, 12) : null);
13
13
 
14
+ export const FORK_DIVERGENCE_RESOLUTION_OPTION = '--allow-fork-divergence-resolution-using-force-push-with-lease';
15
+
14
16
  const encodeRefForGitHubUrl = ref =>
15
17
  encodeURI(String(ref || ''))
16
18
  .replaceAll('#', '%23')
17
19
  .replaceAll('?', '%3F');
18
20
 
21
+ const firstLine = value =>
22
+ String(value || '')
23
+ .split('\n')
24
+ .map(line => line.trim())
25
+ .find(Boolean) || '';
26
+
27
+ const sameLogin = (left, right) => Boolean(left && right && String(left).toLowerCase() === String(right).toLowerCase());
28
+
29
+ const formatCommitLine = commit => {
30
+ const sha = commit?.shortSha || shortSha(commit?.sha) || 'unknown';
31
+ const author = String(commit?.author || 'unknown author').trim();
32
+ const subject = String(commit?.subject || 'no subject').trim();
33
+ return `${sha} ${author} ${subject}`;
34
+ };
35
+
36
+ const formatCommitBullets = commits => {
37
+ const safeCommits = Array.isArray(commits) ? commits : [];
38
+ if (safeCommits.length === 0) return ['- No fork-only commits were returned by the inspection.'];
39
+ return safeCommits.map(commit => `- \`${formatCommitLine(commit)}\``);
40
+ };
41
+
42
+ const buildForkCompareUrl = ({ upstreamRepo, forkedRepo, branchName }) => {
43
+ const [upstreamOwner, upstreamName] = String(upstreamRepo || '').split('/');
44
+ const [forkOwner] = String(forkedRepo || '').split('/');
45
+ if (!upstreamOwner || !upstreamName || !forkOwner || !branchName) return null;
46
+ return `https://github.com/${upstreamRepo}/compare/${encodeRefForGitHubUrl(branchName)}...${encodeRefForGitHubUrl(`${forkOwner}:${branchName}`)}`;
47
+ };
48
+
49
+ const normalizeForkDivergenceSnapshot = (snapshot = {}) => {
50
+ const branchName = snapshot.branchName || 'the default branch';
51
+ const upstreamRepo = snapshot.upstreamRepo || 'the upstream repository';
52
+ const forkedRepo = snapshot.forkedRepo || 'the fork repository';
53
+ const forkRef = snapshot.forkRef || `origin/${branchName}`;
54
+ const upstreamRef = snapshot.upstreamRef || `upstream/${branchName}`;
55
+ const compareUrl = snapshot.compareUrl || buildForkCompareUrl({ upstreamRepo, forkedRepo, branchName });
56
+ const uniqueCommits = Array.isArray(snapshot.uniqueCommits) ? snapshot.uniqueCommits : [];
57
+
58
+ return {
59
+ ...snapshot,
60
+ branchName,
61
+ upstreamRepo,
62
+ forkedRepo,
63
+ forkRef,
64
+ upstreamRef,
65
+ compareUrl,
66
+ uniqueCommits,
67
+ forkUniqueCount: Number.isFinite(snapshot.forkUniqueCount) ? snapshot.forkUniqueCount : null,
68
+ upstreamUniqueCount: Number.isFinite(snapshot.upstreamUniqueCount) ? snapshot.upstreamUniqueCount : null,
69
+ };
70
+ };
71
+
72
+ const buildActorLine = ({ currentUser, taskRequester }) => {
73
+ if (sameLogin(currentUser, taskRequester)) {
74
+ return `Hive Mind is authenticated as \`${currentUser}\`, the same GitHub user that requested this task.`;
75
+ }
76
+
77
+ if (currentUser && taskRequester) {
78
+ return `Hive Mind is authenticated as \`${currentUser}\`; the task requester is \`${taskRequester}\`.`;
79
+ }
80
+
81
+ if (currentUser) {
82
+ return `Hive Mind is authenticated as \`${currentUser}\`; the task requester could not be resolved.`;
83
+ }
84
+
85
+ if (taskRequester) {
86
+ return `The task requester is \`${taskRequester}\`; the authenticated Hive Mind user could not be resolved.`;
87
+ }
88
+
89
+ return 'Hive Mind could not resolve the authenticated user or task requester.';
90
+ };
91
+
92
+ const buildManualForkRepairCommands = ({ snapshot, solveCommand }) => {
93
+ const { branchName, forkedRepo, upstreamRef } = snapshot;
94
+ const commands = ['```bash', `gh repo delete ${forkedRepo} --yes`, `${solveCommand || 'solve <issue-url>'}`, '', '# Manual branch repair after preserving any fork-only commits:', 'git fetch upstream', `git reset --hard ${upstreamRef}`, `git push --force-with-lease origin ${branchName}`, '```'];
95
+ return commands.join('\n');
96
+ };
97
+
98
+ export async function getForkDefaultBranchDivergenceSnapshot({ $, tempDir, branchName, forkedRepo, upstreamRepo }) {
99
+ const forkRef = `origin/${branchName}`;
100
+ const upstreamRef = `upstream/${branchName}`;
101
+ const compareUrl = buildForkCompareUrl({ upstreamRepo, forkedRepo, branchName });
102
+
103
+ const forkFetchResult = await $({ cwd: tempDir, silent: true })`git fetch origin refs/heads/${branchName}:refs/remotes/origin/${branchName} 2>&1`;
104
+ if (forkFetchResult.code !== 0) {
105
+ return {
106
+ branchName,
107
+ forkedRepo,
108
+ upstreamRepo,
109
+ forkRef,
110
+ upstreamRef,
111
+ compareUrl,
112
+ forkUniqueCount: null,
113
+ upstreamUniqueCount: null,
114
+ uniqueCommits: [],
115
+ fetchError: outputOf(forkFetchResult) || `failed to fetch ${forkRef}`,
116
+ };
117
+ }
118
+
119
+ const upstreamFetchResult = await $({ cwd: tempDir, silent: true })`git fetch upstream refs/heads/${branchName}:refs/remotes/upstream/${branchName} 2>&1`;
120
+ if (upstreamFetchResult.code !== 0) {
121
+ return {
122
+ branchName,
123
+ forkedRepo,
124
+ upstreamRepo,
125
+ forkRef,
126
+ upstreamRef,
127
+ compareUrl,
128
+ forkUniqueCount: null,
129
+ upstreamUniqueCount: null,
130
+ uniqueCommits: [],
131
+ fetchError: outputOf(upstreamFetchResult) || `failed to fetch ${upstreamRef}`,
132
+ };
133
+ }
134
+
135
+ const forkUniqueResult = await $({ cwd: tempDir, silent: true })`git rev-list --count ${upstreamRef}..${forkRef} 2>&1`;
136
+ const upstreamUniqueResult = await $({ cwd: tempDir, silent: true })`git rev-list --count ${forkRef}..${upstreamRef} 2>&1`;
137
+ const forkHeadResult = await $({ cwd: tempDir, silent: true })`git rev-parse ${forkRef} 2>&1`;
138
+ const upstreamHeadResult = await $({ cwd: tempDir, silent: true })`git rev-parse ${upstreamRef} 2>&1`;
139
+ const commitsResult = await $({ cwd: tempDir, silent: true })`git log --format=%H%x09%an%x09%s --max-count=20 ${upstreamRef}..${forkRef} 2>&1`;
140
+
141
+ const uniqueCommits =
142
+ commitsResult.code === 0
143
+ ? commitsResult.stdout
144
+ .toString()
145
+ .trim()
146
+ .split('\n')
147
+ .filter(Boolean)
148
+ .map(line => {
149
+ const [sha, author, ...subjectParts] = line.split('\t');
150
+ return {
151
+ sha,
152
+ shortSha: shortSha(sha),
153
+ author,
154
+ subject: subjectParts.join('\t'),
155
+ };
156
+ })
157
+ : [];
158
+
159
+ return {
160
+ branchName,
161
+ forkedRepo,
162
+ upstreamRepo,
163
+ forkRef,
164
+ upstreamRef,
165
+ compareUrl,
166
+ forkUniqueCount: forkUniqueResult.code === 0 ? toCount(forkUniqueResult.stdout) : null,
167
+ upstreamUniqueCount: upstreamUniqueResult.code === 0 ? toCount(upstreamUniqueResult.stdout) : null,
168
+ forkHead: forkHeadResult.code === 0 ? firstLine(forkHeadResult.stdout) : null,
169
+ upstreamHead: upstreamHeadResult.code === 0 ? firstLine(upstreamHeadResult.stdout) : null,
170
+ uniqueCommits,
171
+ inspectError: commitsResult.code === 0 ? null : outputOf(commitsResult),
172
+ };
173
+ }
174
+
175
+ export function buildForkDivergenceBlockedReason({ snapshot }) {
176
+ const details = normalizeForkDivergenceSnapshot(snapshot);
177
+ const lines = ['Repository setup halted - fork divergence requires user decision.', `Fork: ${details.forkedRepo}`, `Upstream: ${details.upstreamRepo}`, `Branch: ${details.branchName}`];
178
+
179
+ if (details.fetchError || details.inspectError) {
180
+ lines.push(`Inspection error: ${details.fetchError || details.inspectError}`);
181
+ lines.push('Hive Mind did not recommend automatic force-with-lease because it could not prove whether fork-only commits would be lost.');
182
+ return lines.join('\n');
183
+ }
184
+
185
+ lines.push(`Fork-only commits that would be overwritten: ${details.forkUniqueCount ?? 'unknown'}`);
186
+ lines.push(`Upstream-only commits missing from fork: ${details.upstreamUniqueCount ?? 'unknown'}`);
187
+ if (details.compareUrl) lines.push(`Compare: ${details.compareUrl}`);
188
+
189
+ if ((details.forkUniqueCount ?? 0) > 0) {
190
+ lines.push('Fork-only commit list:');
191
+ for (const commit of details.uniqueCommits) {
192
+ lines.push(`- ${formatCommitLine(commit)}`);
193
+ }
194
+ }
195
+
196
+ return lines.join('\n');
197
+ }
198
+
199
+ export function buildForkDivergenceFailureActionSection({ snapshot = {}, currentUser = null, taskRequester = null, solveCommand = null } = {}) {
200
+ const details = normalizeForkDivergenceSnapshot(snapshot);
201
+ const actorLine = buildActorLine({ currentUser, taskRequester });
202
+ const sameUser = sameLogin(currentUser, taskRequester);
203
+ const hasInspectionError = Boolean(details.fetchError || details.inspectError);
204
+ const forkUniqueCount = details.forkUniqueCount;
205
+ const upstreamUniqueCount = details.upstreamUniqueCount;
206
+
207
+ const header = ['### What happened', `- Hive Mind checked \`${details.forkRef}\` in \`${details.forkedRepo}\` against \`${details.upstreamRef}\` in \`${details.upstreamRepo}\`.`, `- ${actorLine}`];
208
+ if (details.compareUrl) {
209
+ header.push(`- Compare the branch state: ${details.compareUrl}`);
210
+ }
211
+
212
+ if (hasInspectionError) {
213
+ header.push(`- The safety inspection failed: ${details.fetchError || details.inspectError}`);
214
+ return `${header.join('\n')}
215
+
216
+ ### What you can do
217
+ - Hive Mind did not recommend automatic force-with-lease because it could not prove whether fork-only commits would be overwritten.
218
+ - Ask a Hive Mind administrator to handle manual recreation or fix of the repository.
219
+ - Repository owner path: inspect \`${details.forkedRepo}\`, preserve any needed commits, then recreate or repair the fork default branch.`;
220
+ }
221
+
222
+ if (forkUniqueCount === 0) {
223
+ const rerunLine = sameUser ? `Rerun with \`${FORK_DIVERGENCE_RESOLUTION_OPTION}\` to let Hive Mind update \`${details.forkedRepo}\` using \`git push --force-with-lease origin ${details.branchName}\`.` : `Ask a Hive Mind administrator to rerun with \`${FORK_DIVERGENCE_RESOLUTION_OPTION}\` so Hive Mind can update \`${details.forkedRepo}\` using \`git push --force-with-lease origin ${details.branchName}\`.`;
224
+
225
+ return `${header.join('\n')}
226
+ - GitHub inspection found 0 commit(s) unique to \`${details.forkRef}\`; \`${details.forkRef}\` is ${upstreamUniqueCount ?? 'an unknown number of'} commit(s) behind \`${details.upstreamRef}\`.
227
+
228
+ ### What you can do
229
+ - ${rerunLine}
230
+ - Delete or recreate the fork repository, or fix the repository manually, using these commands when you own the fork or are acting as the Hive Mind administrator:
231
+
232
+ ${buildManualForkRepairCommands({ snapshot: details, solveCommand })}`;
233
+ }
234
+
235
+ const administratorLine = sameUser ? 'Delete or recreate the fork repository, or fix the repository manually, after preserving the fork-only commits listed above.' : 'Ask a Hive Mind administrator to handle manual recreation or fix of the repository.';
236
+
237
+ return `${header.join('\n')}
238
+ - \`${details.forkRef}\` has ${forkUniqueCount ?? 'an unknown number of'} commit(s) unique to \`${details.forkRef}\`; replacing it with \`${details.upstreamRef}\` would remove them from the fork default branch history.
239
+
240
+ ### Commits that would be lost
241
+ ${formatCommitBullets(details.uniqueCommits).join('\n')}
242
+
243
+ ### What you can do
244
+ - ${administratorLine}
245
+ - Preserve the listed commit(s) first by moving them to another branch, cherry-picking them later, or confirming they are disposable.
246
+ - Manual repair example after preserving the listed commits:
247
+
248
+ ${buildManualForkRepairCommands({ snapshot: details, solveCommand })}`;
249
+ }
250
+
19
251
  export function classifyPushRejection(errorOutput = '') {
20
252
  const normalized = String(errorOutput || '').toLowerCase();
21
253
 
@@ -138,9 +370,7 @@ export function buildPushRejectionFailureActionSection({ owner, repo, branchName
138
370
  - Inspect the remote branch: ${links.branchUrl}
139
371
  - Compare the base and head branches: ${links.compareUrl}
140
372
  - If the remote branch already contains the intended commit, rerun the solver. Matching remote branches are treated as usable after this fix.
141
- - If the histories differ, merge or resolve \`${links.headBranchRef}\` against \`${links.baseBranchRef}\`, then rerun the solver.
142
-
143
- Administrator-only CLI details, if any, are printed in the solver terminal log rather than in this GitHub comment.`;
373
+ - Diverged-history path: merge or resolve \`${links.headBranchRef}\` against \`${links.baseBranchRef}\`, then rerun the solver.`;
144
374
  }
145
375
 
146
376
  export function buildPushRejectionExplanation({ branchName, isContinueMode, prNumber, divergence = null, owner = null, repo = null, defaultBranch = null, forkedRepo = null, classification = 'unknown' }) {
@@ -26,7 +26,80 @@ import { safeExit } from './exit-handler.lib.mjs';
26
26
  // Issue #1893: helpers that decide whether the fork's default branch may be
27
27
  // pushed and that distinguish a permission-denied rejection from a genuine
28
28
  // fork divergence.
29
- const { isPermissionDeniedPushError, shouldPushDefaultBranchToFork } = await import('./solve.branch-divergence.lib.mjs');
29
+ const { buildForkDivergenceBlockedReason, buildForkDivergenceFailureActionSection, getForkDefaultBranchDivergenceSnapshot, isPermissionDeniedPushError, shouldPushDefaultBranchToFork } = await import('./solve.branch-divergence.lib.mjs');
30
+
31
+ const firstNonEmptyLine = value =>
32
+ String(value || '')
33
+ .split('\n')
34
+ .map(line => line.trim())
35
+ .find(Boolean) || '';
36
+
37
+ const resolveSolveCommand = argv => {
38
+ const issueUrl = argv.url || argv['issue-url'] || argv._?.[0] || '<issue-url>';
39
+ return `solve ${issueUrl}`;
40
+ };
41
+
42
+ const extractGitHubUrlNumber = argv => {
43
+ const url = String(argv.url || argv['issue-url'] || argv._?.[0] || '');
44
+ const match = url.match(/github\.com\/[^/]+\/[^/]+\/(issues|pull)\/(\d+)/i);
45
+ return match ? { type: match[1] === 'pull' ? 'pulls' : 'issues', number: match[2] } : null;
46
+ };
47
+
48
+ const resolveTaskRequester = async ({ $, owner, repo, argv }) => {
49
+ const target = extractGitHubUrlNumber(argv);
50
+ if (!target) return null;
51
+ const result = await $({ silent: true })`gh api repos/${owner}/${repo}/${target.type}/${target.number} --jq .user.login 2>&1`;
52
+ return result.code === 0 ? firstNonEmptyLine(result.stdout) : null;
53
+ };
54
+
55
+ const logForkDivergenceDetails = async ({ snapshot, currentUser, taskRequester, solveCommand, includeAction = true }) => {
56
+ await log('');
57
+ await log(' 🔍 What happened:');
58
+ await log(` Git rejected updating ${snapshot.forkedRepo}:${snapshot.branchName} after Hive Mind synced the local branch to ${snapshot.upstreamRepo}:${snapshot.branchName}.`);
59
+ await log('');
60
+ await log(' 📦 Current state:');
61
+ await log(` Fork: ${snapshot.forkedRepo}`);
62
+ await log(` Upstream: ${snapshot.upstreamRepo}`);
63
+ await log(` Branch: ${snapshot.branchName}`);
64
+ if (currentUser) await log(` Authenticated user: ${currentUser}`);
65
+ if (taskRequester) await log(` Task requester: ${taskRequester}`);
66
+ if (snapshot.compareUrl) await log(` Compare: ${snapshot.compareUrl}`);
67
+
68
+ if (snapshot.fetchError || snapshot.inspectError) {
69
+ await log('');
70
+ await log(' ⚠️ Safety check incomplete:');
71
+ await log(` ${snapshot.fetchError || snapshot.inspectError}`);
72
+ await log(' Hive Mind cannot prove whether force-with-lease would overwrite fork-only commits.');
73
+ } else {
74
+ await log(` Fork-only commits: ${snapshot.forkUniqueCount ?? 'unknown'}`);
75
+ await log(` Upstream-only commits missing from fork: ${snapshot.upstreamUniqueCount ?? 'unknown'}`);
76
+ if ((snapshot.forkUniqueCount ?? 0) > 0) {
77
+ await log('');
78
+ await log(' ⚠️ Commits that would be lost from the fork default branch:');
79
+ for (const commit of snapshot.uniqueCommits) {
80
+ await log(` - ${commit.shortSha || commit.sha} ${commit.author || 'unknown author'} ${commit.subject || 'no subject'}`);
81
+ }
82
+ if (snapshot.uniqueCommits.length < snapshot.forkUniqueCount) {
83
+ await log(` - ... ${snapshot.forkUniqueCount - snapshot.uniqueCommits.length} more commit(s) not shown`);
84
+ }
85
+ } else {
86
+ await log('');
87
+ await log(' ✅ Safety check:');
88
+ await log(` No commits unique to ${snapshot.forkRef} were found.`);
89
+ }
90
+ }
91
+
92
+ const actionSection = buildForkDivergenceFailureActionSection({ snapshot, currentUser, taskRequester, solveCommand });
93
+ if (includeAction) {
94
+ await log('');
95
+ await log(' 🔧 GitHub comment guidance:');
96
+ for (const line of actionSection.split('\n')) {
97
+ await log(` ${line}`);
98
+ }
99
+ }
100
+ await log('');
101
+ return actionSection;
102
+ };
30
103
 
31
104
  // Set up upstream remote and sync fork
32
105
  export const setupUpstreamAndSync = async (tempDir, forkedRepo, upstreamRemote, owner, repo, argv) => {
@@ -170,16 +243,23 @@ export const setupUpstreamAndSync = async (tempDir, forkedRepo, upstreamRemote,
170
243
  // Fork has diverged from upstream
171
244
  await log('');
172
245
  await log(`${formatAligned('⚠️', 'FORK DIVERGENCE DETECTED', '')}`, { level: 'warn' });
173
- await log('');
174
- await log(' 🔍 What happened:');
175
- await log(` Your fork's ${upstreamDefaultBranch} branch has different commits than upstream`);
176
- await log(' This typically occurs when upstream had a force push (e.g., git reset --hard)');
177
- await log('');
178
- await log(' 📦 Current state:');
179
- await log(` • Fork: ${forkedRepo}`);
180
- await log(` • Upstream: ${owner}/${repo}`);
181
- await log(` • Branch: ${upstreamDefaultBranch}`);
182
- await log('');
246
+ const taskRequester = await resolveTaskRequester({ $, owner, repo, argv });
247
+ const solveCommand = resolveSolveCommand(argv);
248
+ const divergenceSnapshot = await getForkDefaultBranchDivergenceSnapshot({
249
+ $,
250
+ tempDir,
251
+ branchName: upstreamDefaultBranch,
252
+ forkedRepo,
253
+ upstreamRepo: `${owner}/${repo}`,
254
+ });
255
+ const failureActionSection = await logForkDivergenceDetails({
256
+ snapshot: divergenceSnapshot,
257
+ currentUser,
258
+ taskRequester,
259
+ solveCommand,
260
+ includeAction: !argv.allowForkDivergenceResolutionUsingForcePushWithLease,
261
+ });
262
+ const blockedReason = buildForkDivergenceBlockedReason({ snapshot: divergenceSnapshot });
183
263
 
184
264
  // Check if user has enabled automatic force push
185
265
  if (argv.allowForkDivergenceResolutionUsingForcePushWithLease) {
@@ -225,36 +305,12 @@ export const setupUpstreamAndSync = async (tempDir, forkedRepo, upstreamRemote,
225
305
  await log(' 3. Manually sync fork with upstream:');
226
306
  await log(' git fetch upstream');
227
307
  await log(` git reset --hard upstream/${upstreamDefaultBranch}`);
228
- await log(` git push --force origin ${upstreamDefaultBranch}`);
308
+ await log(` git push --force-with-lease origin ${upstreamDefaultBranch}`);
229
309
  await log('');
230
- await safeExit(1, 'Repository setup failed - fork sync failed');
310
+ await safeExit(1, 'Repository setup failed - fork sync failed', { failureActionSection });
231
311
  }
232
312
  } else {
233
- // Flag is not enabled - provide guidance
234
- await log(' 💡 Your options:');
235
- await log('');
236
- await log(' Option 1: Delete your fork and recreate it (SIMPLEST)');
237
- await log(` gh repo delete ${forkedRepo}`);
238
- await log(' Then run the solve command again - the fork will be recreated automatically');
239
- await log(' ⚠️ Only use this if your fork has no unique commits you need to preserve');
240
- await log('');
241
- await log(' Option 2: Enable automatic force-push (DANGEROUS)');
242
- await log(' Add --allow-fork-divergence-resolution-using-force-push-with-lease flag to your command');
243
- await log(' This will automatically sync your fork with upstream using force-with-lease');
244
- await log(' ⚠️ Overwrites fork history - any unique commits will be LOST');
245
- await log('');
246
- await log(' Option 3: Manually resolve the divergence');
247
- await log(' 1. Decide if you need any commits unique to your fork');
248
- await log(' 2. If yes, cherry-pick them after syncing');
249
- await log(' 3. If no, manually force-push:');
250
- await log(' git fetch upstream');
251
- await log(` git reset --hard upstream/${upstreamDefaultBranch}`);
252
- await log(` git push --force origin ${upstreamDefaultBranch}`);
253
- await log('');
254
- await log(' 🔧 To proceed with auto-resolution, restart with:');
255
- await log(` solve ${argv.url || argv['issue-url'] || argv._[0] || '<issue-url>'} --allow-fork-divergence-resolution-using-force-push-with-lease`);
256
- await log('');
257
- await safeExit(1, 'Repository setup halted - fork divergence requires user decision');
313
+ await safeExit(1, blockedReason, { failureActionSection });
258
314
  }
259
315
  } else {
260
316
  // Some other push error (not divergence-related)
package/src/solve.mjs CHANGED
@@ -163,7 +163,7 @@ const cleanupWrapper = async () => {
163
163
  }
164
164
  };
165
165
  const interruptWrapper = createInterruptWrapper({ cleanupContext, checkForUncommittedChanges, shouldAttachLogs, attachLogToGitHub, getLogFile, sanitizeLogContent, $, log });
166
- initializeExitHandler(getAbsoluteLogPath, log, cleanupWrapper, interruptWrapper, ({ code, reason }) => notifyIssueAboutPrePullRequestFailure({ code, reason, argv, globalState: global, $, log, getLogFile, shouldAttachLogs, attachLogToGitHub, sanitizeLogContent, rawCommand }));
166
+ initializeExitHandler(getAbsoluteLogPath, log, cleanupWrapper, interruptWrapper, ({ code, reason, failureActionSection }) => notifyIssueAboutPrePullRequestFailure({ code, reason, failureActionSection, argv, globalState: global, $, log, getLogFile, shouldAttachLogs, attachLogToGitHub, sanitizeLogContent, rawCommand }));
167
167
  installGlobalExitHandlers();
168
168
  // Issue #1823: Configure the working-session guard. When the experimental
169
169
  // --do-not-shutdown-in-the-middle-of-working-session flag is set (hive passes it to every
@@ -1,7 +1,8 @@
1
1
  import { getTrackedToolCommentIds, postTrackedComment, SOLUTION_DRAFT_FAILED_MARKER } from './tool-comments.lib.mjs';
2
2
  import { extractForkReplacementBlockedDetails, isForkReplacementBlockedReason } from './solve.repository-recovery-message.lib.mjs';
3
+ import { FORK_DIVERGENCE_RESOLUTION_OPTION, buildForkDivergenceFailureActionSection } from './solve.branch-divergence.lib.mjs';
3
4
 
4
- export const FORK_DIVERGENCE_RESOLUTION_OPTION = '--allow-fork-divergence-resolution-using-force-push-with-lease';
5
+ export { FORK_DIVERGENCE_RESOLUTION_OPTION };
5
6
 
6
7
  const truncate = (value, maxLength = 2000) => {
7
8
  const text = value === null || value === undefined ? '' : String(value);
@@ -40,20 +41,17 @@ export function buildPrePullRequestFailureActionSection(reason = '') {
40
41
  - It could not prove whether the existing repository has unique commits, so it did not delete it automatically.
41
42
 
42
43
  ### What you can do
43
- - If you control \`${repository}\`, back up any needed work, then delete, rename, archive, or repair it in GitHub and rerun the solver.
44
- - If you do not control \`${repository}\`, ask the repository owner or a Hive Mind administrator to clean it up and rerun the solver.
45
- - Use \`--allow-force-non-fork-repository-deletion\` only after confirming that deleting \`${repository}\` is acceptable and any unique commits can be lost.
46
-
47
- Administrator-only CLI details, if any, are printed in the solver terminal log rather than in this GitHub comment.`;
44
+ - Repository owner path: back up any needed work, then delete, rename, archive, or repair \`${repository}\` in GitHub and rerun the solver.
45
+ - External-owner path: ask the repository owner or a Hive Mind administrator to clean it up and rerun the solver.
46
+ - Use \`--allow-force-non-fork-repository-deletion\` only after confirming that deleting \`${repository}\` is acceptable and any unique commits can be lost.`;
48
47
  }
49
48
 
50
49
  if (isForkDivergenceFailure(reason)) {
51
- return `### What you can do
52
- - If the fork's default branch can be overwritten safely, rerun with \`${FORK_DIVERGENCE_RESOLUTION_OPTION}\` to allow a guarded force-with-lease sync.
53
- - If the fork has commits you need to preserve, resolve the divergence manually, then rerun the solver.
54
- - If this requires elevated Hive Mind access, ask a Hive Mind administrator to handle the affected fork or repository.
55
-
56
- Administrator-only CLI details, if any, are printed in the solver terminal log rather than in this GitHub comment.`;
50
+ return buildForkDivergenceFailureActionSection({
51
+ snapshot: {
52
+ fetchError: 'No fork divergence inspection data was attached to this failure reason.',
53
+ },
54
+ });
57
55
  }
58
56
 
59
57
  if (isPushRejectionFailure(reason)) {
@@ -62,26 +60,20 @@ Administrator-only CLI details, if any, are printed in the solver terminal log r
62
60
  return `### What you can do
63
61
  - Inspect the remote branch${branchUrl ? `: ${branchUrl}` : ' named in the failure'}.
64
62
  - Compare the base and head histories${compareUrl ? `: ${compareUrl}` : ' using the compare link named in the failure'}.
65
- - If the branch belongs to you, merge the remote branch state or choose a new branch name, then rerun the solver.
66
- - Do not force-push unless you have manually confirmed that overwriting the remote branch is safe.
67
-
68
- Administrator-only CLI details, if any, are printed in the solver terminal log rather than in this GitHub comment.`;
63
+ - Branch owner path: merge the remote branch state or choose a new branch name, then rerun the solver.
64
+ - Manual force-push remains blocked until a human confirms that overwriting the remote branch is safe.`;
69
65
  }
70
66
 
71
67
  if (isForkOrRecoveryFailure) {
72
68
  return `### What you can do
73
- - If the affected fork or repository belongs to you, remove, rename, archive, initialize, or otherwise repair it in GitHub, then rerun the solver.
74
- - If the action requires elevated Hive Mind access, ask a Hive Mind administrator to handle the affected fork or repository and rerun the solver.
75
- - Repository deletion can require a separate GitHub account or token with repository deletion permission; Hive Mind does not rely on that permission by default.
76
-
77
- Administrator-only CLI details, if any, are printed in the solver terminal log rather than in this issue comment.`;
69
+ - Repository owner path: remove, rename, archive, initialize, or otherwise repair the affected fork or repository in GitHub, then rerun the solver.
70
+ - External-owner path: ask a Hive Mind administrator to handle manual recreation or fix of the repository and rerun the solver.
71
+ - Repository deletion can require a separate GitHub account or token with repository deletion permission; Hive Mind does not rely on that permission by default.`;
78
72
  }
79
73
 
80
74
  return `### What you can do
81
75
  - Resolve the repository, account, permissions, or environment problem described above, then rerun the solver.
82
- - If this requires elevated Hive Mind access, ask a Hive Mind administrator to handle the specific failure described above.
83
-
84
- Administrator-only CLI details, if any, are printed in the solver terminal log rather than in this issue comment.`;
76
+ - Repository owner or Hive Mind administrator path: handle manual recreation or fix of the repository when the required action is outside the requester access.`;
85
77
  }
86
78
 
87
79
  export function shouldNotifyIssueAboutPrePullRequestFailure({ code, globalState }) {
@@ -128,11 +120,11 @@ export function resolvePreExitFailureNotificationTarget({ code, globalState }) {
128
120
  };
129
121
  }
130
122
 
131
- export function buildPrePullRequestFailureComment({ reason, owner, repo, issueNumber, argv = {}, logAttachmentAttempted = false }) {
123
+ export function buildPrePullRequestFailureComment({ reason, owner, repo, issueNumber, argv = {}, logAttachmentAttempted = false, failureActionSection = null }) {
132
124
  const tool = argv.tool || 'claude';
133
125
  const modelLine = argv.model ? `\n- **Requested model**: \`${argv.model}\`` : '';
134
126
  const logLine = logAttachmentAttempted ? 'Log attachment was attempted but failed. Check the solver terminal log for the complete failure output.' : 'Logs were not attached because `--attach-logs` was not enabled.';
135
- const actionSection = buildPrePullRequestFailureActionSection(reason);
127
+ const actionSection = failureActionSection || buildPrePullRequestFailureActionSection(reason);
136
128
 
137
129
  return `## 🚨 ${SOLUTION_DRAFT_FAILED_MARKER}
138
130
 
@@ -154,12 +146,12 @@ ${logLine}
154
146
  `;
155
147
  }
156
148
 
157
- export function buildExistingPullRequestFailureComment({ reason, owner, repo, prNumber, issueNumber = null, argv = {}, logAttachmentAttempted = false }) {
149
+ export function buildExistingPullRequestFailureComment({ reason, owner, repo, prNumber, issueNumber = null, argv = {}, logAttachmentAttempted = false, failureActionSection = null }) {
158
150
  const tool = argv.tool || 'claude';
159
151
  const modelLine = argv.model ? `\n- **Requested model**: \`${argv.model}\`` : '';
160
152
  const issueLine = issueNumber ? `\n- **Linked issue**: #${issueNumber}` : '';
161
153
  const logLine = logAttachmentAttempted ? 'Log attachment was attempted but failed. Check the solver terminal log for the complete failure output.' : 'Logs were not attached because `--attach-logs` was not enabled.';
162
- const actionSection = buildPrePullRequestFailureActionSection(reason);
154
+ const actionSection = failureActionSection || buildPrePullRequestFailureActionSection(reason);
163
155
 
164
156
  return `## 🚨 ${SOLUTION_DRAFT_FAILED_MARKER}
165
157
 
@@ -191,7 +183,7 @@ const markNotificationPosted = ({ globalState, targetType }) => {
191
183
  };
192
184
 
193
185
  export async function notifyIssueAboutPrePullRequestFailure(options) {
194
- const { code, reason, argv = {}, globalState = globalThis, $, log = async () => {}, getLogFile, shouldAttachLogs = false, attachLogToGitHub, sanitizeLogContent, rawCommand = null, postComment = postTrackedComment } = options;
186
+ const { code, reason, failureActionSection = null, argv = {}, globalState = globalThis, $, log = async () => {}, getLogFile, shouldAttachLogs = false, attachLogToGitHub, sanitizeLogContent, rawCommand = null, postComment = postTrackedComment } = options;
195
187
 
196
188
  const target = resolvePreExitFailureNotificationTarget({ code, globalState });
197
189
  if (!target) {
@@ -200,6 +192,7 @@ export async function notifyIssueAboutPrePullRequestFailure(options) {
200
192
 
201
193
  const { owner, repo, issueNumber, prNumber, targetType, targetNumber } = target;
202
194
  const targetLabel = targetType === 'pr' ? `pull request #${targetNumber}` : `issue #${targetNumber}`;
195
+ const actionSection = failureActionSection || buildPrePullRequestFailureActionSection(reason);
203
196
  globalState.preExitFailureNotificationInProgress = true;
204
197
  if (targetType === 'pr') {
205
198
  globalState.pullRequestFailureNotificationInProgress = true;
@@ -223,7 +216,7 @@ export async function notifyIssueAboutPrePullRequestFailure(options) {
223
216
  sanitizeLogContent,
224
217
  verbose: argv.verbose,
225
218
  errorMessage: `${errorPrefix}\n\nReason: ${reason || 'Unknown error'}`,
226
- failureActionSection: buildPrePullRequestFailureActionSection(reason),
219
+ failureActionSection: actionSection,
227
220
  argv,
228
221
  requestedModel: argv.originalModel || argv.model,
229
222
  tool: argv.tool || 'claude',
@@ -250,6 +243,7 @@ export async function notifyIssueAboutPrePullRequestFailure(options) {
250
243
  argv,
251
244
  rawCommand,
252
245
  logAttachmentAttempted: shouldAttachLogs,
246
+ failureActionSection: actionSection,
253
247
  })
254
248
  : buildPrePullRequestFailureComment({
255
249
  reason,
@@ -259,6 +253,7 @@ export async function notifyIssueAboutPrePullRequestFailure(options) {
259
253
  argv,
260
254
  rawCommand,
261
255
  logAttachmentAttempted: shouldAttachLogs,
256
+ failureActionSection: actionSection,
262
257
  });
263
258
  const posted = await postComment({ $, owner, repo, targetNumber, body });
264
259
  if (posted.ok) {