@link-assistant/hive-mind 1.69.18 → 1.71.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,41 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.71.0
4
+
5
+ ### Minor Changes
6
+
7
+ - aacdb06: Make the `--tool gemini` integration produce meaningful JSON output and reach
8
+ feature parity with `--tool claude` / `--tool codex`. Resolves #1809.
9
+ - The wrapper now feeds the prompt to gemini-cli through `command-stream`'s
10
+ `stdin` option instead of `cat <prompt-file> | gemini`, so the upstream
11
+ non-zero exit code is no longer swallowed by the pipeline.
12
+ - A new `detectGeminiPlainTextError` helper surfaces gemini-cli's plain-text
13
+ failures (auth required, quota exceeded, invalid model, unknown argument,
14
+ fatal error) as structured wrapper errors so headless callers stop seeing
15
+ silent `success: true` runs when authentication is missing. Tracked upstream
16
+ in [`google-gemini/gemini-cli`'s `validateNonInteractiveAuth`](https://github.com/google-gemini/gemini-cli/blob/main/packages/cli/src/validateNonInterActiveAuth.ts);
17
+ see `docs/case-studies/issue-1809/upstream-issue-draft.md` for the proposed
18
+ upstream fix.
19
+ - A run that emits zero `init`/`message`/`tool_use`/`result` JSONL events is
20
+ now classified as a failure regardless of exit code, so empty runs cannot be
21
+ reported as success anymore.
22
+ - New optional flags wired through to gemini-cli: `--gemini-sandbox`
23
+ (`--sandbox`), `--gemini-extensions` (`--extensions`),
24
+ `--gemini-include-directories` (`--include-directories`, in addition to
25
+ `tempDir`/`workspaceTmpDir` which are always included), and
26
+ `--gemini-allowed-mcp-servers` (`--allowed-mcp-server-names`). `--verbose`
27
+ now also toggles gemini-cli's own `--debug` flag.
28
+ - New tests in `tests/test-gemini-support.mjs` lock in plain-text auth-error
29
+ surfacing, zero-event failure detection, and the verbose/include-directories
30
+ argv plumbing.
31
+ - Case study published in `docs/case-studies/issue-1809/`.
32
+
33
+ ## 1.70.0
34
+
35
+ ### Minor Changes
36
+
37
+ - 35dc089: Add `--auto-resolve` to the `/merge` Telegram command. After the normal queue finishes, the bot now iterates every PR that was skipped because of merge conflicts and dispatches a `solve <pr-url> --auto-merge` session through `start-screen` — the same path other commands use — so conflict resolution runs with the default `sonnet` model and the PR is merged once the session finishes. Each PR/issue reference in the `/merge` progress and final messages is now rendered as a clickable MarkdownV2 link to the actual pull request or issue. Resolves #1805.
38
+
3
39
  ## 1.69.18
4
40
 
5
41
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.69.18",
3
+ "version": "1.71.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",
@@ -8,9 +8,6 @@ if (typeof globalThis.use === 'undefined') {
8
8
  }
9
9
 
10
10
  const { $ } = await use('command-stream');
11
- const fs = (await use('fs')).promises;
12
- const path = (await use('path')).default;
13
- const os = (await use('os')).default;
14
11
 
15
12
  import { log } from './lib.mjs';
16
13
  import { reportError } from './sentry.lib.mjs';
@@ -24,6 +21,80 @@ import { getCumulativeContextInputTokens, toTokenCount } from './context-fill.li
24
21
 
25
22
  const shellQuote = value => `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
26
23
 
24
+ // Patterns gemini-cli prints to stdout/stderr when no JSONL event can be emitted.
25
+ // Issue #1809: validateNonInteractiveAuth in gemini-cli bypasses the structured
26
+ // stream-json error path; we surface those plain-text failures ourselves until
27
+ // the upstream fix lands. See docs/case-studies/issue-1809/upstream-issue-draft.md.
28
+ const GEMINI_PLAIN_TEXT_ERROR_PATTERNS = [
29
+ { type: 'AuthenticationRequired', regex: /Please set an Auth method/i },
30
+ { type: 'AuthenticationRequired', regex: /authentication (?:failed|required)/i },
31
+ { type: 'AuthenticationRequired', regex: /invalid (?:api[_ ]?key|credentials)/i },
32
+ { type: 'QuotaExceeded', regex: /quota (?:exceeded|reached)/i },
33
+ { type: 'InvalidModel', regex: /(?:invalid|unknown) model/i },
34
+ { type: 'InvalidArgument', regex: /Unknown (?:argument|option)/i },
35
+ { type: 'FatalError', regex: /^Error:\s/m },
36
+ ];
37
+
38
+ export const detectGeminiPlainTextError = text => {
39
+ if (!text || typeof text !== 'string') return null;
40
+ for (const { type, regex } of GEMINI_PLAIN_TEXT_ERROR_PATTERNS) {
41
+ const match = text.match(regex);
42
+ if (match) {
43
+ const line = (text.split(/\r?\n/).find(l => regex.test(l)) || match[0]).trim();
44
+ return { type, message: line };
45
+ }
46
+ }
47
+ return null;
48
+ };
49
+
50
+ // Issue #1809: Build the gemini-cli argument list once so verbose mode, includes,
51
+ // sandbox, extensions and MCP allow-list can all be toggled by argv consistently.
52
+ // Returns an array suitable for tagged-template interpolation through command-stream.
53
+ export const buildGeminiArgs = (argv, mappedModel, options = {}) => {
54
+ const { tempDir, workspaceTmpDir } = options;
55
+ const args = ['--output-format', 'stream-json', '--model', mappedModel, '--approval-mode', 'yolo', '--skip-trust'];
56
+
57
+ if (argv?.verbose) args.push('--debug');
58
+
59
+ if (argv?.resume) {
60
+ args.unshift('--resume', String(argv.resume));
61
+ }
62
+
63
+ const includeDirs = [];
64
+ if (tempDir) includeDirs.push(tempDir);
65
+ if (workspaceTmpDir && workspaceTmpDir !== tempDir) includeDirs.push(workspaceTmpDir);
66
+ if (Array.isArray(argv?.geminiIncludeDirectories)) {
67
+ for (const dir of argv.geminiIncludeDirectories) if (dir) includeDirs.push(String(dir));
68
+ } else if (typeof argv?.geminiIncludeDirectories === 'string' && argv.geminiIncludeDirectories.trim()) {
69
+ includeDirs.push(
70
+ ...argv.geminiIncludeDirectories
71
+ .split(',')
72
+ .map(d => d.trim())
73
+ .filter(Boolean)
74
+ );
75
+ }
76
+ if (includeDirs.length > 0) {
77
+ args.push('--include-directories', includeDirs.join(','));
78
+ }
79
+
80
+ if (argv?.geminiSandbox) args.push('--sandbox');
81
+
82
+ const collectList = value => {
83
+ if (!value) return [];
84
+ if (Array.isArray(value)) return value.map(String).filter(Boolean);
85
+ return String(value)
86
+ .split(',')
87
+ .map(v => v.trim())
88
+ .filter(Boolean);
89
+ };
90
+ const extensions = collectList(argv?.geminiExtensions);
91
+ if (extensions.length) args.push('--extensions', extensions.join(','));
92
+ const allowedMcp = collectList(argv?.geminiAllowedMcpServers);
93
+ if (allowedMcp.length) args.push('--allowed-mcp-server-names', allowedMcp.join(','));
94
+
95
+ return args;
96
+ };
97
+
27
98
  // Model mapping to translate aliases to full model IDs for Gemini.
28
99
  // Issue #1473: Uses centralized geminiModels from models/index.mjs.
29
100
  export const mapModelToId = model => {
@@ -304,6 +375,7 @@ export const executeGemini = async params => {
304
375
 
305
376
  return await executeGeminiCommand({
306
377
  tempDir,
378
+ workspaceTmpDir,
307
379
  branchName,
308
380
  prompt,
309
381
  systemPrompt,
@@ -321,7 +393,7 @@ export const executeGemini = async params => {
321
393
  };
322
394
 
323
395
  export const executeGeminiCommand = async params => {
324
- const { tempDir, branchName, prompt, systemPrompt, argv, log, formatAligned, getResourceSnapshot, forkedRepo, feedbackLines, geminiPath, $, waitForRetryDelay = waitWithCountdown } = params;
396
+ const { tempDir, workspaceTmpDir, branchName, prompt, systemPrompt, argv, log, formatAligned, getResourceSnapshot, forkedRepo, feedbackLines, geminiPath, $, waitForRetryDelay = waitWithCountdown } = params;
325
397
 
326
398
  let retryCount = 0;
327
399
 
@@ -348,16 +420,15 @@ export const executeGeminiCommand = async params => {
348
420
 
349
421
  const mappedModel = mapModelToId(argv.model || defaultModels.gemini);
350
422
  const combinedPrompt = systemPrompt ? `${systemPrompt}\n\n${prompt}` : prompt;
351
- const promptFile = path.join(os.tmpdir(), `gemini_prompt_${Date.now()}_${process.pid}.txt`);
352
- await fs.writeFile(promptFile, combinedPrompt);
353
423
 
354
- let geminiArgs = `--output-format stream-json --model ${shellQuote(mappedModel)} --approval-mode yolo --skip-trust`;
355
424
  if (argv.resume) {
356
425
  await log(`🔄 Resuming from Gemini session: ${argv.resume}`);
357
- geminiArgs = `--resume ${shellQuote(argv.resume)} ${geminiArgs}`;
358
426
  }
359
427
 
360
- const fullCommand = `(cd ${shellQuote(tempDir)} && cat ${shellQuote(promptFile)} | ${geminiPath} ${geminiArgs})`;
428
+ // Issue #1809: build args via shared helper so verbose/sandbox/include-dirs
429
+ // toggles stay consistent between the logged command and the real invocation.
430
+ const geminiArgList = buildGeminiArgs(argv, mappedModel, { tempDir, workspaceTmpDir });
431
+ const fullCommand = `(cd ${shellQuote(tempDir)} && ${geminiPath} ${geminiArgList.map(shellQuote).join(' ')} <<< <prompt>)`;
361
432
 
362
433
  await log(`\n${formatAligned('📝', 'Raw command:', '')}`);
363
434
  await log(fullCommand);
@@ -370,17 +441,17 @@ export const executeGeminiCommand = async params => {
370
441
  let limitReached = false;
371
442
  let limitResetTime = null;
372
443
  let lastMessage = '';
444
+ let plainTextError = null;
373
445
 
374
446
  try {
375
- const execCommand = argv.resume
376
- ? $({
377
- cwd: tempDir,
378
- mirror: false,
379
- })`cat ${promptFile} | ${geminiPath} --resume ${argv.resume} --output-format stream-json --model ${mappedModel} --approval-mode yolo --skip-trust`
380
- : $({
381
- cwd: tempDir,
382
- mirror: false,
383
- })`cat ${promptFile} | ${geminiPath} --output-format stream-json --model ${mappedModel} --approval-mode yolo --skip-trust`;
447
+ // Issue #1809: feed the prompt directly to gemini-cli stdin via command-stream
448
+ // instead of "cat <file> | gemini" — the pipeline form swallowed the upstream
449
+ // non-zero exit code (no pipefail) and yielded false success: true reports.
450
+ const execCommand = $({
451
+ cwd: tempDir,
452
+ stdin: combinedPrompt,
453
+ mirror: false,
454
+ })`${geminiPath} ${geminiArgList}`;
384
455
 
385
456
  await log(`${formatAligned('📋', 'Command details:', '')}`);
386
457
  await log(formatAligned('📂', 'Working directory:', tempDir, 2));
@@ -406,21 +477,41 @@ export const executeGeminiCommand = async params => {
406
477
  } else {
407
478
  lastMessage = output;
408
479
  }
409
- }
410
-
411
- if (chunk.type === 'stderr') {
480
+ if (!plainTextError) plainTextError = detectGeminiPlainTextError(output);
481
+ } else if (chunk.type === 'stderr') {
412
482
  const errorOutput = chunk.data.toString();
413
483
  if (errorOutput) {
414
484
  await log(errorOutput, { stream: 'stderr' });
415
485
  allOutput += errorOutput;
416
486
  lastMessage = errorOutput;
487
+ if (!plainTextError) plainTextError = detectGeminiPlainTextError(errorOutput);
417
488
  }
418
489
  } else if (chunk.type === 'exit') {
419
490
  exitCode = chunk.code;
420
491
  }
421
492
  }
422
493
 
423
- if (exitCode !== 0 || geminiJsonState.errorMessages?.length > 0) {
494
+ // Issue #1809: require positive evidence that gemini-cli actually ran.
495
+ // An empty JSONL stream + exit 0 (e.g. when stdin is closed early) used
496
+ // to be reported as success: true with messageCount: 0.
497
+ const observedJsonEvents = Object.values(geminiJsonState.eventCounts || {}).reduce((sum, count) => sum + count, 0);
498
+ const hasMeaningfulOutput = observedJsonEvents > 0;
499
+
500
+ // Promote the detected plain-text error into the structured error list
501
+ // so downstream retry / usage-limit detection picks it up.
502
+ if (plainTextError && !geminiJsonState.errorMessages?.some(m => m === plainTextError.message)) {
503
+ geminiJsonState.errorMessages = geminiJsonState.errorMessages || [];
504
+ geminiJsonState.errorMessages.push(plainTextError.message);
505
+ await log(`⚠️ Gemini CLI reported a plain-text error: [${plainTextError.type}] ${plainTextError.message}`, { level: 'warning', verbose: false });
506
+ }
507
+
508
+ const failedExit = exitCode !== 0;
509
+ const hasJsonError = (geminiJsonState.errorMessages?.length || 0) > 0;
510
+ // Zero JSONL events => the wrapper has nothing to attribute as model work,
511
+ // so this run was effectively a no-op and must be reported as failure.
512
+ const emittedNoEvents = !hasMeaningfulOutput;
513
+
514
+ if (failedExit || hasJsonError || emittedNoEvents) {
424
515
  const errorText = geminiJsonState.errorMessages?.length > 0 ? geminiJsonState.errorMessages.join('\n') : allOutput || lastMessage;
425
516
  const retryableError = classifyRetryableError(errorText);
426
517
  if (retryableError.isRetryable) {
@@ -522,8 +613,6 @@ export const executeGeminiCommand = async params => {
522
613
  publicPricingEstimate: null,
523
614
  resultSummary: null,
524
615
  };
525
- } finally {
526
- await fs.unlink(promptFile).catch(() => {});
527
616
  }
528
617
  };
529
618
 
@@ -588,6 +588,25 @@ export const SOLVE_OPTION_DEFINITIONS = {
588
588
  description: 'Experimental and disabled by default. Automatically detect the target issue or pull request language and set the AI work language to English or Russian when one language has more than 51% of all words. Explicit --work-language or --prompt-language takes precedence.',
589
589
  default: false,
590
590
  },
591
+ // Issue #1809: gemini-cli native flags surfaced as solve.mjs options so users
592
+ // can control sandboxing, extensions and MCP server allow-lists per run.
593
+ 'gemini-sandbox': {
594
+ type: 'boolean',
595
+ description: 'Run gemini-cli inside its sandbox (passes --sandbox to gemini-cli). Only used when --tool gemini.',
596
+ default: false,
597
+ },
598
+ 'gemini-extensions': {
599
+ type: 'string',
600
+ description: 'Comma-separated list of gemini-cli extensions to load (passes --extensions to gemini-cli). Only used when --tool gemini.',
601
+ },
602
+ 'gemini-include-directories': {
603
+ type: 'string',
604
+ description: 'Extra directories to expose to gemini-cli (passes --include-directories to gemini-cli, in addition to tempDir/workspaceTmpDir which are always included). Only used when --tool gemini.',
605
+ },
606
+ 'gemini-allowed-mcp-servers': {
607
+ type: 'string',
608
+ description: 'Comma-separated list of MCP server names that gemini-cli is allowed to call (passes --allowed-mcp-server-names to gemini-cli). Only used when --tool gemini.',
609
+ },
591
610
  };
592
611
 
593
612
  function hasRawOption(rawArgs, optionName) {
@@ -20,6 +20,7 @@
20
20
 
21
21
  import { parseRepositoryUrl, checkLabelPermissions, ensureReadyLabel } from './github-merge.lib.mjs';
22
22
  import { createMergeQueueProcessor, MergeStatus, MERGE_QUEUE_CONFIG } from './telegram-merge-queue.lib.mjs';
23
+ import { executeStartScreen } from './telegram-command-execution.lib.mjs';
23
24
 
24
25
  /**
25
26
  * Active merge operations map (repoKey -> { processor, chatId, messageId })
@@ -90,6 +91,77 @@ function parseCommandArgs(text) {
90
91
  return args;
91
92
  }
92
93
 
94
+ /**
95
+ * Issue #1805: Parse boolean flags out of the tokenised `/merge` args.
96
+ * Supports `--flag`, `--flag=true`, `--flag=false`, `--no-flag` and the
97
+ * trailing positional repository URL. We keep the original positional order
98
+ * so callers can still treat `positionals[0]` as the repo URL.
99
+ *
100
+ * @param {string[]} args - The output of `parseCommandArgs(text)`.
101
+ * @returns {{ positionals: string[], flags: Record<string, boolean> }}
102
+ */
103
+ export function parseMergeArgs(args) {
104
+ const flags = {};
105
+ const positionals = [];
106
+ for (const arg of args) {
107
+ if (typeof arg !== 'string') continue;
108
+ if (arg.startsWith('--')) {
109
+ const body = arg.slice(2);
110
+ if (!body) continue;
111
+ // --no-foo => foo=false
112
+ if (body.startsWith('no-')) {
113
+ const key = body.slice(3);
114
+ if (key) flags[key] = false;
115
+ continue;
116
+ }
117
+ const eqIdx = body.indexOf('=');
118
+ if (eqIdx === -1) {
119
+ flags[body] = true;
120
+ } else {
121
+ const key = body.slice(0, eqIdx);
122
+ const value = body.slice(eqIdx + 1).toLowerCase();
123
+ flags[key] = !(value === 'false' || value === '0' || value === 'no' || value === 'off');
124
+ }
125
+ } else {
126
+ positionals.push(arg);
127
+ }
128
+ }
129
+ return { positionals, flags };
130
+ }
131
+
132
+ /**
133
+ * Issue #1805: Spawner used by the merge queue's auto-resolve pass. For each
134
+ * skipped PR we dispatch a `solve <pr-url> --auto-merge` session through
135
+ * the same `start-screen` runtime the bot uses everywhere else. Keeping this
136
+ * in one place means the per-PR sessions behave exactly like any other
137
+ * `/solve` invocation (same logs, same /watch, same isolation backend).
138
+ *
139
+ * @param {Object} target - Info for the conflicted PR.
140
+ * @param {string} target.url - PR HTML URL passed to `solve`.
141
+ * @param {boolean} verbose - Forwarded to the underlying spawn.
142
+ * @returns {Promise<{ success: boolean, sessionName: string|null, error: string|null, warning: string|null }>}
143
+ */
144
+ async function spawnAutoResolveSolve(target, verbose) {
145
+ if (!target || !target.url) {
146
+ return { success: false, sessionName: null, error: 'missing PR URL', warning: null };
147
+ }
148
+ const args = [target.url, '--auto-merge'];
149
+ try {
150
+ const result = await executeStartScreen('solve', args, { verbose });
151
+ if (result.warning) {
152
+ return { success: false, sessionName: null, error: null, warning: result.warning };
153
+ }
154
+ if (!result.success) {
155
+ return { success: false, sessionName: null, error: result.error || 'spawn failed', warning: null };
156
+ }
157
+ const match = result.output && (result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -R\s+(\S+)/));
158
+ const sessionName = match ? match[1] : null;
159
+ return { success: true, sessionName, error: null, warning: null };
160
+ } catch (error) {
161
+ return { success: false, sessionName: null, error: error.message || String(error), warning: null };
162
+ }
163
+ }
164
+
93
165
  /**
94
166
  * Format user-friendly error message
95
167
  * Hides debug info unless verbose mode is enabled
@@ -175,13 +247,17 @@ export function registerMergeCommand(bot, options) {
175
247
 
176
248
  // Parse arguments
177
249
  const args = parseCommandArgs(ctx.message.text);
250
+ // Issue #1805: split positional args from `--auto-resolve` style flags so
251
+ // the repository URL parsing still sees only the URL token.
252
+ const { positionals, flags } = parseMergeArgs(args);
253
+ const autoResolve = flags['auto-resolve'] === true;
178
254
 
179
- if (args.length === 0) {
180
- return await ctx.reply("Missing repository URL\\.\n\nUsage: `/merge <repository-url>`\n\nExample: `/merge https://github.com/owner/repo`\n\nThis will merge all PRs with the 'ready' label, one by one, waiting for CI/CD between each merge\\.", { parse_mode: 'MarkdownV2', reply_to_message_id: ctx.message.message_id });
255
+ if (positionals.length === 0) {
256
+ return await ctx.reply("Missing repository URL\\.\n\nUsage: `/merge <repository-url> [--auto-resolve]`\n\nExample: `/merge https://github.com/owner/repo`\n\nThis will merge all PRs with the 'ready' label, one by one, waiting for CI/CD between each merge\\.\n\nWith `--auto-resolve` the bot also dispatches `/solve <pr> --auto-merge` for every PR that was skipped because of merge conflicts\\.", { parse_mode: 'MarkdownV2', reply_to_message_id: ctx.message.message_id });
181
257
  }
182
258
 
183
259
  // Parse and validate repository URL
184
- const repoUrl = args[0];
260
+ const repoUrl = positionals[0];
185
261
  const parsedUrl = parseRepositoryUrl(repoUrl);
186
262
 
187
263
  if (!parsedUrl.valid) {
@@ -234,6 +310,11 @@ export function registerMergeCommand(bot, options) {
234
310
  // Create the merge queue processor
235
311
  const processor = await createMergeQueueProcessor(owner, repo, {
236
312
  verbose: VERBOSE,
313
+ // Issue #1805: forward the --auto-resolve flag and inject the spawner.
314
+ // The processor only sees the callback, so unit tests can stub it
315
+ // without spawning real screen sessions.
316
+ autoResolve,
317
+ spawnSolveSession: autoResolve ? target => spawnAutoResolveSolve(target, VERBOSE) : null,
237
318
  onProgress: async () => {
238
319
  // Update message with progress and cancel button
239
320
  try {
@@ -44,8 +44,24 @@ export const MergeItemStatus = {
44
44
  MERGED: 'merged',
45
45
  FAILED: 'failed',
46
46
  SKIPPED: 'skipped',
47
+ // Issue #1805: states reached during the post-queue `--auto-resolve` pass.
48
+ // RESOLVING is set while a `/solve <pr> --auto-merge` session is being
49
+ // spawned for a previously-skipped PR; RESOLVE_FAILED records that the
50
+ // spawn (or the resolution itself) didn't succeed.
51
+ RESOLVING: 'resolving',
52
+ RESOLVE_FAILED: 'resolve_failed',
47
53
  };
48
54
 
55
+ /**
56
+ * Marker that identifies SKIPPED items that the auto-resolve pass should
57
+ * pick up. The same string is returned by `checkPRMergeable()` for
58
+ * `mergeStateStatus === 'DIRTY'` (see github-merge.lib.mjs), so matching
59
+ * on it keeps the two modules in sync without sharing extra state.
60
+ *
61
+ * @see https://github.com/link-assistant/hive-mind/issues/1805
62
+ */
63
+ export const MERGE_CONFLICT_SKIP_REASON = 'PR has merge conflicts';
64
+
49
65
  /**
50
66
  * Configuration for merge queue operations
51
67
  * Values are loaded from config.lib.mjs which supports environment variable overrides.
@@ -134,6 +150,11 @@ class MergeQueueItem {
134
150
  return '❌';
135
151
  case MergeItemStatus.SKIPPED:
136
152
  return '⏭️';
153
+ // Issue #1805: auto-resolve pass states.
154
+ case MergeItemStatus.RESOLVING:
155
+ return '🛠️';
156
+ case MergeItemStatus.RESOLVE_FAILED:
157
+ return '⚠️';
137
158
  default:
138
159
  return '❓';
139
160
  }
@@ -152,6 +173,12 @@ export class MergeQueueProcessor {
152
173
  this.onProgress = options.onProgress || null;
153
174
  this.onComplete = options.onComplete || null;
154
175
  this.onError = options.onError || null;
176
+ // Issue #1805: when true the queue runs a second pass after the normal
177
+ // merge loop, spawning `/solve <pr> --auto-merge` for every PR that was
178
+ // SKIPPED due to merge conflicts. The actual spawner is injected so
179
+ // tests can run without touching the screen runtime.
180
+ this.autoResolve = options.autoResolve === true;
181
+ this.spawnSolveSession = typeof options.spawnSolveSession === 'function' ? options.spawnSolveSession : null;
155
182
 
156
183
  // State
157
184
  this.items = [];
@@ -161,6 +188,9 @@ export class MergeQueueProcessor {
161
188
  this.startedAt = null;
162
189
  this.completedAt = null;
163
190
  this.error = null;
191
+ // Issue #1805: track auto-resolve progress so the renderer can surface it.
192
+ this.autoResolveActive = false;
193
+ this.autoResolveCurrent = null;
164
194
 
165
195
  // Statistics
166
196
  this.stats = {
@@ -168,6 +198,11 @@ export class MergeQueueProcessor {
168
198
  merged: 0,
169
199
  failed: 0,
170
200
  skipped: 0,
201
+ // Issue #1805: number of skipped conflict PRs the auto-resolve pass
202
+ // successfully handed off to `solve`, and the number that failed to
203
+ // be handed off (e.g. screen runner missing).
204
+ autoResolved: 0,
205
+ autoResolveFailed: 0,
171
206
  };
172
207
  }
173
208
 
@@ -329,6 +364,15 @@ export class MergeQueueProcessor {
329
364
  }
330
365
  }
331
366
 
367
+ // Issue #1805: After the normal queue settles, optionally hand off
368
+ // every PR that was SKIPPED with a merge-conflict reason to the
369
+ // `/solve <pr> --auto-merge` flow. This lets a single `/merge`
370
+ // invocation both merge the easy PRs and dispatch conflict-resolution
371
+ // sessions for the rest.
372
+ if (this.autoResolve && !this.isCancelled) {
373
+ await this.runAutoResolve();
374
+ }
375
+
332
376
  this.completedAt = new Date();
333
377
  this.status = this.isCancelled ? MergeStatus.CANCELLED : MergeStatus.COMPLETED;
334
378
 
@@ -483,6 +527,100 @@ export class MergeQueueProcessor {
483
527
  this.log('Cancellation requested');
484
528
  }
485
529
 
530
+ /**
531
+ * Issue #1805: Return queue items that were skipped because of merge
532
+ * conflicts. These are the candidates the auto-resolve pass hands off
533
+ * to `/solve <pr> --auto-merge`.
534
+ *
535
+ * @returns {MergeQueueItem[]}
536
+ */
537
+ getConflictedItems() {
538
+ return this.items.filter(item => item.status === MergeItemStatus.SKIPPED && item.error === MERGE_CONFLICT_SKIP_REASON);
539
+ }
540
+
541
+ /**
542
+ * Issue #1805: Iterate every conflict-skipped item and hand it off to a
543
+ * `/solve <pr-url> --auto-merge` session via the injected
544
+ * `spawnSolveSession` callback. Each spawn is awaited so the bot doesn't
545
+ * fan out unbounded Claude sessions. The PR's status is updated as the
546
+ * spawn succeeds or fails.
547
+ *
548
+ * @returns {Promise<void>}
549
+ */
550
+ async runAutoResolve() {
551
+ const conflicted = this.getConflictedItems();
552
+ if (conflicted.length === 0) {
553
+ this.log('Auto-resolve: no merge-conflict skips to process');
554
+ return;
555
+ }
556
+
557
+ if (!this.spawnSolveSession) {
558
+ // Guard against misconfiguration — the queue can't resolve without a
559
+ // spawner. Surface this to the user via the same channel as other
560
+ // queue feedback rather than throwing.
561
+ this.log(`Auto-resolve: ${conflicted.length} conflict PR(s) but no spawnSolveSession callback provided`);
562
+ for (const item of conflicted) {
563
+ item.status = MergeItemStatus.RESOLVE_FAILED;
564
+ item.autoResolveError = 'auto-resolve is not configured';
565
+ this.stats.autoResolveFailed++;
566
+ }
567
+ if (this.onProgress) {
568
+ await this.onProgress(this.getProgressUpdate());
569
+ }
570
+ return;
571
+ }
572
+
573
+ this.autoResolveActive = true;
574
+ this.log(`Auto-resolve: dispatching ${conflicted.length} conflict PR(s) to /solve --auto-merge`);
575
+ try {
576
+ for (const item of conflicted) {
577
+ if (this.isCancelled) {
578
+ this.log('Auto-resolve: cancelled mid-pass');
579
+ break;
580
+ }
581
+
582
+ item.status = MergeItemStatus.RESOLVING;
583
+ this.autoResolveCurrent = item.pr.number;
584
+ if (this.onProgress) {
585
+ await this.onProgress(this.getProgressUpdate());
586
+ }
587
+
588
+ try {
589
+ const result = await this.spawnSolveSession({
590
+ url: item.pr.url,
591
+ owner: this.owner,
592
+ repo: this.repo,
593
+ prNumber: item.pr.number,
594
+ title: item.pr.title,
595
+ });
596
+
597
+ if (result && result.success) {
598
+ item.autoResolveSession = result.sessionName || result.session || null;
599
+ this.stats.autoResolved++;
600
+ this.log(`Auto-resolve: spawned solve session for PR #${item.pr.number}${item.autoResolveSession ? ` (session ${item.autoResolveSession})` : ''}`);
601
+ } else {
602
+ item.status = MergeItemStatus.RESOLVE_FAILED;
603
+ item.autoResolveError = (result && (result.error || result.warning)) || 'spawn failed';
604
+ this.stats.autoResolveFailed++;
605
+ this.log(`Auto-resolve: failed to spawn solve session for PR #${item.pr.number}: ${item.autoResolveError}`);
606
+ }
607
+ } catch (error) {
608
+ item.status = MergeItemStatus.RESOLVE_FAILED;
609
+ item.autoResolveError = error.message || String(error);
610
+ this.stats.autoResolveFailed++;
611
+ console.error(`[ERROR] /merge-queue: auto-resolve error for PR #${item.pr.number}: ${item.autoResolveError}`);
612
+ }
613
+
614
+ if (this.onProgress) {
615
+ await this.onProgress(this.getProgressUpdate());
616
+ }
617
+ }
618
+ } finally {
619
+ this.autoResolveActive = false;
620
+ this.autoResolveCurrent = null;
621
+ }
622
+ }
623
+
486
624
  /**
487
625
  * Wait for any active CI runs on the target branch to complete
488
626
  * Issue #1307: Prevents merging while post-merge CI from previous merges is still running
@@ -658,6 +796,13 @@ export class MergeQueueProcessor {
658
796
  status: this.status,
659
797
  current: currentItem ? currentItem.getDescription() : null,
660
798
  currentStatus: currentItem ? currentItem.status : null,
799
+ // Issue #1805: surface auto-resolve progress so renderers/tests can
800
+ // show what's happening during the post-queue pass.
801
+ autoResolve: {
802
+ enabled: this.autoResolve,
803
+ active: this.autoResolveActive,
804
+ currentPrNumber: this.autoResolveCurrent,
805
+ },
661
806
  progress: {
662
807
  processed,
663
808
  total: this.stats.total,
@@ -666,10 +811,17 @@ export class MergeQueueProcessor {
666
811
  stats: { ...this.stats },
667
812
  items: this.items.map(item => ({
668
813
  prNumber: item.pr.number,
814
+ // Issue #1805: expose PR/issue URLs so renderers can produce
815
+ // clickable links instead of plain `\#NNN` text.
816
+ prUrl: item.pr.url || null,
817
+ issueNumber: item.issue ? item.issue.number : null,
818
+ issueUrl: item.issue ? item.issue.url || null : null,
669
819
  title: item.pr.title,
670
820
  status: item.status,
671
821
  error: item.error,
672
822
  emoji: item.getStatusEmoji(),
823
+ autoResolveSession: item.autoResolveSession || null,
824
+ autoResolveError: item.autoResolveError || null,
673
825
  })),
674
826
  };
675
827
  }
@@ -684,13 +836,24 @@ export class MergeQueueProcessor {
684
836
  status: this.status,
685
837
  duration: `${Math.floor(duration / 60)}m ${duration % 60}s`,
686
838
  stats: { ...this.stats },
839
+ autoResolve: {
840
+ enabled: this.autoResolve,
841
+ resolved: this.stats.autoResolved,
842
+ failed: this.stats.autoResolveFailed,
843
+ },
687
844
  items: this.items.map(item => ({
688
845
  prNumber: item.pr.number,
846
+ // Issue #1805: expose PR/issue URLs so renderers can produce
847
+ // clickable links instead of plain `\#NNN` text.
848
+ prUrl: item.pr.url || null,
689
849
  title: item.pr.title,
690
850
  issueNumber: item.issue ? item.issue.number : null,
851
+ issueUrl: item.issue ? item.issue.url || null : null,
691
852
  status: item.status,
692
853
  error: item.error,
693
854
  emoji: item.getStatusEmoji(),
855
+ autoResolveSession: item.autoResolveSession || null,
856
+ autoResolveError: item.autoResolveError || null,
694
857
  })),
695
858
  };
696
859
  }
@@ -748,11 +911,25 @@ export class MergeQueueProcessor {
748
911
  message += `⏱️ Waiting for post\\-merge CI \\(PR \\#${this.currentPostMergePR}\\)\\.\\.\\.\n\n`;
749
912
  }
750
913
 
751
- // Current item being processed
752
- if (update.current && !this.waitingForTargetBranchCI && !this.waitingForPostMergeCI) {
914
+ // Issue #1805: surface the auto-resolve pass when it is currently
915
+ // active. This appears in place of "current item" because by then the
916
+ // main queue loop has finished.
917
+ if (update.autoResolve && update.autoResolve.active && update.autoResolve.currentPrNumber) {
918
+ const activeItem = update.items.find(it => it.prNumber === update.autoResolve.currentPrNumber);
919
+ const link = activeItem ? this.formatPrLink(activeItem.prNumber, activeItem.title, activeItem.prUrl) : `\\#${update.autoResolve.currentPrNumber}`;
920
+ message += `🛠️ Auto\\-resolving ${link}\n\n`;
921
+ } else if (update.current && !this.waitingForTargetBranchCI && !this.waitingForPostMergeCI) {
922
+ // Current item being processed
753
923
  const statusEmoji = update.currentStatus === MergeItemStatus.WAITING_CI ? '⏱️' : '🔄';
754
- // Issue #1339: escape the current item description for MarkdownV2
755
- message += `${statusEmoji} ${this.escapeMarkdown(update.current)}\n\n`;
924
+ const currentItem = this.items[this.currentIndex];
925
+ if (currentItem) {
926
+ const link = this.formatPrLink(currentItem.pr.number, currentItem.pr.title, currentItem.pr.url);
927
+ const issueSuffix = this.formatIssueRef(currentItem.issue ? currentItem.issue.number : null, currentItem.issue ? currentItem.issue.url : null);
928
+ message += `${statusEmoji} ${link}${issueSuffix}\n\n`;
929
+ } else {
930
+ // Fallback: escape the description if we somehow don't have an item handle
931
+ message += `${statusEmoji} ${this.escapeMarkdown(update.current)}\n\n`;
932
+ }
756
933
  }
757
934
 
758
935
  // Show errors/failures/skips inline so user gets immediate feedback (Issue #1269, #1294)
@@ -762,8 +939,9 @@ export class MergeQueueProcessor {
762
939
  message += `⚠️ *Issues:*\n`;
763
940
  for (const item of problemItems.slice(0, 5)) {
764
941
  const statusEmoji = item.status === MergeItemStatus.FAILED ? '❌' : '⏭️';
765
- // Issue #1339: escape the ellipsis '...' for MarkdownV2 (periods are reserved)
766
- message += ` ${statusEmoji} \\#${item.prNumber}: ${this.escapeMarkdown(item.error.substring(0, 50))}${item.error.length > 50 ? '\\.\\.\\.' : ''}\n`;
942
+ // Issue #1805: emit the PR reference as a clickable link instead of plain text.
943
+ const prRef = this.formatPrLink(item.prNumber, '', item.prUrl);
944
+ message += ` ${statusEmoji} ${prRef}: ${this.escapeMarkdown(item.error.substring(0, 50))}${item.error.length > 50 ? '\\.\\.\\.' : ''}\n`;
767
945
  }
768
946
  if (problemItems.length > 5) {
769
947
  // Issue #1339: escape the ellipsis '...' for MarkdownV2
@@ -772,11 +950,10 @@ export class MergeQueueProcessor {
772
950
  message += '\n';
773
951
  }
774
952
 
775
- // PRs list with emojis
953
+ // PRs list with emojis (Issue #1805: render as clickable MarkdownV2 links)
776
954
  message += `*Queue:*\n`;
777
955
  for (const item of update.items.slice(0, 10)) {
778
- // Issue #1339: escape the ellipsis '...' for MarkdownV2 (periods are reserved)
779
- message += `${item.emoji} \\#${item.prNumber}: ${this.escapeMarkdown(item.title.substring(0, 35))}${item.title.length > 35 ? '\\.\\.\\.' : ''}\n`;
956
+ message += `${item.emoji} ${this.formatPrLink(item.prNumber, item.title, item.prUrl)}\n`;
780
957
  }
781
958
 
782
959
  if (update.items.length > 10) {
@@ -830,7 +1007,20 @@ export class MergeQueueProcessor {
830
1007
  message += `✅ Merged: ${report.stats.merged} `;
831
1008
  message += `❌ Failed: ${report.stats.failed} `;
832
1009
  message += `⏭️ Skipped: ${report.stats.skipped} `;
833
- message += `📋 Total: ${report.stats.total}\n\n`;
1010
+ message += `📋 Total: ${report.stats.total}\n`;
1011
+
1012
+ // Issue #1805: surface the auto-resolve pass summary when it ran. We
1013
+ // always show the line when the flag was set so users see "0 dispatched"
1014
+ // when there was nothing to do.
1015
+ if (report.autoResolve && report.autoResolve.enabled) {
1016
+ message += `🛠️ Auto\\-resolve dispatched: ${report.autoResolve.resolved}`;
1017
+ if (report.autoResolve.failed > 0) {
1018
+ message += ` ⚠️ Auto\\-resolve failed: ${report.autoResolve.failed}`;
1019
+ }
1020
+ message += '\n';
1021
+ }
1022
+
1023
+ message += '\n';
834
1024
 
835
1025
  // Issue #1341: Show branch CI health failure details if applicable
836
1026
  if (this.branchCIFailedRuns && this.branchCIFailedRuns.length > 0) {
@@ -862,19 +1052,25 @@ export class MergeQueueProcessor {
862
1052
  message += '\n';
863
1053
  }
864
1054
 
865
- // Details
1055
+ // Details (Issue #1805: render PR and issue references as clickable
1056
+ // MarkdownV2 links so the user can jump directly to the PR or issue).
866
1057
  if (report.items.length > 0) {
867
1058
  message += `*Results:*\n`;
868
1059
  for (const item of report.items) {
869
- const issueRef = item.issueNumber ? ` \\(Issue \\#${item.issueNumber}\\)` : '';
1060
+ const prLink = this.formatPrLink(item.prNumber, item.title, item.prUrl);
1061
+ const issueRef = this.formatIssueRef(item.issueNumber, item.issueUrl);
870
1062
  // Issue #1294: Show skip/fail reason so users understand what action is required
871
1063
  let reasonText = '';
872
- if (item.error && (item.status === MergeItemStatus.SKIPPED || item.status === MergeItemStatus.FAILED)) {
1064
+ const isAutoResolveState = item.status === MergeItemStatus.RESOLVING || item.status === MergeItemStatus.RESOLVE_FAILED;
1065
+ if (item.autoResolveError && isAutoResolveState) {
1066
+ const truncated = item.autoResolveError.length > 50 ? item.autoResolveError.substring(0, 47) + '...' : item.autoResolveError;
1067
+ reasonText = ` \\(${this.escapeMarkdown(truncated)}\\)`;
1068
+ } else if (item.error && (item.status === MergeItemStatus.SKIPPED || item.status === MergeItemStatus.FAILED)) {
873
1069
  // Truncate long reasons and escape for MarkdownV2
874
1070
  const truncatedReason = item.error.length > 50 ? item.error.substring(0, 47) + '...' : item.error;
875
1071
  reasonText = `: ${this.escapeMarkdown(truncatedReason)}`;
876
1072
  }
877
- message += `${item.emoji} \\#${item.prNumber}${issueRef}${reasonText}\n`;
1073
+ message += `${item.emoji} ${prLink}${issueRef}${reasonText}\n`;
878
1074
  }
879
1075
  }
880
1076
 
@@ -888,6 +1084,43 @@ export class MergeQueueProcessor {
888
1084
  return String(text).replace(/[_*[\]()~`>#+\-=|{}.!]/g, '\\$&');
889
1085
  }
890
1086
 
1087
+ /**
1088
+ * Issue #1805: Escape `)` and `\` inside a URL for a MarkdownV2 inline link.
1089
+ * URLs must NOT be passed through `escapeMarkdown()` because that would also
1090
+ * mangle characters that are valid inside URLs (`.`, `-`, `_`, etc.).
1091
+ */
1092
+ escapeMarkdownLinkUrl(url) {
1093
+ return String(url).replace(/[\\)]/g, '\\$&');
1094
+ }
1095
+
1096
+ /**
1097
+ * Issue #1805: Build a clickable MarkdownV2 link for a PR's `\#N: title`
1098
+ * reference. Falls back to plain escaped text when no URL is available so
1099
+ * the message still renders correctly on legacy items.
1100
+ */
1101
+ formatPrLink(prNumber, title, url, options = {}) {
1102
+ const maxTitle = typeof options.maxTitle === 'number' ? options.maxTitle : 35;
1103
+ const trimmedTitle = title || '';
1104
+ const truncated = trimmedTitle.length > maxTitle ? trimmedTitle.substring(0, maxTitle) : trimmedTitle;
1105
+ const ellipsis = trimmedTitle.length > maxTitle ? '\\.\\.\\.' : '';
1106
+ const titlePart = trimmedTitle ? `: ${this.escapeMarkdown(truncated)}${ellipsis}` : '';
1107
+ const label = `\\#${prNumber}${titlePart}`;
1108
+ if (!url) return label;
1109
+ return `[${label}](${this.escapeMarkdownLinkUrl(url)})`;
1110
+ }
1111
+
1112
+ /**
1113
+ * Issue #1805: Build the ` (Issue #N)` suffix as a clickable link. The
1114
+ * outer parentheses are literal MarkdownV2 (escaped), so the inner inline
1115
+ * link is not nested inside another entity.
1116
+ */
1117
+ formatIssueRef(issueNumber, url) {
1118
+ if (!issueNumber) return '';
1119
+ const label = `Issue \\#${issueNumber}`;
1120
+ if (!url) return ` \\(${label}\\)`;
1121
+ return ` \\([${label}](${this.escapeMarkdownLinkUrl(url)})\\)`;
1122
+ }
1123
+
891
1124
  /**
892
1125
  * Sleep helper
893
1126
  */