@link-assistant/hive-mind 2.11.0 → 2.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.11.1
4
+
5
+ ### Patch Changes
6
+
7
+ - aea5fe1: Put the pull request back into draft whenever a working session starts, restarts or resumes (issue #2123). Draft/ready transitions now live in one shared module (`src/pr-draft-state.lib.mjs`) that is called from `startWorkSession()` for every continue-mode session — the previous `--watch`/`--auto-continue` gate is gone — and from `executeToolIteration()`, which covers watch mode, temporary auto-restart on uncommitted changes, auto-restart-until-mergeable, escalate, keep-working and auto-ensure-requirements. Limit-reset auto-resume/auto-restart now also forwards `--auto-continue` so the resumed process re-attaches to the existing PR instead of running detached from it. The helper is a no-op for PRs that are already in the target state, merged or closed, and logs the observed `isDraft`/`state` under `--verbose`.
8
+
3
9
  ## 2.11.0
4
10
 
5
11
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.11.0",
3
+ "version": "2.11.1",
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,137 @@
1
+ /**
2
+ * Single source of truth for pull request draft/ready state transitions.
3
+ *
4
+ * Issue #2123: every place that starts, restarts or resumes a working session must
5
+ * put the pull request back into draft mode (if it is not already a draft), and every
6
+ * place that ends a working session must convert it back to ready for review.
7
+ *
8
+ * Before this module the logic was duplicated inline in solve.session.lib.mjs and was
9
+ * gated behind `argv.watch || argv.autoContinue`, so auto-restart / auto-resume
10
+ * sessions (temporary watch mode, auto-restart-until-mergeable, escalate,
11
+ * keep-working, auto-ensure, PR-placeholder restart) kept the PR marked as
12
+ * "ready for review" while the AI was actively working on it.
13
+ *
14
+ * @see https://github.com/link-assistant/hive-mind/issues/2123
15
+ */
16
+
17
+ // rate-limit marker (#1726): callers pass in a `$` already wrapped by wrapDollarWithGhRetry.
18
+ import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry } from './github-rate-limit.lib.mjs';
19
+
20
+ const noopLog = async () => {};
21
+
22
+ /**
23
+ * Fetch the draft/open state of a pull request.
24
+ *
25
+ * @param {Object} options
26
+ * @param {string} options.owner - Repository owner
27
+ * @param {string} options.repo - Repository name
28
+ * @param {number|string} options.prNumber - Pull request number
29
+ * @param {Function} options.$ - command-stream style tagged template executor
30
+ * @param {Function} [options.log] - Logger
31
+ * @returns {Promise<{ok: boolean, isDraft: (boolean|null), state: (string|null), merged: boolean, error: (string|null)}>}
32
+ */
33
+ export const getPullRequestDraftState = async ({ owner, repo, prNumber, $, log = noopLog }) => {
34
+ try {
35
+ const result = await $`gh pr view ${prNumber} --repo ${owner}/${repo} --json isDraft,state`;
36
+ if (result.code !== 0) {
37
+ const stderr = result.stderr ? result.stderr.toString().trim() : '';
38
+ return { ok: false, isDraft: null, state: null, merged: false, error: stderr || `gh exited with code ${result.code}` };
39
+ }
40
+
41
+ const raw = result.stdout.toString().trim();
42
+ let parsed;
43
+ try {
44
+ parsed = JSON.parse(raw);
45
+ } catch {
46
+ return { ok: false, isDraft: null, state: null, merged: false, error: `Could not parse gh output: ${raw.slice(0, 200)}` };
47
+ }
48
+
49
+ const state = typeof parsed.state === 'string' ? parsed.state.toUpperCase() : null;
50
+ await log(` 🔍 PR #${prNumber} draft state: isDraft=${parsed.isDraft}, state=${state}`, { verbose: true });
51
+
52
+ return { ok: true, isDraft: parsed.isDraft === true, state, merged: state === 'MERGED', error: null };
53
+ } catch (error) {
54
+ return { ok: false, isDraft: null, state: null, merged: false, error: error.message };
55
+ }
56
+ };
57
+
58
+ /**
59
+ * Internal helper shared by ensurePullRequestIsDraft/ensurePullRequestIsReady.
60
+ *
61
+ * @param {Object} options
62
+ * @param {'draft'|'ready'} options.target - Desired state
63
+ * @returns {Promise<{ok: boolean, changed: boolean, skipped: boolean, reason: (string|null), error: (string|null)}>}
64
+ */
65
+ const setPullRequestDraftState = async ({ target, owner, repo, prNumber, $, log = noopLog, formatAligned = null, indent = 2, reason = null, reportError = null }) => {
66
+ const wantDraft = target === 'draft';
67
+ const label = wantDraft ? 'draft mode' : 'ready for review';
68
+ const write = async (icon, key, value) => {
69
+ await log(formatAligned ? formatAligned(icon, key, value, indent) : `${icon} ${key} ${value}`);
70
+ };
71
+
72
+ if (!owner || !repo || !prNumber) {
73
+ return { ok: false, changed: false, skipped: true, reason: 'missing_pr_context', error: null };
74
+ }
75
+
76
+ try {
77
+ const status = await getPullRequestDraftState({ owner, repo, prNumber, $, log });
78
+
79
+ if (!status.ok) {
80
+ await log(`Warning: Could not check PR #${prNumber} draft status: ${status.error}`, { level: 'warning' });
81
+ return { ok: false, changed: false, skipped: false, reason: 'status_check_failed', error: status.error };
82
+ }
83
+
84
+ // A merged or closed pull request cannot change its draft state; GitHub rejects it.
85
+ if (status.state && status.state !== 'OPEN') {
86
+ await write('ℹ️', 'PR status:', `${status.state.toLowerCase()} - skipping ${label} conversion`);
87
+ return { ok: true, changed: false, skipped: true, reason: `pr_${status.state.toLowerCase()}`, error: null };
88
+ }
89
+
90
+ if (status.isDraft === wantDraft) {
91
+ await write('✅', 'PR status:', `Already in ${label}`);
92
+ return { ok: true, changed: false, skipped: true, reason: 'already_in_target_state', error: null };
93
+ }
94
+
95
+ await write('📝', 'Converting PR:', `To ${label}${reason ? ` (${reason})` : ''}...`);
96
+ const convertResult = wantDraft ? await $`gh pr ready ${prNumber} --repo ${owner}/${repo} --undo` : await $`gh pr ready ${prNumber} --repo ${owner}/${repo}`;
97
+
98
+ if (convertResult.code === 0) {
99
+ await write('✅', 'PR converted:', `Now in ${label}`);
100
+ return { ok: true, changed: true, skipped: false, reason: null, error: null };
101
+ }
102
+
103
+ const stderr = convertResult.stderr ? convertResult.stderr.toString().trim() : '';
104
+ await log(`Warning: Could not convert PR #${prNumber} to ${label}${stderr ? `: ${stderr}` : ''}`, { level: 'warning' });
105
+ return { ok: false, changed: false, skipped: false, reason: 'conversion_failed', error: stderr || `gh exited with code ${convertResult.code}` };
106
+ } catch (error) {
107
+ if (typeof reportError === 'function') {
108
+ reportError(error, {
109
+ context: wantDraft ? 'convert_pr_to_draft' : 'convert_pr_to_ready',
110
+ prNumber,
111
+ owner,
112
+ repo,
113
+ operation: 'pr_status_change',
114
+ });
115
+ }
116
+ await log(`Warning: Could not check/convert PR #${prNumber} draft status: ${error.message}`, { level: 'warning' });
117
+ return { ok: false, changed: false, skipped: false, reason: 'exception', error: error.message };
118
+ }
119
+ };
120
+
121
+ /**
122
+ * Put a pull request into draft mode when a working session starts/restarts/resumes.
123
+ * No-op when the PR is already a draft, merged, or closed.
124
+ */
125
+ export const ensurePullRequestIsDraft = async options => setPullRequestDraftState({ ...options, target: 'draft' });
126
+
127
+ /**
128
+ * Put a pull request back to "ready for review" when a working session ends.
129
+ * No-op when the PR is already ready, merged, or closed.
130
+ */
131
+ export const ensurePullRequestIsReady = async options => setPullRequestDraftState({ ...options, target: 'ready' });
132
+
133
+ export default {
134
+ getPullRequestDraftState,
135
+ ensurePullRequestIsDraft,
136
+ ensurePullRequestIsReady,
137
+ };
@@ -162,6 +162,14 @@ export const autoContinueWhenLimitResets = async (issueUrl, sessionId, argv, sho
162
162
  await log(`🔄 Session will be RESTARTED (fresh start without previous context)`);
163
163
  }
