@link-assistant/hive-mind 2.0.26 → 2.0.28

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.
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Guard PR base branch changes made during an agent session.
3
+ *
4
+ * The solve command creates or continues work against a target base branch.
5
+ * When --base-branch is explicit, that target is a user request, not a
6
+ * suggestion for the agent to retarget later.
7
+ */
8
+
9
+ import { ghWithRateLimitRetry } from './github-rate-limit.lib.mjs';
10
+
11
+ function normalizeBranchName(value) {
12
+ return String(value || '').trim();
13
+ }
14
+
15
+ function commandOutput(result) {
16
+ return [result?.stderr, result?.stdout]
17
+ .filter(Boolean)
18
+ .map(output => output.toString().trim())
19
+ .filter(Boolean)
20
+ .join('\n')
21
+ .trim();
22
+ }
23
+
24
+ function fallbackFormatAligned(icon, label, value) {
25
+ return [icon, label, value].filter(Boolean).join(' ');
26
+ }
27
+
28
+ function stripShellTokenPunctuation(value) {
29
+ const token = String(value || '');
30
+ if (isCommandBoundary(token)) return token;
31
+ return token.replace(/^(?:\(|\{|\[)+|(?:;|\)|&|\||\])+$/g, '');
32
+ }
33
+
34
+ function tokenizeShellCommand(command) {
35
+ const tokens = [];
36
+ const pattern = /"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\S+)/g;
37
+ let match;
38
+ while ((match = pattern.exec(String(command || ''))) !== null) {
39
+ const rawToken = match[1] ?? match[2] ?? match[3] ?? '';
40
+ const token = stripShellTokenPunctuation(rawToken);
41
+ if (token) tokens.push(token);
42
+ }
43
+ return tokens;
44
+ }
45
+
46
+ function isCommandBoundary(token) {
47
+ return token === '&&' || token === '||' || token === ';' || token === '|';
48
+ }
49
+
50
+ function isGhToken(token) {
51
+ return token === 'gh' || token.endsWith('/gh');
52
+ }
53
+
54
+ function commandTargetsPullRequest(target, prNumber) {
55
+ if (!prNumber || !target) return true;
56
+ const normalizedTarget = String(target);
57
+ const normalizedPrNumber = String(prNumber);
58
+ return normalizedTarget === normalizedPrNumber || normalizedTarget.endsWith(`/pull/${normalizedPrNumber}`) || normalizedTarget.endsWith(`/pulls/${normalizedPrNumber}`);
59
+ }
60
+
61
+ function parseGhPrEditBaseChange(tokens, startIndex, prNumber) {
62
+ if (tokens[startIndex + 1] !== 'pr' || tokens[startIndex + 2] !== 'edit') return null;
63
+
64
+ let targetPullRequest = null;
65
+ let attemptedBaseBranch = null;
66
+ const optionsWithValues = new Set(['--repo', '-R', '--title', '--body', '--body-file', '--add-label', '--remove-label', '--add-assignee', '--remove-assignee', '--milestone', '--project']);
67
+ for (let index = startIndex + 3; index < tokens.length; index++) {
68
+ const token = tokens[index];
69
+ if (isCommandBoundary(token)) break;
70
+
71
+ if (token === '--base' || token === '-B') {
72
+ attemptedBaseBranch = normalizeBranchName(tokens[index + 1]);
73
+ index++;
74
+ continue;
75
+ }
76
+ if (token.startsWith('--base=')) {
77
+ attemptedBaseBranch = normalizeBranchName(token.slice('--base='.length));
78
+ continue;
79
+ }
80
+ if (optionsWithValues.has(token)) {
81
+ index++;
82
+ continue;
83
+ }
84
+ if (!targetPullRequest && !token.startsWith('-')) {
85
+ targetPullRequest = token;
86
+ }
87
+ }
88
+
89
+ if (!attemptedBaseBranch || !commandTargetsPullRequest(targetPullRequest, prNumber)) return null;
90
+ return { attemptedBaseBranch, commandKind: 'gh_pr_edit' };
91
+ }
92
+
93
+ function parseGhApiPullRequestBaseChange(tokens, startIndex, prNumber) {
94
+ if (tokens[startIndex + 1] !== 'api') return null;
95
+
96
+ let endpointTargetsPullRequest = false;
97
+ let attemptedBaseBranch = null;
98
+ for (let index = startIndex + 2; index < tokens.length; index++) {
99
+ const token = tokens[index];
100
+ if (isCommandBoundary(token)) break;
101
+
102
+ if (commandTargetsPullRequest(token, prNumber) && token.includes('/pulls/')) {
103
+ endpointTargetsPullRequest = true;
104
+ }
105
+ if (token === '-f' || token === '--field' || token === '-F' || token === '--raw-field') {
106
+ const field = tokens[index + 1] || '';
107
+ if (field.startsWith('base=')) {
108
+ attemptedBaseBranch = normalizeBranchName(field.slice('base='.length));
109
+ }
110
+ index++;
111
+ continue;
112
+ }
113
+ if (token.startsWith('-fbase=') || token.startsWith('--field=base=')) {
114
+ attemptedBaseBranch = normalizeBranchName(token.split('base=').at(-1));
115
+ }
116
+ }
117
+
118
+ if (!endpointTargetsPullRequest || !attemptedBaseBranch) return null;
119
+ return { attemptedBaseBranch, commandKind: 'gh_api_pull_update' };
120
+ }
121
+
122
+ export function getExpectedPullRequestBaseBranch({ argv = {} } = {}) {
123
+ const requestedBaseBranch = normalizeBranchName(argv?.baseBranch);
124
+ return requestedBaseBranch || null;
125
+ }
126
+
127
+ export function detectForbiddenPullRequestBaseChangeCommand(command, { expectedBaseBranch, prNumber } = {}) {
128
+ const normalizedExpectedBaseBranch = normalizeBranchName(expectedBaseBranch);
129
+ if (!normalizedExpectedBaseBranch || typeof command !== 'string' || !command.trim()) return null;
130
+
131
+ const tokens = tokenizeShellCommand(command);
132
+ for (let index = 0; index < tokens.length; index++) {
133
+ if (!isGhToken(tokens[index])) continue;
134
+ const parsed = parseGhPrEditBaseChange(tokens, index, prNumber) || parseGhApiPullRequestBaseChange(tokens, index, prNumber);
135
+ if (!parsed) continue;
136
+ if (parsed.attemptedBaseBranch === normalizedExpectedBaseBranch) continue;
137
+ return {
138
+ command,
139
+ commandKind: parsed.commandKind,
140
+ attemptedBaseBranch: parsed.attemptedBaseBranch,
141
+ expectedBaseBranch: normalizedExpectedBaseBranch,
142
+ prNumber: prNumber || null,
143
+ };
144
+ }
145
+
146
+ return null;
147
+ }
148
+
149
+ export function extractToolCommandTextsFromStreamEvent(event) {
150
+ const commands = [];
151
+ const seen = new Set();
152
+ const visit = value => {
153
+ if (!value || typeof value !== 'object') return;
154
+ if (typeof value.command === 'string' && value.command.trim() && !seen.has(value.command)) {
155
+ seen.add(value.command);
156
+ commands.push(value.command);
157
+ }
158
+ if (Array.isArray(value)) {
159
+ for (const item of value) visit(item);
160
+ return;
161
+ }
162
+ for (const child of Object.values(value)) {
163
+ visit(child);
164
+ }
165
+ };
166
+ visit(event);
167
+ return commands;
168
+ }
169
+
170
+ export function buildPullRequestBaseBranchInterventionMessage({ prNumber, expectedBaseBranch, attemptedBaseBranch, command } = {}) {
171
+ const expected = normalizeBranchName(expectedBaseBranch);
172
+ const attempted = normalizeBranchName(attemptedBaseBranch);
173
+ const pullRequestLabel = prNumber ? `PR #${prNumber}` : 'the pull request';
174
+ const attemptedText = attempted ? ` to ${attempted}` : '';
175
+ const commandText = command ? `\nForbidden command observed: ${command}` : '';
176
+
177
+ return `The user requested --base-branch ${expected}. ${pullRequestLabel} must keep that base branch. Do not change ${pullRequestLabel}'s base${attemptedText}. Restore or keep the base as ${expected}, then continue finishing the pull request and make it ready for review.${commandText}`;
178
+ }
179
+
180
+ export function buildPullRequestBaseBranchMismatchMessage({ prNumber, currentBaseBranch, expectedBaseBranch, operation = 'verify' } = {}) {
181
+ const action = operation === 'auto-merge' ? 'auto-merge' : 'continue';
182
+ return `Cannot ${action} PR #${prNumber} because its base branch changed to ${currentBaseBranch}. The user requested --base-branch ${expectedBaseBranch}; restore the PR base to ${expectedBaseBranch} before ${action}.`;
183
+ }
184
+
185
+ export async function getPullRequestBaseBranch({ owner, repo, prNumber, $, log }) {
186
+ if (typeof $ !== 'function') {
187
+ throw new Error('Cannot verify pull request base branch without a command runner');
188
+ }
189
+
190
+ const result = await ghWithRateLimitRetry(() => $`gh pr view ${prNumber} --repo ${owner}/${repo} --json baseRefName --jq .baseRefName`, {
191
+ label: 'gh pr view baseRefName',
192
+ log,
193
+ });
194
+ if (result.code !== 0) {
195
+ const details = commandOutput(result) || 'unknown error';
196
+ throw new Error(`Could not verify pull request base branch for #${prNumber}: ${details}`);
197
+ }
198
+
199
+ const baseBranch = normalizeBranchName(result.stdout);
200
+ if (!baseBranch) {
201
+ throw new Error(`Could not verify pull request base branch for #${prNumber}: gh returned an empty baseRefName`);
202
+ }
203
+
204
+ return baseBranch;
205
+ }
206
+
207
+ export async function ensurePullRequestBaseBranch({ owner, repo, prNumber, argv = {}, log = async () => {}, formatAligned = fallbackFormatAligned, $, onMismatch = 'restore', operation = 'verify' }) {
208
+ const expectedBaseBranch = getExpectedPullRequestBaseBranch({ argv });
209
+ if (!expectedBaseBranch) {
210
+ return { checked: false, restored: false, reason: 'no_explicit_base_branch' };
211
+ }
212
+
213
+ if (!owner || !repo || !prNumber) {
214
+ return { checked: false, restored: false, reason: 'missing_pull_request_context' };
215
+ }
216
+
217
+ const currentBaseBranch = await getPullRequestBaseBranch({ owner, repo, prNumber, $, log });
218
+ if (currentBaseBranch === expectedBaseBranch) {
219
+ await log(formatAligned('🎯', 'Base branch locked:', `${expectedBaseBranch} (verified)`, 2), { verbose: true });
220
+ return {
221
+ checked: true,
222
+ restored: false,
223
+ currentBaseBranch,
224
+ expectedBaseBranch,
225
+ };
226
+ }
227
+
228
+ await log(formatAligned('⚠️', 'Base branch changed:', `PR #${prNumber} targets ${currentBaseBranch}, expected ${expectedBaseBranch}`, 2), { level: 'warning' });
229
+
230
+ if (onMismatch === 'throw' || onMismatch === 'fail') {
231
+ throw new Error(
232
+ buildPullRequestBaseBranchMismatchMessage({
233
+ prNumber,
234
+ currentBaseBranch,
235
+ expectedBaseBranch,
236
+ operation,
237
+ })
238
+ );
239
+ }
240
+
241
+ await log(formatAligned('🔁', 'Restoring PR base:', expectedBaseBranch, 2));
242
+
243
+ const editResult = await ghWithRateLimitRetry(() => $`gh pr edit ${prNumber} --repo ${owner}/${repo} --base ${expectedBaseBranch}`, {
244
+ label: 'gh pr edit base',
245
+ log,
246
+ });
247
+ if (editResult.code !== 0) {
248
+ const details = commandOutput(editResult) || 'unknown error';
249
+ throw new Error(`Could not restore pull request #${prNumber} base branch to ${expectedBaseBranch}: ${details}`);
250
+ }
251
+
252
+ const restoredBaseBranch = await getPullRequestBaseBranch({ owner, repo, prNumber, $, log });
253
+ if (restoredBaseBranch !== expectedBaseBranch) {
254
+ throw new Error(`Pull request #${prNumber} still targets ${restoredBaseBranch} after attempting to restore ${expectedBaseBranch}`);
255
+ }
256
+
257
+ await log(formatAligned('✅', 'Base branch restored:', `PR #${prNumber} now targets ${expectedBaseBranch}`, 2));
258
+
259
+ return {
260
+ checked: true,
261
+ restored: true,
262
+ previousBaseBranch: currentBaseBranch,
263
+ currentBaseBranch: restoredBaseBranch,
264
+ expectedBaseBranch,
265
+ };
266
+ }
@@ -31,6 +31,7 @@ const fs = (await use('fs')).promises;
31
31
  // Import shared library functions
32
32
  const lib = await import('./lib.mjs');
33
33
  const { log, formatAligned, extractToolErrorCore } = lib;
34
+ const { ensurePullRequestBaseBranch } = await import('./solve.pr-base-guard.lib.mjs');
34
35
 
35
36
  // Import Sentry integration
36
37
  const sentryLib = await import('./sentry.lib.mjs');
@@ -460,6 +461,7 @@ export const executeToolIteration = async params => {
460
461
  });
