@link-assistant/hive-mind 2.11.3 → 2.11.4

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.
@@ -67,6 +67,10 @@ const { reportError } = sentryLib;
67
67
  const prIssueLinking = await import('./pr-issue-linking.lib.mjs');
68
68
  const { buildIssueReference, ensureIssueLinkInPullRequestBody } = prIssueLinking;
69
69
 
70
+ // Issue #2119: the one place that decides whether a pull request changed anything.
71
+ const { formatChangeSummary, getPullRequestChangeStats } = await import('./pull-request-changes.lib.mjs');
72
+ const { buildNoChangesNotice, redactWorkspacePaths } = await import('./working-session-summary.lib.mjs');
73
+
70
74
  /**
71
75
  * Placeholder patterns used to detect auto-generated PR content that was not updated by the agent.
72
76
  * These patterns match the initial WIP PR created by solve.auto-pr.lib.mjs.
@@ -158,7 +162,7 @@ export const ensurePullRequestIssueLink = async ({ prNumber, issueNumber, owner,
158
162
  await writeSanitizedPublicationFile(tempBodyFile, linkResult.body);
159
163
 
160
164
  try {
161
- const updateResult = await command`gh pr edit ${prNumber} --repo ${owner}/${repo} --body-file "${tempBodyFile}"`;
165
+ const updateResult = await command`gh pr edit ${prNumber} --repo ${owner}/${repo} --body-file ${tempBodyFile}`;
162
166
  await fs.unlink(tempBodyFile).catch(() => {});
163
167
 
164
168
  if (updateResult.code === 0) {
@@ -787,7 +791,7 @@ export const verifyResults = async (owner, repo, branchName, issueNumber, prNumb
787
791
  if (prTitleHasPlaceholder && !argv.autoRestartOnNonUpdatedPullRequestDescription) {
788
792
  const updatedTitle = await sanitizeForPublication(pr.title.replace(/^\[WIP\]\s*/, ''));
789
793
  await log(` 📝 Removing [WIP] prefix from PR title...`);
