@link-assistant/hive-mind 1.30.5 → 1.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,78 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.31.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 5108367: fix: fix root causes of 20-32h process hang after session ends (Issue #1335)
8
+
9
+ Two separate bugs caused `solve` processes to run for 20–32 hours after work was complete:
10
+
11
+ **Bug A — Infinite loop for repos without CI:** When `--auto-restart-until-mergeable` is used
12
+ on a repository with no CI/CD workflows, the `watchUntilMergeable` loop was permanently stuck
13
+ on "CI/CD checks have not started yet" with no exit condition. The root cause was that the code
14
+ treated `no_checks` identically for both transient race conditions (CI hasn't started yet after
15
+ a push) and permanent states (repo has no CI at all). Fixed by checking whether the repository
16
+ actually has GitHub Actions workflows configured (`hasRepoWorkflows()`). If none exist, the
17
+ `no_checks` state is permanent and the monitor exits immediately, treating the PR as CI-passing.
18
+ If workflows exist, the state is a transient race condition and the loop keeps waiting.
19
+
20
+ **Bug B — No process exit after session ends:** After a successful run (PR became mergeable,
21
+ work session ended), `solve.mjs` never called `process.exit()`. Sentry's profiling integration
22
+ (`@sentry/profiling-node`) kept the Node.js event loop alive indefinitely. Fixed by calling
23
+ `safeExit(0)` at the end of the `finally` block in `solve.mjs`, which flushes Sentry events
24
+ (up to 2 seconds) and then calls `process.exit(0)`.
25
+
26
+ Also adds `--verbose` debug logging of active Node.js handles at exit to aid diagnosis of
27
+ future occurrences.
28
+
29
+ ## 1.31.0
30
+
31
+ ### Minor Changes
32
+
33
+ - feat: add --finalize option (Issue #1383)
34
+
35
+ Adds new experimental CLI options to the `solve` command:
36
+ - `--finalize [N]`: After the main solve completes, automatically restarts the AI tool N times (default: 1 when used as a flag) with a requirements-check prompt to verify all requirements are met. Uses the same model as `--model` by default.
37
+ - `--finalize-model`: Override the model used during `--finalize` iterations (defaults to `--model`).
38
+ - `--prompt-ensure-all-requirements-are-met`: Adds a system prompt hint in the "Self review" section instructing the AI to ensure all changes are correct, consistent, validated, tested, logged and fully meet all discussed requirements. Enabled automatically during `--finalize` iterations only (not the first regular run).
39
+
40
+ This forces the AI tool to double-check itself after the main solve, verifying changes meet all requirements from the issue description and PR comments, and that CI/CD checks pass.
41
+
42
+ feat: auto-commit uncommitted changes and upload log on CTRL+C interrupt (Issue #1351)
43
+
44
+ Previously, when a user pressed CTRL+C to interrupt a running solve session, uncommitted changes were silently lost (or left uncommitted) and log files were not uploaded to the PR/issue even when `--attach-logs` was enabled. Additionally, the terminal showed "Claude command completed" instead of "Claude command interrupted".
45
+
46
+ Now on CTRL+C:
47
+ 1. **Auto-commit**: Any uncommitted changes in the working directory are automatically committed and pushed to the branch before cleanup occurs.
48
+ 2. **Log upload**: If `--attach-logs` is enabled, the log file is automatically uploaded to the GitHub PR/issue as a comment.
49
+ 3. **Accurate message**: The terminal now correctly shows "Claude command interrupted" instead of "Claude command completed" when the process exits with code 130 (SIGINT).
50
+
51
+ Changes made:
52
+ - `src/exit-handler.lib.mjs`: Added optional `interrupt` parameter to `initializeExitHandler()`; SIGINT handler now calls it before cleanup, guarded against double invocation
53
+ - `src/solve.mjs`: Extended `cleanupContext` with branch/PR/owner/repo fields; new `interruptWrapper` auto-commits and uploads logs on CTRL+C
54
+ - `src/claude.lib.mjs`, `src/opencode.lib.mjs`, `src/codex.lib.mjs`, `src/agent.lib.mjs`: Detect exit code 130 and print "interrupted" instead of "completed"
55
+
56
+ Full case study analysis including timeline reconstruction, root cause analysis, and implementation details in `docs/case-studies/issue-1351/`.
57
+
58
+ fix: prevent false positive ready tag sync by using issue timeline API (Issue #1413)
59
+
60
+ Previously, `syncReadyTags()` used a GitHub full-text body search to find PRs linked to an issue:
61
+
62
+ ```js
63
+ gh pr list --search "in:body closes #1411 OR fixes #1411 OR resolves #1411"
64
+ ```
65
+
66
+ This caused a false positive: PR #843 matched because `1411` appeared as a source code line reference inside its body, not as a genuine issue-closing keyword.
67
+
68
+ Now uses the GitHub issue timeline API (`GET /repos/{owner}/{repo}/issues/{issue_number}/timeline`) to find PRs with genuine `cross-referenced` events, which is the same data GitHub uses to auto-close issues when PRs are merged.
69
+
70
+ fix: hide cancel button and show cancelling state on /merge cancel (Issue #1407)
71
+
72
+ When user clicked the "🛑 Cancel" button during `/merge` queue processing, the cancel button remained visible in the Telegram message until the current PR finished processing (potentially hours if waiting for CI). The toast message "The current PR will finish processing" was also confusing.
73
+
74
+ The fix immediately hides the cancel button by editing the message without `reply_markup`, shows a "🛑 Cancelling..." indicator in the progress message when cancellation is requested, and adds `isCancelled` support to `waitForCI()` for early exit when the operation is cancelled.
75
+
3
76
  ## 1.30.5
4
77
 
5
78
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.30.5",
3
+ "version": "1.31.1",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
package/src/agent.lib.mjs CHANGED
@@ -911,6 +911,9 @@ export const executeAgentCommand = async params => {
911
911
  // Explicit JSON error message from agent (Issue #1201: includes streaming-detected errors)
912
912
  errorInfo.message = `Agent reported error: ${outputError.match}`;
913
913
  await log(`\n\n❌ ${errorInfo.message}`, { level: 'error' });
914
+ } else if (exitCode === 130) {
915
+ errorInfo.message = 'Agent command interrupted (CTRL+C)';
916
+ await log('\n\n⚠️ Agent command interrupted (CTRL+C)');
914
917
  } else {
915
918
  errorInfo.message = `Agent command failed with exit code ${exitCode}`;
916
919
  await log(`\n\n❌ ${errorInfo.message}`, { level: 'error' });
@@ -212,7 +212,12 @@ Self review.
212
212
  - When you check your solution draft, run all tests locally.
213
213
  - When you check your solution draft, verify git status shows a clean working tree with no uncommitted changes.
214
214
  - When you compare with repo style, use gh pr diff [number].
215
- - When you finalize, confirm code, tests, and description are consistent.
215
+ - When you finalize, confirm code, tests, and description are consistent.${
216
+ argv && argv.promptEnsureAllRequirementsAreMet
217
+ ? `
218
+ - When no explicit feedback or requirements is provided, ensure all changes are correct, consistent, validated, tested, logged and fully meet all discussed requirements (check issue description and all comments in issue and in pull request). Ensure all CI/CD checks pass.`
219
+ : ''
220
+ }
216
221
 
217
222
  GitHub CLI command patterns.
218
223
  - IMPORTANT: Always use --paginate flag when fetching lists from GitHub API to ensure all results are returned (GitHub returns max 30 per page by default).
@@ -1295,7 +1295,10 @@ export const executeClaudeCommand = async params => {
1295
1295
  }
1296
1296
  // Issue #1088: If error_during_execution occurred but command didn't fail,
1297
1297
  // log it as "Finished with errors" instead of pure success
1298
- if (errorDuringExecution) {
1298
+ // Issue #1351: Distinguish interrupted sessions (exit code 130) from normal completion
1299
+ if (exitCode === 130) {
1300
+ await log('\n\n⚠️ Claude command interrupted (CTRL+C)');
1301
+ } else if (errorDuringExecution) {
1299
1302
  await log('\n\n⚠️ Claude command finished with errors');
1300
1303
  } else {
1301
1304
  await log('\n\n✅ Claude command completed');
@@ -254,7 +254,12 @@ Self review.
254
254
  - When you check your solution draft, run all tests locally.
255
255
  - When you check your solution draft, verify git status shows a clean working tree with no uncommitted changes.
256
256
  - When you compare with repo style, use gh pr diff [number].
257
- - When you finalize, confirm code, tests, and description are consistent.
257
+ - When you finalize, confirm code, tests, and description are consistent.${
258
+ argv && argv.promptEnsureAllRequirementsAreMet
259
+ ? `
260
+ - When no explicit feedback or requirements is provided, ensure all changes are correct, consistent, validated, tested, logged and fully meet all discussed requirements (check issue description and all comments in issue and in pull request). Ensure all CI/CD checks pass.`
261
+ : ''
262
+ }
258
263
 
259
264
  GitHub CLI command patterns.
260
265
  - IMPORTANT: Always use --paginate flag when fetching lists from GitHub API to ensure all results are returned (GitHub returns max 30 per page by default).
package/src/codex.lib.mjs CHANGED
@@ -398,6 +398,8 @@ export const executeCodexCommand = async params => {
398
398
  for (const line of messageLines) {
399
399
  await log(line, { level: 'warning' });
400
400
  }
401
+ } else if (exitCode === 130) {
402
+ await log('\n\n⚠️ Codex command interrupted (CTRL+C)');
401
403
  } else {
402
404
  await log(`\n\n❌ Codex command failed with exit code ${exitCode}`, { level: 'error' });
403
405
  }
@@ -220,7 +220,12 @@ Self review.
220
220
  - When you check your solution draft, run all tests locally.
221
221
  - When you check your solution draft, verify git status shows a clean working tree with no uncommitted changes.
222
222
  - When you compare with repo style, use gh pr diff [number].
223
- - When you finalize, confirm code, tests, and description are consistent.
223
+ - When you finalize, confirm code, tests, and description are consistent.${
224
+ argv && argv.promptEnsureAllRequirementsAreMet
225
+ ? `
226
+ - When no explicit feedback or requirements is provided, ensure all changes are correct, consistent, validated, tested, logged and fully meet all discussed requirements (check issue description and all comments in issue and in pull request). Ensure all CI/CD checks pass.`
227
+ : ''
228
+ }
224
229
 
225
230
  GitHub CLI command patterns.
226
231
  - IMPORTANT: Always use --paginate flag when fetching lists from GitHub API to ensure all results are returned (GitHub returns max 30 per page by default).
@@ -25,17 +25,22 @@ let exitMessageShown = false;
25
25
  let getLogPathFunction = null;
26
26
  let logFunction = null;
27
27
  let cleanupFunction = null;
28
+ let interruptFunction = null;
29
+ let interruptHandlerRan = false;
28
30
 
29
31
  /**
30
32
  * Initialize the exit handler with required dependencies
31
33
  * @param {Function} getLogPath - Function that returns the current log path
32
34
  * @param {Function} log - Logging function
33
35
  * @param {Function} cleanup - Optional cleanup function to call on exit
36
+ * @param {Function} interrupt - Optional interrupt function to call on SIGINT/SIGTERM before cleanup
37
+ * (e.g., auto-commit uncommitted changes, upload logs)
34
38
  */
35
- export const initializeExitHandler = (getLogPath, log, cleanup = null) => {
39
+ export const initializeExitHandler = (getLogPath, log, cleanup = null, interrupt = null) => {
36
40
  getLogPathFunction = getLogPath;
37
41
  logFunction = log;
38
42
  cleanupFunction = cleanup;
43
+ interruptFunction = interrupt;
39
44
  };
40
45
 
41
46
  /**
@@ -114,6 +119,15 @@ export const installGlobalExitHandlers = () => {
114
119
 
115
120
  // Handle SIGINT (CTRL+C)
116
121
  process.on('SIGINT', async () => {
122
+ // Run interrupt handler first (auto-commit, log upload, etc.) — guard against double invocation
123
+ if (interruptFunction && !interruptHandlerRan) {
124
+ interruptHandlerRan = true;
125
+ try {
126
+ await interruptFunction();
127
+ } catch {
128
+ // Ignore interrupt handler errors
129
+ }
130
+ }
117
131
  if (cleanupFunction) {
118
132
  try {
119
133
  await cleanupFunction();
@@ -208,4 +222,5 @@ export const installGlobalExitHandlers = () => {
208
222
  */
209
223
  export const resetExitHandler = () => {
210
224
  exitMessageShown = false;
225
+ interruptHandlerRan = false;
211
226
  };
@@ -0,0 +1,251 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * GitHub Merge Ready Tag Sync Library
4
+ *
5
+ * Provides utilities for syncing 'ready' tags between linked PRs and issues,
6
+ * and for finding genuinely linked PRs via the GitHub issue timeline API.
7
+ * Split from github-merge.lib.mjs to maintain file size limits.
8
+ *
9
+ * @see https://github.com/link-assistant/hive-mind/issues/1413
10
+ */
11
+
12
+ import { promisify } from 'util';
13
+ import { exec as execCallback } from 'child_process';
14
+
15
+ const exec = promisify(execCallback);
16
+
17
+ import { extractLinkedIssueNumber } from './github-linking.lib.mjs';
18
+
19
+ // READY_LABEL is also exported from github-merge.lib.mjs (which re-exports it from here)
20
+ export const READY_LABEL = {
21
+ name: 'ready',
22
+ description: 'Is ready to be merged',
23
+ color: '0E8A16', // Green color
24
+ };
25
+
26
+ /**
27
+ * Add a label to a GitHub issue or pull request
28
+ * @param {'issue'|'pr'} type - Whether to add to issue or PR
29
+ * @param {string} owner - Repository owner
30
+ * @param {string} repo - Repository name
31
+ * @param {number} number - Issue or PR number
32
+ * @param {string} labelName - Label name to add
33
+ * @param {boolean} verbose - Whether to log verbose output
34
+ * @returns {Promise<{success: boolean, error: string|null}>}
35
+ */
36
+ async function addLabel(type, owner, repo, number, labelName, verbose = false) {
37
+ const cmd = type === 'issue' ? 'issue' : 'pr';
38
+ try {
39
+ await exec(`gh ${cmd} edit ${number} --repo ${owner}/${repo} --add-label "${labelName}"`);
40
+ if (verbose) console.log(`[VERBOSE] /merge: Added '${labelName}' label to ${type} #${number}`);
41
+ return { success: true, error: null };
42
+ } catch (error) {
43
+ if (verbose) console.log(`[VERBOSE] /merge: Failed to add label to ${type} #${number}: ${error.message}`);
44
+ return { success: false, error: error.message };
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Get open PRs that are genuinely linked to an issue via GitHub's issue timeline.
50
+ *
51
+ * Issue #1413: This replaces the previous full-text body search approach which
52
+ * caused false positives. For example, a search for `fixes #1411` would incorrectly
53
+ * match PR #843 because its body contained the string `1411→` as a source code line
54
+ * number in a code snippet — not as an issue closing reference.
55
+ *
56
+ * The GitHub issue timeline API returns `cross-referenced` events for PRs that
57
+ * explicitly close the issue using GitHub's reserved keywords (fixes/closes/resolves).
58
+ * This is the same data GitHub uses to auto-close issues when PRs are merged, so
59
+ * it reliably identifies genuine closing references.
60
+ *
61
+ * @param {string} owner - Repository owner
62
+ * @param {string} repo - Repository name
63
+ * @param {number} issueNumber - Issue number to find linked PRs for
64
+ * @param {boolean} verbose - Whether to log verbose output
65
+ * @returns {Promise<Array<{number: number, title: string}>>} Array of open PRs that close this issue
66
+ */
67
+ export async function getLinkedPRsFromTimeline(owner, repo, issueNumber, verbose = false) {
68
+ try {
69
+ const { stdout: timelineJson } = await exec(`gh api repos/${owner}/${repo}/issues/${issueNumber}/timeline --paginate`);
70
+ const timeline = JSON.parse(timelineJson.trim() || '[]');
71
+
72
+ // Extract cross-referenced events where the source is an open PR
73
+ // (source.issue.pull_request != null means the source is a PR, not a plain issue)
74
+ const linkedPRNumbers = new Set();
75
+ const linkedPRs = [];
76
+
77
+ for (const event of timeline) {
78
+ if (event.event === 'cross-referenced' && event.source?.issue?.pull_request != null && event.source?.issue?.state === 'open') {
79
+ const prNumber = event.source.issue.number;
80
+ if (!linkedPRNumbers.has(prNumber)) {
81
+ linkedPRNumbers.add(prNumber);
82
+ linkedPRs.push({
83
+ number: prNumber,
84
+ title: event.source.issue.title || '',
85
+ });
86
+ }
87
+ }
88
+ }
89
+
90
+ if (verbose) {
91
+ console.log(`[VERBOSE] /merge: Issue #${issueNumber} has ${linkedPRs.length} genuinely linked open PR(s) via timeline`);
92
+ for (const pr of linkedPRs) {
93
+ console.log(`[VERBOSE] /merge: PR #${pr.number}: ${pr.title}`);
94
+ }
95
+ }
96
+
97
+ return linkedPRs;
98
+ } catch (error) {
99
+ if (verbose) {
100
+ console.log(`[VERBOSE] /merge: Error fetching timeline for issue #${issueNumber}: ${error.message}`);
101
+ }
102
+ return [];
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Sync 'ready' tags between linked pull requests and issues
108
+ *
109
+ * Issue #1367: Before building the merge queue, ensure that:
110
+ * 1. If a PR has 'ready' label and is clearly linked to an issue (via standard GitHub
111
+ * keywords in the PR body/title), the issue also gets 'ready' label.
112
+ * 2. If an issue has 'ready' label and has a clearly linked open PR, the PR also gets
113
+ * 'ready' label.
114
+ *
115
+ * This ensures the final list of ready PRs reflects all ready work, regardless of
116
+ * where the 'ready' label was originally applied.
117
+ *
118
+ * @param {string} owner - Repository owner
119
+ * @param {string} repo - Repository name
120
+ * @param {boolean} verbose - Whether to log verbose output
121
+ * @returns {Promise<{synced: number, errors: number, details: Array<Object>}>}
122
+ */
123
+ export async function syncReadyTags(owner, repo, verbose = false) {
124
+ const synced = [];
125
+ const errors = [];
126
+
127
+ if (verbose) {
128
+ console.log(`[VERBOSE] /merge: Syncing 'ready' tags for ${owner}/${repo}...`);
129
+ }
130
+
131
+ try {
132
+ // Fetch open PRs with 'ready' label (including body for link detection)
133
+ const { stdout: prsJson } = await exec(`gh pr list --repo ${owner}/${repo} --label "${READY_LABEL.name}" --state open --json number,title,body,labels --limit 100`);
134
+ const readyPRs = JSON.parse(prsJson.trim() || '[]');
135
+
136
+ if (verbose) {
137
+ console.log(`[VERBOSE] /merge: Found ${readyPRs.length} open PRs with 'ready' label for tag sync`);
138
+ }
139
+
140
+ // Fetch open issues with 'ready' label
141
+ const { stdout: issuesJson } = await exec(`gh issue list --repo ${owner}/${repo} --label "${READY_LABEL.name}" --state open --json number,title --limit 100`);
142
+ const readyIssues = JSON.parse(issuesJson.trim() || '[]');
143
+
144
+ if (verbose) {
145
+ console.log(`[VERBOSE] /merge: Found ${readyIssues.length} open issues with 'ready' label for tag sync`);
146
+ }
147
+
148
+ // Build a set of issue numbers that already have 'ready'
149
+ const readyIssueNumbers = new Set(readyIssues.map(i => String(i.number)));
150
+
151
+ // Step 1: For each PR with 'ready', find linked issue and sync label to it
152
+ for (const pr of readyPRs) {
153
+ try {
154
+ const prBody = pr.body || '';
155
+ const linkedIssueNumber = extractLinkedIssueNumber(prBody);
156
+
157
+ if (!linkedIssueNumber) {
158
+ if (verbose) {
159
+ console.log(`[VERBOSE] /merge: PR #${pr.number} has no linked issue (no closing keyword in body)`);
160
+ }
161
+ continue;
162
+ }
163
+
164
+ if (readyIssueNumbers.has(String(linkedIssueNumber))) {
165
+ if (verbose) {
166
+ console.log(`[VERBOSE] /merge: Issue #${linkedIssueNumber} already has 'ready' label (linked from PR #${pr.number})`);
167
+ }
168
+ continue;
169
+ }
170
+
171
+ // Issue doesn't have 'ready' label yet - add it
172
+ if (verbose) {
173
+ console.log(`[VERBOSE] /merge: PR #${pr.number} has 'ready', adding to linked issue #${linkedIssueNumber}`);
174
+ }
175
+
176
+ const result = await addLabel('issue', owner, repo, linkedIssueNumber, READY_LABEL.name, verbose);
177
+ if (result.success) {
178
+ synced.push({ type: 'pr-to-issue', prNumber: pr.number, issueNumber: Number(linkedIssueNumber) });
179
+ // Mark this issue as now having 'ready' so we don't process it again
180
+ readyIssueNumbers.add(String(linkedIssueNumber));
181
+ } else {
182
+ errors.push({ type: 'pr-to-issue', prNumber: pr.number, issueNumber: Number(linkedIssueNumber), error: result.error });
183
+ }
184
+ } catch (err) {
185
+ if (verbose) {
186
+ console.log(`[VERBOSE] /merge: Error syncing label from PR #${pr.number}: ${err.message}`);
187
+ }
188
+ errors.push({ type: 'pr-to-issue', prNumber: pr.number, error: err.message });
189
+ }
190
+ }
191
+
192
+ // Build a set of PR numbers that already have 'ready'
193
+ const readyPRNumbers = new Set(readyPRs.map(p => String(p.number)));
194
+
195
+ // Step 2: For each issue with 'ready', find linked PRs and sync label to them
196
+ for (const issue of readyIssues) {
197
+ try {
198
+ // Issue #1413: Use the GitHub issue timeline API to find PRs that genuinely
199
+ // close this issue via closing keywords. This avoids false positives from
200
+ // full-text search, which can match PRs that contain the issue number as a
201
+ // source code line number (e.g. "1411→ await log(...)") rather than as a
202
+ // real closing reference.
203
+ const linkedPRs = await getLinkedPRsFromTimeline(owner, repo, issue.number, verbose);
204
+
205
+ for (const linkedPR of linkedPRs) {
206
+ if (readyPRNumbers.has(String(linkedPR.number))) {
207
+ if (verbose) {
208
+ console.log(`[VERBOSE] /merge: PR #${linkedPR.number} already has 'ready' label (linked from issue #${issue.number})`);
209
+ }
210
+ continue;
211
+ }
212
+
213
+ // PR doesn't have 'ready' label yet - add it
214
+ if (verbose) {
215
+ console.log(`[VERBOSE] /merge: Issue #${issue.number} has 'ready', adding to linked PR #${linkedPR.number}`);
216
+ }
217
+
218
+ const result = await addLabel('pr', owner, repo, linkedPR.number, READY_LABEL.name, verbose);
219
+ if (result.success) {
220
+ synced.push({ type: 'issue-to-pr', issueNumber: issue.number, prNumber: linkedPR.number });
221
+ // Mark this PR as now having 'ready'
222
+ readyPRNumbers.add(String(linkedPR.number));
223
+ } else {
224
+ errors.push({ type: 'issue-to-pr', issueNumber: issue.number, prNumber: linkedPR.number, error: result.error });
225
+ }
226
+ }
227
+ } catch (err) {
228
+ if (verbose) {
229
+ console.log(`[VERBOSE] /merge: Error syncing label from issue #${issue.number}: ${err.message}`);
230
+ }
231
+ errors.push({ type: 'issue-to-pr', issueNumber: issue.number, error: err.message });
232
+ }
233
+ }
234
+ } catch (error) {
235
+ if (verbose) {
236
+ console.log(`[VERBOSE] /merge: Error during tag sync: ${error.message}`);
237
+ }
238
+ errors.push({ type: 'fetch', error: error.message });
239
+ }
240
+
241
+ if (verbose) {
242
+ console.log(`[VERBOSE] /merge: Tag sync complete. Synced: ${synced.length}, Errors: ${errors.length}`);
243
+ }
244
+
245
+ return {
246
+ synced: synced.length,
247
+ errors: errors.length,
248
+ details: synced,
249
+ errorDetails: errors,
250
+ };
251
+ }