@link-assistant/hive-mind 1.69.1 → 1.69.3

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.69.3
4
+
5
+ ### Patch Changes
6
+
7
+ - d7f95e8: Add experimental `--auto-support-agents-md-as-claude-md` support for temporarily exposing AGENTS.md as CLAUDE.md during Claude runs.
8
+ - 890e81f: Stop auto-merge from waiting forever when cancelled CI cannot be re-run automatically.
9
+
10
+ ## 1.69.2
11
+
12
+ ### Patch Changes
13
+
14
+ - 0ff36b4: Count open draft pull requests when `/hive --skip-issues-with-prs` checks linked solution drafts, preventing duplicate work while an existing PR is still in progress.
15
+
3
16
  ## 1.69.1
4
17
 
5
18
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.69.1",
3
+ "version": "1.69.3",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -0,0 +1,158 @@
1
+ const AGENTS_MD_FILENAMES = Object.freeze(['AGENTS.md', 'agents.md']);
2
+ const CLAUDE_MD_FILENAME = 'CLAUDE.md';
3
+
4
+ const noopLog = async () => {};
5
+ const fallbackFormatAligned = (_icon, label, value) => `${label} ${value}`;
6
+
7
+ const readFileIfExists = async (fs, filePath) => {
8
+ try {
9
+ return { exists: true, content: await fs.readFile(filePath, 'utf8') };
10
+ } catch (error) {
11
+ if (error.code === 'ENOENT') return { exists: false, content: null };
12
+ throw error;
13
+ }
14
+ };
15
+
16
+ export const userInputMentionsClaudeMd = input => /\bCLAUDE\.md\b/i.test(String(input || ''));
17
+
18
+ export const findAgentsMdFile = async ({ tempDir, fs, path }) => {
19
+ for (const fileName of AGENTS_MD_FILENAMES) {
20
+ const filePath = path.join(tempDir, fileName);
21
+ const result = await readFileIfExists(fs, filePath);
22
+ if (result.exists) return { fileName, filePath, content: result.content };
23
+ }
24
+ return null;
25
+ };
26
+
27
+ export const prepareAgentsMdAsClaudeMd = async params => {
28
+ const { tempDir, argv, prompt = '', fs, path, log = noopLog, formatAligned = fallbackFormatAligned } = params;
29
+ const state = {
30
+ enabled: !!argv?.autoSupportAgentsMdAsClaudeMd,
31
+ created: false,
32
+ cleanupCandidate: false,
33
+ userInputMentionsClaudeMd: userInputMentionsClaudeMd(prompt),
34
+ claudePath: path.join(tempDir, CLAUDE_MD_FILENAME),
35
+ agentsPath: null,
36
+ };
37
+
38
+ if (!state.enabled) return state;
39
+
40
+ const tool = argv?.tool || 'claude';
41
+ if (tool !== 'claude') {
42
+ await log(' AGENTS.md as CLAUDE.md support skipped: only supported for --tool claude', { verbose: true });
43
+ return { ...state, skippedReason: 'non-claude-tool' };
44
+ }
45
+
46
+ const agentsFile = await findAgentsMdFile({ tempDir, fs, path });
47
+ if (!agentsFile) {
48
+ await log(' AGENTS.md as CLAUDE.md support enabled, but no AGENTS.md file was found', { verbose: true });
49
+ return { ...state, skippedReason: 'missing-agents-md' };
50
+ }
51
+
52
+ state.agentsPath = agentsFile.filePath;
53
+ const claudeFile = await readFileIfExists(fs, state.claudePath);
54
+ if (claudeFile.exists) {
55
+ if (claudeFile.content === agentsFile.content) {
56
+ state.cleanupCandidate = !state.userInputMentionsClaudeMd;
57
+ const action = state.cleanupCandidate ? 'will remove it after Claude exits' : 'leaving it untouched because user input mentions CLAUDE.md';
58
+ await log(` CLAUDE.md already matches AGENTS.md; ${action}`, { verbose: true });
59
+ return { ...state, skippedReason: 'claude-md-already-matches-agents-md' };
60
+ }
61
+ await log(' Existing CLAUDE.md differs from AGENTS.md; leaving it untouched', { verbose: true });
62
+ return { ...state, skippedReason: 'claude-md-differs' };
63
+ }
64
+
65
+ await fs.writeFile(state.claudePath, agentsFile.content);
66
+ state.created = true;
67
+ await log(formatAligned('AGENTS.md', 'Temporary CLAUDE.md:', `created from ${agentsFile.fileName}`));
68
+ return state;
69
+ };
70
+
71
+ const gitStatusForClaudeMd = async ({ $, tempDir }) => {
72
+ if (!$) return '';
73
+ const result = await $({ cwd: tempDir })`git status --porcelain -- ${CLAUDE_MD_FILENAME} 2>&1`;
74
+ return result.stdout?.toString().trim() || '';
75
+ };
76
+
77
+ const isClaudeMdTracked = async ({ $, tempDir }) => {
78
+ if (!$) return false;
79
+ const result = await $({ cwd: tempDir })`git ls-files --error-unmatch -- ${CLAUDE_MD_FILENAME} 2>&1`;
80
+ return result.code === 0;
81
+ };
82
+
83
+ const removeTrackedClaudeMd = async ({ $, tempDir }) => {
84
+ if (!$) return { code: 1, stdout: '', stderr: 'No git runner provided' };
85
+ return await $({ cwd: tempDir })`git rm -f -- ${CLAUDE_MD_FILENAME} 2>&1`;
86
+ };
87
+
88
+ const commitClaudeMdRemoval = async ({ $, tempDir }) => {
89
+ const message = 'Remove temporary CLAUDE.md copy from AGENTS.md';
90
+ return await $({ cwd: tempDir })`git commit -m ${message} -- ${CLAUDE_MD_FILENAME} 2>&1`;
91
+ };
92
+
93
+ export const cleanupAgentsMdAsClaudeMd = async params => {
94
+ const { state, tempDir, branchName, fs, path, $, log = noopLog, formatAligned = fallbackFormatAligned } = params;
95
+ if (!state?.created && !state?.cleanupCandidate) return { action: 'skipped' };
96
+
97
+ const claudePath = state.claudePath || path.join(tempDir, CLAUDE_MD_FILENAME);
98
+ const claudeFile = await readFileIfExists(fs, claudePath);
99
+ if (!claudeFile.exists) return { action: 'already-removed' };
100
+
101
+ const agentsFile = await findAgentsMdFile({ tempDir, fs, path });
102
+ if (!agentsFile || claudeFile.content !== agentsFile.content) {
103
+ await log(' Temporary CLAUDE.md changed or AGENTS.md is missing; leaving it untouched', { verbose: true });
104
+ return { action: 'left-modified' };
105
+ }
106
+
107
+ const status = await gitStatusForClaudeMd({ $, tempDir });
108
+ const tracked = await isClaudeMdTracked({ $, tempDir });
109
+ const isUntracked = status.split('\n').some(line => line.startsWith('??'));
110
+
111
+ if (isUntracked || (!tracked && !status)) {
112
+ await fs.rm(claudePath, { force: true });
113
+ await log(formatAligned('AGENTS.md', 'Temporary CLAUDE.md:', 'removed'));
114
+ return { action: 'removed-untracked' };
115
+ }
116
+
117
+ if (state.userInputMentionsClaudeMd) {
118
+ await log(' Temporary CLAUDE.md matches AGENTS.md but user input mentions CLAUDE.md; leaving it untouched', { verbose: true });
119
+ return { action: 'left-user-mentioned-claude-md' };
120
+ }
121
+
122
+ const rmResult = await removeTrackedClaudeMd({ $, tempDir });
123
+ if (rmResult.code !== 0) {
124
+ await log(` Warning: could not remove temporary CLAUDE.md from git: ${rmResult.stderr || rmResult.stdout}`, { verbose: true });
125
+ return { action: 'remove-failed' };
126
+ }
127
+
128
+ const postRemoveStatus = await gitStatusForClaudeMd({ $, tempDir });
129
+ if (!postRemoveStatus) {
130
+ await log(formatAligned('AGENTS.md', 'Temporary CLAUDE.md:', 'removed from git index'));
131
+ return { action: 'removed-staged-addition' };
132
+ }
133
+
134
+ const commitResult = await commitClaudeMdRemoval({ $, tempDir });
135
+ if (commitResult.code !== 0) {
136
+ await log(` Warning: temporary CLAUDE.md was removed locally but commit failed: ${commitResult.stderr || commitResult.stdout}`, { verbose: true });
137
+ return { action: 'removed-uncommitted' };
138
+ }
139
+
140
+ if (branchName) {
141
+ const pushResult = await $({ cwd: tempDir })`git push origin ${branchName} 2>&1`;
142
+ if (pushResult.code !== 0) {
143
+ await log(` Warning: temporary CLAUDE.md removal commit was created but push failed: ${pushResult.stderr || pushResult.stdout}`, { verbose: true });
144
+ }
145
+ }
146
+
147
+ await log(formatAligned('AGENTS.md', 'Temporary CLAUDE.md:', 'removed from committed changes'));
148
+ return { action: 'removed-committed-copy' };
149
+ };
150
+
151
+ export const withAgentsMdAsClaudeMd = async (params, execute) => {
152
+ const state = await prepareAgentsMdAsClaudeMd(params);
153
+ try {
154
+ return await execute();
155
+ } finally {
156
+ await cleanupAgentsMdAsClaudeMd({ ...params, state });
157
+ }
158
+ };
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Helpers for cancelled/stale CI handling in auto-merge mode.
5
+ *
6
+ * GitHub can expose several operational cases as a cancelled/stale status:
7
+ * manual cancellation, concurrency cancellation, queue/run limits, or a
8
+ * workflow/job timeout. If hive-mind cannot re-trigger the run, a human has to
9
+ * inspect the logs because polling the same conclusion will not make progress.
10
+ *
11
+ * @see https://github.com/link-assistant/hive-mind/issues/1769
12
+ */
13
+
14
+ import { CANCELLED_CI_REVIEW_MARKER } from './tool-comments.lib.mjs';
15
+
16
+ const CANCELLED_OR_STALE_CONCLUSIONS = new Set(['cancelled', 'stale']);
17
+
18
+ export { CANCELLED_CI_REVIEW_MARKER };
19
+
20
+ export const getRetriggerableWorkflowRuns = (runs = []) => runs.filter(run => CANCELLED_OR_STALE_CONCLUSIONS.has(run?.conclusion));
21
+
22
+ export const shouldStopForCancelledCIReview = ({ retriggerableRuns = [], rerunTriggered = false, rerunFailures = [] }) => {
23
+ if (rerunTriggered) {
24
+ return false;
25
+ }
26
+
27
+ return retriggerableRuns.length === 0 || rerunFailures.length > 0;
28
+ };
29
+
30
+ const formatMarkdownList = (items, fallback) => {
31
+ if (!items || items.length === 0) {
32
+ return `- ${fallback}`;
33
+ }
34
+
35
+ return items.map(item => `- ${item}`).join('\n');
36
+ };
37
+
38
+ const formatRunReference = run => {
39
+ if (!run) {
40
+ return 'Unknown workflow run';
41
+ }
42
+
43
+ const name = run.name || run.path || (run.id ? `Workflow run ${run.id}` : 'Workflow run');
44
+ const runId = run.id ? ` (${run.id})` : '';
45
+ const status = [run.status, run.conclusion].filter(Boolean).join('/');
46
+ const statusPart = status ? ` [${status}]` : '';
47
+ const urlPart = run.html_url ? ` - ${run.html_url}` : '';
48
+
49
+ return `${name}${runId}${statusPart}${urlPart}`;
50
+ };
51
+
52
+ const formatRerunFailure = failure => {
53
+ const runPart = formatRunReference(failure?.run);
54
+ const error = failure?.error || 'Unknown error';
55
+ return `${runPart}: ${error}`;
56
+ };
57
+
58
+ export const buildCancelledCIReviewComment = ({ blocker, runs = [], rerunFailures = [], rerunAttempted = false, sha }) => {
59
+ const cancelledDetails = blocker?.details || [];
60
+ const effectiveSha = sha || blocker?.sha;
61
+ const shaLine = effectiveSha ? `\n\n**Commit:** ${effectiveSha}` : '';
62
+ const rerunSummary = rerunAttempted ? 'Automatic re-run was attempted, but no workflow run was successfully re-triggered.' : 'Automatic re-run was not possible.';
63
+
64
+ return `## ${CANCELLED_CI_REVIEW_MARKER}
65
+
66
+ Hive Mind detected cancelled or stale CI/CD checks and cannot get them running automatically.${shaLine}
67
+
68
+ **Cancelled checks**
69
+ ${formatMarkdownList(cancelledDetails, 'No cancelled check details were available.')}
70
+
71
+ **Workflow runs inspected**
72
+ ${formatMarkdownList(runs.map(formatRunReference), 'No cancelled/stale workflow run was found for this commit SHA.')}
73
+
74
+ **Automatic re-run result**
75
+ ${rerunSummary}
76
+ ${formatMarkdownList(rerunFailures.map(formatRerunFailure), 'No successful automatic re-run was recorded.')}
77
+
78
+ **Action required**
79
+ 1. Review the cancelled CI logs to decide whether this was a real timeout/failure or a deliberate manual cancellation.
80
+ 2. If the cancelled check is required, re-run the workflow manually from GitHub Actions or push a new commit.
81
+ 3. If the cancellation was deliberate and non-blocking, decide whether this PR can be merged outside automation.
82
+
83
+ If workflow/job \`timeout-minutes\` or a runner execution limit caused the cancellation, treat it as a CI failure and fix the timeout, test, or infrastructure before merging.
84
+
85
+ ---
86
+ Hive Mind is stopping because continuing to poll the same cancelled/stale check would not change the mergeability result.`;
87
+ };
@@ -26,6 +26,7 @@ import { ensureClaudeQuietConfig } from './claude-quiet-config.lib.mjs';
26
26
  import { fetchModelInfo } from './model-info.lib.mjs';