164
164
 
165
+ // Issue #2123: the resumed/restarted process is launched with the ISSUE url, so without
166
+ // --auto-continue it would not enter continue mode, would not find the existing PR, and
167
+ // therefore would never convert that PR back to draft (nor post the auto-resume/auto-restart
168
+ // session comment). Preserve the flag so the new session attaches to the same PR.
169
+ if (argv.autoContinue) {
170
+ resumeArgs.push('--auto-continue');
171
+ }
172
+
165
173
  // Preserve auto-resume/auto-restart flag for subsequent limit hits
166
174
  if (argv.autoResumeOnLimitReset) {
167
175
  resumeArgs.push('--auto-resume-on-limit-reset');
@@ -33,6 +33,8 @@ const lib = await import('./lib.mjs');
33
33
  const { log, formatAligned, extractToolErrorCore } = lib;
34
34
  const { ensurePullRequestBaseBranch } = await import('./solve.pr-base-guard.lib.mjs');
35
35
  const { RESOURCE_PHASE_RESTART_AFTER, RESOURCE_PHASE_RESTART_BEFORE, recordResourceSnapshot } = await import('./solve.resource-diagnostics.lib.mjs');
36
+ // Issue #2123: shared draft/ready transitions for working sessions.
37
+ const { ensurePullRequestIsDraft } = await import('./pr-draft-state.lib.mjs');
36
38
 
37
39
  // Import Sentry integration
38
40
  const sentryLib = await import('./sentry.lib.mjs');
@@ -185,6 +187,23 @@ export const executeToolIteration = async params => {
185
187
  label: 'before AI restart iteration',
186
188
  });
