@link-assistant/hive-mind 2.0.26 → 2.0.27

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,11 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.0.27
4
+
5
+ ### Patch Changes
6
+
7
+ - d3fdb7b: Respect explicit `--base-branch` through solve sessions by instructing agents not to retarget PRs and restoring the requested PR base before verification or auto-merge handling.
8
+
3
9
  ## 2.0.26
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.0.26",
3
+ "version": "2.0.27",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -7,6 +7,7 @@ import { getArchitectureCareSubPrompt } from './architecture-care.prompts.lib.mj
7
7
  import { getExperimentsExamplesSubPrompt } from './experiments-examples.prompts.lib.mjs';
8
8
  import { getThinkingPromptInstruction } from './thinking-prompt.lib.mjs';
9
9
  import { buildWorkLanguageDirective } from './work-language.prompts.lib.mjs';
10
+ import { buildRequestedBaseBranchDirective } from './solve-option-contract.prompts.lib.mjs';
10
11
 
11
12
  /**
12
13
  * Build the user prompt for Agent
@@ -50,6 +51,11 @@ export const buildUserPrompt = params => {
50
51
  }
51
52
  }
52
53
 
54
+ const requestedBaseBranchDirective = buildRequestedBaseBranchDirective(argv);
55
+ if (requestedBaseBranchDirective) {
56
+ promptLines.push(requestedBaseBranchDirective);
57
+ }
58
+
53
59
  // Add blank line
54
60
  promptLines.push('');
55
61
 
@@ -790,6 +790,20 @@ export const createBidirectionalHandler = options => {
790
790
  return writeFrameToStdin(state.claudeStdin, frame, log, verbose);
791
791
  };
792
792
 
793
+ /**
794
+ * Stream a non-comment feedback message into the attached Claude stdin.
795
+ *
796
+ * @param {string} feedbackText
797
+ * @param {Object} [options]
798
+ * @param {string} [options.kind='metadata']
799
+ * @returns {Promise<boolean>} Whether the write succeeded
800
+ */
801
+ const sendFeedback = async (feedbackText, options = {}) => {
802
+ if (!state.claudeStdin) return false;
803
+ const frame = formatFeedbackForClaude(feedbackText, { kind: options.kind || 'metadata' });
804
+ return writeFrameToStdin(state.claudeStdin, frame, log, verbose);
805
+ };
806
+
793
807
  /**
794
808
  * Get current handler state (for debugging)
795
809
  *
@@ -828,6 +842,7 @@ export const createBidirectionalHandler = options => {
828
842
  attachClaudeStdin,
829
843
  detachClaudeStdin,
830
844
  streamInitialPrompt,
845
+ sendFeedback,
831
846
  // Issue #1708: queue mode + status streaming
832
847
  markAiBusy,
833
848
  markAiIdle,
@@ -34,11 +34,10 @@ import { createThinkingBlockRecovery } from './claude.thinking-block-recovery.li
34
34
  import { buildMissingClaudeResultMessage, collectClaudeStreamEventFacts, getClaudeMessageContent, shouldFailClaudeStreamWithoutResult } from './claude.stream-events.lib.mjs';
35
35
  import { formatNumber, mapModelToId, checkModelVisionCapability } from './claude.model-utils.lib.mjs';
36
36
  import { showResumeCommand } from './claude.resume-output.lib.mjs';
37
+ import { createPullRequestBaseBranchCommandIntervention } from './solve.pr-base-command-intervention.lib.mjs';
37
38
  export { availableModels, fetchModelInfo }; // Re-export for backward compatibility
38
39
  export { formatNumber, mapModelToId, checkModelVisionCapability };
39
- // Function to validate Claude CLI connection with retry logic
40
40
  export const validateClaudeConnection = async (model = 'haiku') => {
41
- // Map model alias to full ID
42
41
  const mappedModel = mapModelToId(model);
43
42
  const maxRetries = 3;
44
43
  const baseDelay = timeouts.retryBaseDelay;
@@ -60,14 +59,12 @@ export const validateClaudeConnection = async (model = 'haiku') => {
60
59
  }
61
60
  }
62
61
  } catch (versionError) {
63
- // Version check failed, but we'll continue with the main validation
64
62
  if (retryCount === 0) {
65
63
  await log(`⚠️ Claude CLI version check failed (${versionError.code}), proceeding with connection test...`);
66
64
  }
67
65
  }
68
66
  let result;
69
67
  try {
70
- // Primary validation: use printf piping with specified model
71
68
  result = await $`printf hi | claude --model ${mappedModel} -p`;
72
69
  } catch (pipeError) {
73
70
  await log(`⚠️ Pipe validation failed (${pipeError.code}), trying timeout approach...`);
@@ -109,9 +106,7 @@ export const validateClaudeConnection = async (model = 'haiku') => {
109
106
  return null;
110
107
  };
111
108
  const jsonError = checkForJsonError(stdout) || checkForJsonError(stderr);
112
- // Check for API overload error pattern (Issue #1439: also detect 529 overloaded_error)
113
109
  const isOverloadError = (stdout.includes('API Error: 500') && stdout.includes('Overloaded')) || (stdout.includes('API Error: 529') && stdout.includes('Overloaded')) || (stderr.includes('API Error: 500') && stderr.includes('Overloaded')) || (stderr.includes('API Error: 529') && stderr.includes('Overloaded')) || (jsonError && (jsonError.type === 'api_error' || jsonError.type === 'overloaded_error') && jsonError.message === 'Overloaded');
114
- // Handle overload errors with retry
115
110
  if (isOverloadError) {
116
111
  if (retryCount < maxRetries) {
117
112
  const delay = baseDelay * Math.pow(2, retryCount);
@@ -187,8 +182,7 @@ export const validateClaudeConnection = async (model = 'haiku') => {
187
182
  await log(' 💡 Make sure Claude CLI is installed and accessible', { level: 'error' });
188
183
  return false;
189
184
  }
190
- }; // End of attemptValidation function
191
- // Start the validation with retry logic
185
+ };
192
186
  return await attemptValidation();
193
187
  };
194
188
  export { handleClaudeRuntimeSwitch }; // Re-export from ./claude.runtime-switch.lib.mjs
@@ -203,18 +197,15 @@ export const setClaudeVersion = version => {
203
197
  /** Resolve thinking settings based on --think and --thinking-budget options */
