@link-assistant/hive-mind 1.70.0 → 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,35 @@
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
+
3
33
  ## 1.70.0
4
34
 
5
35
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.70.0",
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) {