461
462
  }
462
463
 
464
+ await ensurePullRequestBaseBranch({ owner, repo, prNumber, argv, log, formatAligned, $ });
463
465
  return toolResult;
464
466
  };
465
467
 
@@ -1234,8 +1234,7 @@ bot.on('message', async (ctx, next) => {
1234
1234
  // /subscribe + /unsubscribe (#1688) are intentionally not in the text fallback — Telegraf's bot.command() is sufficient.
1235
1235
  const solveHandlers = Object.fromEntries(SOLVE_COMMAND_NAMES.map(command => [command, handleSolveCommand]));
1236
1236
  const taskHandlers = Object.fromEntries(TASK_COMMAND_NAMES.map(command => [command, handleTaskCommand]));
1237
- // /queue is the short alias for /solve_queue (issue #1837)
1238
- const handlers = { ...solveHandlers, ...taskHandlers, auth: handleAuthCommand, hive: handleHiveCommand, solve_queue: handleSolveQueueCommand, solvequeue: handleSolveQueueCommand, queue: handleSolveQueueCommand };
1237
+ const handlers = { ...solveHandlers, ...taskHandlers, auth: handleAuthCommand, hive: handleHiveCommand, queue: handleSolveQueueCommand };
1239
1238
 
1240
1239
  const handler = handlers[extracted.command];
1241
1240
  if (!handler) return next();
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Telegram /solve_queue command implementation
2
+ * Telegram /queue command implementation
3
3
  *
4
- * This module provides the /solve_queue command functionality for the Telegram bot,
4
+ * This module provides the /queue command functionality for the Telegram bot,
5
5
  * allowing users to view the current solve queue status.
6
6
  *
7
7
  * Features:
@@ -15,7 +15,7 @@
15
15
 
16
16
  import { t } from './i18n.lib.mjs';
17
17
 
18
- const GROUP_ONLY_MESSAGE = '❌ The /solve_queue command only works in group chats. Please add this bot to a group and make it an admin.';
18
+ const GROUP_ONLY_MESSAGE = '❌ The /queue command only works in group chats. Please add this bot to a group and make it an admin.';
19
19
 
20
20
  function commandText(key, params = {}, locale = null, fallback = key) {
21
21
  const translated = t(key, params, locale ? { locale } : {});
@@ -23,7 +23,7 @@ function commandText(key, params = {}, locale = null, fallback = key) {
23
23
  }
24
24
 
25
25
  /**
26
- * Registers the /solve_queue command handler with the bot
26
+ * Registers the /queue command handler with the bot
27
27
  * @param {Object} bot - The Telegraf bot instance
28
28
  * @param {Object} options - Options object
29
29
  * @param {boolean} options.VERBOSE - Whether to enable verbose logging
@@ -41,11 +41,11 @@ export function registerSolveQueueCommand(bot, options) {
41
41
  const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb, getSolveQueue, safeReply, resolveLocale } = options;
42
42
 
43
43
  async function handleSolveQueueCommand(ctx) {
44
- VERBOSE && console.log('[VERBOSE] /solve_queue command received');
44
+ VERBOSE && console.log('[VERBOSE] /queue command received');
45
45
 
46
46
  await addBreadcrumb({
47
47
  category: 'telegram.command',
48
- message: '/solve_queue command received',
48
+ message: '/queue command received',
49
49
  level: 'info',
50
50
  data: { chatId: ctx.chat?.id, chatType: ctx.chat?.type, userId: ctx.from?.id, username: ctx.from?.username },
51
51
  });
@@ -54,18 +54,18 @@ export function registerSolveQueueCommand(bot, options) {
54
54
 
55
55
  // Ignore messages sent before bot started
56
56
  if (isOldMessage(ctx)) {
57
- VERBOSE && console.log('[VERBOSE] /solve_queue ignored: old message');
57
+ VERBOSE && console.log('[VERBOSE] /queue ignored: old message');
58
58
  return;
59
59
  }
60
60
 
61
61
  // Ignore forwarded or reply messages
62
62
  if (isForwardedOrReply(ctx)) {
63
- VERBOSE && console.log('[VERBOSE] /solve_queue ignored: forwarded or reply');
63
+ VERBOSE && console.log('[VERBOSE] /queue ignored: forwarded or reply');
64
64
  return;
65
65
  }
66
66
 
67
67
  if (!isGroupChat(ctx)) {
68
- VERBOSE && console.log('[VERBOSE] /solve_queue ignored: not a group chat');
68
+ VERBOSE && console.log('[VERBOSE] /queue ignored: not a group chat');
69
69
  await replyWithFallback(commandText('telegram.solve_queue_only_in_groups', {}, locale, GROUP_ONLY_MESSAGE), {
70
70
  reply_to_message_id: ctx.message.message_id,
71
71
  fallbackLocale: locale,
@@ -75,13 +75,13 @@ export function registerSolveQueueCommand(bot, options) {
75
75
 
76
76
  const authorize = isTopicAuthorized || (ctx => isChatAuthorized(ctx.chat.id));
77
77
  if (!authorize(ctx)) {
78
- VERBOSE && console.log('[VERBOSE] /solve_queue ignored: not authorized');
78
+ VERBOSE && console.log('[VERBOSE] /queue ignored: not authorized');
79
79
  const errMsg = buildAuthErrorMessage ? buildAuthErrorMessage(ctx) : `❌ This chat (ID: ${ctx.chat.id}) is not authorized.`;
80
80
  await replyWithFallback(errMsg, { reply_to_message_id: ctx.message.message_id, fallbackLocale: locale });
81
81
  return;
82
82
  }
83
83
 
84
- VERBOSE && console.log('[VERBOSE] /solve_queue passed all checks, generating status...');
84
+ VERBOSE && console.log('[VERBOSE] /queue passed all checks, generating status...');
85
85
 
86
86
  const solveQueue = getSolveQueue({ verbose: VERBOSE });
87
87
 
@@ -97,12 +97,7 @@ export function registerSolveQueueCommand(bot, options) {
97
97
  });
98
98
  }
99
99
 
100
- // Match /solve_queue, /solve-queue, /solvequeue, or the short /queue alias (case-insensitive)
101
- // Note: Telegram Bot API only supports underscores in command names, not hyphens.
102
- // The entity-based matching handles /solve_queue, /solvequeue, and /queue.
103
- // /solve-queue is handled by the text-based fallback in telegram-bot.mjs (issue #1232).
104
- // The /queue alias was added in issue #1837 to make checking the queue faster to type.
105
- bot.command(/^(?:solve[_-]?queue|queue)$/i, handleSolveQueueCommand);
100
+ bot.command(/^queue$/i, handleSolveQueueCommand);
106
101
 
107
102
  return { handleSolveQueueCommand };
108
103
  }
@@ -8,7 +8,7 @@ const execAsync = promisify(exec);
8
8
 
9
9
  /**
10
10
  * Build a clickable, human-readable link to a queued issue/PR for the
11
- * /solve_queue (/queue) detailed status (issue #1837).
11
+ * /queue detailed status (issue #1837).
12
12
  *
13
13
  * For GitHub issue/PR URLs we render a compact `[owner/repo#number](url)`
14
14
  * Markdown link so the list is scannable and clickable. When the label would
@@ -269,7 +269,7 @@ export async function getRunningSessionItems(verbose = false) {
269
269
  return await impl(verbose);
270
270
  } catch (error) {
271
271
  if (verbose) {
272
- console.error('[VERBOSE] /solve_queue error getting running session items:', error.message);
272
+ console.error('[VERBOSE] /queue error getting running session items:', error.message);
273
273
  }
274
274
  return [];
275
275
  }
@@ -300,9 +300,9 @@ export async function getRunningProcesses(processName, verbose = false) {
300
300
  .filter(p => p.pid);
301
301
 
302
302
  if (verbose) {
303
- console.log(`[VERBOSE] /solve_queue found ${processes.length} running ${processName} processes`);
303
+ console.log(`[VERBOSE] /queue found ${processes.length} running ${processName} processes`);
304
304
  if (processes.length > 0) {
305
- console.log(`[VERBOSE] /solve_queue processes: ${JSON.stringify(processes)}`);
305
+ console.log(`[VERBOSE] /queue processes: ${JSON.stringify(processes)}`);
306
306
  }
307
307
  }
308
308
 
@@ -312,7 +312,7 @@ export async function getRunningProcesses(processName, verbose = false) {
312
312
  };
313
313
  } catch (error) {
314
314
  if (verbose) {
315
- console.error(`[VERBOSE] /solve_queue error counting ${processName} processes:`, error.message);
315
+ console.error(`[VERBOSE] /queue error counting ${processName} processes:`, error.message);
316
316
  }
317
317
  return { count: 0, processes: [] };
318
318
  }
@@ -250,7 +250,7 @@ export class SolveQueue {
250
250
  */
251
251
  log(message) {
252
252
  if (this.verbose) {
253
- console.log(`[VERBOSE] /solve_queue: ${message}`);
253
+ console.log(`[VERBOSE] /queue: ${message}`);
254
254
  }
255
255
  }
256
256
 
@@ -1469,7 +1469,7 @@ export async function getRunningIsolatedSessions(verbose = false) {
1469
1469
  return await getRunningTrackedIsolationSessions(verbose);
1470
1470
  } catch (error) {
1471
1471
  if (verbose) {
1472
- console.error(`[VERBOSE] /solve_queue error getting isolated sessions:`, error.message);
1472
+ console.error(`[VERBOSE] /queue error getting isolated sessions:`, error.message);
1473
1473
  }
1474
1474
  return { count: 0, sessions: [], byTool: {} };
1475
1475
  }