790
- const titleResult = await $`gh pr edit ${pr.number} --repo ${owner}/${repo} --title "${updatedTitle}"`;
794
+ const titleResult = await $`gh pr edit ${pr.number} --repo ${owner}/${repo} --title ${updatedTitle}`;
791
795
  if (titleResult.code === 0) {
792
796
  await log(` ✅ Updated PR title to: "${updatedTitle}"`);
793
797
  } else {
@@ -801,14 +805,14 @@ export const verifyResults = async (owner, repo, branchName, issueNumber, prNumb
801
805
  if (hasPlaceholder && !argv.autoRestartOnNonUpdatedPullRequestDescription) {
802
806
  await log(` 📝 Updating PR description to remove placeholder text...`);
803
807
 
804
- // Build a summary of the changes from the PR diff
805
- const diffResult = await $`gh pr diff ${pr.number} --repo ${owner}/${repo} 2>&1`;
806
- const diffOutput = diffResult.code === 0 ? diffResult.stdout.toString() : '';
807
-
808
- // Count files changed
809
- const filesChanged = (diffOutput.match(/^diff --git/gm) || []).length;
810
- const additions = (diffOutput.match(/^\+[^+]/gm) || []).length;
811
- const deletions = (diffOutput.match(/^-[^-]/gm) || []).length;
808
+ // Issue #2119: measure the net diff. The reproduction PRs published
809
+ // "1 file(s) modified, 1 line(s) added" for a pull request that
810
+ // changed nothing, because the stats were never checked for being
811
+ // empty.
812
+ const changeStats = await getPullRequestChangeStats({ owner, repo, prNumber: pr.number, $ });
813
+ if (!changeStats.hasChanges) {
814
+ await log(` ⚠️ PR #${pr.number} has an empty diff - the description will say so instead of claiming changes`, { level: 'warning' });
815
+ }
812
816
 
813
817
  // Get the issue title for context
814
818
  const issueTitleResult = await $`gh issue view ${issueNumber} --repo ${owner}/${repo} --json title --jq .title 2>&1`;
@@ -822,9 +826,7 @@ export const verifyResults = async (owner, repo, branchName, issueNumber, prNumb
822
826
  This pull request implements a solution for ${issueRef}: ${issueTitle}
823
827
 
824
828
  ### Changes
825
- - ${filesChanged} file(s) modified
826
- - ${additions} line(s) added
827
- - ${deletions} line(s) removed
829
+ ${formatChangeSummary(changeStats)}
828
830
 
829
831
  ### Issue Reference
830
832
  Fixes ${issueRef}
@@ -836,7 +838,7 @@ Fixes ${issueRef}
836
838
  await writeSanitizedPublicationFile(tempBodyFile, newDescription);
837
839
 
838
840
  try {
839
- const descResult = await $`gh pr edit ${pr.number} --repo ${owner}/${repo} --body-file "${tempBodyFile}"`;
841
+ const descResult = await $`gh pr edit ${pr.number} --repo ${owner}/${repo} --body-file ${tempBodyFile}`;
840
842
  await fs.unlink(tempBodyFile).catch(() => {});
841
843
 
842
844
  if (descResult.code === 0) {
@@ -1269,7 +1271,7 @@ export const buildWorkingSessionSummaryDetails = ({ publicPricingEstimate = null
1269
1271
  return `${costInfo}${budgetStats}`.trim();
1270
1272
  };
1271
1273
 
1272
- export const attachSolutionSummary = async ({ resultSummary, prNumber, issueNumber, owner, repo, publicPricingEstimate = null, anthropicTotalCostUSD = null, pricingInfo = null, budgetStatsData = null }) => {
1274
+ export const attachSolutionSummary = async ({ resultSummary, prNumber, issueNumber, owner, repo, publicPricingEstimate = null, anthropicTotalCostUSD = null, pricingInfo = null, budgetStatsData = null, changeStats = null }) => {
1273
1275
  if (!resultSummary || typeof resultSummary !== 'string') {
1274
1276
  await log('⚠️ No working session summary available to attach', { verbose: true });
1275
1277
  return false;
@@ -1290,10 +1292,16 @@ export const attachSolutionSummary = async ({ resultSummary, prNumber, issueNumb
1290
1292
  pricingInfo,
1291
1293
  budgetStatsData,
1292
1294
  });
1295
+ // Issue #2119: publish what the session actually produced. The reported
1296
+ // summary said "The `pwd` command completed" and printed the solver's own
1297
+ // /tmp workspace, on a pull request that was still empty.
1298
+ const noChangesNotice = buildNoChangesNotice(changeStats);
1299
+ const summaryBody = redactWorkspacePaths(resultSummary);
1300
+
1293
1301
  const comment = `${toolComments.WORKING_SESSION_SUMMARY_AUTOMATION_MARKER}
1294
1302
  ## ${toolComments.WORKING_SESSION_SUMMARY_MARKER}
1295
1303
 
1296
- ${resultSummary}${usageDetails ? `\n\n${usageDetails}` : ''}
1304
+ ${summaryBody}${noChangesNotice ? `\n\n${noChangesNotice}` : ''}${usageDetails ? `\n\n${usageDetails}` : ''}
1297
1305
 
1298
1306
  ---
1299
1307
  *${toolComments.WORKING_SESSION_SUMMARY_AUTOMATED_FOOTER}*`;
@@ -1395,6 +1403,10 @@ export const maybeAttachWorkingSessionSummary = async ({ argv, resultSummary, wo
1395
1403
  ...sessionUsage,
1396
1404
  })
1397
1405
  : null);
1406
+ // Issue #2119: a summary posted on a pull request that changed nothing must
1407
+ // say so, instead of reading as a report of completed work.
1408
+ const changeStats = prNumber ? await getPullRequestChangeStats({ owner, repo, prNumber, $ }) : null;
1409
+
1398
1410
  const ok = await attachSolutionSummary({
1399
1411
  resultSummary,
1400
1412
  prNumber,
@@ -1405,6 +1417,7 @@ export const maybeAttachWorkingSessionSummary = async ({ argv, resultSummary, wo
1405
1417
  anthropicTotalCostUSD,
1406
1418
  pricingInfo,
1407
1419
  budgetStatsData: resolvedBudgetStatsData,
1420
+ changeStats,
1408
1421
  });
1409
1422
  return { attached: !!ok, reason: ok ? 'attached' : 'post_failed', budgetStatsData: resolvedBudgetStatsData };
1410
1423
  };
@@ -46,7 +46,12 @@ const { checkGitHubTerminalState } = terminalStateLib;
46
46
 
47
47
  // Issue #1574: Interruptible sleep so CTRL+C is never blocked by a lingering timer
48
48
  const { interruptibleSleep } = await import('./interruptible-sleep.lib.mjs');
49
- const { formatAutoIterationLimit, hasReachedAutoIterationLimit, normalizeAutoIterationLimit } = await import('./auto-iteration-limits.lib.mjs');
49
+ // Issue #2119: one auto-restart budget shared with solve.auto-merge.lib.mjs, so
50
+ // a limit of 5 means 5 AI sessions in total rather than 5 per subsystem, and
51
+ // every label renders in the same `N/M` form.
52
+ const autoRestartBudget = await import('./auto-restart-budget.lib.mjs');
53
+ const { beginAutoRestartBudget, consumeAutoRestartIteration, formatAutoRestartLabel, formatAutoRestartLimit, getAutoRestartIterationsUsed, getRemainingAutoRestartIterations, hasExhaustedAutoRestartBudget } = autoRestartBudget;
54
+ const { failOnAutoRestartBudgetExhausted } = await import('./auto-restart-exhaustion.lib.mjs');
50
55
 
51
56
  // Issue #1625: Central marker constants + tracked comment posting
52
57
  const toolComments = await import('./tool-comments.lib.mjs');
@@ -78,7 +83,9 @@ export const watchForFeedback = async params => {
78
83
 
79
84
  const watchInterval = argv.watchInterval || 60; // seconds
80
85
  const isTemporaryWatch = argv.temporaryWatch || false;
81
- const maxAutoRestartIterations = normalizeAutoIterationLimit(argv.autoRestartMaxIterations);
86
+ // Issue #2119: claim the shared budget; the same limit is honoured by the
87
+ // auto-merge restart loop that solve.mjs runs afterwards.
88
+ const maxAutoRestartIterations = beginAutoRestartBudget({ maxIterations: argv.autoRestartMaxIterations });
82
89
 
83
90
  // Track latest session data across all iterations for accurate pricing
84
91
  // Issue #1056: Seed from the *initial* tool execution so the first auto-restart
@@ -105,7 +112,7 @@ export const watchForFeedback = async params => {
105
112
  await log(formatAligned('', 'Monitoring PR:', `#${prNumber}`, 2));
106
113
  await log(formatAligned('', 'Mode:', 'Auto-restart (NOT --watch mode)', 2));
107
114
  await log(formatAligned('', 'Stop conditions:', 'All changes committed OR PR merged OR max iterations reached', 2));
108
- await log(formatAligned('', 'Max iterations:', formatAutoIterationLimit(maxAutoRestartIterations), 2));
115
+ await log(formatAligned('', 'Max iterations:', formatAutoRestartLimit(), 2));
109
116
  await log(formatAligned('', 'Note:', 'No wait time between iterations in auto-restart mode', 2));
110
117
  } else {
111
118
  await log(formatAligned('👁️', 'WATCH MODE ACTIVATED', ''));
@@ -118,8 +125,12 @@ export const watchForFeedback = async params => {
118
125
  await log('');
119
126
 
120
127
  let iteration = 0;
121
- let autoRestartCount = 0;
128
+ // Issue #2119: mirrors the shared budget counter so every label in this loop
129
+ // reports the run-wide iteration number, not a per-subsystem one.
130
+ let autoRestartCount = getAutoRestartIterationsUsed();
122
131
  let firstIterationInTemporaryMode = isTemporaryWatch;
132
+ // Issue #2119: set when the budget runs out, so the caller learns the run failed.
133
+ let budgetExhaustion = null;
123
134
 
124
135
  while (true) {
125
136
  iteration++;
@@ -211,13 +222,25 @@ export const watchForFeedback = async params => {
211
222
  break;
212
223
  }
213
224
 
214
- // Check if we've reached max iterations
215
- if (hasReachedAutoIterationLimit(autoRestartCount, maxAutoRestartIterations)) {
216
- await log('');
217
- await log(formatAligned('⚠️', 'MAX ITERATIONS REACHED', `Exiting auto-restart mode after ${autoRestartCount} iterations`));
218
- await log(formatAligned('', 'Some uncommitted changes may remain', '', 2));
219
- await log(formatAligned('', 'Please review and commit manually if needed', '', 2));
220
- await log('');
225
+ // Issue #2119: the shared budget is exhausted. Previously this logged a
226
+ // warning and broke out of the loop, leaving the very uncommitted changes
227
+ // that triggered every restart on a temporary clone that is then deleted.
228
+ // Now the run fails and the work is auto-committed first, so the result
229
+ // stays visible in the PR.
230
+ if (hasExhaustedAutoRestartBudget()) {
231
+ const changes = await getUncommittedChangesDetails(tempDir);
232
+ budgetExhaustion = await failOnAutoRestartBudgetExhausted({
233
+ owner,
234
+ repo,
235
+ prNumber,
236
+ tempDir,
237
+ branchName: prBranch || branchName,
238
+ $,
239
+ log,
240
+ formatAligned,
241
+ blocker: changes.length > 0 ? `uncommitted changes remained: ${changes.join(', ')}` : 'uncommitted changes remained',
242
+ subsystem: 'auto-restart on uncommitted changes',
243
+ });
221
244
  break;
222
245
  }
223
246
  }
@@ -274,17 +297,18 @@ export const watchForFeedback = async params => {
274
297
  }
275
298
  await log('');
276
299
 
277
- // Increment auto-restart counter and log restart number
278
- autoRestartCount++;
300
+ // Issue #2119: claim one iteration from the run-wide budget shared with
301
+ // the auto-merge restart loop.
302
+ autoRestartCount = consumeAutoRestartIteration();
279
303
  autoRestartIterationsRan = true; // Issue #1290: Mark that auto-restart iterations ran
280
304
  lastIterationLogUploaded = false; // Reset log upload tracking for new iteration
281
- const restartLabel = firstIterationInTemporaryMode ? 'Initial restart' : `Restart ${autoRestartCount}/${maxAutoRestartIterations}`;
305
+ const restartLabel = `Restart ${formatAutoRestartLabel(autoRestartCount)}`;
282
306
  await log(formatAligned('🔄', `${restartLabel}:`, `Running ${argv.tool.toUpperCase()} to handle uncommitted changes...`));
283
307
 
284
308
  // Post a comment to PR about auto-restart
285
309
  if (prNumber) {
286
310
  try {
287
- const remainingIterations = maxAutoRestartIterations === 0 ? null : maxAutoRestartIterations - autoRestartCount;
311
+ const remainingIterations = getRemainingAutoRestartIterations();
288
312
 
289
313
  // Get uncommitted files list for the comment
290
314
  let uncommittedFilesList = '';
@@ -292,7 +316,7 @@ export const watchForFeedback = async params => {
292
316
  uncommittedFilesList = '\n\n**Uncommitted files:**\n```\n' + changes.join('\n') + '\n```';
293
317
  }
294
318
 
295
- const iterationLabel = maxAutoRestartIterations === 0 ? `${autoRestartCount}` : `${autoRestartCount}/${maxAutoRestartIterations}`;
319
+ const iterationLabel = formatAutoRestartLabel(autoRestartCount);
296
320
  const stopText = remainingIterations === null ? 'Auto-restart is configured with no iteration limit.' : `Auto-restart will stop after changes are committed or discarded, or after ${remainingIterations} more iteration${remainingIterations !== 1 ? 's' : ''}.`;
297
321
  const commentBody = `## 🔄 ${AUTO_RESTART_MARKER} ${iterationLabel}\n\nDetected uncommitted changes from previous run. Starting new session to review and commit or discard them.${uncommittedFilesList}\n\n---\n*${stopText} Please wait until working session will end and give your feedback.*`;
298
322
  // Issue #1625: Track so this doesn't falsely count as AI-authored.
@@ -471,7 +495,7 @@ export const watchForFeedback = async params => {
471
495
  const logFile = getLogFile();
472
496
  if (logFile) {
473
497
  // Use "Auto-restart X/Y Failure Log" format to distinguish from success logs
474
- const iterationLabel = maxAutoRestartIterations === 0 ? `${autoRestartCount}` : `${autoRestartCount}/${maxAutoRestartIterations}`;
498
+ const iterationLabel = formatAutoRestartLabel(autoRestartCount);
475
499
  const customTitle = `⚠️ Auto-restart ${iterationLabel} Failure Log`;
476
500
  const logUploadSuccess = await attachLogToGitHub({
477
501
  logFile,
@@ -607,7 +631,7 @@ export const watchForFeedback = async params => {
607
631
  const logFile = getLogFile();
608
632
  if (logFile) {
609
633
  // Use "Auto-restart X/Y Log" format as requested in issue #1107
610
- const iterationLabel = maxAutoRestartIterations === 0 ? `${autoRestartCount}` : `${autoRestartCount}/${maxAutoRestartIterations}`;
634
+ const iterationLabel = formatAutoRestartLabel(autoRestartCount);
611
635
  const customTitle = `🔄 Auto-restart ${iterationLabel} Log`;
612
636
  const logUploadSuccess = await attachLogToGitHub({
613
637
  logFile,
@@ -733,6 +757,11 @@ export const watchForFeedback = async params => {
733
757
  latestAnthropicCost,
734
758
  autoRestartIterationsRan, // True if any auto-restart iterations actually ran
735
759
  lastIterationLogUploaded, // True if the last iteration's logs were uploaded
760
+ // Issue #2119: false when the shared auto-restart budget ran out, so the run
761
+ // is reported as failed instead of silently exiting with work still pending.
762
+ success: !budgetExhaustion,
763
+ reason: budgetExhaustion?.reason || null,
764
+ autoRestartLimitReached: Boolean(budgetExhaustion),
736
765
  };
737
766
  };
738
767
 
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Issue #2119: make the published "Working session summary" comment honest.
5
+ *
6
+ * The Kotlin reproduction run ended with the AI tool answering a single `pwd`
7
+ * and returning, and Hive Mind published exactly that as the session's result:
8
+ *
9
+ * <!-- hive-mind:working-session-summary -->
10
+ * ## Working session summary
11
+ *
12
+ * The `pwd` command completed. Output:
13
+ *
14
+ * ```text
15
+ * /tmp/gh-issue-solver-1785421161275
16
+ * ```
17
+ *
18
+ * (https://github.com/konard/test-hello-world-019fb330-fa49-7c9d-a664-b7ea33bb698a/pull/2#issuecomment-5132013034)
19
+ *
20
+ * Two things are wrong with that comment, and both are Hive Mind's to fix - the
21
+ * tool returning nothing useful is a separate, upstream problem:
22
+ *
23
+ * 1. It reads as a report of completed work. A reader has to open the diff to
24
+ * discover the pull request is still empty. Stating that in the comment
25
+ * turns a misleading summary into an accurate one.
26
+ * 2. It publishes the solver's private workspace path. That path is an
27
+ * implementation detail of the machine the run happened on; it is noise in
28
+ * a public comment and it tells readers about the host filesystem.
29
+ */
30
+
31
+ /** Solver workspace directories, as created by solve.repository.lib.mjs. */
32
+ const WORKSPACE_PATH_PATTERN = /(?:\/private)?\/(?:tmp|var\/folders\/[^\s/]+\/[^\s/]+\/[^\s/]+)\/gh-issue-solver(?:-resume)?-[A-Za-z0-9._-]+/g;
33
+
34
+ /** Replacement shown in place of a redacted workspace path. */
35
+ export const WORKSPACE_PATH_PLACEHOLDER = '<workspace>';
36
+
37
+ /**
38
+ * Replace solver workspace paths with a placeholder.
39
+ *
40
+ * Only the solver's own `gh-issue-solver-*` directories are touched: paths the
41
+ * user actually cares about (repository-relative paths, other absolute paths)
42
+ * are left exactly as the AI wrote them.
43
+ *
44
+ * @param {string} text
45
+ * @returns {string}
46
+ */
47
+ export const redactWorkspacePaths = text => {
48
+ if (typeof text !== 'string' || !text) return text;
49
+ return text.replace(WORKSPACE_PATH_PATTERN, WORKSPACE_PATH_PLACEHOLDER);
50
+ };
51
+
52
+ /**
53
+ * The line appended when the pull request still has an empty diff.
54
+ *
55
+ * @param {{measured: boolean, hasChanges: boolean}|null} changeStats - from
56
+ * `getPullRequestChangeStats`; `null` or unmeasured stats produce no notice,
57
+ * so a failed diff read never turns into a false "no changes" claim.
58
+ * @returns {string} the notice, or an empty string when none applies
59
+ */
60
+ export const buildNoChangesNotice = changeStats => {
61
+ if (!changeStats || !changeStats.measured || changeStats.hasChanges) return '';
62
+ return '> ⚠️ This pull request still contains no changes - nothing was implemented yet.';
63
+ };
64
+
65
+ export default { buildNoChangesNotice, redactWorkspacePaths, WORKSPACE_PATH_PLACEHOLDER };
@@ -101,7 +101,7 @@ ${youTrackIssue.description || 'No description provided.'}
101
101
  if (needsUpdate) {
102
102
  await log(` 📝 Updating issue #${existingIssue.number} for ${youTrackId}...`);
103
103
 
104
- const updateResult = await $`gh issue edit ${existingIssue.number} --repo ${owner}/${repo} --title "${ghTitle}" --body "${ghBody}"`;
104
+ const updateResult = await $`gh issue edit ${existingIssue.number} --repo ${owner}/${repo} --title ${ghTitle} --body ${ghBody}`;
105
105
 
106
106
  if (updateResult.code === 0) {
107
107
  await log(` ✅ Updated issue #${existingIssue.number}`);
@@ -130,7 +130,7 @@ ${youTrackIssue.description || 'No description provided.'}
130
130
  await log(` ➕ Creating GitHub issue for ${youTrackId}...`);
131
131
 
132
132
  try {
133
- const createResult = await $`gh issue create --repo ${owner}/${repo} --title "${ghTitle}" --body "${ghBody}" --label "help wanted"`;
133
+ const createResult = await $`gh issue create --repo ${owner}/${repo} --title ${ghTitle} --body ${ghBody} --label "help wanted"`;
134
134
 
135
135
  if (createResult.code === 0) {
136
136
  const issueUrl = createResult.stdout.toString().trim();