@link-assistant/hive-mind 1.64.4 → 1.65.0

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,14 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.65.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 14fe57e: Prevent normal Docker release manifest jobs from downloading DinD digest artifacts.
8
+ - 74ce579: Reduce `/terminal_watch` Telegram edits by updating only when the displayed terminal snapshot changes and count only real terminal snapshot updates.
9
+ - 78ab6e2: Add `--auto-delete-branch-on-merge` option for the `solve` command. When set together with `--watch`, the branch is deleted from the remote after the pull request is merged; when set together with `--auto-merge`, the auto-merge call requests branch deletion as part of the merge. The option is opt-in (default `false`), enables full GitHub Flow automation, avoids temporary auto-restart cleanup, uses the GitHub REST API for watch-mode deletion, and treats "branch already gone" responses as success so it does not warn when GitHub's "Automatically delete head branches" repo setting beats us to it.
10
+ - 152de95: Add a Claude CLI streaming input case study with reproducible experiment scripts.
11
+
3
12
  ## 1.64.4
4
13
 
5
14
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.64.4",
3
+ "version": "1.65.0",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -67,6 +67,8 @@ const { maybeAttachWorkingSessionSummary } = resultsLib;
67
67
  const { interruptibleSleep } = await import('./interruptible-sleep.lib.mjs');
68
68
  const { formatAutoIterationLimit, hasReachedAutoIterationLimit, normalizeAutoIterationLimit, shouldSyncBeforeRestart } = await import('./auto-iteration-limits.lib.mjs');
69
69
 
