@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 +6 -0
- package/package.json +1 -1
- package/src/agent.prompts.lib.mjs +6 -0
- package/src/bidirectional-interactive.lib.mjs +15 -0
- package/src/claude.lib.mjs +60 -64
- package/src/claude.prompts.lib.mjs +6 -0
- package/src/codex.lib.mjs +44 -1
- package/src/codex.prompts.lib.mjs +6 -0
- package/src/gemini.prompts.lib.mjs +6 -0
- package/src/opencode.prompts.lib.mjs +6 -1
- package/src/qwen.prompts.lib.mjs +6 -1
- package/src/solve-option-contract.prompts.lib.mjs +19 -0
- package/src/solve.auto-merge.lib.mjs +13 -0
- package/src/solve.auto-pr.lib.mjs +1 -1
- package/src/solve.finalize.lib.mjs +21 -0
- package/src/solve.mjs +11 -19
- package/src/solve.pr-base-command-intervention.lib.mjs +62 -0
- package/src/solve.pr-base-guard.lib.mjs +266 -0
- package/src/solve.restart-shared.lib.mjs +2 -0
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
|
@@ -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,
|
package/src/claude.lib.mjs
CHANGED
|
@@ -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
|
-
};
|
|
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';
|
|
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;
|
|
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;
|
|
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
|
-
|
|
564
|
-
|
|
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
|
-
|
|
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:
|
|
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 "${
|
|
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${
|
|
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 =
|
|
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:
|
|
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,
|
|
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
|
|
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.
|
package/src/qwen.prompts.lib.mjs
CHANGED
|
@@ -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: ${
|
|
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
|
|
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
|
|