27
27
  import { classifyRetryableError, maybeSwitchToFallbackModel } from './tool-retry.lib.mjs';
28
28
  import { resolveSubSessionSize } from './sub-session-size.lib.mjs'; // Issue #1706
29
+ import { withAgentsMdAsClaudeMd } from './agents-md-claude-support.lib.mjs';
29
30
  export { availableModels }; // Re-export for backward compatibility
30
31
  export { fetchModelInfo };
31
32
  const showResumeCommand = async (sessionId, tempDir, claudePath, model, log) => {
@@ -294,7 +295,6 @@ export const executeClaude = async params => {
294
295
  if (argv.verbose) {
295
296
  await log(`👁️ Model vision capability: ${modelSupportsVision ? 'supported' : 'not supported'}`, { verbose: true });
296
297
  }
297
- // Build the user prompt
298
298
  const prompt = buildUserPrompt({
299
299
  issueUrl,
300
300
  issueNumber,
@@ -313,7 +313,6 @@ export const executeClaude = async params => {
313
313
  argv,
314
314
  claudeVersion: getClaudeVersion(),
315
315
  });
316
- // Build the system prompt
317
316
  const systemPrompt = buildSystemPrompt({
318
317
  owner,
319
318
  repo,
@@ -329,7 +328,6 @@ export const executeClaude = async params => {
329
328
  argv,
330
329
  modelSupportsVision,
331
330
  });
332
- // Log prompt details in verbose mode
333
331
  if (argv.verbose) {
334
332
  await log('\n📝 Final prompt structure:', { verbose: true });
335
333
  await log(` Characters: ${prompt.length}`, { verbose: true });
@@ -349,35 +347,36 @@ export const executeClaude = async params => {
349
347
  await log('---END SYSTEM PROMPT---', { verbose: true });
350
348
  }
351
349
  }
352
- // Escape prompts for shell usage
353
350
  const escapedPrompt = prompt.replace(/"/g, '\\"').replace(/\$/g, '\\$');
354
351
  const escapedSystemPrompt = systemPrompt.replace(/"/g, '\\"').replace(/\$/g, '\\$');
355
- // Execute the Claude command
356
- return await executeClaudeCommand({
357
- tempDir,
358
- branchName,
359
- prompt,
360
- systemPrompt,
361
- escapedPrompt,
362
- escapedSystemPrompt,
363
- argv,
364
- log,
365
- setLogFile,
366
- getLogFile,
367
- formatAligned,
368
- getResourceSnapshot,
369
- forkedRepo,
370
- feedbackLines,
371
- claudePath,
372
- $,
373
- // For interactive mode
374
- owner,
375
- repo,
376
- prNumber,
377
- // Issue #1708: forwarded so the bidirectional handler can poll
378
- // issue title/body changes and uncommitted changes during the session.
379
- issueNumber,
380
- });
352
+
353
+ return await withAgentsMdAsClaudeMd({ tempDir, branchName, argv, prompt, fs, path, $, log, formatAligned }, () =>
354
+ executeClaudeCommand({
355
+ tempDir,
356
+ branchName,
357
+ prompt,
358
+ systemPrompt,
359
+ escapedPrompt,
360
+ escapedSystemPrompt,
361
+ argv,
362
+ log,
363
+ setLogFile,
364
+ getLogFile,
365
+ formatAligned,
366
+ getResourceSnapshot,
367
+ forkedRepo,
368
+ feedbackLines,
369
+ claudePath,
370
+ $,
371
+ // For interactive mode
372
+ owner,
373
+ repo,
374
+ prNumber,
375
+ // Issue #1708: forwarded so the bidirectional handler can poll
376
+ // issue title/body changes and uncommitted changes during the session.
377
+ issueNumber,
378
+ })
379
+ );
381
380
  };
382
381
  /** Check if a model supports vision (image input) using models.dev API @returns {Promise<boolean>} */
383
382
  export const checkModelVisionCapability = async modelId => {
@@ -26,66 +26,71 @@ export function getGitHubLinkingKeywords() {
26
26
  return ['close', 'closes', 'closed', 'fix', 'fixes', 'fixed', 'resolve', 'resolves', 'resolved'];
27
27
  }
28
28
 
29
- /**
30
- * Check if PR body contains a valid GitHub linking keyword for the given issue
31
- *
32
- * @param {string} prBody - The pull request body text
33
- * @param {string|number} issueNumber - The issue number to check for
34
- * @param {string} [owner] - Repository owner (for cross-repo references)
35
- * @param {string} [repo] - Repository name (for cross-repo references)
36
- * @returns {boolean} True if a valid linking keyword is found
37
- */
38
- export function hasGitHubLinkingKeyword(prBody, issueNumber, owner = null, repo = null) {
39
- if (!prBody || !issueNumber) {
40
- return false;
41
- }
29
+ function escapeRegExp(value) {
30
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
31
+ }
42
32
 
43
- const keywords = getGitHubLinkingKeywords();
44
- const issueNumStr = issueNumber.toString();
33
+ function buildClosingReferencePatterns(keyword, issueNumber, owner = null, repo = null) {
34
+ const issueNumStr = escapeRegExp(issueNumber);
35
+ const separator = String.raw`(?:\s+|\s*:\s*)`;
36
+ const prefix = String.raw`\b${keyword}${separator}`;
37
+ const references = [String.raw`#${issueNumStr}\b`];
45
38
 
46
- // Build regex patterns for each valid format:
47
- // 1. KEYWORD #123
48
- // 2. KEYWORD owner/repo#123
49
- // 3. KEYWORD https://github.com/owner/repo/issues/123
39
+ if (owner && repo) {
40
+ references.push(`${escapeRegExp(owner)}/${escapeRegExp(repo)}#${issueNumStr}\\b`);
41
+ references.push(`https://github\\.com/${escapeRegExp(owner)}/${escapeRegExp(repo)}/issues/${issueNumStr}\\b`);
42
+ }
50
43
 
51
- for (const keyword of keywords) {
52
- // Pattern 1: KEYWORD #123
53
- // Must have word boundary before keyword and # immediately before number
54
- const pattern1 = new RegExp(`\\b${keyword}\\s+#${issueNumStr}\\b`, 'i');
44
+ references.push(String.raw`[\w.-]+/[\w.-]+#${issueNumStr}\b`);
45
+ references.push(`https://github\\.com/[^/\\s]+/[^/\\s]+/issues/${issueNumStr}\\b`);
55
46
 
56
- if (pattern1.test(prBody)) {
57
- return true;
58
- }
59
-
60
- // Pattern 2: KEYWORD owner/repo#123 (for cross-repo or fork references)
61
- if (owner && repo) {
62
- const pattern2 = new RegExp(`\\b${keyword}\\s+${owner}/${repo}#${issueNumStr}\\b`, 'i');
47
+ return references.map(reference => new RegExp(`${prefix}${reference}`, 'i'));
48
+ }
63
49
 
64
- if (pattern2.test(prBody)) {
65
- return true;
66
- }
67
- }
50
+ /**
51
+ * Check whether text contains a GitHub closing keyword for a specific issue.
52
+ *
53
+ * This is the shared parser used by solve and hive code paths so they agree on
54
+ * which PR body/title references are real closing links.
55
+ *
56
+ * @param {string} text - Pull request body or title text
57
+ * @param {string|number} issueNumber - Issue number to check for
58
+ * @param {string} [owner] - Repository owner for exact owner/repo references
59
+ * @param {string} [repo] - Repository name for exact owner/repo references
60
+ * @returns {boolean} True if a valid closing reference is found
61
+ */
62
+ export function prClosesIssue(text, issueNumber, owner = null, repo = null) {
63
+ if (!text || typeof text !== 'string' || issueNumber === null || issueNumber === undefined || String(issueNumber).trim() === '') {
64
+ return false;
65
+ }
68
66
 
69
- // Pattern 3: KEYWORD https://github.com/owner/repo/issues/123
70
- if (owner && repo) {
71
- const pattern3 = new RegExp(`\\b${keyword}\\s+https://github\\.com/${owner}/${repo}/issues/${issueNumStr}\\b`, 'i');
67
+ const issueNumStr = String(issueNumber).trim();
72
68
 
73
- if (pattern3.test(prBody)) {
69
+ for (const keyword of getGitHubLinkingKeywords()) {
70
+ const patterns = buildClosingReferencePatterns(keyword, issueNumStr, owner, repo);
71
+ for (const pattern of patterns) {
72
+ if (pattern.test(text)) {
74
73
  return true;
75
74
  }
76
75
  }
77
-
78
- // Pattern 4: Also check for any URL format (generic)
79
- const pattern4 = new RegExp(`\\b${keyword}\\s+https://github\\.com/[^/]+/[^/]+/issues/${issueNumStr}\\b`, 'i');
80
-
81
- if (pattern4.test(prBody)) {
82
- return true;
83
- }
84
76
  }
85
77
 
86
78
  return false;
87
79
  }
88
80
 
81
+ /**
82
+ * Check if PR body contains a valid GitHub linking keyword for the given issue
83
+ *
84
+ * @param {string} prBody - The pull request body text
85
+ * @param {string|number} issueNumber - The issue number to check for
86
+ * @param {string} [owner] - Repository owner (for cross-repo references)
87
+ * @param {string} [repo] - Repository name (for cross-repo references)
88
+ * @returns {boolean} True if a valid linking keyword is found
89
+ */
90
+ export function hasGitHubLinkingKeyword(prBody, issueNumber, owner = null, repo = null) {
91
+ return prClosesIssue(prBody, issueNumber, owner, repo);
92
+ }
93
+
89
94
  /**
90
95
  * Extract issue number from PR body using GitHub linking keywords
91
96
  * This is used to find which issue a PR is linked to
@@ -10,53 +10,46 @@ if (typeof globalThis.use === 'undefined') {
10
10
  // Import dependencies
11
11
  import { log, cleanErrorMessage } from './lib.mjs';
12
12
  import { githubLimits, timeouts } from './config.lib.mjs';
13
+ import { prClosesIssue } from './github-linking.lib.mjs';
13
14
 
14
15
  import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry, execGhWithRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller. execGhWithRetry adds transient-network retry (#1756).
16
+ export { prClosesIssue };
17
+
15
18
  /**
16
- * Check if a PR body/title indicates it fixes/closes/resolves a specific issue number
17
- * GitHub auto-closes issues when PR body contains keywords like "fixes #123", "closes #123", "resolves #123"
18
- * See: https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue
19
- * @param {string} text - PR body or title text
20
- * @param {number} issueNumber - Issue number to check for
21
- * @returns {boolean} True if the text contains a closing keyword for this issue
19
+ * Extract open pull requests that are linked to an issue with closing keywords.
20
+ * Draft pull requests are still open in-progress solution drafts, so they must
21
+ * count for /hive --skip-issues-with-prs.
22
+ * @param {Object} issueData - GraphQL issue node with timelineItems
23
+ * @param {number} issueNum - Issue number to check
24
+ * @param {Function} logger - Async logger, defaults to shared log helper
25
+ * @returns {Promise<Array<Object>>} Linked open PRs that close the issue
22
26
  */
23
- export function prClosesIssue(text, issueNumber) {
24
- if (!text || typeof text !== 'string') {
25
- return false;
26
- }
27
-
28
- // GitHub closing keywords (case-insensitive)
29
- // Supports: fix, fixes, fixed, close, closes, closed, resolve, resolves, resolved
30
- // Also supports variations with repository prefix like "fixes owner/repo#123"
31
- const closingKeywords = ['fix', 'fixes', 'fixed', 'close', 'closes', 'closed', 'resolve', 'resolves', 'resolved'];
32
-
33
- // Build regex pattern that matches any of the keywords followed by #N or repo#N
34
- // Examples matched:
35
- // - "fixes #123"
36
- // - "Closes #123"
37
- // - "RESOLVED #123"
38
- // - "fixes owner/repo#123"
39
- // - "fix: #123" (common commit style)
40
- const issueNum = issueNumber.toString();
41
-
42
- for (const keyword of closingKeywords) {
43
- // Pattern: keyword + optional colon/space + optional repo prefix + # + issue number
44
- // Must be followed by word boundary (not part of larger number)
45
- const patterns = [
46
- // Standard format: "fixes #123"
47
- new RegExp(`\\b${keyword}\\s*:?\\s*#${issueNum}\\b`, 'i'),
48
- // With repo prefix: "fixes owner/repo#123"
49
- new RegExp(`\\b${keyword}\\s*:?\\s*[\\w.-]+/[\\w.-]+#${issueNum}\\b`, 'i'),
50
- ];
51
-
52
- for (const pattern of patterns) {
53
- if (pattern.test(text)) {
54
- return true;
27
+ export async function extractLinkedPullRequestsForIssue(issueData, issueNum, logger = log) {
28
+ const linkedPRs = [];
29
+
30
+ for (const item of issueData.timelineItems?.nodes || []) {
31
+ if (item?.source && item.source.state === 'OPEN') {
32
+ // Check if PR actually closes this issue (has "fixes #N", "closes #N", or "resolves #N")
33
+ const prBody = item.source.body || '';
34
+ const prTitle = item.source.title || '';
35
+ const closesThisIssue = prClosesIssue(prBody, issueNum) || prClosesIssue(prTitle, issueNum);
36
+
37
+ if (closesThisIssue) {
38
+ linkedPRs.push({
39
+ number: item.source.number,
40
+ title: item.source.title,
41
+ state: item.source.state,
42
+ isDraft: Boolean(item.source.isDraft),
43
+ url: item.source.url,
44
+ });
45
+ } else {
46
+ // Log that we're skipping a PR that only mentions the issue
47
+ await logger(` ℹ️ PR #${item.source.number} mentions issue #${issueNum} but doesn't close it (no fixes/closes/resolves keyword)`, { verbose: true });
55
48
  }
56
49
  }
57
50
  }
58
51
 
59
- return false;
52
+ return linkedPRs;
60
53
  }
61
54
 
62
55
  /**
@@ -140,31 +133,11 @@ export async function batchCheckPullRequestsForIssues(owner, repo, issueNumbers)
140
133
  for (const issueNum of batch) {
141
134
  const issueData = data.data?.repository?.[`issue${issueNum}`];
142
135
  if (issueData) {
143
- const linkedPRs = [];
144
-
145
- // Extract linked PRs from timeline items
136
+ // Extract linked PRs from timeline items, including draft PRs.
146
137
  // Issue #1094: Only count PRs that explicitly fix/close/resolve this issue
147
138
  // This prevents false positives from PRs that only mention issues without solving them
148
- for (const item of issueData.timelineItems?.nodes || []) {
149
- if (item?.source && item.source.state === 'OPEN' && !item.source.isDraft) {
150
- // Check if PR actually closes this issue (has "fixes #N", "closes #N", or "resolves #N")
151
- const prBody = item.source.body || '';
152
- const prTitle = item.source.title || '';
153
- const closesThisIssue = prClosesIssue(prBody, issueNum) || prClosesIssue(prTitle, issueNum);
154
-
155
- if (closesThisIssue) {
156
- linkedPRs.push({
157
- number: item.source.number,
158
- title: item.source.title,
159
- state: item.source.state,
160
- url: item.source.url,
161
- });
162
- } else {
163
- // Log that we're skipping a PR that only mentions the issue
164
- await log(` ℹ️ PR #${item.source.number} mentions issue #${issueNum} but doesn't close it (no fixes/closes/resolves keyword)`, { verbose: true });
165
- }
166
- }
167
- }
139
+ // Issue #1760: Draft PRs are still active solution drafts and must block duplicate work
140
+ const linkedPRs = await extractLinkedPullRequestsForIssue(issueData, issueNum);
168
141
 
169
142
  results[issueNum] = {
170
143
  title: issueData.title,
@@ -337,6 +310,7 @@ export async function batchCheckArchivedRepositories(repositories) {
337
310
  // Export all functions as default object too
338
311
  export default {
339
312
  prClosesIssue,
313
+ extractLinkedPullRequestsForIssue,
340
314
  batchCheckPullRequestsForIssues,
341
315
  batchCheckArchivedRepositories,
342
316
  };
@@ -55,6 +55,10 @@ import { limitReset } from './config.lib.mjs';
55
55
  const autoMergeHelpers = await import('./solve.auto-merge-helpers.lib.mjs');
56
56
  const { checkForExistingComment, checkForNonBotComments, getMergeBlockers } = autoMergeHelpers;
57
57
 
58
+ // Issue #1769: cancelled/stale CI re-run failures need a human action stop, not polling forever.
59
+ const cancelledCiRerunLib = await import('./cancelled-ci-rerun.lib.mjs');
60
+ const { buildCancelledCIReviewComment, getRetriggerableWorkflowRuns, shouldStopForCancelledCIReview } = cancelledCiRerunLib;
61
+
58
62
  // Issue #1625: Shared marker constants + posting/tracking helpers
59
63
  const toolComments = await import('./tool-comments.lib.mjs');
60
64
  const { READY_TO_MERGE_MARKER, AUTO_RESTART_MARKER, AUTO_MERGED_MARKER, postTrackedComment } = toolComments;
@@ -432,23 +436,37 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
432
436
  if (cancelledBlocker && !billingBlocker) {
433
437
  await log('');
434
438
  await log(formatAligned('🔄', 'CANCELLED CI/CD CHECKS DETECTED', ''));
435
- await log(formatAligned('', 'Cancelled checks:', cancelledBlocker.details.join(', '), 2));
439
+ await log(formatAligned('', 'Cancelled checks:', (cancelledBlocker.details || []).join(', '), 2));
436
440
 
437
441
  // Attempt to re-trigger the cancelled/stale workflow runs
438
442
  const sha = cancelledBlocker.sha;
443
+ let runs = [];
444
+ let retriggerable = [];
445
+ let rerunTriggered = false;
446
+ let rerunAttempted = false;
447
+ const rerunFailures = [];
448
+
439
449
  if (sha) {
440
- const runs = await getWorkflowRunsForSha(owner, repo, sha, argv.verbose);
441
- const retriggerable = runs.filter(r => r.conclusion === 'cancelled' || r.conclusion === 'stale');
442
- let rerunTriggered = false;
450
+ runs = await getWorkflowRunsForSha(owner, repo, sha, argv.verbose);
451
+ retriggerable = getRetriggerableWorkflowRuns(runs);
452
+
453
+ if (retriggerable.length === 0) {
454
+ await log(formatAligned('', '⚠️ No cancelled/stale workflow run found for this SHA', '', 2));
455
+ rerunFailures.push({
456
+ error: 'No cancelled/stale workflow run was found for this commit SHA.',
457
+ });
458
+ }
443
459
 
444
460
  for (const run of retriggerable) {
445
461
  await log(formatAligned('', `Re-triggering workflow "${run.name}" (${run.id})...`, '', 2));
462
+ rerunAttempted = true;
446
463
  const rerunResult = await rerunWorkflowRun(owner, repo, run.id, argv.verbose);
447
464
  if (rerunResult.success) {
448
465
  await log(formatAligned('', `✅ Re-triggered: ${run.name}`, '', 2));
449
466
  rerunTriggered = true;
450
467
  } else {
451
468
  await log(formatAligned('', `⚠️ Could not re-trigger ${run.name}: ${rerunResult.error}`, '', 2));
469
+ rerunFailures.push({ run, error: rerunResult.error });
452
470
  }
453
471
  }
454
472
 
@@ -457,6 +475,38 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
457
475
  // Don't restart AI - just wait for re-triggered jobs to complete
458
476
  // The next iteration of the loop will check the new status
459
477
  }
478
+ } else {
479
+ await log(formatAligned('', '⚠️ Cancelled CI blocker did not include a commit SHA', '', 2));
480
+ rerunFailures.push({
481
+ error: 'Cancelled CI blocker did not include a commit SHA, so automatic workflow re-run could not identify the run.',
482
+ });
483
+ }
484
+
485
+ if (shouldStopForCancelledCIReview({ retriggerableRuns: retriggerable, rerunTriggered, rerunFailures })) {
486
+ await log(formatAligned('🛑', 'CANCELLED CI/CD NEEDS HUMAN REVIEW', 'Automatic re-run could not be started'));
487
+
488
+ try {
489
+ const commentBody = buildCancelledCIReviewComment({
490
+ blocker: cancelledBlocker,
491
+ runs,
492
+ rerunFailures,
493
+ rerunAttempted,
494
+ sha,
495
+ });
496
+ await postTrackedComment({ $, owner, repo, targetNumber: prNumber, body: commentBody });
497
+ await log(formatAligned('', '💬 Posted cancelled CI review notification to PR', '', 2));
498
+ } catch (commentError) {
499
+ reportError(commentError, {
500
+ context: 'post_cancelled_ci_review_comment',
501
+ owner,
502
+ repo,
503
+ prNumber,
504
+ operation: 'comment_on_pr',
505
+ });
506
+ await log(formatAligned('', '⚠️ Could not post cancelled CI review comment to PR', '', 2));
507
+ }
508
+
509
+ return { success: false, reason: 'ci_cancelled_requires_review', latestSessionId, latestAnthropicCost };
460
510
  }
461
511
  // Don't set shouldRestart for cancelled checks - wait for re-triggered jobs instead
462
512
  }
@@ -110,6 +110,11 @@ export const SOLVE_OPTION_DEFINITIONS = {
110
110
  description: 'Automatically use .gitkeep if CLAUDE.md is in .gitignore (pre-checks before creating file)',
111
111
  default: true,
112
112
  },
113
+ 'auto-support-agents-md-as-claude-md': {
114
+ type: 'boolean',
115
+ description: '[EXPERIMENTAL] Temporarily copy AGENTS.md/agents.md to CLAUDE.md while Claude runs, then remove the temporary copy',
116
+ default: false,
117
+ },
113
118
  'attach-logs': {
114
119
  type: 'boolean',
115
120
  description: 'Upload the solution draft log file to the Pull Request on completion (⚠️ WARNING: May expose sensitive data)',
@@ -55,6 +55,9 @@ export const AUTO_MERGED_MARKER = 'Auto-merged';
55
55
  // solve.auto-merge.lib.mjs — billing-limit notification (spending cap / free tier)
56
56
  export const BILLING_LIMIT_MARKER = 'GitHub Actions Billing Limit';
57
57
 
58
+ // solve.auto-merge.lib.mjs — cancelled/stale CI needs manual review
59
+ export const CANCELLED_CI_REVIEW_MARKER = 'Cancelled CI/CD Requires Review';
60
+
58
61
  // solve.results.lib.mjs — working session summary comments posted by
59
62
  // --attach-solution-summary / --auto-attach-solution-summary at the end of
60
63
  // every working session (top-level solve, auto-restart-until-mergeable
@@ -99,7 +102,7 @@ export const USAGE_LIMIT_REACHED_MARKER = 'Usage Limit Reached';
99
102
  * named constants above so that adding a new marker only requires adding
100
103
  * the constant and appending it here.
101
104
  */
102
- export const TOOL_GENERATED_COMMENT_MARKERS = [AI_WORK_SESSION_STARTED_MARKER, AI_WORK_SESSION_COMPLETED_MARKER, AI_WORK_SESSION_RESUMED_MARKER, AUTO_RESUME_ON_LIMIT_RESET_MARKER, AUTO_RESTART_ON_LIMIT_RESET_MARKER, SOLUTION_DRAFT_LOG_MARKER, AUTO_RESTART_MARKER, AUTO_RESTART_UNTIL_MERGEABLE_LOG_MARKER, READY_TO_MERGE_MARKER, AUTO_MERGED_MARKER, BILLING_LIMIT_MARKER, MAINTAINER_ACCESS_REQUEST_MARKER, LIVE_PROGRESS_SECTION_START_MARKER, SESSION_FORCE_KILLED_MARKER, REPOSITORY_INITIALIZATION_REQUIRED_MARKER, INTERACTIVE_SESSION_STARTED_MARKER, INTERACTIVE_SESSION_ENDED_MARKER, NOW_WORKING_SESSION_IS_ENDED_MARKER, SOLUTION_DRAFT_FAILED_MARKER, SOLUTION_DRAFT_FINISHED_WITH_ERRORS_MARKER, USAGE_LIMIT_REACHED_MARKER, WORKING_SESSION_SUMMARY_MARKER];
105
+ export const TOOL_GENERATED_COMMENT_MARKERS = [AI_WORK_SESSION_STARTED_MARKER, AI_WORK_SESSION_COMPLETED_MARKER, AI_WORK_SESSION_RESUMED_MARKER, AUTO_RESUME_ON_LIMIT_RESET_MARKER, AUTO_RESTART_ON_LIMIT_RESET_MARKER, SOLUTION_DRAFT_LOG_MARKER, AUTO_RESTART_MARKER, AUTO_RESTART_UNTIL_MERGEABLE_LOG_MARKER, READY_TO_MERGE_MARKER, AUTO_MERGED_MARKER, BILLING_LIMIT_MARKER, CANCELLED_CI_REVIEW_MARKER, MAINTAINER_ACCESS_REQUEST_MARKER, LIVE_PROGRESS_SECTION_START_MARKER, SESSION_FORCE_KILLED_MARKER, REPOSITORY_INITIALIZATION_REQUIRED_MARKER, INTERACTIVE_SESSION_STARTED_MARKER, INTERACTIVE_SESSION_ENDED_MARKER, NOW_WORKING_SESSION_IS_ENDED_MARKER, SOLUTION_DRAFT_FAILED_MARKER, SOLUTION_DRAFT_FINISHED_WITH_ERRORS_MARKER, USAGE_LIMIT_REACHED_MARKER, WORKING_SESSION_SUMMARY_MARKER];
103
106
 
104
107
  /**
105
108
  * Markers that indicate the end of a working session. Used by