70
+ const shouldDeleteBranchAfterMerge = argv => argv.autoDeleteBranchOnMerge || argv.deleteBranchAfterMerge || false;
71
+
70
72
  /**
71
73
  * Main function: Watch and restart until PR becomes mergeable
72
74
  * This implements --auto-restart-until-mergeable functionality
@@ -273,7 +275,11 @@ export const watchUntilMergeable = async params => {
273
275
  if (isAutoMerge) {
274
276
  // Attempt to merge the PR
275
277
  await log(formatAligned('🔀', 'Auto-merging PR...', ''));
276
- const mergeResult = await mergePullRequest(owner, repo, prNumber, { squash: argv.squash || false, deleteAfter: argv.deleteBranchAfterMerge || false }, argv.verbose);
278
+ const deleteAfterMerge = shouldDeleteBranchAfterMerge(argv);
279
+ if (deleteAfterMerge) {
280
+ await log(formatAligned('', 'Branch cleanup:', 'will delete branch after successful merge', 2));
281
+ }
282
+ const mergeResult = await mergePullRequest(owner, repo, prNumber, { squash: argv.squash || false, deleteAfter: deleteAfterMerge }, argv.verbose);
277
283
 
278
284
  if (mergeResult.success) {
279
285
  await log(formatAligned('🎉', 'PR MERGED SUCCESSFULLY!', ''));
@@ -1045,7 +1051,11 @@ export const attemptAutoMerge = async params => {
1045
1051
  await log(formatAligned('✅', 'PR is mergeable:', 'Attempting to merge...', 2));
1046
1052
 
1047
1053
  // Attempt to merge
1048
- const mergeResult = await mergePullRequest(owner, repo, prNumber, { squash: argv.squash || false, deleteAfter: argv.deleteBranchAfterMerge || false }, argv.verbose);
1054
+ const deleteAfterMerge = shouldDeleteBranchAfterMerge(argv);
1055
+ if (deleteAfterMerge) {
1056
+ await log(formatAligned('', 'Branch cleanup:', 'will delete branch after successful merge', 2));
1057
+ }
1058
+ const mergeResult = await mergePullRequest(owner, repo, prNumber, { squash: argv.squash || false, deleteAfter: deleteAfterMerge }, argv.verbose);
1049
1059
 
1050
1060
  if (mergeResult.success) {
1051
1061
  await log(formatAligned('🎉', 'PR MERGED SUCCESSFULLY!', ''));
@@ -243,6 +243,11 @@ export const SOLVE_OPTION_DEFINITIONS = {
243
243
  description: 'Interval in seconds for checking feedback in watch mode (default: 60)',
244
244
  default: 60,
245
245
  },
246
+ 'auto-delete-branch-on-merge': {
247
+ type: 'boolean',
248
+ description: 'Automatically delete the branch after the pull request is merged in --watch mode or by --auto-merge. Enables full GitHub Flow support (issue #401).',
249
+ default: false,
250
+ },
246
251
  'min-disk-space': {
247
252
  type: 'number',
248
253
  description: 'Minimum required disk space in MB (default: 2048)',
@@ -107,6 +107,52 @@ export const watchForFeedback = async params => {
107
107
  await log('');
108
108
  await log(formatAligned('🎉', 'PR MERGED!', 'Stopping watch mode'));
109
109
  await log(formatAligned('', 'Pull request:', `#${prNumber} has been merged`, 2));
110
+
111
+ // Issue #401: If --auto-delete-branch-on-merge is enabled in --watch mode,
112
+ // delete the branch from the remote after the PR is merged. This enables
113
+ // full GitHub Flow automation. Only applies in --watch mode (not auto-restart),
114
+ // because auto-restart is for completing local work, not finalizing GitHub Flow.
115
+ const shouldAutoDeleteBranch = !isTemporaryWatch && argv.autoDeleteBranchOnMerge && branchName;
116
+ if (shouldAutoDeleteBranch) {
117
+ await log('');
118
+ await log(formatAligned('🗑️', 'AUTO-DELETE:', `Deleting branch ${branchName} after merge`));
119
+ try {
120
+ // Delete the branch from the remote via GitHub REST API.
121
+ // We use `gh api ... -X DELETE` rather than `git push --delete` so we don't
122
+ // require a configured local remote in tempDir at this point in the run.
123
+ const deleteBranchResult = await $`gh api repos/${owner}/${repo}/git/refs/heads/${branchName} -X DELETE`;
124
+ if (deleteBranchResult.code === 0) {
125
+ await log(formatAligned('✅', 'Branch deleted:', `${branchName}`, 2));
126
+ } else {
127
+ const stderrText = deleteBranchResult.stderr?.toString().trim() || 'Unknown error';
128
+ // 422 Reference does not exist -> branch was already deleted (e.g. GitHub's "Automatically delete head branches"
129
+ // setting raced ahead of us). Treat as success rather than warning.
130
+ if (/Reference does not exist|Not Found|422|404/i.test(stderrText)) {
131
+ await log(formatAligned('✅', 'Branch already removed:', `${branchName} (no action needed)`, 2));
132
+ } else {
133
+ await log(formatAligned('⚠️', 'Branch deletion failed:', stderrText, 2));
134
+ reportError(new Error(`Branch deletion returned non-zero exit code: ${stderrText}`), {
135
+ context: 'delete_branch_on_merge_non_zero',
136
+ owner,
137
+ repo,
138
+ branchName,
139
+ exitCode: deleteBranchResult.code,
140
+ operation: 'delete_remote_branch',
141
+ });
142
+ }
143
+ }
144
+ } catch (deleteError) {
145
+ reportError(deleteError, {
146
+ context: 'delete_branch_on_merge',
147
+ owner,
148
+ repo,
149
+ branchName,
150
+ operation: 'delete_remote_branch',
151
+ });
152
+ await log(formatAligned('⚠️', 'Branch deletion error:', cleanErrorMessage(deleteError), 2));
153
+ }
154
+ }
155
+
110
156
  await log('');
111
157
  break;
112
158
  }
@@ -173,12 +173,18 @@ async function querySessionStatusWithRetry(querySessionStatus, sessionId, verbos
173
173
 
174
174
  // Note: /terminal_watch never uploads the full session log itself (issue #1720).
175
175
  // Use /log <uuid> if you want the log file delivered as a document.
176
- export function watchTerminalLogSession({ bot, chatId, messageId, sessionId, logPath, querySessionStatus, isTerminalSessionStatus, options = {}, repoDescription = null, verbose = false }) {
176
+ function getDisplayedTerminalSnapshot(logText, options) {
177
+ return sanitizeCodeBlock(tailTextForTerminal(logText, options));
178
+ }
179
+
180
+ export function watchTerminalLogSession({ bot, chatId, messageId, sessionId, logPath, querySessionStatus, isTerminalSessionStatus, options = {}, repoDescription = null, verbose = false, initialStatusResult = null, initialLogText = null, initialMessage = '' }) {
177
181
  const key = `${chatId}:${messageId}:${sessionId}`;
178
182
  activeWatches.get(key)?.stop();
179
183
 
180
184
  let stopped = false;
181
- let lastMessage = '';
185
+ const hasInitialLogText = initialLogText !== null && initialLogText !== undefined;
186
+ let lastSnapshot = hasInitialLogText ? getDisplayedTerminalSnapshot(initialLogText, options) : null;
187
+ let lastMessage = initialMessage || (hasInitialLogText ? formatTerminalWatchMessage({ sessionId, statusResult: initialStatusResult, logText: initialLogText, options, updateCount: 0, completed: !!initialStatusResult?.status && isTerminalSessionStatus(initialStatusResult.status), repoDescription }) : '');
182
188
  let updateCount = 0;
183
189
  let timer = null;
184
190
  const intervalMs = options.intervalMs || DEFAULT_INTERVAL_MS;
@@ -189,11 +195,16 @@ export function watchTerminalLogSession({ bot, chatId, messageId, sessionId, log
189
195
  const statusResult = await querySessionStatus(sessionId, verbose);
190
196
  const completed = !!statusResult?.status && isTerminalSessionStatus(statusResult.status);
191
197
  const logText = await readLogFile(logPath);
192
- const message = formatTerminalWatchMessage({ sessionId, statusResult, logText, options, updateCount: ++updateCount, completed, repoDescription });
193
- if (message !== lastMessage) {
198
+ const snapshot = getDisplayedTerminalSnapshot(logText, options);
199
+ const snapshotChanged = snapshot !== lastSnapshot;
200
+ if (snapshotChanged) updateCount++;
201
+ const message = formatTerminalWatchMessage({ sessionId, statusResult, logText, options, updateCount, completed, repoDescription });
202
+ const shouldEdit = !lastMessage || snapshotChanged || (completed && message !== lastMessage);
203
+ if (shouldEdit && message !== lastMessage) {
194
204
  await bot.telegram.editMessageText(chatId, messageId, undefined, message, { parse_mode: 'Markdown' });
195
205
  lastMessage = message;
196
206
  }
207
+ lastSnapshot = snapshot;
197
208
  if (completed) {
198
209
  stopped = true;
199
210
  activeWatches.delete(key);
@@ -255,14 +266,15 @@ async function startWatchFromResolvedSession({ bot, ctx, sessionId, statusResult
255
266
  if (!targetChatId) return { started: false, reason: 'Missing target chat id' };
256
267
 
257
268
  const initialLogText = await readLogFile(logPath);
258
- const initialText = formatTerminalWatchMessage({ sessionId, statusResult, logText: initialLogText, options: watchOptions, repoDescription });
269
+ const initialCompleted = !!statusResult?.status && isTerminalSessionStatus(statusResult.status);
270
+ const initialText = formatTerminalWatchMessage({ sessionId, statusResult, logText: initialLogText, options: watchOptions, completed: initialCompleted, repoDescription });
259
271
  let replyToMessageId = ctx.message?.message_id || undefined;
260
272
  if (decision.destination === 'dm' && ctx.chat.type !== 'private') {
261
273
  replyToMessageId = await forwardOrCopyToDm(ctx, ctx.message?.reply_to_message || ctx.message);
262
274
  }
263
275
 
264
276
  const watchMessage = await createWatchMessage({ ctx, targetChatId, replyToMessageId, text: initialText });
265
- watchTerminalLogSession({ bot, chatId: targetChatId, messageId: watchMessage.message_id, sessionId, logPath, querySessionStatus, isTerminalSessionStatus, options: watchOptions, repoDescription, verbose });
277
+ watchTerminalLogSession({ bot, chatId: targetChatId, messageId: watchMessage.message_id, sessionId, logPath, querySessionStatus, isTerminalSessionStatus, options: watchOptions, repoDescription, verbose, initialStatusResult: statusResult, initialLogText, initialMessage: initialText });
266
278
 
267
279
  if (!auto && decision.destination === 'dm' && ctx.chat.type !== 'private') {
268
280
  await ctx.reply(`📬 Started terminal watch for \`${sessionId}\` in your direct messages.`, { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });