@link-assistant/hive-mind 2.1.8 → 2.1.11

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,24 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.1.11
4
+
5
+ ### Patch Changes
6
+
7
+ - 208e123: Fix false-positive npm releases (issue #2028). `setup-npm.mjs` now pins npm to the 11.x line and validates the result instead of installing `npm@latest` (which pulled in npm 12.0.0, whose sigstore regression crashes provenance publishes — npm/cli#9722). `publish-to-npm.mjs` no longer trusts the publish command's exit status alone: it observes the real exit code, scans output for failure patterns, and verifies the version is actually live on npm before reporting success, so a failed publish can no longer be reported as a successful release. Added `sanitize-npm-userconfig.mjs` to remove the deprecated `always-auth` npm warning from release logs.
8
+
9
+ ## 2.1.10
10
+
11
+ ### Patch Changes
12
+
13
+ - 9964e69: Retry Claude stream-json sessions with `--resume` when the stream ends without a terminal result event after tool output.
14
+ - afbc353: Avoid fetching `use-m` at isolation-runner import time when tests only use pure helper exports.
15
+
16
+ ## 2.1.9
17
+
18
+ ### Patch Changes
19
+
20
+ - 5559c5e: Check all replacement repository branch tips before deleting a non-fork fork replacement, and report concrete branch-only commits when deletion is unsafe.
21
+
3
22
  ## 2.1.8
4
23
 
5
24
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.1.8",
3
+ "version": "2.1.11",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -1163,6 +1163,11 @@ export const executeClaudeCommand = async params => {
1163
1163
  queuedFeedback,
1164
1164
  };
1165
1165
  }
1166
+ if (shouldFailClaudeStreamWithoutResult({ commandFailed, streamingInput, resultEventReceived })) {
1167
+ commandFailed = true;
1168
+ lastMessage = buildMissingClaudeResultMessage({ lastToolResultError, lastMessage });
1169
+ await log(`\n\n❌ Command failed: ${lastMessage}`, { level: 'error' });
1170
+ }
1166
1171
  const retryableLastError = classifyRetryableError(lastMessage);
1167
1172
  // Issue #1834: Corrupted extended-thinking blocks → try to resume the session first, then fall
1168
1173
  // back to a fresh restart (PR #1835 feedback). When both caps are reached, tryThinkingBlockRecovery
@@ -1291,11 +1296,6 @@ export const executeClaudeCommand = async params => {
1291
1296
  .join('\n');
1292
1297
  await log(`\n\n❌ Command failed: No messages processed and errors detected in stderr\nStderr errors:\n${errorsPreview}`, { level: 'error' });
1293
1298
  }