187
189
 
190
+ // Issue #2123: every restart/resume iteration is a new working session, so the PR must be
191
+ // put back into draft before the AI starts changing it. This single call covers watch mode,
192
+ // temporary auto-restart, auto-restart-until-mergeable, escalate, keep-working,
193
+ // auto-ensure-requirements and the PR-placeholder restart, which all funnel through here.
194
+ if (prNumber) {
195
+ await ensurePullRequestIsDraft({
196
+ owner,
197
+ repo,
198
+ prNumber,
199
+ $,
200
+ log,
201
+ formatAligned,
202
+ reason: 'restart iteration',
203
+ reportError,
204
+ });
205
+ }
206
+
188
207
  // Import necessary modules for tool execution
189
208
  const memoryCheck = await import('./memory-check.mjs');
190
209
  const { getResourceSnapshot } = memoryCheck;
@@ -9,6 +9,10 @@
9
9
  import { 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, postTrackedComment } from './tool-comments.lib.mjs';
10
10
 
11
11
  import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller
12
+
13
+ // Issue #2123: draft/ready transitions live in one shared module so every session
14
+ // start/restart/resume path behaves identically.
15
+ import { ensurePullRequestIsDraft, ensurePullRequestIsReady } from './pr-draft-state.lib.mjs';
12
16
  /**
13
17
  * Session type definitions for different work session contexts
14
18
  * See: https://github.com/link-assistant/hive-mind/issues/1152
@@ -70,39 +74,30 @@ function getSessionCommentContent(sessionType, timestamp) {
70
74
  * @param {string} [options.sessionType='new'] - One of SESSION_TYPES values
71
75
  */
