@link-assistant/hive-mind 1.36.0 → 1.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.37.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f02c1fc: fix synthetic model appearing in PR comments by filtering internal Claude CLI router entries (Issue #1486)
8
+ - dd87b23: Add opusplan model support and --plan-model option for flexible plan/execution model pairing
9
+
10
+ ## 1.36.1
11
+
12
+ ### Patch Changes
13
+
14
+ - 74bf211: fix false positive 'Ready to merge' by adding workflow run grace period (Issue #1480)
15
+
3
16
  ## 1.36.0
4
17
 
5
18
  ### Minor Changes
package/README.md CHANGED
@@ -859,6 +859,12 @@ ps axjf
859
859
  pkill -f gh-issue-solver-1773073065743
860
860
  ```
861
861
 
862
+ ### Kill all headless browsers spawned by ms-playwright
863
+
864
+ ```bash
865
+ pkill -f ms-playwright/chromium_headless_shell-1200
866
+ ```
867
+
862
868
  That can be done, but not recommended as reboot have better effect.
863
869
 
864
870
  ## 📄 License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.36.0",
3
+ "version": "1.37.0",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -586,13 +586,13 @@ export const calculateSessionTokens = async (sessionId, tempDir) => {
586
586
  // Read the entire file
587
587
  const fileContent = await fs.readFile(sessionFile, 'utf8');
588
588
  const lines = fileContent.trim().split('\n');
589
- // Parse each line and accumulate token counts per model
590
589
  for (const line of lines) {
591
590
  if (!line.trim()) continue;
592
591
  try {
593
592
  const entry = JSON.parse(line);
594
593
  if (entry.message && entry.message.usage && entry.message.model) {
595
594
  const model = entry.message.model;
595
+ if (model.startsWith('<') && model.endsWith('>')) continue; // Issue #1486: skip <synthetic> etc.
596
596
  const usage = entry.message.usage;
597
597
  // Initialize model entry if it doesn't exist
598
598
  if (!modelUsage[model]) {
@@ -842,11 +842,12 @@ export const executeClaudeCommand = async params => {
842
842
  } else if (argv.interactiveMode) {
843
843
  await log('⚠️ Interactive mode: Disabled - missing PR info (owner/repo/prNumber)', { verbose: true });
844
844
  }
845
- // Build claude command with optional resume flag
846
845
  let execCommand;
847
846
  const mappedModel = mapModelToId(argv.model);
848
- // Build claude command arguments
849
- let claudeArgs = `--output-format stream-json --verbose --dangerously-skip-permissions --model ${mappedModel}`;
847
+ const resolvedPlanModel = argv.planModel ? mapModelToId(argv.planModel) : undefined; // Issue #1223
848
+ const effectiveModel = resolvedPlanModel ? 'opusplan' : mappedModel;
849
+ const resolvedExecutionModel = resolvedPlanModel ? mappedModel : undefined;
850
+ let claudeArgs = `--output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel}`;
850
851
  if (argv.resume) {
851
852
  await log(`🔄 Resuming from session: ${argv.resume}`);
852
853
  claudeArgs = `--resume ${argv.resume} ${claudeArgs}`;
@@ -862,22 +863,22 @@ export const executeClaudeCommand = async params => {
862
863
  }
863
864
  try {
864
865
  const { thinkingBudget: resolvedThinkingBudget, thinkLevel, isNewVersion, maxBudget } = await resolveThinkingSettings(argv, log);
865
- const claudeEnv = getClaudeEnv({ thinkingBudget: resolvedThinkingBudget, model: mappedModel, thinkLevel, maxBudget });
866
+ const claudeEnv = getClaudeEnv({ thinkingBudget: resolvedThinkingBudget, model: effectiveModel, thinkLevel, maxBudget, planModel: resolvedPlanModel, executionModel: resolvedExecutionModel });
866
867
  if (argv.verbose) claudeEnv.ANTHROPIC_LOG = 'debug';
867
- const modelMaxOutputTokens = getMaxOutputTokensForModel(mappedModel);
868
+ const modelMaxOutputTokens = getMaxOutputTokensForModel(effectiveModel);
868
869
  if (argv.verbose) {
869
870
  await log(`📊 CLAUDE_CODE_MAX_OUTPUT_TOKENS: ${modelMaxOutputTokens}, MCP_TIMEOUT: ${claudeCode.mcpTimeout}ms, MCP_TOOL_TIMEOUT: ${claudeCode.mcpToolTimeout}ms, ANTHROPIC_LOG: debug`, { verbose: true });
871
+ if (resolvedPlanModel) await log(`📊 opusplan: plan=${resolvedPlanModel}, exec=${resolvedExecutionModel}`, { verbose: true });
870
872
  if (resolvedThinkingBudget !== undefined) await log(`📊 MAX_THINKING_TOKENS: ${resolvedThinkingBudget}`, { verbose: true });
871
873
  if (claudeEnv.CLAUDE_CODE_EFFORT_LEVEL) await log(`📊 CLAUDE_CODE_EFFORT_LEVEL: ${claudeEnv.CLAUDE_CODE_EFFORT_LEVEL}`, { verbose: true });
872
874
  if (!isNewVersion && thinkLevel) await log(`📊 Thinking level (via keywords): ${thinkLevel}`, { verbose: true });
873
875
  }
876
+ const simpleEscapedSystem = systemPrompt.replace(/"/g, '\\"');
874
877
  if (argv.resume) {
875
878
  const simpleEscapedPrompt = prompt.replace(/"/g, '\\"');
876
- const simpleEscapedSystem = systemPrompt.replace(/"/g, '\\"');
877
- execCommand = $({ cwd: tempDir, mirror: false, env: claudeEnv })`${claudePath} --resume ${argv.resume} --output-format stream-json --verbose --dangerously-skip-permissions --model ${mappedModel} -p "${simpleEscapedPrompt}" --append-system-prompt "${simpleEscapedSystem}"`;
879
+ execCommand = $({ cwd: tempDir, mirror: false, env: claudeEnv })`${claudePath} --resume ${argv.resume} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} -p "${simpleEscapedPrompt}" --append-system-prompt "${simpleEscapedSystem}"`;
878
880
  } else {
879
- const simpleEscapedSystem = systemPrompt.replace(/"/g, '\\"');
880
- execCommand = $({ cwd: tempDir, stdin: prompt, mirror: false, env: claudeEnv })`${claudePath} --output-format stream-json --verbose --dangerously-skip-permissions --model ${mappedModel} --append-system-prompt "${simpleEscapedSystem}"`;
881
+ execCommand = $({ cwd: tempDir, stdin: prompt, mirror: false, env: claudeEnv })`${claudePath} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} --append-system-prompt "${simpleEscapedSystem}"`;
881
882
  }
882
883
  await log(`${formatAligned('📋', 'Command details:', '')}`);
883
884
  await log(formatAligned('📂', 'Working directory:', tempDir, 2));
@@ -1262,7 +1263,6 @@ export const executeClaudeCommand = async params => {
1262
1263
  sessionId,
1263
1264
  resumeCommand: argv.url ? `${process.argv[0]} ${process.argv[1]} --auto-continue ${argv.url}` : null,
1264
1265
  });
1265
-
1266
1266
  for (const line of messageLines) {
1267
1267
  await log(line, { level: 'warning' });
1268
1268
  }
@@ -174,9 +174,10 @@ export const DEFAULT_MAX_THINKING_BUDGET_OPUS_46 = parseIntWithDefault('HIVE_MIN
174
174
  export const isOpus46OrLater = model => {
175
175
  if (!model) return false;
176
176
  const normalizedModel = model.toLowerCase();
177
- // Check for explicit opus-4-6 or later versions
177
+ // Check for explicit opus-4-6 or later versions, or opusplan (Issue #1223)
178
178
  // Note: The 'opus' alias now maps to Opus 4.6 (Issue #1433), so we also check for the alias directly
179
- return normalizedModel === 'opus' || normalizedModel.includes('opus-4-6') || normalizedModel.includes('opus-4-7') || normalizedModel.includes('opus-5');
179
+ // opusplan uses Opus for planning, so it should get Opus-level settings
180
+ return normalizedModel === 'opus' || normalizedModel === 'opusplan' || normalizedModel.includes('opus-4-6') || normalizedModel.includes('opus-4-7') || normalizedModel.includes('opus-5');
180
181
  };
181
182
 
182
183
  /**
@@ -318,6 +319,10 @@ export const supportsThinkingBudget = (version, minVersion = '2.1.12') => {
318
319
  // Also sets MCP_TIMEOUT and MCP_TOOL_TIMEOUT for MCP tool execution (see issue #1066)
319
320
  // Supports model-specific max output tokens for Opus 4.6 (Issue #1221)
320
321
  // Sets CLAUDE_CODE_EFFORT_LEVEL for Opus 4.6 models (Issue #1238)
322
+ // Supports planModel/executionModel for opusplan mode (Issue #1223)
323
+ // See: https://code.claude.com/docs/en/model-config
324
+ // ANTHROPIC_DEFAULT_OPUS_MODEL → model used in plan mode (and for 'opus' alias)
325
+ // ANTHROPIC_DEFAULT_SONNET_MODEL → model used in execution mode (and for 'sonnet' alias)
321
326
  export const getClaudeEnv = (options = {}) => {
322
327
  // Get max output tokens based on model (Issue #1221)
323
328
  const maxOutputTokens = options.model ? getMaxOutputTokensForModel(options.model) : claudeCode.maxOutputTokens;
@@ -327,8 +332,6 @@ export const getClaudeEnv = (options = {}) => {
327
332
  CLAUDE_CODE_MAX_OUTPUT_TOKENS: String(maxOutputTokens),
328
333
  // MCP timeout configurations to prevent tool calls from hanging indefinitely
329
334
  // See: https://github.com/link-assistant/hive-mind/issues/1066
330
- // MCP_TIMEOUT: Timeout for MCP server startup
331
- // MCP_TOOL_TIMEOUT: Timeout for MCP tool execution (the one that prevents stuck tools)
332
335
  MCP_TIMEOUT: String(claudeCode.mcpTimeout),
333
336
  MCP_TOOL_TIMEOUT: String(claudeCode.mcpToolTimeout),
334
337
  };
@@ -354,6 +357,17 @@ export const getClaudeEnv = (options = {}) => {
354
357
  env.CLAUDE_CODE_EFFORT_LEVEL = effortLevel;
355
358
  }
356
359
  }
360
+ // Set ANTHROPIC_DEFAULT_OPUS_MODEL when planModel is specified (Issue #1223)
361
+ // This tells Claude Code which model to use during plan mode in opusplan
362
+ if (options.planModel) {
363
+ env.ANTHROPIC_DEFAULT_OPUS_MODEL = String(options.planModel);
364
+ }
365
+ // Set ANTHROPIC_DEFAULT_SONNET_MODEL when executionModel is specified (Issue #1223)
366
+ // This tells Claude Code which model to use during execution mode in opusplan
367
+ // Enables combinations like --plan-model opus --model haiku
368
+ if (options.executionModel) {
369
+ env.ANTHROPIC_DEFAULT_SONNET_MODEL = String(options.executionModel);
370
+ }
357
371
 
358
372
  return env;
359
373
  };
@@ -413,6 +427,7 @@ const defaultAvailableModels = `(
413
427
  opus
414
428
  sonnet
415
429
  haiku
430
+ opusplan
416
431
  )`;
417
432
 
418
433
  export const modelConfig = {
@@ -1286,6 +1286,170 @@ export async function getActiveRepoWorkflows(owner, repo, verbose = false) {
1286
1286
  }
1287
1287
  }
1288
1288
 
1289
+ /**
1290
+ * Get the committed date of a specific commit from GitHub API
1291
+ * Issue #1480: Used to determine how recently a commit was pushed, to distinguish between
1292
+ * "CI not yet registered in API" (race condition) and "CI definitively not triggered"
1293
+ * @param {string} owner - Repository owner
1294
+ * @param {string} repo - Repository name
1295
+ * @param {string} sha - Commit SHA
1296
+ * @param {boolean} verbose - Whether to log verbose output
1297
+ * @returns {Promise<{date: Date|null, ageSeconds: number|null}>}
1298
+ */
1299
+ export async function getCommitDate(owner, repo, sha, verbose = false) {
1300
+ try {
1301
+ const { stdout } = await exec(`gh api repos/${owner}/${repo}/commits/${sha} --jq '.commit.committer.date'`);
1302
+ const dateStr = stdout.trim();
1303
+ if (!dateStr) {
1304
+ return { date: null, ageSeconds: null };
1305
+ }
1306
+ const commitDate = new Date(dateStr);
1307
+ const ageSeconds = Math.floor((Date.now() - commitDate.getTime()) / 1000);
1308
+ if (verbose) {
1309
+ console.log(`[VERBOSE] /merge: Commit ${sha.substring(0, 7)} date: ${dateStr} (${ageSeconds}s ago)`);
1310
+ }
1311
+ return { date: commitDate, ageSeconds };
1312
+ } catch (error) {
1313
+ if (verbose) {
1314
+ console.log(`[VERBOSE] /merge: Error fetching commit date for ${sha}: ${error.message}`);
1315
+ }
1316
+ return { date: null, ageSeconds: null };
1317
+ }
1318
+ }
1319
+
1320
+ /**
1321
+ * Check if any previous commits in a PR had workflow runs triggered.
1322
+ * Issue #1480: If earlier commits in the same PR triggered CI, we should expect CI
1323
+ * for the HEAD commit too (unless conditions changed). This provides an additional
1324
+ * signal that CI should be expected and avoids false "CI not triggered" conclusions.
1325
+ * @param {string} owner - Repository owner
1326
+ * @param {string} repo - Repository name
1327
+ * @param {number} prNumber - Pull request number
1328
+ * @param {string} headSha - Current HEAD SHA (to exclude from check)
1329
+ * @param {boolean} verbose - Whether to log verbose output
1330
+ * @returns {Promise<{hadPreviousCI: boolean, previousCommitsWithCI: number, totalPreviousCommits: number}>}
1331
+ */
1332
+ export async function checkPreviousPRCommitsHadCI(owner, repo, prNumber, headSha, verbose = false) {
1333
+ try {
1334
+ // Get all commits in the PR
1335
+ const { stdout: commitsJson } = await exec(`gh api "repos/${owner}/${repo}/pulls/${prNumber}/commits?per_page=100" --jq '[.[].sha]'`);
1336
+ const allShas = JSON.parse(commitsJson.trim() || '[]');
1337
+
1338
+ // Exclude the current HEAD SHA
1339
+ const previousShas = allShas.filter(sha => sha !== headSha);
1340
+
1341
+ if (previousShas.length === 0) {
1342
+ if (verbose) {
1343
+ console.log(`[VERBOSE] /merge: PR #${prNumber} has no previous commits to check for CI history`);
1344
+ }
1345
+ return { hadPreviousCI: false, previousCommitsWithCI: 0, totalPreviousCommits: 0 };
1346
+ }
1347
+
1348
+ // Check the most recent previous commits (limit to last 3 to avoid excessive API calls)
1349
+ const commitsToCheck = previousShas.slice(-3);
1350
+ let commitsWithCI = 0;
1351
+
1352
+ for (const sha of commitsToCheck) {
1353
+ try {
1354
+ const { stdout } = await exec(`gh api "repos/${owner}/${repo}/actions/runs?head_sha=${sha}&per_page=1" --jq '.total_count'`);
1355
+ const count = parseInt(stdout.trim(), 10);
1356
+ if (count > 0) {
1357
+ commitsWithCI++;
1358
+ }
1359
+ } catch {
1360
+ // Skip errors for individual commits
1361
+ }
1362
+ }
1363
+
1364
+ const hadPreviousCI = commitsWithCI > 0;
1365
+
1366
+ if (verbose) {
1367
+ console.log(`[VERBOSE] /merge: PR #${prNumber} previous CI history: ${commitsWithCI}/${commitsToCheck.length} checked commits had workflow runs (total PR commits: ${allShas.length})`);
1368
+ }
1369
+
1370
+ return { hadPreviousCI, previousCommitsWithCI: commitsWithCI, totalPreviousCommits: previousShas.length };
1371
+ } catch (error) {
1372
+ if (verbose) {
1373
+ console.log(`[VERBOSE] /merge: Error checking previous PR commits CI history: ${error.message}`);
1374
+ }
1375
+ return { hadPreviousCI: false, previousCommitsWithCI: 0, totalPreviousCommits: 0 };
1376
+ }
1377
+ }
1378
+
1379
+ /**
1380
+ * Check if any workflow files in the repository have PR-related triggers
1381
+ * Issue #1480: Used as additional signal to determine if CI should run on PRs.
1382
+ * Parses .github/workflows/*.yml files from the repository content API.
1383
+ * @param {string} owner - Repository owner
1384
+ * @param {string} repo - Repository name
1385
+ * @param {boolean} verbose - Whether to log verbose output
1386
+ * @returns {Promise<{hasPRTriggers: boolean, hasWorkflowFiles: boolean, workflows: Array<{name: string, triggers: string[]}>}>}
1387
+ */
1388
+ export async function checkWorkflowsHavePRTriggers(owner, repo, verbose = false) {
1389
+ try {
1390
+ // List workflow files in .github/workflows/
1391
+ const { stdout: listJson } = await exec(`gh api "repos/${owner}/${repo}/contents/.github/workflows" --jq '[.[] | select(.name | test("\\\\.(yml|yaml)$")) | {name: .name, download_url: .download_url, path: .path}]' 2>/dev/null`);
1392
+ const files = JSON.parse(listJson.trim() || '[]');
1393
+
1394
+ if (files.length === 0) {
1395
+ if (verbose) {
1396
+ console.log(`[VERBOSE] /merge: No workflow files found in ${owner}/${repo}/.github/workflows/ — no CI/CD will execute`);
1397
+ }
1398
+ // Issue #1480: hasWorkflowFiles=false is a strong signal that no CI/CD is configured at the file level
1399
+ return { hasPRTriggers: false, hasWorkflowFiles: false, workflows: [] };
1400
+ }
1401
+
1402
+ const prTriggerPatterns = [/\bon:\s*\n\s+pull_request/m, /\bon:\s*\[.*pull_request.*\]/m, /\bon:\s*pull_request\b/m, /\bpull_request_target\b/m];
1403
+
1404
+ // Also check for push triggers (push to PR branches triggers CI)
1405
+ const pushTriggerPatterns = [/\bon:\s*\n\s+push/m, /\bon:\s*\[.*push.*\]/m, /\bon:\s*push\b/m];
1406
+
1407
+ const results = [];
1408
+
1409
+ for (const file of files) {
1410
+ try {
1411
+ // Fetch file content (use raw content from the API)
1412
+ const { stdout: contentJson } = await exec(`gh api "repos/${owner}/${repo}/contents/${file.path}" --jq '.content'`);
1413
+ const content = Buffer.from(contentJson.trim().replace(/"/g, ''), 'base64').toString('utf-8');
1414
+
1415
+ const triggers = [];
1416
+ if (prTriggerPatterns.some(p => p.test(content))) {
1417
+ triggers.push('pull_request');
1418
+ }
1419
+ if (pushTriggerPatterns.some(p => p.test(content))) {
1420
+ triggers.push('push');
1421
+ }
1422
+
1423
+ if (triggers.length > 0) {
1424
+ results.push({ name: file.name, triggers });
1425
+ }
1426
+
1427
+ if (verbose) {
1428
+ console.log(`[VERBOSE] /merge: Workflow ${file.name}: triggers=[${triggers.join(', ')}]`);
1429
+ }
1430
+ } catch (fileError) {
1431
+ if (verbose) {
1432
+ console.log(`[VERBOSE] /merge: Error reading workflow file ${file.name}: ${fileError.message}`);
1433
+ }
1434
+ }
1435
+ }
1436
+
1437
+ const hasPRTriggers = results.length > 0;
1438
+
1439
+ if (verbose) {
1440
+ console.log(`[VERBOSE] /merge: ${results.length}/${files.length} workflow files have PR/push triggers`);
1441
+ }
1442
+
1443
+ return { hasPRTriggers, hasWorkflowFiles: true, workflows: results };
1444
+ } catch (error) {
1445
+ if (verbose) {
1446
+ console.log(`[VERBOSE] /merge: Error checking workflow PR triggers: ${error.message}`);
1447
+ }
1448
+ // On error, assume workflows might have PR triggers (safer: avoids false positives)
1449
+ return { hasPRTriggers: true, hasWorkflowFiles: true, workflows: [] };
1450
+ }
1451
+ }
1452
+
1289
1453
  // Issue #1341: Re-export post-merge CI functions from separate module
1290
1454
  import { waitForCommitCI, checkBranchCIHealth, getMergeCommitSha } from './github-merge-ci.lib.mjs';
1291
1455
  export { waitForCommitCI, checkBranchCIHealth, getMergeCommitSha };
@@ -1323,6 +1487,10 @@ export default {
1323
1487
  checkBranchCIHealth,
1324
1488
  getMergeCommitSha,
1325
1489
  getActiveRepoWorkflows,
1490
+ // Issue #1480: Commit date, workflow PR triggers, and previous commit CI history for race condition detection
1491
+ getCommitDate,
1492
+ checkPreviousPRCommitsHadCI,
1493
+ checkWorkflowsHavePRTriggers,
1326
1494
  // Issue #1413: Use issue timeline to find genuinely linked PRs (avoids false positives from text search)
1327
1495
  getLinkedPRsFromTimeline,
1328
1496
  };
@@ -390,9 +390,7 @@ export async function attachLogToGitHub(options) {
390
390
  if (useLargeFileMode && verbose) {
391
391
  await log(` 📁 Large log file (${Math.round(logStats.size / 1024 / 1024)}MB), will use gh-upload-log`, { verbose: true });
392
392
  }
393
- // Calculate token usage if sessionId and tempDir are provided (skip for agent tool with pricing)
394
- let totalCostUSD = publicPricingEstimate;
395
- // Issue #1225: Collect actual model IDs from Claude session JSON output
393
+ let totalCostUSD = publicPricingEstimate; // Issue #1225: token usage + actual model IDs
396
394
  let actualModelIds = null;
397
395
  if (totalCostUSD === null && sessionId && tempDir && !errorMessage) {
398
396
  try {
@@ -401,23 +399,15 @@ export async function attachLogToGitHub(options) {
401
399
  if (tokenUsage) {
402
400
  if (tokenUsage.totalCostUSD !== null && tokenUsage.totalCostUSD !== undefined) {
403
401
  totalCostUSD = tokenUsage.totalCostUSD;
404
- if (verbose) {
405
- await log(` 💰 Calculated cost: $${totalCostUSD.toFixed(6)}`, { verbose: true });
406
- }
402
+ if (verbose) await log(` 💰 Calculated cost: $${totalCostUSD.toFixed(6)}`, { verbose: true });
407
403
  }
408
- // Extract actual model IDs from session data (Issue #1225)
409
404
  if (tokenUsage.modelUsage && Object.keys(tokenUsage.modelUsage).length > 0) {
410
405
  actualModelIds = Object.keys(tokenUsage.modelUsage);
411
- if (verbose) {
412
- await log(` 🤖 Actual models used: ${actualModelIds.join(', ')}`, { verbose: true });
413
- }
406
+ if (verbose) await log(` 🤖 Actual models used: ${actualModelIds.join(', ')}`, { verbose: true });
414
407
  }
415
408
  }
416
409
  } catch (tokenError) {
417
- // Don't fail the entire upload if token calculation fails
418
- if (verbose) {
419
- await log(` ⚠️ Could not calculate token cost: ${tokenError.message}`, { verbose: true });
420
- }
410
+ if (verbose) await log(` ⚠️ Could not calculate token cost: ${tokenError.message}`, { verbose: true });
421
411
  }
422
412
  }
423
413
  // Issue #1454: Use resultModelUsage from result JSON when it has more models (includes subagent models)
@@ -433,6 +423,11 @@ export async function attachLogToGitHub(options) {
433
423
  if (!actualModelIds && pricingInfo?.modelId) {
434
424
  actualModelIds = [pricingInfo.modelId];
435
425
  }
426
+ // Issue #1486: Filter out internal/synthetic model entries (e.g., "<synthetic>" from Claude CLI's inference router)
427
+ if (actualModelIds) {
428
+ actualModelIds = actualModelIds.filter(id => !(id.startsWith('<') && id.endsWith('>')));
429
+ if (actualModelIds.length === 0) actualModelIds = null;
430
+ }
436
431
  // Issue #1225: Fetch model information for comment using actual models from CLI output
437
432
  let modelInfoString = '';
438
433
  if (requestedModel || tool || actualModelIds) {
@@ -21,7 +21,7 @@ const HIVE_CUSTOM_SOLVE_OPTIONS = {
21
21
  model: {
22
22
  type: 'string',
23
23
  description: `${buildModelOptionDescription()}, or any model ID supported by the tool`,
24
- alias: 'm',
24
+ alias: ['m', 'worker-model'],
25
25
  default: 'sonnet',
26
26
  },
27
27
  'dry-run': {
package/src/hive.mjs CHANGED
@@ -152,9 +152,7 @@ if (isDirectExecution) {
152
152
  // Strategy 2: Fallback to gh api --paginate approach (comprehensive but slower)
153
153
  await log(' 📋 Using gh api --paginate approach for comprehensive coverage...', { verbose: true });
154
154
 
155
- // First, get list of ALL repositories using gh api with --paginate for unlimited pagination
156
- // This approach uses the GitHub API directly to fetch all repositories without any limits
157
- // Include isArchived field to filter out archived repositories
155
+ // Get list of ALL repositories using gh api with --paginate (includes isArchived for filtering)
158
156
  let repoListCmd;
159
157
  if (scope === 'organization') {
160
158
  repoListCmd = `gh api orgs/${owner}/repos --paginate --jq '.[] | {name: .name, owner: .owner.login, isArchived: .archived}'`;
@@ -436,8 +434,6 @@ if (isDirectExecution) {
436
434
  initializeExitHandler(getAbsoluteLogPath, log);
437
435
  installGlobalExitHandlers();
438
436
 
439
- // Unhandled error handlers are now managed by exit-handler.lib.mjs
440
-
441
437
  // Validate GitHub URL requirement
442
438
  if (!githubUrl) {
443
439
  await log('❌ GitHub URL is required', { level: 'error' });
@@ -470,18 +466,28 @@ if (isDirectExecution) {
470
466
  }
471
467
  }
472
468
 
473
- // Validate model name EARLY - this always runs regardless of --skip-tool-connection-check
474
- // Model validation is a simple string check and should always be performed
469
+ // --plan flag expansion: shortcut for --plan-model opus --worker-model sonnet (Issue #1223)
470
+ if (argv.plan) {
471
+ if (!rawArgs.includes('--plan-model')) argv.planModel = 'opus';
472
+ if (!rawArgs.includes('--model') && !rawArgs.includes('-m') && !rawArgs.includes('--worker-model')) argv.model = 'sonnet';
473
+ }
474
+
475
+ // Validate model names EARLY (simple string check, always runs)
475
476
  const tool = argv.tool || 'claude';
476
477
  await validateAndExitOnInvalidModel(argv.model, tool, safeExit);
478
+ if (argv.planModel) {
479
+ if (tool !== 'claude') {
480
+ await log(`❌ --plan-model is only supported with --tool claude (current tool: ${tool})`, { level: 'error' });
481
+ await safeExit(1, '--plan-model requires --tool claude');
482
+ }
483
+ await validateAndExitOnInvalidModel(argv.planModel, tool, safeExit);
484
+ }
477
485
 
478
486
  // Handle -s (--skip-issues-with-prs) and --auto-continue interaction
479
- // Detect if user explicitly passed --auto-continue or --no-auto-continue
480
487
  const hasExplicitAutoContinue = rawArgs.includes('--auto-continue');
481
488
  const hasExplicitNoAutoContinue = rawArgs.includes('--no-auto-continue');
482
489
 
483
490
  if (argv.skipIssuesWithPrs) {
484
- // If user explicitly passed --auto-continue with -s, that's a conflict
485
491
  if (hasExplicitAutoContinue) {
486
492
  await log('❌ Conflicting options: --skip-issues-with-prs and --auto-continue cannot be used together', {
487
493
  level: 'error',
@@ -492,8 +498,7 @@ if (isDirectExecution) {
492
498
  await safeExit(1, 'Error occurred');
493
499
  }
494
500
 
495
- // If user didn't explicitly set auto-continue, disable it when -s is used
496
- // This is because -s means "skip issues with PRs" which conflicts with auto-continue
501
+ // -s implies disabling auto-continue unless explicitly set
497
502
  if (!hasExplicitNoAutoContinue) {
498
503
  argv.autoContinue = false;
499
504
  }
@@ -778,10 +783,8 @@ if (isDirectExecution) {
778
783
  }
779
784
  if (argv.skipToolConnectionCheck || argv.toolConnectionCheck === false) args.push('--skip-tool-connection-check');
780
785
  if (argv.dryRun) args.push('--dry-run');
781
- if (argv.autoCleanup) args.push('--auto-cleanup'); // hive default differs from solve's auto-detect default
782
-
783
- // Options already handled above or deprecated aliases (skip in generic loop)
784
- const SKIP_AUTO_FORWARD = new Set(['model', 'base-branch', 'skip-tool-connection-check', 'tool-connection-check', 'skip-tool-check', 'skip-claude-check', 'tool-check', 'dry-run', 'auto-cleanup']);
786
+ if (argv.autoCleanup) args.push('--auto-cleanup');
787
+ const SKIP_AUTO_FORWARD = new Set(['model', 'worker-model', 'base-branch', 'skip-tool-connection-check', 'tool-connection-check', 'skip-tool-check', 'skip-claude-check', 'tool-check', 'dry-run', 'auto-cleanup']);
785
788
 
786
789
  for (const optionName of getSolvePassthroughOptionNames()) {
787
790
  if (SKIP_AUTO_FORWARD.has(optionName)) continue;
@@ -1431,8 +1434,7 @@ if (isDirectExecution) {
1431
1434
  await safeExit(0, 'Process completed');
1432
1435
  }
1433
1436
 
1434
- // Function to validate Claude CLI connection
1435
- // validateClaudeConnection is now imported from lib.mjs
1437
+ // validateClaudeConnection is imported from lib.mjs
1436
1438
 
1437
1439
  // Handle graceful shutdown
1438
1440
  process.on('SIGINT', () => gracefulShutdown('interrupt'));
@@ -1484,8 +1486,7 @@ if (isDirectExecution) {
1484
1486
  await safeExit(1, 'Error occurred');
1485
1487
  }
1486
1488
  } catch (fatalError) {
1487
- // Handle any errors that occurred during initialization or execution
1488
- // This prevents silent failures when the script hangs or crashes
1489
+ // Handle fatal errors during initialization or execution
1489
1490
  console.error('\n❌ Fatal error occurred during hive initialization or execution');
1490
1491
  console.error(` ${fatalError.message || fatalError}`);
1491
1492
  if (fatalError.stack) {
@@ -30,6 +30,7 @@ export const claudeModels = {
30
30
  haiku: 'claude-haiku-4-5-20251001', // Haiku 4.5
31
31
  'haiku-3-5': 'claude-3-5-haiku-20241022', // Haiku 3.5
32
32
  'haiku-3': 'claude-3-haiku-20240307', // Haiku 3
33
+ opusplan: 'opusplan', // Special mode: Opus for planning, Sonnet for execution (Issue #1223)
33
34
  // Shorter version aliases (Issue #1221, Issue #1329 - PR comment feedback)
34
35
  'sonnet-4-6': 'claude-sonnet-4-6', // Sonnet 4.6 short alias (Issue #1329)
35
36
  'opus-4-6': 'claude-opus-4-6', // Opus 4.6 short alias
@@ -258,7 +259,7 @@ export const isModelCompatibleWithTool = (tool, model) => {
258
259
 
259
260
  switch (tool) {
260
261
  case 'claude':
261
- return mappedModel.startsWith('claude-');
262
+ return mappedModel.startsWith('claude-') || mappedModel === 'opusplan';
262
263
  case 'agent':
263
264
  return mappedModel.includes('/') || Object.keys(agentModels).includes(model);
264
265
  case 'opencode':
@@ -293,7 +294,7 @@ export const getValidModelsForTool = tool => {
293
294
  // Primary (non-alias, non-deprecated) short names shown in CLI help descriptions
294
295
  // These are the recommended model names users should see in --model help text
295
296
  export const primaryModelNames = {
296
- claude: ['opus', 'sonnet', 'haiku'],
297
+ claude: ['opus', 'sonnet', 'haiku', 'opusplan'],
297
298
  opencode: ['grok', 'gpt4o'],
298
299
  codex: ['gpt5', 'gpt5-codex', 'o3'],
299
300
  agent: ['minimax-m2.5-free', 'big-pickle', 'gpt-5-nano', 'glm-5-free', 'deepseek-r1-free'],
@@ -33,7 +33,7 @@ const { reportError } = sentryLib;
33
33
 
34
34
  // Import GitHub merge functions
35
35
  const githubMergeLib = await import('./github-merge.lib.mjs');
36
- const { checkPRMergeable, checkMergePermissions, mergePullRequest, waitForCI, checkForBillingLimitError, getRepoVisibility, BILLING_LIMIT_ERROR_PATTERN, getDetailedCIStatus, rerunWorkflowRun, getWorkflowRunsForSha, getActiveRepoWorkflows } = githubMergeLib;
36
+ const { checkPRMergeable, checkMergePermissions, mergePullRequest, waitForCI, checkForBillingLimitError, getRepoVisibility, BILLING_LIMIT_ERROR_PATTERN, getDetailedCIStatus, rerunWorkflowRun, getWorkflowRunsForSha, getActiveRepoWorkflows, getCommitDate, checkPreviousPRCommitsHadCI, checkWorkflowsHavePRTriggers } = githubMergeLib;
37
37
 
38
38
  // Import GitHub functions for log attachment
39
39
  const githubLib = await import('./github.lib.mjs');
@@ -255,14 +255,86 @@ const getMergeBlockers = async (owner, repo, prNumber, verbose = false) => {
255
255
  details: workflowRuns.map(r => r.name),
256
256
  });
257
257
  } else {
258
- // No workflow runs for this SHA — CI was definitively NOT triggered
259
- // Issue #1442: This is the root cause of the infinite loop. Fork PRs needing
260
- // maintainer approval, paths-ignore filtering, workflow conditions not matching,
261
- // etc. all result in zero workflow runs. No need for timeout — exit immediately.
262
- if (verbose) {
263
- console.log(`[VERBOSE] /merge: PR #${prNumber} has no CI checks and no workflow runs for SHA ${ciStatus.sha.substring(0, 7)} — CI was not triggered (fork PR, paths-ignore, workflow conditions, etc.)`);
258
+ // No workflow runs for this SHA — but this could be a race condition!
259
+ // Issue #1480: GitHub Actions workflow runs take 30-120 seconds to appear in the
260
+ // API after a push. The previous fix (issue #1442) assumed 0 workflow runs meant
261
+ // "CI definitively NOT triggered", but this caused false positive "Ready to merge"
262
+ // when checked too soon after a push.
263
+ //
264
+ // Multi-layer defense (Issue #1480 enhanced):
265
+ // Layer 1: Grace period — check commit age
266
+ // Layer 2: Workflow file parsing — check .github/workflows for PR triggers
267
+ // Layer 3: Previous commit CI history — check if earlier PR commits had CI runs
268
+ const WORKFLOW_RUN_GRACE_PERIOD_SECONDS = 120; // 2 minutes — generous to cover slow GitHub API registration
269
+ const commitInfo = await getCommitDate(owner, repo, ciStatus.sha, verbose);
270
+
271
+ // Issue #1480: Parse workflow files for PR triggers (used in both grace period and post-grace checks)
272
+ const prTriggers = await checkWorkflowsHavePRTriggers(owner, repo, verbose);
273
+
274
+ // Issue #1480: If .github/workflows folder doesn't exist or has no workflow files,
275
+ // that's a definitive signal — no CI/CD will execute, skip grace period entirely
276
+ if (!prTriggers.hasWorkflowFiles) {
277
+ if (verbose) {
278
+ console.log(`[VERBOSE] /merge: PR #${prNumber} repo has no workflow files in .github/workflows/ — CI definitively not configured at file level`);
279
+ }
280
+ return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: true };
281
+ }
282
+
283
+ if (commitInfo.ageSeconds !== null && commitInfo.ageSeconds < WORKFLOW_RUN_GRACE_PERIOD_SECONDS) {
284
+ // Commit is recent — workflow runs may not have appeared in the API yet
285
+ if (verbose) {
286
+ console.log(`[VERBOSE] /merge: PR #${prNumber} has no workflow runs for SHA ${ciStatus.sha.substring(0, 7)}, but commit is only ${commitInfo.ageSeconds}s old (grace period: ${WORKFLOW_RUN_GRACE_PERIOD_SECONDS}s) — treating as potential race condition`);
287
+ }
288
+
289
+ if (prTriggers.hasPRTriggers) {
290
+ // Workflows have PR/push triggers AND commit is recent — almost certainly a race condition
291
+ if (verbose) {
292
+ console.log(`[VERBOSE] /merge: Workflow files confirm PR/push triggers exist (${prTriggers.workflows.map(w => w.name).join(', ')}) — waiting for workflow runs to appear`);
293
+ }
294
+ blockers.push({
295
+ type: 'ci_pending',
296
+ message: `CI/CD workflow runs have not appeared yet — commit is ${commitInfo.ageSeconds}s old, waiting for GitHub to register workflow runs (grace period: ${WORKFLOW_RUN_GRACE_PERIOD_SECONDS}s)`,
297
+ details: prTriggers.workflows.map(w => w.name),
298
+ });
299
+ } else {
300
+ // No PR triggers found in workflow files — but commit is still recent, be safe and wait
301
+ if (verbose) {
302
+ console.log(`[VERBOSE] /merge: No PR/push triggers found in workflow files, but commit is only ${commitInfo.ageSeconds}s old — waiting to be safe`);
303
+ }
304
+ blockers.push({
305
+ type: 'ci_pending',
306
+ message: `CI/CD workflow runs have not appeared yet — commit is ${commitInfo.ageSeconds}s old, waiting for GitHub to register workflow runs (grace period: ${WORKFLOW_RUN_GRACE_PERIOD_SECONDS}s)`,
307
+ details: [],
308
+ });
309
+ }
310
+ } else {
311
+ // Commit is old enough (grace period elapsed) — but check additional signals before concluding
312
+ // Issue #1480: Layer 3 — Check if previous commits in this PR had CI runs.
313
+ // If earlier commits had CI, the HEAD commit should also have CI unless conditions changed.
314
+ const previousCI = await checkPreviousPRCommitsHadCI(owner, repo, prNumber, ciStatus.sha, verbose);
315
+
316
+ if (previousCI.hadPreviousCI && prTriggers.hasPRTriggers) {
317
+ // Previous commits had CI AND workflow files have PR triggers — something is wrong,
318
+ // this could be a GitHub API glitch or delayed registration beyond the grace period.
319
+ // Wait one more cycle to be safe.
320
+ if (verbose) {
321
+ console.log(`[VERBOSE] /merge: PR #${prNumber} previous commits had CI (${previousCI.previousCommitsWithCI}/${previousCI.totalPreviousCommits}) and workflows have PR triggers, but HEAD has no runs — waiting as safety measure`);
322
+ }
323
+ blockers.push({
324
+ type: 'ci_pending',
325
+ message: `CI/CD workflow runs missing for HEAD — previous PR commits had CI (${previousCI.previousCommitsWithCI} of ${previousCI.totalPreviousCommits}), workflows have PR triggers, possible API delay`,
326
+ details: prTriggers.workflows.map(w => w.name),
327
+ });
328
+ } else {
329
+ // CI was definitively NOT triggered
330
+ // Issue #1442: Fork PRs needing maintainer approval, paths-ignore filtering,
331
+ // workflow conditions not matching, etc. all result in zero workflow runs.
332
+ if (verbose) {
333
+ console.log(`[VERBOSE] /merge: PR #${prNumber} has no CI checks and no workflow runs for SHA ${ciStatus.sha.substring(0, 7)} (commit age: ${commitInfo.ageSeconds ?? 'unknown'}s, grace period: ${WORKFLOW_RUN_GRACE_PERIOD_SECONDS}s elapsed, previous CI: ${previousCI.hadPreviousCI}, PR triggers: ${prTriggers.hasPRTriggers}) — CI was not triggered`);
334
+ }
335
+ return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: true };
336
+ }
264
337
  }
265
- return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: true };
266
338
  }
267
339
  } else {
268
340
  // Repo has NO workflows — this is truly "no CI configured"
@@ -284,6 +284,21 @@ export const SOLVE_OPTION_DEFINITIONS = {
284
284
  choices: ['claude', 'opencode', 'codex', 'agent'],
285
285
  default: 'claude',
286
286
  },
287
+ plan: {
288
+ type: 'boolean',
289
+ description: 'Enable plan mode: uses opus for planning, sonnet for execution (shortcut for --plan-model opus --worker-model sonnet). Only works with --tool claude.',
290
+ default: false,
291
+ },
292
+ 'plan-model': {
293
+ type: 'string',
294
+ description: 'Model to use for plan mode (e.g., opus). When specified, auto-switches to opusplan mode and sets ANTHROPIC_DEFAULT_OPUS_MODEL. Use with --model/--worker-model to set separate plan and execution models (e.g., --plan-model opus --model sonnet). Only works with --tool claude.',
295
+ default: undefined,
296
+ },
297
+ 'worker-model': {
298
+ type: 'string',
299
+ description: 'Alias for --model: Model to use for execution/worker mode when --plan-model is specified. When used with --plan-model, sets ANTHROPIC_DEFAULT_SONNET_MODEL for Claude Code opusplan mode.',
300
+ default: undefined,
301
+ },
287
302
  'execute-tool-with-bun': {
288
303
  type: 'boolean',
289
304
  description: 'Execute the AI tool using bunx (experimental, may improve speed and memory usage)',
@@ -441,7 +456,7 @@ export const createYargsConfig = yargsInstance => {
441
456
  .option('model', {
442
457
  type: 'string',
443
458
  description: buildModelOptionDescription(),
444
- alias: 'm',
459
+ alias: ['m', 'worker-model'],
445
460
  default: currentParsedArgs => {
446
461
  // Dynamic default based on tool selection (Issue #1473: centralized in models/index.mjs)
447
462
  return defaultModels[currentParsedArgs?.tool] || defaultModels.claude;
@@ -549,7 +564,20 @@ export const parseArguments = async (yargs, hideBin) => {
549
564
  // Post-processing: Fix model default for opencode and codex tools
550
565
  // Yargs doesn't properly handle dynamic defaults based on other arguments,
551
566
  // so we need to handle this manually after parsing
552
- const modelExplicitlyProvided = rawArgs.includes('--model') || rawArgs.includes('-m');
567
+ const modelExplicitlyProvided = rawArgs.includes('--model') || rawArgs.includes('-m') || rawArgs.includes('--worker-model');
568
+ const planModelExplicitlyProvided = rawArgs.includes('--plan-model');
569
+
570
+ // --plan flag expansion (Issue #1223)
571
+ // When --plan is set, it acts as a shortcut for --plan-model opus --worker-model sonnet
572
+ // Explicit --plan-model and --model/--worker-model values take precedence
573
+ if (argv && argv.plan) {
574
+ if (!planModelExplicitlyProvided) {
575
+ argv.planModel = 'opus';
576
+ }
577
+ if (!modelExplicitlyProvided) {
578
+ argv.model = 'sonnet';
579
+ }
580
+ }
553
581
 
554
582
  // Normalize alias flags: legacy --skip-tool-check and --skip-claude-check behave like --skip-tool-connection-check
555
583
  if (argv) {
package/src/solve.mjs CHANGED
@@ -211,6 +211,15 @@ if (!(await validateContinueOnlyOnFeedback(argv, isPrUrl, isIssueUrl))) {
211
211
  const tool = argv.tool || 'claude';
212
212
  await validateAndExitOnInvalidModel(argv.model, tool, safeExit);
213
213
 
214
+ // Validate --plan-model if provided (Issue #1223)
215
+ if (argv.planModel) {
216
+ if (tool !== 'claude') {
217
+ await log(`❌ --plan-model is only supported with --tool claude (current tool: ${tool})`, { level: 'error' });
218
+ await safeExit(1, '--plan-model requires --tool claude');
219
+ }
220
+ await validateAndExitOnInvalidModel(argv.planModel, tool, safeExit);
221
+ }
222
+
214
223
  // Perform all system checks (skip tool connection check in dry-run or when --skip-tool-connection-check; model validation always runs)
215
224
  const skipToolConnectionCheck = argv.dryRun || argv.skipToolConnectionCheck || argv.toolConnectionCheck === false;
216
225
  if (!(await performSystemChecks(argv.minDiskSpace || 2048, skipToolConnectionCheck, argv.model, argv))) {