1294
- if (shouldFailClaudeStreamWithoutResult({ commandFailed, streamingInput, resultEventReceived })) {
1295
- commandFailed = true;
1296
- lastMessage = buildMissingClaudeResultMessage({ lastToolResultError, lastMessage });
1297
- await log(`\n\n❌ Command failed: ${lastMessage}`, { level: 'error' });
1298
- }
1299
1299
  if (commandFailed) {
1300
1300
  // Take resource snapshot after failure
1301
1301
  const resourcesAfter = await getResourceSnapshot();
@@ -20,11 +20,26 @@ import os from 'node:os';
20
20
  import path from 'node:path';
21
21
  import { isExecutingSessionStatus, isTerminalSessionStatus } from './session-status.lib.mjs';
22
22
 
23
- if (typeof use === 'undefined') {
24
- await ensureUseM();
25
- }
23
+ let commandStreamDollarPromise = null;
24
+
25
+ async function getCommandStreamDollar() {
26
+ if (!commandStreamDollarPromise) {
27
+ commandStreamDollarPromise = (async () => {
28
+ if (typeof globalThis.use === 'undefined') {
29
+ await ensureUseM();
30
+ }
31
+ const { $ } = await globalThis.use('command-stream');
32
+ return $;
33
+ })();
34
+ }
26
35
 
27
- const { $ } = await use('command-stream');
36
+ try {
37
+ return await commandStreamDollarPromise;
38
+ } catch (error) {
39
+ commandStreamDollarPromise = null;
40
+ throw error;
41
+ }
42
+ }
28
43
 
29
44
  // Re-export the shared status predicates so existing callers that reach them via
30
45
  // the isolation-runner module (e.g. session-monitor's `runner.isExecutingSessionStatus`)
@@ -516,6 +531,7 @@ export function readSessionExitFromLog(logPath, options = {}) {
516
531
  */
517
532
  async function findStartCommandBinary() {
518
533
  try {
534
+ const $ = await getCommandStreamDollar();
519
535
  const result = await $({ mirror: false })`which $`;
520
536
  const path = result.stdout?.toString().trim() || '';
521
537
  return path || null;
@@ -677,6 +693,7 @@ export async function querySessionStatus(sessionId, verbose = false) {
677
693
  }
678
694
 
679
695
  try {
696
+ const $ = await getCommandStreamDollar();
680
697
  const result = await $({ mirror: false })`${binPath} --status ${sessionId} --output-format json`;
681
698
 
682
699
  const stdout = result.stdout?.toString().trim() || '';
@@ -755,6 +772,7 @@ export async function listIsolationSessions(verbose = false) {
755
772
  return [];
756
773
  }
757
774
  try {
775
+ const $ = await getCommandStreamDollar();
758
776
  const result = await $({ mirror: false })`${binPath} --list --output-format json`;
759
777
  const stdout = result.stdout?.toString().trim() || '';
760
778
  const sessions = parseSessionListOutput(stdout);
@@ -791,6 +809,7 @@ export async function stopIsolatedSession(sessionId, verbose = false) {
791
809
  }
792
810
 
793
811
  try {
812
+ const $ = await getCommandStreamDollar();
794
813
  const result = await $({ mirror: false })`${binPath} --stop ${sessionId}`;
795
814
  const stdout = result.stdout?.toString() || '';
796
815
  const stderr = result.stderr?.toString() || '';
@@ -827,6 +846,7 @@ export async function stopIsolatedSession(sessionId, verbose = false) {
827
846
  */
828
847
  export async function checkScreenSessionRunning(sessionName, verbose = false) {
829
848
  try {
849
+ const $ = await getCommandStreamDollar();
830
850
  const result = await $({ mirror: false })`screen -ls`;
831
851
  const output = result.stdout?.toString() || '';
832
852
  const exists = output.includes(sessionName);
@@ -857,6 +877,7 @@ export async function checkScreenSessionRunning(sessionName, verbose = false) {
857
877
  */
858
878
  export async function checkDockerContainerRunning(containerName, verbose = false) {
859
879
  try {
880
+ const $ = await getCommandStreamDollar();
860
881
  const result = await $({ mirror: false })`docker inspect -f ${'{{.State.Running}}'} ${containerName}`;
861
882
  const running = (result.stdout?.toString() || '').trim() === 'true';
862
883
  if (verbose) {
@@ -890,6 +911,7 @@ export function parseDockerContainerWritableLayerSizeOutput(output) {
890
911
  export async function getDockerContainerWritableLayerSize(containerName, verbose = false) {
891
912
  if (!containerName) return null;
892
913
  try {
914
+ const $ = await getCommandStreamDollar();
893
915
  const result = await $({ mirror: false })`docker inspect --size -f ${'{{.SizeRw}}'} ${containerName}`;
894
916
  const bytes = parseDockerContainerWritableLayerSizeOutput(result.stdout?.toString() || '');
895
917
  if (verbose) {
@@ -922,6 +944,7 @@ export async function releaseDockerContainerStartGate(containerName, verbose = f
922
944
 
923
945
  for (let attempt = 1; attempt <= 5; attempt++) {
924
946
  try {
947
+ const $ = await getCommandStreamDollar();
925
948
  await $({ mirror: false })`docker exec ${containerName} sh -c ${releaseCommand}`;
926
949
  if (verbose) {
927
950
  console.log(`[VERBOSE] isolation-runner: released docker start gate for '${containerName}'`);
@@ -960,6 +983,7 @@ export async function removeDockerContainer(containerName, verbose = false) {
960
983
  }
961
984
 
962
985
  try {
986
+ const $ = await getCommandStreamDollar();
963
987
  const result = await $({ mirror: false })`docker rm -f ${containerName}`;
964
988
  const stdout = result.stdout?.toString() || '';
965
989
  const stderr = result.stderr?.toString() || '';
@@ -992,6 +1016,7 @@ export async function removeDockerContainer(containerName, verbose = false) {
992
1016
  */
993
1017
  export async function checkTmuxSessionRunning(sessionName, verbose = false) {
994
1018
  try {
1019
+ const $ = await getCommandStreamDollar();
995
1020
  await $({ mirror: false })`tmux has-session -t ${sessionName}`;
996
1021
  if (verbose) console.log(`[VERBOSE] isolation-runner: tmux has-session '${sessionName}': running`);
997
1022
  return true;
@@ -1035,6 +1060,7 @@ export async function checkBackendSessionAlive(sessionId, backend, verbose = fal
1035
1060
  */
1036
1061
  export async function checkDockerImagePresent(image, verbose = false) {
1037
1062
  try {
1063
+ const $ = await getCommandStreamDollar();
1038
1064
  await $({ mirror: false })`docker image inspect ${image}`;
1039
1065
  if (verbose) console.log(`[VERBOSE] isolation-runner: docker image inspect '${image}': present`);
1040
1066
  return true;
@@ -1062,6 +1088,7 @@ export async function checkDockerImagePresent(image, verbose = false) {
1062
1088
  */
1063
1089
  export async function checkDockerStorageDriver(verbose = false) {
1064
1090
  try {
1091
+ const $ = await getCommandStreamDollar();
1065
1092
  const result = await $({ mirror: false })`docker info --format ${'{{.Driver}}'}`;
1066
1093
  const driver = (result.stdout?.toString() || '').trim().toLowerCase() || null;
1067
1094
  if (verbose) console.log(`[VERBOSE] isolation-runner: docker storage driver: ${driver || '(unknown)'}`);
@@ -1091,6 +1118,7 @@ export async function checkDockerDiskSpace(verbose = false) {
1091
1118
  try {
1092
1119
  let dataRoot = '/var/lib/docker';
1093
1120
  try {
1121
+ const $ = await getCommandStreamDollar();
1094
1122
  const info = await $({ mirror: false })`docker info --format ${'{{.DockerRootDir}}'}`;
1095
1123
  const root = (info.stdout?.toString() || '').trim();
1096
1124
  if (root) dataRoot = root;
@@ -1099,6 +1127,7 @@ export async function checkDockerDiskSpace(verbose = false) {
1099
1127
  // fails on it (e.g. the path does not exist) we return null below.
1100
1128
  }
1101
1129
 
1130
+ const $ = await getCommandStreamDollar();
1102
1131
  const df = await $({ mirror: false })`df -Pk ${dataRoot}`;
1103
1132
  // `df -P` guarantees one logical line per filesystem (no wrapping). The last
1104
1133
  // line is the data row: Filesystem 1024-blocks Used Available Capacity Mount
@@ -1,5 +1,5 @@
1
1
  import { getTrackedToolCommentIds, postTrackedComment, SOLUTION_DRAFT_FAILED_MARKER } from './tool-comments.lib.mjs';
2
- import { extractForkReplacementBlockedDetails, isForkReplacementBlockedReason } from './solve.repository-recovery-message.lib.mjs';
2
+ import { extractForkReplacementBlockedDetails, isForkReplacementBlockedReason, GITHUB_FORK_SUPPORT_URL } from './solve.repository-recovery-message.lib.mjs';
3
3
  import { FORK_DIVERGENCE_RESOLUTION_OPTION, buildForkDivergenceFailureActionSection } from './solve.branch-divergence.lib.mjs';
4
4
 
5
5
  export { FORK_DIVERGENCE_RESOLUTION_OPTION };
@@ -41,6 +41,7 @@ export function buildPrePullRequestFailureActionSection(reason = '') {
41
41
  - It could not prove whether the existing repository has unique commits, so it did not delete it automatically.
42
42
 
43
43
  ### What you can do
44
+ - Recover the fork without deleting: if \`${repository}\` was a fork of \`${upstream}\` that GitHub detached (for example after a private/public visibility change), ask GitHub Support at ${GITHUB_FORK_SUPPORT_URL} ("Attach, detach or reroute forks") to re-attach it. Detachment from a visibility change is documented as permanent, so this is not guaranteed, and while detached the repository cannot open a cross-repository pull request to \`${upstream}\`.
44
45
  - Repository owner path: back up any needed work, then delete, rename, archive, or repair \`${repository}\` in GitHub and rerun the solver.
45
46
  - External-owner path: ask the repository owner or a Hive Mind administrator to clean it up and rerun the solver.
46
47
  - Use \`--allow-force-non-fork-repository-deletion\` only after confirming that deleting \`${repository}\` is acceptable and any unique commits can be lost.`;
@@ -26,27 +26,47 @@ export function buildForkReplacementSafetyCheckDescription({ aheadBy = null, com
26
26
  return 'Hive Mind could not prove the repository has no unique commits.';
27
27
  }
28
28
 
29
- export function buildForkReplacementBlockedReason({ existingRepository, expectedUpstream, relationshipDescription, safetyCheckDescription } = {}) {
29
+ // GitHub Support's self-service fork request workflow. See
30
+ // https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/detaching-a-fork
31
+ export const GITHUB_FORK_SUPPORT_URL = 'https://support.github.com/request/fork';
32
+
33
+ /**
34
+ * Explain how to recover the GitHub fork relationship without deleting the
35
+ * existing repository. GitHub detaches a fork from its upstream network when the
36
+ * repository's visibility is toggled (for example private -> public), and that
37
+ * detachment is documented as permanent with no API/self-service reattachment.
38
+ * The only non-deletion path is a GitHub Support request. See issue #2019.
39
+ */
40
+ export function buildDetachedForkRecoveryGuidance({ existingRepository, expectedUpstream } = {}) {
41
+ const repository = fallback(existingRepository, 'the existing repository');
42
+ const upstream = fallback(expectedUpstream, 'the expected upstream repository');
43
+
44
+ return `Recover the fork link WITHOUT deleting ${repository}: if ${repository} was once a GitHub fork of ${upstream} that got detached (for example after its visibility was switched to private and then back to public), the only path that keeps the repository is a GitHub Support request. Open ${GITHUB_FORK_SUPPORT_URL}, choose the "Attach, detach or reroute forks" flow, and ask to re-attach ${repository} to ${upstream}. GitHub documents visibility-change detachment as permanent, so reattachment is not guaranteed. While detached, ${repository} cannot open a cross-repository pull request to ${upstream}, so the solver needs either a reattached fork or a fresh fork to continue.`;
45
+ }
46
+
47
+ export function buildForkReplacementBlockedReason({ existingRepository, expectedUpstream, relationshipDescription, safetyCheckDescription, likelyDetachedFork = false } = {}) {
30
48
  const repository = fallback(existingRepository, 'the existing repository');
31
49
  const upstream = fallback(expectedUpstream, 'the expected upstream repository');
32
50
  const relationship = fallback(relationshipDescription, `${repository} is not a confirmed GitHub fork of ${upstream}.`);
33
51
  const safetyCheck = fallback(safetyCheckDescription, buildForkReplacementSafetyCheckDescription());
52
+ const detachedForkNote = likelyDetachedFork ? `\n- Likely cause: ${repository} shares history with ${upstream} but GitHub reports it as a non-fork, which matches a fork that was detached from its network (commonly after a private/public visibility change).` : '';
34
53
 
35
54
  return `${FORK_REPLACEMENT_BLOCKED_REASON_PREFIX}
36
55
 
37
56
  What happened:
38
57
  - Expected fork or replacement repository: ${repository}
39
58
  - Expected upstream: ${upstream}
40
- - Actual state: ${relationship}
59
+ - Actual state: ${relationship}${detachedForkNote}
41
60
  - Safety check: ${safetyCheck}
42
61
 
43
62
  Why it stopped:
44
63
  Hive Mind did not delete ${repository} because the repository may contain commits that would be lost.
45
64
 
46
65
  Options:
47
- 1. Back up any needed work in ${repository}, then delete, rename, archive, or repair that repository in GitHub and rerun the solver.
48
- 2. If you do not control ${repository}, ask the repository owner or a Hive Mind administrator to clean it up and rerun the solver.
49
- 3. Rerun with --allow-force-non-fork-repository-deletion only after confirming that deleting ${repository} is acceptable.`;
66
+ 1. ${buildDetachedForkRecoveryGuidance({ existingRepository: repository, expectedUpstream: upstream })}
67
+ 2. Back up any needed work in ${repository}, then delete, rename, archive, or repair that repository in GitHub and rerun the solver.
68
+ 3. If you do not control ${repository}, ask the repository owner or a Hive Mind administrator to clean it up and rerun the solver.
69
+ 4. Rerun with --allow-force-non-fork-repository-deletion only after confirming that deleting ${repository} is acceptable.`;
50
70
  }
51
71
 
52
72
  export function isForkReplacementBlockedReason(reason = '') {
@@ -0,0 +1,163 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ import { summarizeGitHubCompareFailure } from './solve.repository-recovery-message.lib.mjs';
6
+
7
+ const resultText = result => `${result?.stderr?.toString?.() || ''}${result?.stdout?.toString?.() || ''}`.trim();
8
+ const resultStdout = result => result?.stdout?.toString?.().trim() || '';
9
+ const shortSha = sha => String(sha || '').slice(0, 12);
10
+
11
+ const branchLabel = branch => {
12
+ const subject = branch.subject ? ` ${branch.subject}` : '';
13
+ return `${branch.ref} (${branch.uniqueCommitCount} commit(s), ${shortSha(branch.sha)}${subject})`;
14
+ };
15
+
16
+ export function buildReplacementBranchSafetyDescription({ uniqueBranches = [], branchCount = 0, failureOutput = '' } = {}) {
17
+ if (failureOutput) {
18
+ return `Local Git branch reachability check failed (${summarizeGitHubCompareFailure(failureOutput)}), so Hive Mind could not prove all replacement repository branches are preserved upstream.`;
19
+ }
20
+
21
+ if (uniqueBranches.length > 0) {
22
+ const examples = uniqueBranches.slice(0, 3).map(branchLabel).join('; ');
23
+ const remaining = uniqueBranches.length > 3 ? `; and ${uniqueBranches.length - 3} more` : '';
24
+ return `Local Git branch reachability found ${uniqueBranches.length} replacement branch tip(s) with commits not reachable from upstream branches or PR refs: ${examples}${remaining}.`;
25
+ }
26
+
27
+ return `Local Git branch reachability found all ${branchCount} replacement branch tip(s) reachable from upstream branches or PR refs.`;
28
+ }
29
+
30
+ async function runStep(commandPromise, failurePrefix) {
31
+ const result = await commandPromise;
32
+ if (result.code !== 0) {
33
+ return {
34
+ ok: false,
35
+ output: `${failurePrefix}: ${resultText(result) || `exit ${result.code}`}`,
36
+ };
37
+ }
38
+ return { ok: true, result };
39
+ }
40
+
41
+ const parseReplacementRefs = output =>
42
+ String(output || '')
43
+ .split('\n')
44
+ .map(line => line.trim())
45
+ .filter(Boolean)
46
+ .map(line => {
47
+ const [ref, sha] = line.split(/\s+/, 2);
48
+ return { ref, sha };
49
+ })
50
+ .filter(item => item.ref && item.sha);
51
+
52
+ /**
53
+ * Verify that deleting a non-fork replacement repository will not remove branch
54
+ * tips that are absent from the upstream repository. This is intentionally used
55
+ * only in the rare fork-replacement recovery path.
56
+ */
57
+ export async function checkReplacementRepositoryBranchSafety({ $, owner, repo, existingRepository, tempRoot = os.tmpdir() }) {
58
+ const tempDir = await fs.mkdtemp(path.join(tempRoot, 'hive-mind-fork-replacement-'));
59
+
60
+ try {
61
+ const init = await runStep($({ cwd: tempDir })`git init -q 2>&1`, 'git init failed');
62
+ if (!init.ok) {
63
+ return {
64
+ safeToDelete: false,
65
+ branchCount: 0,
66
+ uniqueBranches: [],
67
+ safetyCheckDescription: buildReplacementBranchSafetyDescription({ failureOutput: init.output }),
68
+ };
69
+ }
70
+
71
+ const upstreamUrl = `https://github.com/${owner}/${repo}.git`;
72
+ const replacementUrl = `https://github.com/${existingRepository}.git`;
73
+
74
+ const upstreamFetch = await runStep($({ cwd: tempDir })`git fetch --filter=blob:none ${upstreamUrl} '+refs/heads/*:refs/remotes/upstream/*' '+refs/pull/*/head:refs/remotes/upstream-pr/*' 2>&1`, 'upstream fetch failed');
75
+ if (!upstreamFetch.ok) {
76
+ return {
77
+ safeToDelete: false,
78
+ branchCount: 0,
79
+ uniqueBranches: [],
80
+ safetyCheckDescription: buildReplacementBranchSafetyDescription({ failureOutput: upstreamFetch.output }),
81
+ };
82
+ }
83
+
84
+ const replacementFetch = await runStep($({ cwd: tempDir })`git fetch --filter=blob:none ${replacementUrl} '+refs/heads/*:refs/remotes/replacement/*' 2>&1`, 'replacement fetch failed');
85
+ if (!replacementFetch.ok) {
86
+ return {
87
+ safeToDelete: false,
88
+ branchCount: 0,
89
+ uniqueBranches: [],
90
+ safetyCheckDescription: buildReplacementBranchSafetyDescription({ failureOutput: replacementFetch.output }),
91
+ };
92
+ }
93
+
94
+ const refsResult = await runStep($({ cwd: tempDir })`git for-each-ref --format='%(refname:short) %(objectname)' refs/remotes/replacement 2>&1`, 'replacement ref listing failed');
95
+ if (!refsResult.ok) {
96
+ return {
97
+ safeToDelete: false,
98
+ branchCount: 0,
99
+ uniqueBranches: [],
100
+ safetyCheckDescription: buildReplacementBranchSafetyDescription({ failureOutput: refsResult.output }),
101
+ };
102
+ }
103
+
104
+ const replacementRefs = parseReplacementRefs(resultStdout(refsResult.result));
105
+ const uniqueBranches = [];
106
+
107
+ for (const { ref, sha } of replacementRefs) {
108
+ const countResult = await runStep($({ cwd: tempDir })`git rev-list --count ${ref} --not --remotes=upstream --remotes=upstream-pr 2>&1`, `unique commit count failed for ${ref}`);
109
+ if (!countResult.ok) {
110
+ return {
111
+ safeToDelete: false,
112
+ branchCount: replacementRefs.length,
113
+ uniqueBranches,
114
+ safetyCheckDescription: buildReplacementBranchSafetyDescription({ failureOutput: countResult.output }),
115
+ };
116
+ }
117
+
118
+ const uniqueCommitCount = Number.parseInt(resultStdout(countResult.result), 10);
119
+ if (!Number.isFinite(uniqueCommitCount)) {
120
+ return {
121
+ safeToDelete: false,
122
+ branchCount: replacementRefs.length,
123
+ uniqueBranches,
124
+ safetyCheckDescription: buildReplacementBranchSafetyDescription({ failureOutput: `invalid unique commit count for ${ref}: ${resultStdout(countResult.result)}` }),
125
+ };
126
+ }
127
+
128
+ if (uniqueCommitCount > 0) {
129
+ const subjectResult = await $({ cwd: tempDir })`git log -1 --format=%s ${ref} 2>&1`;
130
+ uniqueBranches.push({
131
+ ref: ref.replace(/^replacement\//, ''),
132
+ sha,
133
+ uniqueCommitCount,
134
+ subject: subjectResult.code === 0 ? resultStdout(subjectResult) : '',
135
+ });
136
+ }
137
+ }
138
+
139
+ const reachableBranchCount = replacementRefs.length - uniqueBranches.length;
140
+
141
+ return {
142
+ safeToDelete: uniqueBranches.length === 0,
143
+ branchCount: replacementRefs.length,
144
+ reachableBranchCount,
145
+ // At least one branch tip is fully reachable from upstream refs, so the
146
+ // replacement shares history with upstream. Combined with GitHub reporting
147
+ // it as a non-fork, that matches a fork detached from its network (see
148
+ // issue #2019), typically after a private/public visibility change.
149
+ likelyDetachedFork: replacementRefs.length > 0 && reachableBranchCount > 0,
150
+ uniqueBranches,
151
+ safetyCheckDescription: buildReplacementBranchSafetyDescription({ uniqueBranches, branchCount: replacementRefs.length }),
152
+ };
153
+ } catch (error) {
154
+ return {
155
+ safeToDelete: false,
156
+ branchCount: 0,
157
+ uniqueBranches: [],
158
+ safetyCheckDescription: buildReplacementBranchSafetyDescription({ failureOutput: error.message }),
159
+ };
160
+ } finally {
161
+ await fs.rm(tempDir, { recursive: true, force: true });
162
+ }
163
+ }
@@ -30,6 +30,7 @@ const { log, formatAligned } = lib;
30
30
  // Import exit handler
31
31
  import { safeExit } from './exit-handler.lib.mjs';
32
32
  import { parseForkFullNameFromGhOutput } from './github-repository-names.lib.mjs';
33
+ import { checkReplacementRepositoryBranchSafety } from './solve.repository-safety.lib.mjs';
33
34
  import { buildForkReplacementBlockedReason, buildForkReplacementSafetyCheckDescription } from './solve.repository-recovery-message.lib.mjs';
34
35
 
35
36
  // Import GitHub utilities for permission checks
@@ -525,34 +526,75 @@ export const setupRepository = async (argv, owner, repo, forkOwner = null, issue
525
526
  const relationshipDescription = !forkValidation.isFork ? `${existingForkName} is not a GitHub fork of ${owner}/${repo}.` : `${existingForkName} is a GitHub fork of ${forkValidation.parent || 'unknown parent'}, not ${owner}/${repo}.`;
526
527
  await log(`${formatAligned('', '', detail)}`);
527
528
  await log(`${formatAligned('', '', `Fork parent: ${forkValidation.parent || 'N/A (not a fork)'}, source: ${forkValidation.source || 'N/A'}, expected: ${owner}/${repo}`)}`);
528
- // Safety check: compare commits before deleting to avoid data loss
529
- await log(`${formatAligned('🔍', 'Safety check:', 'Comparing commits against upstream...')}`);
529
+ // Safety check: compare commits before deleting to avoid data loss.
530
+ // GitHub compare only answers for fork-network refs, so a local Git
531
+ // fallback checks every replacement branch tip before deletion.
532
+ await log(`${formatAligned('🔍', 'Safety check:', 'Comparing default branch against upstream...')}`);
530
533
  let safeToDelete = false;
531
534
  let safetyCheckDescription = buildForkReplacementSafetyCheckDescription();
535
+ let shouldRunBranchReachabilityCheck = false;
536
+ let likelyDetachedFork = false;
532
537
  try {
533
538
  const cmp = await $`gh api repos/${owner}/${repo}/compare/${owner}:HEAD...${existingForkName.split('/')[0]}:HEAD --paginate --jq '.ahead_by' 2>&1`;
534
539
  const aheadBy = parseInt(cmp.stdout.toString().trim(), 10);
535
540
  if (cmp.code === 0 && aheadBy === 0) {
536
- await log(`${formatAligned('✅', 'Safe to delete:', 'No additional commits in non-fork repository')}`);
537
- safeToDelete = true;
541
+ await log(`${formatAligned('✅', 'Default branch:', 'No additional commits in replacement repository')}`);
542
+ shouldRunBranchReachabilityCheck = true;
538
543
  } else if (cmp.code === 0) {
539
544
  safetyCheckDescription = buildForkReplacementSafetyCheckDescription({ aheadBy });
540
- const aheadDescription = Number.isFinite(aheadBy) ? `${aheadBy} commit(s)` : 'an unknown number of commit(s)';
541
- await log(`${formatAligned('⚠️', 'UNSAFE:', `Repository has ${aheadDescription} ahead of upstream that would be lost`)}`, { level: 'warning' });
545
+ if (Number.isFinite(aheadBy)) {
546
+ await log(`${formatAligned('⚠️', 'UNSAFE:', `Default branch has ${aheadBy} commit(s) ahead of upstream that would be lost`)}`, { level: 'warning' });
547
+ } else {
548
+ await log(`${formatAligned('⚠️', 'Compare unclear:', 'GitHub compare did not return a numeric ahead_by value')}`, { level: 'warning' });
549
+ shouldRunBranchReachabilityCheck = true;
550
+ }
542
551
  } else {
543
552
  const compareOutput = (cmp.stderr?.toString() || '') + (cmp.stdout?.toString() || '');
544
553
  safetyCheckDescription = buildForkReplacementSafetyCheckDescription({ compareFailureOutput: compareOutput });
545
554
  await log(`${formatAligned('⚠️', 'Compare failed:', compareOutput.split('\n')[0])}`, { level: 'warning' });
555
+ shouldRunBranchReachabilityCheck = true;
546
556
  }
547
557
  } catch (e) {
548
558
  safetyCheckDescription = buildForkReplacementSafetyCheckDescription({ compareFailureOutput: e.message });
549
559
  await log(`${formatAligned('⚠️', 'Compare error:', e.message)}`, { level: 'warning' });
560
+ shouldRunBranchReachabilityCheck = true;
561
+ }
562
+
563
+ if (shouldRunBranchReachabilityCheck) {
564
+ await log(`${formatAligned('🔍', 'Safety check:', 'Checking all replacement branches against upstream refs...')}`);
565
+ const branchSafety = await checkReplacementRepositoryBranchSafety({
566
+ $,
567
+ owner,
568
+ repo,
569
+ existingRepository: existingForkName,
570
+ });
571
+ safetyCheckDescription = branchSafety.safetyCheckDescription;
572
+ likelyDetachedFork = Boolean(branchSafety.likelyDetachedFork);
573
+
574
+ if (likelyDetachedFork) {
575
+ 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`)}`);
576
+ }
577
+
578
+ if (branchSafety.safeToDelete) {
579
+ await log(`${formatAligned('✅', 'Safe to delete:', `All ${branchSafety.branchCount} replacement branch tip(s) are reachable from upstream`)}`);
580
+ safeToDelete = true;
581
+ } else if (branchSafety.uniqueBranches.length > 0) {
582
+ await log(`${formatAligned('⚠️', 'UNSAFE:', `${branchSafety.uniqueBranches.length} replacement branch tip(s) have commits not reachable from upstream`)}`, { level: 'warning' });
583
+ for (const branch of branchSafety.uniqueBranches.slice(0, 3)) {
584
+ await log(`${formatAligned('', '', `${branch.ref}: ${branch.uniqueCommitCount} commit(s), ${branch.sha.slice(0, 12)} ${branch.subject || ''}`)}`, { level: 'warning' });
585
+ }
586
+ } else {
587
+ await log(`${formatAligned('⚠️', 'Branch check failed:', safetyCheckDescription)}`, { level: 'warning' });
588
+ }
550
589
  }
551
590
  if (!safeToDelete) {
552
591
  if (argv.allowForceNonForkRepositoryDeletion) {
553
592
  // Force flag set — proceed with deletion despite the failed safety check.
554
593
  await log(`${formatAligned('⚠️', 'Force deletion ENABLED:', '--allow-force-non-fork-repository-deletion — proceeding despite potential data loss')}`, { level: 'warning' });
555
594
  } else {
595
+ if (likelyDetachedFork) {
596
+ await log(` 🔗 Recover without deletion: ask GitHub Support to re-attach ${existingForkName} to ${owner}/${repo} at https://support.github.com/request/fork ("Attach, detach or reroute forks")`);
597
+ }
556
598
  await log(` 💡 Manual fix required: back up work, then: gh repo delete ${existingForkName} --yes`);
557
599
  await log(` Then run this command again to create a proper fork of ${owner}/${repo}`);
558
600
  await log(` 🔧 Or force deletion (DANGEROUS): solve ${argv.url || argv['issue-url'] || argv._[0] || '<issue-url>'} --allow-force-non-fork-repository-deletion`);
@@ -563,6 +605,7 @@ export const setupRepository = async (argv, owner, repo, forkOwner = null, issue
563
605
  expectedUpstream: `${owner}/${repo}`,
564
606
  relationshipDescription,
565
607
  safetyCheckDescription,
608
+ likelyDetachedFork,
566
609
  })
567
610
  );
568
611
  }
@@ -59,6 +59,13 @@ export const classifyRetryableError = value => {
59
59
  return { message, isRetryable: true, isCapacity: false, label: 'Stream disconnected before completion' };
60
60
  }
61
61
 
62
+ // Issue #2023: Claude Code can close a stream-json process after tool output
63
+ // (for example "Exit code 144") without emitting the terminal result event.
64
+ // The session is still resumable; failing immediately loses in-progress work.
65
+ if (lower.includes('claude stream ended without a terminal result event')) {
66
+ return { message, isRetryable: true, isCapacity: false, label: 'Claude stream ended without terminal result' };
67
+ }
68
+
62
69
  // Issue #1937: Stream idle timeout. When the Anthropic streaming response stalls
63
70
  // (no bytes for the SDK's idle window) after the model has already emitted part of
64
71
  // its answer, the Claude CLI aborts the turn and surfaces a synthetic assistant /