72
76
  export async function startWorkSession({ isContinueMode, prNumber, argv, log, formatAligned, $, sessionType = SESSION_TYPES.NEW }) {
73
- // Record work start time and convert PR to draft if in continue/watch mode
77
+ // Record work start time and convert PR to draft.
78
+ //
79
+ // Issue #2123: the draft conversion used to be gated behind `argv.watch || argv.autoContinue`,
80
+ // so plain `--resume`/continue-mode sessions left the PR marked "ready for review" while the
81
+ // AI was still working on it. Any continue-mode session with a PR now converts it to draft.
74
82
  const workStartTime = new Date();
75
- if (isContinueMode && prNumber && (argv.watch || argv.autoContinue)) {
83
+ const shouldPostSessionComment = argv.watch || argv.autoContinue;
84
+ if (isContinueMode && prNumber) {
76
85
  await log(`\n${formatAligned('🚀', 'Starting work session:', workStartTime.toISOString())}`);
77
86
 
78
- // Convert PR back to draft if not already
79
- try {
80
- const prStatusResult = await $`gh pr view ${prNumber} --repo ${global.owner}/${global.repo} --json isDraft --jq .isDraft`;
81
- if (prStatusResult.code === 0) {
82
- const isDraft = prStatusResult.stdout.toString().trim() === 'true';
83
- if (!isDraft) {
84
- await log(formatAligned('📝', 'Converting PR:', 'Back to draft mode...', 2));
85
- const convertResult = await $`gh pr ready ${prNumber} --repo ${global.owner}/${global.repo} --undo`;
86
- if (convertResult.code === 0) {
87
- await log(formatAligned('✅', 'PR converted:', 'Now in draft mode', 2));
88
- } else {
89
- await log('Warning: Could not convert PR to draft', { level: 'warning' });
90
- }
91
- } else {
92
- await log(formatAligned('✅', 'PR status:', 'Already in draft mode', 2));
93
- }
94
- }
95
- } catch (error) {
96
- const sentryLib = await import('./sentry.lib.mjs');
97
- const { reportError } = sentryLib;
98
- reportError(error, {
99
- context: 'convert_pr_to_draft',
100
- prNumber,
101
- operation: 'pr_status_change',
102
- });
103
- await log('Warning: Could not check/convert PR draft status', { level: 'warning' });
104
- }
87
+ const { reportError } = await import('./sentry.lib.mjs');
88
+ await ensurePullRequestIsDraft({
89
+ owner: global.owner,
90
+ repo: global.repo,
91
+ prNumber,
92
+ $,
93
+ log,
94
+ formatAligned,
95
+ reason: `session start: ${sessionType}`,
96
+ reportError,
97
+ });
98
+ }
105
99
 
100
+ if (isContinueMode && prNumber && shouldPostSessionComment) {
106
101
  // Post a comment marking the start of work session with appropriate header based on session type.
107
102
  // Issue #1625: Use postTrackedComment so the comment ID is registered in-memory and can be
108
103
  // excluded from the "did the AI post anything?" check in checkForAiCreatedComments().
@@ -131,14 +126,17 @@ export async function startWorkSession({ isContinueMode, prNumber, argv, log, fo
131
126
  }
132
127
 
133
128
  export async function endWorkSession({ isContinueMode, prNumber, argv, log, formatAligned, $, logsAttached = false }) {
134
- // Post end work session comment and convert PR back to ready if in continue mode
135
- if (isContinueMode && prNumber && (argv.watch || argv.autoContinue)) {
129
+ // Post end work session comment and convert PR back to ready if in continue mode.
130
+ // Issue #2123: the ready conversion mirrors startWorkSession's draft conversion, so it must
131
+ // run for every continue-mode session, not only for --watch/--auto-continue ones.
132
+ if (isContinueMode && prNumber) {
136
133
  const workEndTime = new Date();
134
+ const shouldPostSessionComment = argv.watch || argv.autoContinue;
137
135
  await log(`\n${formatAligned('🏁', 'Ending work session:', workEndTime.toISOString())}`);
138
136
 
139
137
  // Only post end comment if logs were NOT already attached
140
138
  // The attachLogToGitHub comment already serves as finishing status with "Now working session is ended" text
141
- if (!logsAttached) {
139
+ if (shouldPostSessionComment && !logsAttached) {
142
140
  // Post a comment marking the end of work session.
143
141
  // Issue #1625: Track the comment ID so it won't be mistaken for AI-authored content.
144
142
  try {
@@ -159,36 +157,21 @@ export async function endWorkSession({ isContinueMode, prNumber, argv, log, form
159
157
  });
160
158
  await log('Warning: Could not post work end comment', { level: 'warning' });
161
159
  }
162
- } else {
160
+ } else if (shouldPostSessionComment) {
163
161
  await log(formatAligned('ℹ️', 'Skipping:', 'End comment (logs already attached with session end message)', 2));
164
162
  }
165
163
 
166
- // Convert PR back to ready for review
167
- try {
168
- const prStatusResult = await $`gh pr view ${prNumber} --repo ${global.owner}/${global.repo} --json isDraft --jq .isDraft`;
169
- if (prStatusResult.code === 0) {
170
- const isDraft = prStatusResult.stdout.toString().trim() === 'true';
171
- if (isDraft) {
172
- await log(formatAligned('🔀', 'Converting PR:', 'Back to ready for review...', 2));
173
- const convertResult = await $`gh pr ready ${prNumber} --repo ${global.owner}/${global.repo}`;
174
- if (convertResult.code === 0) {
175
- await log(formatAligned('✅', 'PR converted:', 'Ready for review', 2));
176
- } else {
177
- await log('Warning: Could not convert PR to ready', { level: 'warning' });
178
- }
179
- } else {
180
- await log(formatAligned('✅', 'PR status:', 'Already ready for review', 2));
181
- }
182
- }
183
- } catch (error) {
184
- const sentryLib = await import('./sentry.lib.mjs');
185
- const { reportError } = sentryLib;
186
- reportError(error, {
187
- context: 'convert_pr_to_ready',
188
- prNumber,
189
- operation: 'pr_status_change',
190
- });
191
- await log('Warning: Could not convert PR to ready status', { level: 'warning' });
192
- }
164
+ // Convert PR back to ready for review (issue #2123: shared implementation)
165
+ const { reportError } = await import('./sentry.lib.mjs');
166
+ await ensurePullRequestIsReady({
167
+ owner: global.owner,
168
+ repo: global.repo,
169
+ prNumber,
170
+ $,
171
+ log,
172
+ formatAligned,
173
+ reason: 'session end',
174
+ reportError,
175
+ });
193
176
  }
194
177
  }