204
198
  export const resolveThinkingSettings = async (argv, log) => {
205
199
  const minVersion = argv.thinkingBudgetClaudeMinimumVersion || '2.1.12';
206
- const version = detectedClaudeVersion || '0.0.0'; // Assume old version if not detected
200
+ const version = detectedClaudeVersion || '0.0.0';
207
201
  const isNewVersion = supportsThinkingBudget(version, minVersion);
208
- // Get max thinking budget from argv or use default (see issue #1146)
209
202
  const maxBudget = argv.maxThinkingBudget ?? DEFAULT_MAX_THINKING_BUDGET;
210
- // Get thinking level mappings calculated from maxBudget
211
203
  const thinkingLevelToTokens = getThinkingLevelToTokens(maxBudget);
212
204
  const tokensToThinkingLevel = getTokensToThinkingLevel(maxBudget);
213
205
  let thinkingBudget = argv.thinkingBudget;
214
206
  let thinkLevel = argv.think;
215
207
  let translation = null;
216
208
  if (isNewVersion) {
217
- // Claude Code >= 2.1.12: translate --think to --thinking-budget
218
209
  if (thinkLevel !== undefined && thinkingBudget === undefined) {
219
210
  thinkingBudget = thinkingLevelToTokens[thinkLevel];
220
211
  translation = `--think ${thinkLevel} → --thinking-budget ${thinkingBudget}`;
@@ -227,7 +218,6 @@ export const resolveThinkingSettings = async (argv, log) => {
227
218
  }
228
219
  }
229
220
  } else {
230
- // Claude Code < 2.1.12: translate --thinking-budget to --think keywords
231
221
  if (thinkingBudget !== undefined && thinkLevel === undefined) {
232
222
  thinkLevel = tokensToThinkingLevel(thinkingBudget);
233
223
  translation = `--thinking-budget ${thinkingBudget} → --think ${thinkLevel}`;
@@ -235,7 +225,6 @@ export const resolveThinkingSettings = async (argv, log) => {
235
225
  await log(`📊 Translating for Claude Code ${version} (< ${minVersion}):`, { verbose: true });
236
226
  await log(` ${translation}`, { verbose: true });
237
227
  }
238
- // Clear thinkingBudget since old versions don't support it
239
228
  thinkingBudget = undefined;
240
229
  }
241
230
  }
@@ -243,10 +232,8 @@ export const resolveThinkingSettings = async (argv, log) => {
243
232
  };
244
233
  /** Check if Playwright MCP is available and connected to Claude @returns {Promise<boolean>} */
245
234
  export const checkPlaywrightMcpAvailability = ensureClaudePlaywrightMcpServer;
246
- /** Execute Claude with all prompts and settings - main entry point */
247
235
  export const executeClaude = async params => {
248
236
  const { issueUrl, issueNumber, prNumber, prUrl, branchName, tempDir, workspaceTmpDir, isContinueMode, mergeStateStatus, forkedRepo, feedbackLines, forkActionsUrl, owner, repo, argv, log, setLogFile, getLogFile, formatAligned, getResourceSnapshot, claudePath, $ } = params;
249
- // Check if agent-commander is installed when the option is enabled
250
237
  if (argv.promptSubagentsViaAgentCommander) {
251
238
  try {
252
239
  await $`which start-agent`;
@@ -256,9 +243,7 @@ export const executeClaude = async params => {
256
243
  await log('⚠️ agent-commander not installed; prompt guidance will be skipped (npm i -g @link-assistant/agent-commander)');
257
244
  }
258
245
  }
259
- // Import prompt building functions from claude.prompts.lib.mjs
260
246
  const { buildUserPrompt, buildSystemPrompt } = await import('./claude.prompts.lib.mjs');
261
- // Check if the model supports vision using models.dev API
262
247
  const mappedModel = mapModelToId(argv.model);
263
248
  const modelSupportsVision = await checkModelVisionCapability(mappedModel);
264
249
  if (argv.verbose) {
@@ -304,7 +289,6 @@ export const executeClaude = async params => {
304
289
  if (feedbackLines && feedbackLines.length > 0) {
305
290
  await log(' Feedback info: Included', { verbose: true });
306
291
  }
307
- // In dry-run mode, output the actual prompts for debugging
308
292
  if (argv.dryRun) {
309
293
  await log('\n📋 User prompt content:', { verbose: true });
310
294
  await log('---BEGIN USER PROMPT---', { verbose: true });
@@ -359,41 +343,30 @@ export const calculateSessionTokens = async (sessionId, tempDir, resultModelUsag
359
343
  const os = (await use('os')).default;
360
344
  const homeDir = options.homeDir || os.homedir();
361
345
  const fetchModelInfoForUsage = options.fetchModelInfo || fetchModelInfo;
362
- // Construct the path to the session JSONL file
363
- // Format: ~/.claude/projects/<project-dir>/<session-id>.jsonl
364
- // The project directory name is the full path with slashes replaced by dashes
365
- // e.g., /tmp/gh-issue-solver-123 becomes -tmp-gh-issue-solver-123
366
346
  const projectDirName = tempDir.replace(/\//g, '-');
367
347
  const sessionFile = path.join(homeDir, '.claude', 'projects', projectDirName, `${sessionId}.jsonl`);
368
348
  try {
369
349
  await fs.access(sessionFile);
370
350
  } catch {
371
- // File doesn't exist yet or can't be accessed
372
351
  return null;
373
352
  }
374
- // Initialize per-model usage tracking
375
353
  const modelUsage = {};
376
354
  // Issue #1501: Deduplicate JSONL entries by message ID (stream-json splits responses)
377
355
  const seenMessageIds = new Set();
378
356
  let duplicateCount = 0;
379
- // Issue #1501: Track peak context usage per request (not cumulative)
380
357
  const peakContextByModel = {};
381
358
  let globalPeakContext = 0;
382
- // Issue #1491: Track sub-sessions between compactification events
383
359
  const subSessions = [];
384
360
  let currentSubSession = createEmptySubSessionUsage();
385
361
  const compactifications = [];
386
362
  try {
387
- // Read the entire file
388
363
  const fileContent = await fs.readFile(sessionFile, 'utf8');
389
364
  const lines = fileContent.trim().split('\n');
390
365
  for (const line of lines) {
391
366
  if (!line.trim()) continue;
392
367
  try {
393
368
  const entry = JSON.parse(line);
394
- // Issue #1491: Detect compactification boundary events
395
369
  if (entry.type === 'system' && entry.subtype === 'compact_boundary') {
396
- // Save current sub-session and start a new one
397
370
  if (currentSubSession.messageCount > 0) {
398
371
  subSessions.push(currentSubSession);
399
372
  }
@@ -411,7 +384,7 @@ export const calculateSessionTokens = async (sessionId, tempDir, resultModelUsag
411
384
  if (msgId) {
412
385
  if (seenMessageIds.has(msgId)) {
413
386
  duplicateCount++;
414
- continue; // Skip — already counted this message's usage
387
+ continue;
415
388
  }
416
389
  seenMessageIds.add(msgId);
417
390
  }
@@ -429,7 +402,6 @@ export const calculateSessionTokens = async (sessionId, tempDir, resultModelUsag
429
402
  if (requestContext > globalPeakContext) {
430
403
  globalPeakContext = requestContext;
431
404
  }
432
- // Issue #1491: Also track per-sub-session usage
433
405
  if (usage.input_tokens) currentSubSession.inputTokens += usage.input_tokens;
434
406
  if (usage.cache_creation_input_tokens) currentSubSession.cacheCreationTokens += usage.cache_creation_input_tokens;
435
407
  if (usage.cache_read_input_tokens) currentSubSession.cacheReadTokens += usage.cache_read_input_tokens;
@@ -448,16 +420,13 @@ export const calculateSessionTokens = async (sessionId, tempDir, resultModelUsag
448
420
  continue;
449
421
  }
450
422
  }
451
- // Push the final sub-session
452
423
  if (currentSubSession.messageCount > 0) {
453
424
  subSessions.push(currentSubSession);
454
425
  }
455
426
  mergeResultModelUsage(modelUsage, resultModelUsage);
456
- // If no usage data was found, return null
457
427
  if (Object.keys(modelUsage).length === 0) {
458
428
  return null;
459
429
  }
460
- // Fetch model information for each model
461
430
  const modelInfoPromises = Object.keys(modelUsage).map(async modelId => {
462
431
  const modelInfo = await fetchModelInfoForUsage(modelId);
463
432
  return { modelId, modelInfo };
@@ -469,7 +438,6 @@ export const calculateSessionTokens = async (sessionId, tempDir, resultModelUsag
469
438
  modelInfoMap[modelId] = modelInfo;
470
439
  }
471
440
  }
472
- // Calculate cost for each model and store all characteristics
473
441
  for (const [modelId, usage] of Object.entries(modelUsage)) {
474
442
  const modelInfo = modelInfoMap[modelId];
475
443
  // Issue #1501: Attach peak context usage per model
@@ -480,7 +448,7 @@ export const calculateSessionTokens = async (sessionId, tempDir, resultModelUsag
480
448
  usage.costUSD = costData.total;
481
449
  usage.costBreakdown = costData.breakdown;
482
450
  usage.modelName = modelInfo.name || modelId;
483
- usage.modelInfo = modelInfo; // Store complete model info
451
+ usage.modelInfo = modelInfo;
484
452
  } else {
485
453
  usage.costUSD = usage._resultCostUSD ?? null;
486
454
  usage.costBreakdown = null;
@@ -491,7 +459,6 @@ export const calculateSessionTokens = async (sessionId, tempDir, resultModelUsag
491
459
  usage.modelInfo = ctx || out ? { limit: { context: ctx || null, output: out || null } } : null;
492
460
  }
493
461
  }
494
- // Calculate grand totals across all models
495
462
  let totalInputTokens = 0;
496
463
  let totalCacheCreationTokens = 0;
497
464
  let totalCacheReadTokens = 0;
@@ -508,12 +475,9 @@ export const calculateSessionTokens = async (sessionId, tempDir, resultModelUsag
508
475
  hasCostData = true;
509
476
  }
510
477
  }
511
- // Calculate total tokens (input + cache_creation + output, cache_read doesn't count as new tokens)
512
478
  const totalTokens = totalInputTokens + totalCacheCreationTokens + totalOutputTokens;
513
479
  return {
514
- // Per-model breakdown
515
480
  modelUsage,
516
- // Grand totals
517
481
  inputTokens: totalInputTokens,
518
482
  cacheCreationTokens: totalCacheCreationTokens,
519
483
  cacheReadTokens: totalCacheReadTokens,
@@ -540,7 +504,6 @@ export const executeClaudeCommand = async params => {
540
504
  branchName,
541
505
  prompt,
542
506
  systemPrompt,
543
- escapedPrompt,
544
507
  escapedSystemPrompt,
545
508
  argv,
546
509
  log,
@@ -560,22 +523,19 @@ export const executeClaudeCommand = async params => {
560
523
  // and issue body/title polling in setupBidirectionalHandler.
561
524
  issueNumber,
562
525
  } = params;
563
- // Issue #817: Apply bidirectional-mode composition and tool-support validation before running.
564
- // This may enable argv.interactiveMode, argv.acceptIncommingCommentsAsInput, and
565
- // argv.excludeAllOwnIncommingCommentsFromInput when --bidirectional-interactive-mode is set.
526
+ const expectedBaseBranch = String(argv?.baseBranch || '').trim();
527
+ const escapePromptForShell = promptText => String(promptText).replace(/"/g, '\\"').replace(/\$/g, '\\$');
566
528
  await validateBidirectionalModeConfig(argv, log);
567
- // Issue #1331: Unified retry configuration for all transient API errors
568
- // (Overloaded, 503 Network Error, Internal Server Error) - same params, all with session preservation
569
529
  let retryCount = 0;
530
+ let baseBranchInterventionPrompt = null;
531
+ let baseBranchInterventionResumeCount = 0;
570
532
  // Issue #1834 (PR #1835 feedback): corrupted-thinking-block recovery — resume the session first,
571
533
  // then escalate to a fresh restart, auto-committing uncommitted work before each attempt. Created
572
534
  // once so its resume/restart caps persist across recursive retry calls.
573
535
  const tryThinkingBlockRecovery = createThinkingBlockRecovery({ argv, tempDir, branchName, $, log });
574
- // Helper `waitWithCountdown` (per-minute countdown for delays >1 minute, Issue #1331) is shared
575
- // from tool-retry.lib.mjs so claude/codex/gemini/qwen/opencode all use one implementation.
576
- // Function to execute with retry logic
577
536
  const executeWithRetry = async () => {
578
- // Execute claude command from the cloned repository directory
537
+ const promptForAttempt = baseBranchInterventionPrompt ? `${prompt}\n\n${baseBranchInterventionPrompt}\n` : prompt;
538
+ const escapedPromptForAttempt = escapePromptForShell(promptForAttempt);
579
539
  if (retryCount === 0) {
580
540
  await log(`\n${formatAligned('🤖', 'Executing Claude:', argv.model.toUpperCase())}`);
581
541
  } else {
@@ -585,7 +545,7 @@ export const executeClaudeCommand = async params => {
585
545
  // Issue #1949: logExecutionContext shows the requested alias with its resolved
586
546
  // full ID (e.g. "opus (claude-opus-4-8)"). The old `argv.model === 'opus' ?
587
547
  // 'opus' : 'sonnet'` heuristic mislabelled every non-"opus" alias as "sonnet".
588
- await logExecutionContext({ log, model: argv.model, tool: 'claude', tempDir, branchName, promptLength: prompt.length, systemPromptLength: systemPrompt.length, feedbackLines });
548
+ await logExecutionContext({ log, model: argv.model, tool: 'claude', tempDir, branchName, promptLength: promptForAttempt.length, systemPromptLength: systemPrompt.length, feedbackLines });
589
549
  }
590
550
  const resourcesBefore = await getResourceSnapshot();
591
551
  await log('📈 System resources before execution:', { verbose: true });
@@ -628,7 +588,6 @@ export const executeClaudeCommand = async params => {
628
588
  outputTokens: 0,
629
589
  eventCount: 0,
630
590
  };
631
- // Create interactive mode handler if enabled
632
591
  let interactiveHandler = null;
633
592
  if (argv.interactiveMode && owner && repo && prNumber) {
634
593
  await log('🔌 Interactive mode: Creating handler for real-time PR comments', { verbose: true });
@@ -649,10 +608,6 @@ export const executeClaudeCommand = async params => {
649
608
  } else if (argv.interactiveMode) {
650
609
  await log('⚠️ Interactive mode: Disabled - missing PR info (owner/repo/prNumber)', { verbose: true });
651
610
  }
652
- // Issue #817 / #1708: Set up bidirectional handler when --accept-incomming-comments-as-input
653
- // (or composite --bidirectional-interactive-mode / --auto-input-until-mergeable) is enabled.
654
- // Returns null when inactive. issueNumber + tempDir are forwarded so the handler can
655
- // poll issue title/body changes and uncommitted changes during the session (Issue #1708).
656
611
  const bidirectionalHandler = await setupBidirectionalHandler({ argv, owner, repo, prNumber, issueNumber, tempDir, $, log });
657
612
  const progressMonitor = await initProgressMonitoring(argv, { owner, repo, prNumber, $, log }); // works with or without --interactive-mode
658
613
  let execCommand;
@@ -668,7 +623,6 @@ export const executeClaudeCommand = async params => {
668
623
  const useClaudeFallbackModel = !resolvedPlanModel && mappedFallbackModel && mappedFallbackModel !== effectiveModel;
669
624
  let claudeArgs = `--output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel}`;
670
625
  if (useClaudeFallbackModel) claudeArgs += ` --fallback-model ${mappedFallbackModel}`;
671
- // Declare queuedFeedback for use in catch/finally blocks and return value
672
626
  let queuedFeedback = [];
673
627
  // Issue #817: When --accept-incomming-comments-as-input is set and we are
674
628
  // not resuming a prior session, drive Claude via NDJSON stream-json input
@@ -692,19 +646,18 @@ export const executeClaudeCommand = async params => {
692
646
  // Prompt is delivered as the first NDJSON frame on stdin (not as -p).
693
647
  claudeArgs += ` -p --input-format stream-json --append-system-prompt "${escapedSystemPrompt}"`;
694
648
  } else {
695
- claudeArgs += ` -p "${escapedPrompt}" --append-system-prompt "${escapedSystemPrompt}"`;
649
+ claudeArgs += ` -p "${escapedPromptForAttempt}" --append-system-prompt "${escapedSystemPrompt}"`;
696
650
  }
697
651
  const fullCommand = `(cd "${tempDir}" && ${claudePath} ${claudeArgs} | jq -c .)`;
698
652
  await log(`\n${formatAligned('📝', 'Raw command:', '')}`);
699
653
  await log(`${fullCommand}`);
700
654
  await log('');
701
655
  if (argv.verbose) {
702
- await log(`📋 User prompt:\n---BEGIN USER PROMPT---\n${prompt}\n---END USER PROMPT---`, { verbose: true });
656
+ await log(`📋 User prompt:\n---BEGIN USER PROMPT---\n${promptForAttempt}\n---END USER PROMPT---`, { verbose: true });
703
657
  await log(`📋 System prompt:\n---BEGIN SYSTEM PROMPT---\n${systemPrompt}\n---END SYSTEM PROMPT---`, { verbose: true });
704
658
  }
705
659
  try {
706
660
  const { thinkingBudget: resolvedThinkingBudget, thinkLevel, isNewVersion, maxBudget } = await resolveThinkingSettings(argv, log);
707
- // Issue #1706: --sub-session-size + --disable-1m-context. Resolve here, then pass into getClaudeEnv along with the rest.
708
661
  const { parsed: parsedSubSessionSize, contextWindowTokens } = await resolveSubSessionSize({ rawValue: argv.subSessionSize, tool: 'claude', modelId: effectiveModel, fetchModelInfo, log });
709
662
  // Issue #817: streaming mode sets exitAfterStopDelayMs=60000 so the headless Claude process stays alive between NDJSON turns.
710
663
  const claudeEnv = getClaudeEnv({ thinkingBudget: resolvedThinkingBudget, model: effectiveModel, thinkLevel, maxBudget, planModel: resolvedPlanModel, executionModel: resolvedExecutionModel, subAgentModel: resolvedSubAgentModel, showThinkingContent: argv.showThinkingContent, exitAfterStopDelayMs: streamingInput ? 60_000 : undefined, disable1mContext: !!argv.disable1mContext, subSessionSize: parsedSubSessionSize, contextWindowTokens });
@@ -728,7 +681,7 @@ export const executeClaudeCommand = async params => {
728
681
  const fallbackModelArgs = useClaudeFallbackModel ? ['--fallback-model', mappedFallbackModel] : []; // Issue #1949: Claude Code's per-request overload fallback
729
682
  if (useClaudeFallbackModel && argv.verbose) await log(`📊 Claude --fallback-model: ${mappedFallbackModel} (Issue #1949 — primary --model ${effectiveModel} stays stable across overload retries)`, { verbose: true });
730
683
  if (argv.resume) {
731
- const simpleEscapedPrompt = prompt.replace(/"/g, '\\"');
684
+ const simpleEscapedPrompt = promptForAttempt.replace(/"/g, '\\"');
732
685
  execCommand = $({ cwd: tempDir, mirror: false, env: claudeEnv })`${claudePath} --resume ${argv.resume} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} -p "${simpleEscapedPrompt}" --append-system-prompt "${simpleEscapedSystem}"`;
733
686
  } else if (streamingInput) {
734
687
  // Issue #817: Drive Claude via --input-format stream-json on a pipe
@@ -737,10 +690,10 @@ export const executeClaudeCommand = async params => {
737
690
  const streamingInputArgs = ['-p', '--input-format', 'stream-json'];
738
691
  execCommand = $({ cwd: tempDir, stdin: 'pipe', mirror: false, env: claudeEnv })`${claudePath} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} ${streamingInputArgs} --append-system-prompt "${simpleEscapedSystem}"`;
739
692
  } else {
740
- execCommand = $({ cwd: tempDir, stdin: prompt, mirror: false, env: claudeEnv })`${claudePath} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} --append-system-prompt "${simpleEscapedSystem}"`;
693
+ execCommand = $({ cwd: tempDir, stdin: promptForAttempt, mirror: false, env: claudeEnv })`${claudePath} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} --append-system-prompt "${simpleEscapedSystem}"`;
741
694
  }
742
695
  if (streamingInput) {
743
- await attachStreamingInput(bidirectionalHandler, execCommand, prompt, log, !!argv.verbose);
696
+ await attachStreamingInput(bidirectionalHandler, execCommand, promptForAttempt, log, !!argv.verbose);
744
697
  }
745
698
  await log(`${formatAligned('📋', 'Command details:', '')}`);
746
699
  await log(formatAligned('📂', 'Working directory:', tempDir, 2));
@@ -815,6 +768,19 @@ export const executeClaudeCommand = async params => {
815
768
  activityTimeoutId.unref();
816
769
  }
817
770
  };
771
+ const baseBranchCommandIntervention = createPullRequestBaseBranchCommandIntervention({
772
+ expectedBaseBranch,
773
+ prNumber,
774
+ log,
775
+ toolLabel: 'Claude',
776
+ sendInput: message => (streamingInput && bidirectionalHandler?.sendFeedback ? bidirectionalHandler.sendFeedback(message, { kind: 'metadata' }) : false),
777
+ stopSession: async () => {
778
+ if (forceExitTriggered || !execCommand?.kill) return false;
779
+ forceExitTriggered = true;
780
+ killProcessTree('SIGTERM');
781
+ return true;
782
+ },
783
+ });
818
784
  for await (const chunk of execCommand.stream()) {
819
785
  // Issue #1510: Continue processing stream after SIGTERM to capture final output
820
786
  // The stream will naturally end when the process exits (SIGTERM) or is force-killed (SIGKILL after 5s)
@@ -854,6 +820,7 @@ export const executeClaudeCommand = async params => {
854
820
  }
855
821
  }
856
822
  await log(JSON.stringify(data, null, 2));
823
+ await baseBranchCommandIntervention.handleStreamEvent(data);
857
824
  if (!sessionId && data.session_id) {
858
825
  sessionId = data.session_id;
859
826
  await log(`📌 Session ID: ${sessionId}`);
@@ -1088,6 +1055,7 @@ export const executeClaudeCommand = async params => {
1088
1055
  try {
1089
1056
  const data = sanitizeObjectStrings(JSON.parse(stdoutLineBuffer));
1090
1057
  await log(JSON.stringify(data, null, 2));
1058
+ await baseBranchCommandIntervention.handleStreamEvent(data);
1091
1059
  const eventFacts = collectClaudeStreamEventFacts(data);
1092
1060
  messageCount += eventFacts.messageCountDelta;
1093
1061
  toolUseCount += eventFacts.toolUseCountDelta;
@@ -1167,6 +1135,34 @@ export const executeClaudeCommand = async params => {
1167
1135
 
1168
1136
  // Issue #817: Stop bidirectional mode monitoring and collect queued feedback
1169
1137
  queuedFeedback = await finalizeBidirectionalHandler(bidirectionalHandler, log);
1138
+ const baseBranchIntervention = baseBranchCommandIntervention.getIntervention();
1139
+ if (baseBranchIntervention && !baseBranchCommandIntervention.wasSent()) {
1140
+ if ((sessionId || argv.resume) && baseBranchInterventionResumeCount < 1) {
1141
+ argv.resume = sessionId || argv.resume;
1142
+ baseBranchInterventionPrompt = baseBranchIntervention.message;
1143
+ baseBranchInterventionResumeCount++;
1144
+ await log('\n🔄 Resuming Claude with requested base-branch correction prompt...');
1145
+ return await executeWithRetry();
1146
+ }
1147
+
1148
+ return {
1149
+ success: false,
1150
+ sessionId,
1151
+ limitReached,
1152
+ limitResetTime,
1153
+ limitTimezone,
1154
+ messageCount,
1155
+ toolUseCount,
1156
+ errorDuringExecution,
1157
+ anthropicTotalCostUSD,
1158
+ resultSummary,
1159
+ errorInfo: {
1160
+ message: baseBranchIntervention.message,
1161
+ violation: baseBranchIntervention.violation,
1162
+ },
1163
+ queuedFeedback,
1164
+ };
1165
+ }
1170
1166
  const retryableLastError = classifyRetryableError(lastMessage);
1171
1167
  // Issue #1834: Corrupted extended-thinking blocks → try to resume the session first, then fall
1172
1168
  // back to a fresh restart (PR #1835 feedback). When both caps are reached, tryThinkingBlockRecovery
@@ -9,6 +9,7 @@ import { getExperimentsExamplesSubPrompt } from './experiments-examples.prompts.
9
9
  import { primaryModelNames } from './models/index.mjs';
10
10
  import { getThinkingPromptInstruction } from './thinking-prompt.lib.mjs';
11
11
  import { buildWorkLanguageDirective } from './work-language.prompts.lib.mjs';
12
+ import { buildRequestedBaseBranchDirective } from './solve-option-contract.prompts.lib.mjs';
12
13
 
13
14
  /**
14
15
  * Build the user prompt for Claude
@@ -57,6 +58,11 @@ export const buildUserPrompt = params => {
57
58
  }
58
59
  }
59
60
 
61
+ const requestedBaseBranchDirective = buildRequestedBaseBranchDirective(argv);
62
+ if (requestedBaseBranchDirective) {
63
+ promptLines.push(requestedBaseBranchDirective);
64
+ }
65
+
60
66
  // Add contributing guidelines if available
61
67
  if (contributingGuidelines) {
62
68
  promptLines.push('');
package/src/codex.lib.mjs CHANGED
@@ -35,6 +35,7 @@ import { classifyRetryableError, getRetryDelayMs, maybeSwitchToFallbackModel, wa
35
35
  import { parseSubSessionSize, buildCodexSubSessionSizeConfigArgs, buildCodexDisable1mContextConfigArgs } from './sub-session-size.lib.mjs'; // Issue #1706
36
36
  import { getCumulativeContextInputTokens } from './context-fill.lib.mjs';
37
37
  import { deployHandoffSkill } from './handoff-skill.lib.mjs'; // Issue #1877
38
+ import { createPullRequestBaseBranchCommandIntervention } from './solve.pr-base-command-intervention.lib.mjs';
38
39
  import Decimal from 'decimal.js-light';
39
40
 
40
41
  const CODEX_USAGE_FIELD_NAMES = ['input_tokens', 'cached_input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_creation_input_tokens', 'reasoning_tokens', 'reasoning_output_tokens', 'input_tokens_details.cached_tokens', 'input_tokens_details.cache_read_tokens', 'input_tokens_details.cache_write_tokens', 'input_tokens_details.cache_creation_tokens', 'input_tokens_details.cache_creation_input_tokens', 'output_tokens_details.reasoning_tokens'];
@@ -767,9 +768,12 @@ export const executeCodexCommand = async params => {
767
768
  const { tempDir, branchName, prompt, systemPrompt, argv, log, formatAligned, getResourceSnapshot, forkedRepo, feedbackLines, codexPath, $, owner, repo, prNumber, calculatePricing = calculateCodexPricing, waitForRetryDelay = waitWithCountdown } = params;
768
769
 
769
770
  const shellQuote = value => `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
771
+ const expectedBaseBranch = String(argv?.baseBranch || '').trim();
770
772
 
771
773
  // Retry configuration
772
774
  let retryCount = 0;
775
+ let baseBranchInterventionPrompt = null;
776
+ let baseBranchInterventionResumeCount = 0;
773
777
 
774
778
  const executeWithRetry = async () => {
775
779
  // Execute codex command from the cloned repository directory
@@ -806,7 +810,8 @@ export const executeCodexCommand = async params => {
806
810
 
807
811
  // For Codex, we combine system and user prompts into a single message
808
812
  // Codex doesn't have separate system prompt support in CLI mode
809
- const combinedPrompt = systemPrompt ? `${systemPrompt}\n\n${prompt}` : prompt;
813
+ const promptForAttempt = baseBranchInterventionPrompt ? `${prompt}\n\n${baseBranchInterventionPrompt}\n` : prompt;
814
+ const combinedPrompt = systemPrompt ? `${systemPrompt}\n\n${promptForAttempt}` : promptForAttempt;
810
815
 
811
816
  // Write the combined prompt to a file for piping
812
817
  // Use OS temporary directory instead of repository workspace to avoid polluting the repo
@@ -919,6 +924,17 @@ export const executeCodexCommand = async params => {
919
924
  let lastMessage = '';
920
925
  let lastTextContent = ''; // Issue #1263: Track last text content for result summary
921
926
  let authError = false;
927
+ const baseBranchCommandIntervention = createPullRequestBaseBranchCommandIntervention({
928
+ expectedBaseBranch,
929
+ prNumber,
930
+ log,
931
+ toolLabel: 'Codex',
932
+ stopSession: async () => {
933
+ if (!execCommand?.kill) return false;
934
+ execCommand.kill('SIGTERM');
935
+ return true;
936
+ },
937
+ });
922
938
  let codexJsonState = {
923
939
  sessionId: null,
924
940
  authError: false,
@@ -949,6 +965,7 @@ export const executeCodexCommand = async params => {
949
965
  lastMessage = output;
950
966
 
951
967
  codexJsonState = parseCodexExecJsonOutput(output, codexJsonState, mappedModel);
968
+ await baseBranchCommandIntervention.handleCommandExecutions(codexJsonState.commandExecutions);
952
969
 
953
970
  if (interactiveHandler || progressMonitor) {
954
971
  for (const rawLine of output.split('\n')) {
@@ -990,6 +1007,7 @@ export const executeCodexCommand = async params => {
990
1007
  await log(errorOutput, { stream: 'stderr' });
991
1008
  }
992
1009
  codexJsonState = parseCodexExecJsonOutput(errorOutput, codexJsonState, mappedModel);
1010
+ await baseBranchCommandIntervention.handleCommandExecutions(codexJsonState.commandExecutions);
993
1011
  } else if (chunk.type === 'exit') {
994
1012
  exitCode = chunk.code;
995
1013
  }
@@ -1064,6 +1082,31 @@ export const executeCodexCommand = async params => {
1064
1082
  await log(`🤖 Codex exec JSON did not expose model IDs; using requested model for reporting: ${mappedModel}`, { verbose: true });
1065
1083
  }
1066
1084
 
1085
+ const baseBranchIntervention = baseBranchCommandIntervention.getIntervention();
1086
+ if (baseBranchIntervention) {
1087
+ if ((sessionId || argv.resume) && baseBranchInterventionResumeCount < 1) {
1088
+ argv.resume = sessionId || argv.resume;
1089
+ baseBranchInterventionPrompt = baseBranchIntervention.message;
1090
+ baseBranchInterventionResumeCount++;
1091
+ await log('\n🔄 Resuming Codex with requested base-branch correction prompt...');
1092
+ return await executeWithRetry();
1093
+ }
1094
+
1095
+ return {
1096
+ success: false,
1097
+ sessionId,
1098
+ limitReached,
1099
+ limitResetTime,
1100
+ codexJsonDetails: codexJsonState,
1101
+ errorInfo: {
1102
+ message: baseBranchIntervention.message,
1103
+ violation: baseBranchIntervention.violation,
1104
+ },
1105
+ result: baseBranchIntervention.message,
1106
+ resultSummary: lastTextContent || null,
1107
+ };
1108
+ }
1109
+
1067
1110
  const firstActualModelId = mappedModel;
1068
1111
  const pricingInfo = firstActualModelId ? await calculatePricing(firstActualModelId, codexJsonState.tokenUsage.stepCount > 0 ? codexJsonState.tokenUsage : null) : null;
1069
1112
  if (pricingInfo?.totalCostUSD !== null && pricingInfo?.totalCostUSD !== undefined) {
@@ -8,6 +8,7 @@ import { getHandoffSubPrompt } from './handoff.prompts.lib.mjs';
8
8
  import { getExperimentsExamplesSubPrompt } from './experiments-examples.prompts.lib.mjs';
9
9
  import { getThinkingPromptInstruction } from './thinking-prompt.lib.mjs';
10
10
  import { buildWorkLanguageDirective } from './work-language.prompts.lib.mjs';
11
+ import { buildRequestedBaseBranchDirective } from './solve-option-contract.prompts.lib.mjs';
11
12
 
12
13
  /**
13
14
  * Build the user prompt for Codex
@@ -51,6 +52,11 @@ export const buildUserPrompt = params => {
51
52
  }
52
53
  }
53
54
 
55
+ const requestedBaseBranchDirective = buildRequestedBaseBranchDirective(argv);
56
+ if (requestedBaseBranchDirective) {
57
+ promptLines.push(requestedBaseBranchDirective);
58
+ }
59
+
54
60
  // Add blank line
55
61
  promptLines.push('');
56
62
 
@@ -7,6 +7,7 @@ import { getArchitectureCareSubPrompt } from './architecture-care.prompts.lib.mj
7
7
  import { getExperimentsExamplesSubPrompt } from './experiments-examples.prompts.lib.mjs';
8
8
  import { getThinkingPromptInstruction } from './thinking-prompt.lib.mjs';
9
9
  import { buildWorkLanguageDirective } from './work-language.prompts.lib.mjs';
10
+ import { buildRequestedBaseBranchDirective } from './solve-option-contract.prompts.lib.mjs';
10
11
 
11
12
  /**
12
13
  * Build the user prompt for Gemini
@@ -44,6 +45,11 @@ export const buildUserPrompt = params => {
44
45
  }
45
46
  }
46
47
 
48
+ const requestedBaseBranchDirective = buildRequestedBaseBranchDirective(argv);
49
+ if (requestedBaseBranchDirective) {
50
+ promptLines.push(requestedBaseBranchDirective);
51
+ }
52
+
47
53
  promptLines.push('');
48
54
 
49
55
  if (isContinueMode && feedbackLines && feedbackLines.length > 0) {
@@ -7,6 +7,7 @@ import { getArchitectureCareSubPrompt } from './architecture-care.prompts.lib.mj
7
7
  import { getExperimentsExamplesSubPrompt } from './experiments-examples.prompts.lib.mjs';
8
8
  import { getThinkingPromptInstruction } from './thinking-prompt.lib.mjs';
9
9
  import { buildWorkLanguageDirective } from './work-language.prompts.lib.mjs';
10
+ import { buildRequestedBaseBranchDirective } from './solve-option-contract.prompts.lib.mjs';
10
11
 
11
12
  /**
12
13
  * Build the user prompt for OpenCode
@@ -50,6 +51,11 @@ export const buildUserPrompt = params => {
50
51
  }
51
52
  }
52
53
 
54
+ const requestedBaseBranchDirective = buildRequestedBaseBranchDirective(argv);
55
+ if (requestedBaseBranchDirective) {
56
+ promptLines.push(requestedBaseBranchDirective);
57
+ }
58
+
53
59
  // Add blank line
54
60
  promptLines.push('');
55
61
 
@@ -119,7 +125,6 @@ CI investigation with workspace tmp directory.
119
125
  }
120
126
 
121
127
  return `You are an AI issue solver using OpenCode.
122
-
123
128
  General guidelines.
124
129
  - When you execute commands and the output becomes large, save the logs to files for easier review.
125
130
  - When running commands, avoid setting a timeout yourself. Let them run as long as needed.
@@ -7,6 +7,7 @@ import { getArchitectureCareSubPrompt } from './architecture-care.prompts.lib.mj
7
7
  import { getExperimentsExamplesSubPrompt } from './experiments-examples.prompts.lib.mjs';
8
8
  import { getThinkingPromptInstruction } from './thinking-prompt.lib.mjs';
9
9
  import { buildWorkLanguageDirective } from './work-language.prompts.lib.mjs';
10
+ import { buildRequestedBaseBranchDirective } from './solve-option-contract.prompts.lib.mjs';
10
11
 
11
12
  /**
12
13
  * Build the user prompt for Qwen Code
@@ -44,6 +45,11 @@ export const buildUserPrompt = params => {
44
45
  }
45
46
  }
46
47
 
48
+ const requestedBaseBranchDirective = buildRequestedBaseBranchDirective(argv);
49
+ if (requestedBaseBranchDirective) {
50
+ promptLines.push(requestedBaseBranchDirective);
51
+ }
52
+
47
53
  promptLines.push('');
48
54
 
49
55
  if (isContinueMode && feedbackLines && feedbackLines.length > 0) {
@@ -105,7 +111,6 @@ CI investigation with workspace tmp directory.
105
111
  }
106
112
 
107
113
  return `You are an AI issue solver using Qwen Code.
108
-
109
114
  General guidelines.
110
115
  - When you execute commands and the output becomes large, save the logs to files for easier review.
111
116
  - When running commands, avoid setting a timeout yourself. Let them run as long as needed.
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Prompt snippets for user-requested solve options.
3
+ */
4
+
5
+ function normalizeBranchName(value) {
6
+ return String(value || '').trim();
7
+ }
8
+
9
+ export function buildRequestedBaseBranchDirective(argv = {}) {
10
+ const baseBranch = normalizeBranchName(argv?.baseBranch);
11
+ if (!baseBranch) {
12
+ return '';
13
+ }
14
+
15
+ return `Requested by user --base-branch: ${baseBranch}
16
+ The user expects the pull request base branch to remain ${baseBranch}.`;
17
+ }
18
+
19
+ export const buildLockedSolveOptionsDirective = buildRequestedBaseBranchDirective;
@@ -81,6 +81,7 @@ const { maybeAttachWorkingSessionSummary, ensurePullRequestIssueLink } = results
81
81
  // Issue #1574: Interruptible sleep so CTRL+C is never blocked by a lingering timer
82
82
  const { interruptibleSleep } = await import('./interruptible-sleep.lib.mjs');
83
83
  const { formatAutoIterationLimit, hasReachedAutoIterationLimit, normalizeAutoIterationLimit, shouldSyncBeforeRestart } = await import('./auto-iteration-limits.lib.mjs');
84
+ const { ensurePullRequestBaseBranch } = await import('./solve.pr-base-guard.lib.mjs');
84
85
 
85
86
  // Issue #1895: explicitly close linked issues after merging a PR into a
86
87
  // non-default branch, where GitHub does not auto-close them.
@@ -1352,6 +1353,18 @@ export const startAutoRestartUntilMergeable = async params => {
1352
1353
  return null;
1353
1354
  }
1354
1355
 
1356
+ await ensurePullRequestBaseBranch({
1357
+ owner,
1358
+ repo,
1359
+ prNumber,
1360
+ argv,
1361
+ log,
1362
+ formatAligned,
1363
+ $,
1364
+ onMismatch: isAutoMerge ? 'throw' : 'restore',
1365
+ operation: isAutoMerge ? 'auto-merge' : 'auto-restart-until-mergeable',
1366
+ });
1367
+
1355
1368
  // Issue #1226: Check if running in fork mode — auto-merge cannot work without write access
1356
1369
  if (argv.fork && isAutoMerge) {
1357
1370
  await log('');
@@ -890,7 +890,7 @@ _Details will be added as the solution draft is developed..._
890
890
 
891
891
  if (argv.verbose) {
892
892
  await log(` PR Title: [WIP] ${issueTitle}`, { verbose: true });
893
- await log(` Base branch: ${defaultBranch}`, { verbose: true });
893
+ await log(` Base branch: ${targetBranch}`, { verbose: true });
894
894
  await log(` Head branch: ${branchName}`, { verbose: true });
895
895
  if (currentUser) {
896
896
  await log(` Assignee: ${currentUser}`, { verbose: true });
@@ -0,0 +1,21 @@
1
+ export async function finalizeSolveProcess({ tempDir, argv, limitReached, path, getLogFile, log, closeSentry, logActiveHandles, cleanupTempDirectory, safeExit }) {
2
+ await cleanupTempDirectory(tempDir, argv, limitReached);
3
+
4
+ // Show final log file reference so users always know where to find the complete log
5
+ if (getLogFile()) {
6
+ const finalLogPath = path.resolve(getLogFile());
7
+ await log(`\n📁 Complete log file: ${finalLogPath}`);
8
+ }
9
+
10
+ // Issue #1346: Flush Sentry events before exit.
11
+ // closeSentry() uses a hard Promise.race deadline so it cannot block indefinitely.
12
+ await closeSentry();
13
+
14
+ // Issue #1431: Log active handles before draining.
15
+ // Always logged to file and console so future hangs are immediately visible in logs.
16
+ // drainHandles() inside safeExit() will unref/close these before process.exit().
17
+ await logActiveHandles(msg => log(msg));
18
+
19
+ // Issue #1431: safeExit() unrefs handles so the event loop exits naturally, then calls process.exit(0)
20
+ await safeExit(0, 'Process completed');
21
+ }
package/src/solve.mjs CHANGED
@@ -47,6 +47,7 @@ const { startAutoRestartUntilMergeable } = await import('./solve.auto-merge.lib.
47
47
  const { runAutoEnsureRequirements } = await import('./solve.auto-ensure.lib.mjs');
48
48
  const { runKeepWorkingUntilDone } = await import('./solve.keep-working.lib.mjs');
49
49
  const { runEscalation } = await import('./solve.escalate.lib.mjs');
50
+ const { finalizeSolveProcess } = await import('./solve.finalize.lib.mjs');
50
51
  const exitHandler = await import('./exit-handler.lib.mjs');
51
52
  const { initializeExitHandler, installGlobalExitHandlers, safeExit, logActiveHandles } = exitHandler;
52
53
  const { createInterruptWrapper } = await import('./solve.interrupt.lib.mjs');
@@ -54,6 +55,7 @@ const { createInterruptWrapper } = await import('./solve.interrupt.lib.mjs');
54
55
  const { configureWorkingSession, beginWorkingSession, endWorkingSession } = await import('./working-session.lib.mjs');
55
56
  const getResourceSnapshot = memoryCheck.getResourceSnapshot;
56
57
  const { handleAutoPrCreation } = await import('./solve.auto-pr.lib.mjs');
58
+ const { ensurePullRequestBaseBranch } = await import('./solve.pr-base-guard.lib.mjs');
57
59
  const { setupRepositoryAndClone, verifyDefaultBranchAndStatus } = await import('./solve.repo-setup.lib.mjs');
58
60
  const { recordAfterCloneSize, recordAfterAgentSize } = await import('./solve.disk-diagnostics.lib.mjs');
59
61
  const { createOrCheckoutBranch } = await import('./solve.branch.lib.mjs');
@@ -617,6 +619,10 @@ try {
617
619
  await handleNoPrAvailableError({ isContinueMode, tempDir, issueNumber, issueUrl, owner, repo, log, formatAligned });
618
620
  }
619
621
 
622
+ const enforceRequestedBaseBranch = () => ensurePullRequestBaseBranch({ owner, repo, prNumber, argv, log, formatAligned, $ });
623
+
624
+ await enforceRequestedBaseBranch();
625
+
620
626
  if (isContinueMode) {
621
627
  await log(`\n${formatAligned('🔄', 'Continue mode:', 'ACTIVE')}`);
622
628
  await log(formatAligned('', 'Using existing PR:', `#${prNumber}`, 2));
@@ -1203,6 +1209,8 @@ try {
1203
1209
  await safeExit(0, 'Auto-continue child process will handle post-processing');
1204
1210
  }
1205
1211
 
1212
+ await enforceRequestedBaseBranch();
1213
+
1206
1214
  // Issue #1263 / #1728: Working session summary attachment.
1207
1215
  // Routed through the shared maybeAttachWorkingSessionSummary helper so that
1208
1216
  // top-level solve, auto-restart-until-mergeable, and watch-mode iterations
@@ -1329,6 +1337,8 @@ try {
1329
1337
  }
1330
1338
  }
1331
1339
 
1340
+ await enforceRequestedBaseBranch();
1341
+
1332
1342
  // Track whether logs were successfully attached (used by endWorkSession)
1333
1343
  let logsAttached = false;
1334
1344
 
@@ -1475,23 +1485,5 @@ try {
1475
1485
  $,
1476
1486
  });
1477
1487
  } finally {
1478
- await cleanupTempDirectory(tempDir, argv, limitReached);
1479
-
1480
- // Show final log file reference so users always know where to find the complete log
1481
- if (getLogFile()) {
1482
- const finalLogPath = path.resolve(getLogFile());
1483
- await log(`\n📁 Complete log file: ${finalLogPath}`);
1484
- }
1485
-
1486
- // Issue #1346: Flush Sentry events before exit.
1487
- // closeSentry() uses a hard Promise.race deadline so it cannot block indefinitely.
1488
- await closeSentry();
1489
-
1490
- // Issue #1431: Log active handles before draining.
1491
- // Always logged to file and console so future hangs are immediately visible in logs.
1492
- // drainHandles() inside safeExit() will unref/close these before process.exit().
1493
- await logActiveHandles(msg => log(msg));
1494
-
1495
- // Issue #1431: safeExit() unrefs handles so the event loop exits naturally, then calls process.exit(0)
1496
- await safeExit(0, 'Process completed');
1488
+ await finalizeSolveProcess({ tempDir, argv, limitReached, path, getLogFile, log, closeSentry, logActiveHandles, cleanupTempDirectory, safeExit });
1497
1489
  }
@@ -0,0 +1,62 @@
1
+ import { buildPullRequestBaseBranchInterventionMessage, detectForbiddenPullRequestBaseChangeCommand, extractToolCommandTextsFromStreamEvent } from './solve.pr-base-guard.lib.mjs';
2
+
3
+ export function createPullRequestBaseBranchCommandIntervention({ expectedBaseBranch, prNumber, log = async () => {}, toolLabel = 'AI tool', sendInput = null, stopSession = null } = {}) {
4
+ const observedCommands = new Set();
5
+ let intervention = null;
6
+ let sent = false;
7
+
8
+ const handleCommand = async command => {
9
+ if (!expectedBaseBranch || intervention || sent || !command || observedCommands.has(command)) return;
10
+ observedCommands.add(command);
11
+
12
+ const violation = detectForbiddenPullRequestBaseChangeCommand(command, {
13
+ expectedBaseBranch,
14
+ prNumber,
15
+ });
16
+ if (!violation) return;
17
+
18
+ const message = buildPullRequestBaseBranchInterventionMessage(violation);
19
+ await log(`\n⚠️ Forbidden PR base retarget command observed from ${toolLabel}: ${command}`, { level: 'warning' });
20
+
21
+ if (sendInput) {
22
+ try {
23
+ sent = await sendInput(message);
24
+ } catch (sendError) {
25
+ await log(` Could not send requested base-branch correction to ${toolLabel} input: ${sendError.message}`, { level: 'warning' });
26
+ }
27
+ if (sent) {
28
+ await log(` Sent requested base-branch correction to ${toolLabel} input.`, { level: 'warning' });
29
+ return;
30
+ }
31
+ }
32
+
33
+ intervention = { violation, message };
34
+ await log(` ${message}`, { level: 'warning' });
35
+ if (stopSession) {
36
+ try {
37
+ const stopped = await stopSession();
38
+ if (stopped) {
39
+ await log(` Stopped ${toolLabel} session to resume with requested base-branch correction.`, { level: 'warning' });
40
+ }
41
+ } catch (stopError) {
42
+ await log(` Could not stop ${toolLabel} process for immediate correction: ${stopError.message}`, { level: 'warning' });
43
+ }
44
+ }
45
+ };
46
+
47
+ const handleCommands = async commands => {
48
+ for (const command of commands) {
49
+ await handleCommand(command);
50
+ if (intervention || sent) return;
51
+ }
52
+ };
53
+
54
+ return {
55
+ handleCommand,
56
+ handleCommands,
57
+ handleCommandExecutions: async commandExecutions => handleCommands((commandExecutions || []).map(commandExecution => commandExecution?.command).filter(Boolean)),
58
+ handleStreamEvent: async event => handleCommands(extractToolCommandTextsFromStreamEvent(event)),
59
+ getIntervention: () => intervention,
60
+ wasSent: () => sent,
61
+ };
62
+ }
@@ -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