@link-assistant/hive-mind 2.19.1 → 2.20.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,33 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.20.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 84ea8a3: Accept a repository URL in `solve` and close every open issue of that repository with one pull request, and add `--ensure-all-sub-issues-addressed` to prove it (issue #2212).
8
+
9
+ `solve https://github.com/owner/repo` used to be rejected: `validateGitHubUrl` only ever accepted an issue or a pull request URL. It now means something. Repository mode lists every open issue of the repository, creates one combined issue that lists them as GitHub **native sub-issues**, and hands that issue to the normal single-issue flow — so a single pull request can close all of them at once, and merging it closes the combined issue too.
10
+
11
+ - **The combined issue is built from what GitHub actually allows.** A parent issue takes at most 100 sub-issues, so a repository with more open issues gets the oldest 100 and the rest are reported as intentionally left out — in the run log and in the issue body, not silently. Pull requests are filtered out of the REST `/issues` response, the selection is oldest-first with the issue number as tie-breaker, and each issue is attached with its REST database id through `POST …/sub_issues`. That endpoint is documented as prone to secondary rate limiting, so attachments are spaced one second apart and a rate-limited attachment is retried with a one- then two-minute backoff; an attachment that fails for any other reason (an issue that already has a parent, say) is reported and skipped instead of aborting the run.
12
+ - **`--ensure-all-sub-issues-addressed` works on any issue.** After the main solve completes it lists the sub-issues of the issue being solved and checks the pull request title and description for a closing reference GitHub really recognizes — reusing `prClosesIssue`, so `Fixes #1, #2` correctly counts as closing only `#1`, exactly as GitHub reads it. When references are missing it restarts the AI tool with the concrete list of sub-issues and the exact lines to add, asking it to double check that each one was really addressed in this single pull request. Bare flag means 5 restarts; it also takes a count or `forever`. It runs last among the post-solve loops, because `--escalate`, `--auto-ensure-requirements` and `--keep-working` may still rewrite the description.
13
+ - **Repository mode turns on `--deep-analysis` and `--ensure-all-sub-issues-addressed` for the issue it generated**, so the run asks for the deep analysis `/fix` asks for and then verifies its own promise.
14
+ - **The Telegram bot accepts repository URLs in `/solve` too**, and labels such a run as a URL rather than as an issue.
15
+
16
+ `tests/test-solve-repository-mode-2212.mjs` (47 assertions) covers the rejection this starts from, the pure helpers, the orchestration against a fake `gh`, the CLI flag and the `solve.mjs` wiring; `tests/test-telegram-solve-repository-url-2212.mjs` (25 assertions) covers the Telegram entry point. `examples/solve-repository-mode-preview.mjs` previews a real repository read-only, creating nothing. The analysis, the cited GitHub documentation and the run evidence are in `docs/case-studies/issue-2212/`.
17
+
18
+ ## 2.19.2
19
+
20
+ ### Patch Changes
21
+
22
+ - 57b8258: Never merge hive-mind's own placeholder file, and restart when a pull request changes nothing.
23
+
24
+ `konard/audio-decomposer#1` was solved twice, both pull requests were auto-merged, and the complete diff of both was the `.gitkeep` hive-mind writes only so that an empty branch has something to open a pull request from. Two defects had to line up for that, and both are fixed here.
25
+
26
+ - **The placeholder is reverted before anything can merge it.** `cleanupClaudeFile()` ran after `startAutoRestartUntilMergeable()`, so with `--auto-merge` it was structurally guaranteed to be too late: on that pull request the revert commit is timestamped four seconds after the merge commit. It now runs before the watch loop and still after `verifyResults()`, which is the ordering issue #1516 actually asked for. As defense in depth, the watch loop reverts a placeholder that survived into its own diff — a crashed session, a resumed run — instead of merging it.
27
+ - **An appended placeholder is recognised as a placeholder.** The empty-pull-request detector matched an added `# .gitkeep file auto-generated at …` line, which the solver only writes when it _creates_ the file; when the file already exists it appends `# Updated: <timestamp>` instead, and that reads as an ordinary modification. The diff was counted as real work, so the pull request looked mergeable and the auto-restart from issue #2119 never fired. The measurement now reconstructs both sides of the file and compares them with hive-mind's own generated lines removed, so created, appended and re-appended placeholders are all caught — while a genuine edit to a `.gitkeep` or `CLAUDE.md` the repository owns still counts as work.
28
+
29
+ The leak was not a one-off: `.gitkeep` on the default branch of `link-foundation/rust-ai-driven-development-pipeline-template` had accumulated eight solver-generated lines from eight merged pull requests, one of which also carried real changes. Each miss makes the next one certain, because a surviving file forces the append path that the detector could not see. The full reconstruction is in `docs/case-studies/issue-2211`.
30
+
3
31
  ## 2.19.1
4
32
 
5
33
  ### Patch Changes
package/README.hi.md CHANGED
@@ -322,6 +322,9 @@ solve https://github.com/owner/repo/issues/123 --base-branch develop --fork
322
322
  # Continue working on existing PR
323
323
  solve https://github.com/owner/repo/pull/456 --model opus
324
324
 
325
+ # Solve every open issue of a repository with one pull request (repository mode)
326
+ solve https://github.com/owner/repo
327
+
325
328
  # Resume from Claude session when limit is reached
326
329
  solve https://github.com/owner/repo/issues/123 --resume session-id
327
330
 
@@ -354,6 +357,8 @@ review --repo owner/repo --pr 456
354
357
  solve <issue-url> [options]
355
358
  ```
356
359
 
360
+ > **📦 Repository mode**: issue URL की जगह repository URL दें — solve उस repository के सभी open issues इकट्ठा करता है (सबसे पुराने पहले, अधिकतम 100 — GitHub की प्रति parent sub-issue सीमा), उन्हें GitHub native sub-issues के रूप में सूचीबद्ध करने वाला एक संयुक्त issue बनाता है, और उसी issue को हल करता है — ताकि एक ही pull request उन सबको एक साथ बंद कर सके। यह `--deep-analysis` और `--ensure-all-sub-issues-addressed` को भी चालू कर देता है। देखें [docs/CONFIGURATION.md](./docs/CONFIGURATION.md#solve-options)।
361
+
357
362
  **सबसे अधिक उपयोग किए जाने वाले विकल्प:**
358
363
 
359
364
  | विकल्प | संक्षिप्त | विवरण | डिफ़ॉल्ट |
package/README.md CHANGED
@@ -328,6 +328,9 @@ solve https://github.com/owner/repo/issues/123 --base-branch develop --fork
328
328
  # Continue working on existing PR
329
329
  solve https://github.com/owner/repo/pull/456 --model opus
330
330
 
331
+ # Solve every open issue of a repository with one pull request (repository mode)
332
+ solve https://github.com/owner/repo
333
+
331
334
  # Resume from Claude session when limit is reached
332
335
  solve https://github.com/owner/repo/issues/123 --resume session-id
333
336
 
@@ -360,6 +363,8 @@ review --repo owner/repo --pr 456
360
363
  solve <issue-url> [options]
361
364
  ```
362
365
 
366
+ > **📦 Repository mode**: pass a repository URL instead of an issue URL and solve collects every open issue of that repository (oldest first, at most 100 — GitHub's sub-issue limit per parent), creates one combined issue that lists them as GitHub native sub-issues, and solves that issue — so a single pull request can close all of them at once. It also turns on `--deep-analysis` and `--ensure-all-sub-issues-addressed`. See [docs/CONFIGURATION.md](./docs/CONFIGURATION.md#solve-options).
367
+
363
368
  **Most frequently used options:**
364
369
 
365
370
  | Option | Alias | Description | Default |
package/README.ru.md CHANGED
@@ -322,6 +322,9 @@ solve https://github.com/owner/repo/issues/123 --base-branch develop --fork
322
322
  # Continue working on existing PR
323
323
  solve https://github.com/owner/repo/pull/456 --model opus
324
324
 
325
+ # Solve every open issue of a repository with one pull request (repository mode)
326
+ solve https://github.com/owner/repo
327
+
325
328
  # Resume from Claude session when limit is reached
326
329
  solve https://github.com/owner/repo/issues/123 --resume session-id
327
330
 
@@ -354,6 +357,8 @@ review --repo owner/repo --pr 456
354
357
  solve <issue-url> [options]
355
358
  ```
356
359
 
360
+ > **📦 Режим репозитория**: передайте URL репозитория вместо URL задачи — solve соберёт все открытые задачи этого репозитория (сначала самые старые, не более 100 — это лимит GitHub на количество под-задач у одной родительской задачи), создаст одну сводную задачу, где они перечислены как нативные под-задачи GitHub, и будет решать её — так что один pull request сможет закрыть их все сразу. Режим также включает `--deep-analysis` и `--ensure-all-sub-issues-addressed`. См. [docs/CONFIGURATION.md](./docs/CONFIGURATION.md#solve-options).
361
+
357
362
  **Наиболее часто используемые параметры:**
358
363
 
359
364
  | Параметр | Сокр. | Описание | По умолчанию |
package/README.zh.md CHANGED
@@ -322,6 +322,9 @@ solve https://github.com/owner/repo/issues/123 --base-branch develop --fork
322
322
  # Continue working on existing PR
323
323
  solve https://github.com/owner/repo/pull/456 --model opus
324
324
 
325
+ # Solve every open issue of a repository with one pull request (repository mode)
326
+ solve https://github.com/owner/repo
327
+
325
328
  # Resume from Claude session when limit is reached
326
329
  solve https://github.com/owner/repo/issues/123 --resume session-id
327
330
 
@@ -354,6 +357,8 @@ review --repo owner/repo --pr 456
354
357
  solve <issue-url> [options]
355
358
  ```
356
359
 
360
+ > **📦 仓库模式**:传入仓库 URL(而不是 issue URL),solve 会收集该仓库的所有开放 issue(最旧优先,最多 100 个 —— GitHub 每个父 issue 的子 issue 上限),创建一个把它们列为 GitHub 原生子 issue 的合并 issue,并解决该 issue —— 这样一个 pull request 就能一次性关闭它们全部。该模式还会自动启用 `--deep-analysis` 和 `--ensure-all-sub-issues-addressed`。参见 [docs/CONFIGURATION.md](./docs/CONFIGURATION.md#solve-options)。
361
+
357
362
  **最常用选项:**
358
363
 
359
364
  | 选项 | 简写 | 描述 | 默认值 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.19.1",
3
+ "version": "2.20.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",
@@ -48,20 +48,140 @@ import { quietProbe } from './quiet-probe.lib.mjs';
48
48
  const LARGE_DIFF_WARNING_BYTES = 8 * 1024 * 1024;
49
49
 
50
50
  /**
51
- * The solver's own scaffolding files, recognised by the content it writes into
52
- * them (`src/solve.auto-pr.lib.mjs`). A pull request whose whole diff is one of
53
- * these contains no solution: the placeholder exists only to give an empty
54
- * branch something to open a pull request from, and is reverted once the AI
55
- * commits real work.
56
- *
57
- * Matching on content, not on the file name, keeps a repository's own
58
- * `.gitkeep` or `CLAUDE.md` edits counted as the real changes they are.
51
+ * The solver's own scaffolding files (`src/solve.auto-pr.lib.mjs`). A pull
52
+ * request whose whole diff is one of these contains no solution: the
53
+ * placeholder exists only to give an empty branch something to open a pull
54
+ * request from, and is reverted once the AI commits real work.
55
+ *
56
+ * Issue #2211: asking "did the diff *add* the auto-generated header line?" only
57
+ * recognised the case where the solver created the file. When the repository
58
+ * already tracks a `.gitkeep` - the normal state of every repository generated
59
+ * from a template whose own solver run leaked one - `solve.auto-pr.lib.mjs`
60
+ * appends `# Updated: <timestamp>` instead, so the header is a context line, no
61
+ * pattern matched, and a pull request whose entire diff was that one timestamp
62
+ * measured as one changed file and was auto-merged:
63
+ *
64
+ * https://github.com/konard/audio-decomposer/pull/3
65
+ * .gitkeep | 2 +-
66
+ *
67
+ * The question asked here is therefore the one that actually decides it: once
68
+ * the solver's own generated lines are removed from both sides of the diff, is
69
+ * the file unchanged? That answers "created", "appended to" and "appended to
70
+ * again" with one rule, and it still counts a repository's own `.gitkeep` or
71
+ * `CLAUDE.md` edits as the real changes they are - a change to any line the
72
+ * solver did not write makes the two sides differ.
59
73
  */
60
- const PLACEHOLDER_CONTENT_PATTERNS = new Map([
61
- ['.gitkeep', [/^\+#\s*\.gitkeep file auto-generated at .+ for PR creation at branch /m]],
62
- ['CLAUDE.md', [/^\+Issue to solve: \S+/m, /^\+Your prepared branch: \S+/m]],
74
+
75
+ /** Lines `solve.auto-pr.lib.mjs` writes into `.gitkeep`. */
76
+ const GITKEEP_GENERATED_LINE_PATTERNS = [/^#\s*\.gitkeep file auto-generated at \S+ for PR creation at branch \S+ for issue \S+\s*$/, /^#\s*Updated: \d{4}-\d{2}-\d{2}T[\d:.]+Z?\s*$/];
77
+
78
+ /** The line that opens the task block `solve.auto-pr.lib.mjs` writes into `CLAUDE.md`. */
79
+ const CLAUDE_MD_TASK_BLOCK_HEAD = /^Issue to solve: \S+\s*$/;
80
+
81
+ /** Lines of that task block, including the ones only `--fork` runs emit. */
82
+ const CLAUDE_MD_GENERATED_LINE_PATTERNS = [CLAUDE_MD_TASK_BLOCK_HEAD, /^Your prepared branch: \S+\s*$/, /^Your prepared working directory: \S+\s*$/, /^Your forked repository: \S+\s*$/, /^Original repository \(upstream\): \S+\s*$/, /^Proceed\.\s*$/, /^Run timestamp: \d{4}-\d{2}-\d{2}T[\d:.]+Z?\s*$/];
83
+
84
+ const PLACEHOLDER_GENERATED_LINES = new Map([
85
+ ['.gitkeep', GITKEEP_GENERATED_LINE_PATTERNS],
86
+ ['CLAUDE.md', CLAUDE_MD_GENERATED_LINE_PATTERNS],
63
87
  ]);
64
88
 
89
+ /**
90
+ * Rebuild both sides of one file's unified-diff section.
91
+ *
92
+ * Only the hunks are read: everything before the first `@@` is git's own header
93
+ * (`index`, `new file mode`, `--- a/x`, `+++ b/x`) and belongs to neither side,
94
+ * and `` is a note about the previous line, not a
95
+ * line of the file.
96
+ *
97
+ * Context outside the hunks is missing from both sides equally, which is all the
98
+ * caller needs: it compares the two reconstructions against each other.
99
+ *
100
+ * @param {string} body - the section text, hunk headers included.
101
+ * @returns {{oldLines: string[], newLines: string[]}}
102
+ */
103
+ const reconstructSides = body => {
104
+ const oldLines = [];
105
+ const newLines = [];
106
+ let inHunk = false;
107
+ for (const line of body.split('\n')) {
108
+ if (line.startsWith('@@')) {
109
+ inHunk = true;
110
+ continue;
111
+ }
112
+ if (!inHunk || line.startsWith('\\')) continue;
113
+ const text = line.slice(1);
114
+ if (line[0] === '-') oldLines.push(text);
115
+ else if (line[0] === '+') newLines.push(text);
116
+ else {
117
+ // ' ' is a context line; a completely empty line is a context line whose
118
+ // content is empty (git omits the trailing space on some diffs).
119
+ oldLines.push(text);
120
+ newLines.push(text);
121
+ }
122
+ }
123
+ return { oldLines, newLines };
124
+ };
125
+
126
+ /**
127
+ * True when the first non-blank line at or after `from` opens the CLAUDE.md
128
+ * task block, i.e. the preceding `---` is the separator the solver writes
129
+ * rather than a horizontal rule a human wrote.
130
+ */
131
+ const opensClaudeTaskBlock = (lines, from) => {
132
+ for (let i = from; i < lines.length; i++) {
133
+ if (lines[i].trim() === '') continue;
134
+ return CLAUDE_MD_TASK_BLOCK_HEAD.test(lines[i]);
135
+ }
136
+ return false;
137
+ };
138
+
139
+ /**
140
+ * Drop every line the solver generated, leaving whatever the repository owns.
141
+ *
142
+ * @returns {{kept: string[], removed: number}}
143
+ */
144
+ const stripGeneratedLines = (path, lines) => {
145
+ const patterns = PLACEHOLDER_GENERATED_LINES.get(path) || [];
146
+ const kept = [];
147
+ let removed = 0;
148
+ for (let i = 0; i < lines.length; i++) {
149
+ const line = lines[i];
150
+ if (patterns.some(pattern => pattern.test(line))) {
151
+ removed += 1;
152
+ continue;
153
+ }
154
+ // The CLAUDE.md append path writes "\n\n---\n\n" ahead of the task block;
155
+ // that separator is generated too, but only in that position.
156
+ if (path === 'CLAUDE.md' && /^-{3,}\s*$/.test(line) && opensClaudeTaskBlock(lines, i + 1)) {
157
+ removed += 1;
158
+ continue;
159
+ }
160
+ kept.push(line);
161
+ }
162
+ return { kept, removed };
163
+ };
164
+
165
+ /**
166
+ * Is this section nothing but the solver's placeholder bookkeeping?
167
+ *
168
+ * @param {string|null} path - the file's path, or null when it is not a
169
+ * placeholder candidate.
170
+ * @param {string} body - the section text.
171
+ * @returns {boolean}
172
+ */
173
+ const isPlaceholderSection = (path, body) => {
174
+ if (!PLACEHOLDER_GENERATED_LINES.has(path)) return false;
175
+ const { oldLines, newLines } = reconstructSides(body);
176
+ const before = stripGeneratedLines(path, oldLines);
177
+ const after = stripGeneratedLines(path, newLines);
178
+ // Nothing generated on either side means this diff is not the solver's doing.
179
+ if (before.removed === 0 && after.removed === 0) return false;
180
+ // Trailing blank lines are what the append path leaves behind; they are not a
181
+ // change anyone made.
182
+ return before.kept.join('\n').trimEnd() === after.kept.join('\n').trimEnd();
183
+ };
184
+
65
185
  /**
66
186
  * Measure a unified diff in a single pass.
67
187
  *
@@ -96,8 +216,7 @@ const measureDiff = diff => {
96
216
 
97
217
  const closeSection = () => {
98
218
  if (!section) return;
99
- const isPlaceholder = Boolean(section.patterns) && section.patterns.every(pattern => pattern.test(section.body));
100
- if (isPlaceholder) placeholderSections += 1;
219
+ if (isPlaceholderSection(section.path, section.body)) placeholderSections += 1;
101
220
  else {
102
221
  filesChanged += 1;
103
222
  additions += section.additions;
@@ -116,14 +235,13 @@ const measureDiff = diff => {
116
235
  closeSection();
117
236
  const match = /^diff --git a\/(.+) b\/(.+)$/.exec(line);
118
237
  const path = match ? match[2] : '';
119
- const patterns = PLACEHOLDER_CONTENT_PATTERNS.get(path) || null;
120
- section = { patterns, body: '', additions: 0, deletions: 0 };
238
+ section = { path, candidate: PLACEHOLDER_GENERATED_LINES.has(path), body: '', additions: 0, deletions: 0 };
121
239
  continue;
122
240
  }
123
241
  if (!section) continue;
124
242
  // Only a placeholder candidate needs its text kept; every other file is
125
243
  // reduced to two counters as it streams past.
126
- if (section.patterns) section.body += `${line}\n`;
244
+ if (section.candidate) section.body += `${line}\n`;
127
245
  if (line.length > 1) {
128
246
  if (line[0] === '+' && line[1] !== '+') section.additions += 1;
129
247
  else if (line[0] === '-' && line[1] !== '-') section.deletions += 1;
@@ -147,7 +265,7 @@ const measureDiff = diff => {
147
265
  * @param {number} params.prNumber
148
266
  * @param {Function} params.$ command-stream tagged-template executor
149
267
  * @param {Function} [params.log] - optional logger for the size diagnostic
150
- * @returns {Promise<{hasChanges: boolean, filesChanged: number, additions: number, deletions: number, placeholderOnly: boolean, measured: boolean, diffBytes: number}>}
268
+ * @returns {Promise<{hasChanges: boolean, filesChanged: number, additions: number, deletions: number, placeholderOnly: boolean, placeholderSections: number, measured: boolean, diffBytes: number}>}
151
269
  * The counts cover the AI's own work: the solver's placeholder file is
152
270
  * excluded and reported through `placeholderOnly` instead. `measured` is
153
271
  * false when the diff could not be fetched, in which case callers must not
@@ -187,6 +305,11 @@ export const getPullRequestChangeStats = async ({ owner, repo, prNumber, $, log
187
305
  additions,
188
306
  deletions,
189
307
  placeholderOnly: filesChanged === 0 && placeholderSections > 0,
308
+ // Issue #2211: a pull request that has real changes *and* still carries the
309
+ // solver's placeholder is not empty, but it must not be merged with the
310
+ // placeholder in it either. Reported separately so the merge watcher can
311
+ // clean it up before merging instead of shipping it to the default branch.
312
+ placeholderSections,
190
313
  measured,
191
314
  diffBytes,
192
315
  };
@@ -233,4 +356,10 @@ export const EMPTY_PULL_REQUEST_BLOCKER = 'The pull request contains no changes
233
356
  */
234
357
  export const buildEmptyPullRequestBlocker = (stats = null) => (stats?.placeholderOnly ? 'The pull request contains only the placeholder file the solver commits to open a pull request, so there is nothing to merge' : EMPTY_PULL_REQUEST_BLOCKER);
235
358
 
236
- export default { getPullRequestChangeStats, formatChangeSummary, EMPTY_PULL_REQUEST_BLOCKER, buildEmptyPullRequestBlocker };
359
+ /**
360
+ * Exported for tests and for `experiments/issue-2211`: measuring a diff without
361
+ * a GitHub round trip is the only way to replay an archived pull request.
362
+ */
363
+ export const __measureDiffForTests = measureDiff;
364
+
365
+ export default { getPullRequestChangeStats, formatChangeSummary, EMPTY_PULL_REQUEST_BLOCKER, buildEmptyPullRequestBlocker, __measureDiffForTests: measureDiff };
@@ -150,3 +150,31 @@ export default {
150
150
  resolveDraftBlocker,
151
151
  resolveMergeFailure,
152
152
  };
153
+
154
+ /**
155
+ * Issue #2211: revert a solver placeholder that survived into the pull request diff.
156
+ *
157
+ * `src/solve.mjs` reverts the placeholder before this loop starts, so reaching
158
+ * here means the session that created it crashed, was resumed from a different
159
+ * working directory, or was restarted inside the loop. Merging anyway publishes
160
+ * hive-mind's own scaffolding to the default branch, which is what happened to
161
+ * https://github.com/konard/audio-decomposer/pull/3 - and, eight times over, to
162
+ * the `.gitkeep` on the default branch of the template repository it was
163
+ * generated from. See docs/case-studies/issue-2211.
164
+ *
165
+ * Failure is not fatal: the caller re-measures the diff, and a placeholder that
166
+ * could not be reverted still keeps the pull request from looking mergeable.
167
+ *
168
+ * @returns {Promise<boolean>} whether a cleanup was attempted
169
+ */
170
+ export const revertPlaceholderBeforeMerge = async ({ changeStats, tempDir, branchName, argv, log, formatAligned, cleanErrorMessage }) => {
171
+ if (!tempDir || !(changeStats?.placeholderSections > 0)) return false;
172
+ await log(formatAligned('🧹', 'Placeholder in diff:', 'reverting the solver placeholder file before it can be merged', 2), { level: 'warning' });
173
+ try {
174
+ const { cleanupClaudeFile } = await import('./solve.results.lib.mjs');
175
+ await cleanupClaudeFile(tempDir, branchName, null, argv);
176
+ } catch (error) {
177
+ await log(formatAligned('⚠️', 'Placeholder cleanup failed:', cleanErrorMessage(error), 2), { level: 'warning' });
178
+ }
179
+ return true;
180
+ };
@@ -34,7 +34,7 @@ const { mergePullRequest, getRepoVisibility, BILLING_LIMIT_ERROR_PATTERN, getDet
34
34
  // Issue #2182: guard rails for this loop (wall-clock ceiling, draft self-heal,
35
35
  // classified merge failures). See solve.auto-merge-guards.lib.mjs.
36
36
  const autoMergeGuards = await import('./solve.auto-merge-guards.lib.mjs');
37
- const { DRAFT_RECHECK_DELAY_MS, evaluateWatchTimeout, resolveDraftBlocker, resolveMergeFailure } = autoMergeGuards;
37
+ const { DRAFT_RECHECK_DELAY_MS, evaluateWatchTimeout, resolveDraftBlocker, resolveMergeFailure, revertPlaceholderBeforeMerge } = autoMergeGuards;
38
38
  // Re-exported so callers and tests keep a single entry point for the watch loop.
39
39
  export const { DEFAULT_WATCH_TIMEOUT_HOURS, normalizeWatchTimeoutHours } = autoMergeGuards;
40
40
  // Import GitHub functions for log attachment
@@ -131,6 +131,8 @@ export const watchUntilMergeable = async params => {
131
131
  // Issue #1503: Track consecutive "no workflow runs" checks per-SHA (reset on new push)
132
132
  let consecutiveNoRunsChecks = 0;
133
133
  let lastKnownHeadSha = null;
134
+ // Issue #2211: revert a leftover solver placeholder at most once per watch.
135
+ let placeholderCleanupAttempted = false;
134
136
  // Issue #1567: Initial cooldown to let CI register and solution logs post
135
137
  const INITIAL_COOLDOWN_SECONDS = MIN_CI_CHECK_INTERVAL_SECONDS;
136
138
  // Issue #2182: this loop used to be `while (true)` with no wall-clock ceiling
@@ -330,6 +332,16 @@ export const watchUntilMergeable = async params => {
330
332
  if (isEmptyPullRequest) {
331
333
  await log(formatAligned('⚠️', 'PR is empty:', changeStats.placeholderOnly ? 'only the solver placeholder file is in the diff - not treating it as mergeable' : 'net diff contains no files - not treating it as mergeable', 2), { level: 'warning' });
332
334
  }
335
+ // Issue #2211: defense in depth - never merge the solver's own placeholder.
336
+ if (changeStats.placeholderSections > 0 && !placeholderCleanupAttempted) {
337
+ placeholderCleanupAttempted = true;
338
+ if (await revertPlaceholderBeforeMerge({ changeStats, tempDir, branchName: prBranch || branchName, argv, log, formatAligned, cleanErrorMessage })) {
339
+ // Give the revert push a moment to register, then re-measure so the
340
+ // merge decision is made on the post-cleanup diff.
341
+ await interruptibleSleep(DRAFT_RECHECK_DELAY_MS);
342
+ continue;
343
+ }
344
+ }
333
345
  // If PR is mergeable, no blockers, no new comments, no issue metadata
334
346
  // edits, no uncommitted changes and it actually changes something
335
347
  if (blockers.length === 0 && !hasNewComments && !hasIssueMetadataChanges && !hasUncommittedChanges && !isEmptyPullRequest) {
@@ -696,6 +696,12 @@ export const SOLVE_OPTION_DEFINITIONS = {
696
696
  alias: ['keep-going-until-all-requirements-are-fully-done', 'keep-working', 'keep-going'],
697
697
  default: undefined,
698
698
  },
699
+ 'ensure-all-sub-issues-addressed': {
700
+ type: 'string',
701
+ description: '[EXPERIMENTAL] After the main solve completes, check that the pull request description closes every GitHub native sub-issue of the issue being solved with a reference GitHub actually recognizes. When references are missing, automatically restart the AI tool and ask it to double check that each of those sub-issues was really addressed in this single pull request. Accepts a number of restarts (default: 5), or "forever"/"unlimited" to remove the limit. Bare flag means the default of 5. Enabled automatically when solving a repository URL.',
702
+ alias: ['ensure-all-sub-issues', 'ensure-sub-issues'],
703
+ default: undefined,
704
+ },
699
705
  escalate: {
700
706
  type: 'string',
701
707
  description: '[EXPERIMENTAL] Start solving with a cheaper/lower-tier model and automatically escalate to a more capable (more expensive) model while unfinished work remains. Accepts a model range "<lower>-<upper>" using short Claude tier names (ladder: haiku < sonnet < opus < fable), e.g. "sonnet-opus". A single name (e.g. "opus") means just that tier. Bare flag means "sonnet-fable". The idea: iterate cheaply first so expensive models do more reading and less writing.',
@@ -1059,6 +1065,26 @@ export const parseArguments = async (yargs = getLinoYargsFactory(), hideBinFn =
1059
1065
  }
1060
1066
  }
1061
1067
 
1068
+ // --ensure-all-sub-issues-addressed normalization (issue #2212)
1069
+ // Same value semantics as --keep-working-until-all-requirements-are-fully-done:
1070
+ // a number of restarts, the keywords "forever"/"unlimited"/"infinite", or no
1071
+ // value (bare flag => default of 5). Final numeric normalization happens at
1072
+ // runtime in solve.ensure-sub-issues.detect.lib.mjs.
1073
+ {
1074
+ const ensureSubIssuesAliases = ['--ensure-all-sub-issues-addressed', '--ensure-all-sub-issues', '--ensure-sub-issues'];
1075
+ const ensureSubIssuesProvided = ensureSubIssuesAliases.some(alias => hasRawOption(rawArgs, alias));
1076
+ if (ensureSubIssuesProvided) {
1077
+ const current = argv.ensureAllSubIssuesAddressed;
1078
+ if (current === true || current === '' || current === undefined || current === null) {
1079
+ argv.ensureAllSubIssuesAddressed = 5;
1080
+ } else if (typeof current === 'string') {
1081
+ argv.ensureAllSubIssuesAddressed = current.trim();
1082
+ }
1083
+ } else if (argv.ensureAllSubIssuesAddressed === undefined) {
1084
+ argv.ensureAllSubIssuesAddressed = undefined;
1085
+ }
1086
+ }
1087
+
1062
1088
  // --escalate / --escalate-from / --escalate-steps normalization (issue #1885)
1063
1089
  // The bare `--escalate` flag is a string-typed option, so yargs yields `true`
1064
1090
  // (or an empty string) for a value-less flag. Canonicalize that to the default
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Pure detection helpers for `--ensure-all-sub-issues-addressed` (issue #2212).
5
+ *
6
+ * The option checks that the pull request description closes every sub-issue of
7
+ * the issue being solved with a reference GitHub actually recognizes. When a
8
+ * reference is missing, solve auto-restarts the AI tool and asks it to double
9
+ * check that every sub-issue was really addressed in this single pull request.
10
+ *
11
+ * GitHub requires the full closing syntax per issue — `Fixes #1, #2` closes only
12
+ * `#1` — which is exactly what `prClosesIssue` implements, so this module reuses
13
+ * it instead of writing a second, subtly different parser.
14
+ *
15
+ * Everything here is network-free so it can be unit tested in isolation. The
16
+ * network side lives in `solve.ensure-sub-issues.lib.mjs`.
17
+ */
18
+
19
+ import { prClosesIssue } from './github-linking.lib.mjs';
20
+ import { normalizeKeepWorkingLimit } from './solve.keep-working.detect.lib.mjs';
21
+
22
+ /** Default number of auto-restarts when closing references are missing. */
23
+ export const DEFAULT_ENSURE_SUB_ISSUES_LIMIT = 5;
24
+
25
+ /**
26
+ * Reinforcement prompt appended to every restart, in addition to the concrete
27
+ * list of sub-issues whose closing references are missing.
28
+ */
29
+ export const ENSURE_SUB_ISSUES_PROMPT = 'Double check that every sub-issue listed above was really addressed in this single pull request, then make sure the pull request description closes each of them with a GitHub recognized closing reference.';
30
+
31
+ /**
32
+ * Normalize the `--ensure-all-sub-issues-addressed` value into a restart limit.
33
+ *
34
+ * Shares the semantics of `--keep-working-until-all-requirements-are-fully-done`:
35
+ * - falsy (undefined / null / false / "") -> 0 (disabled)
36
+ * - bare flag (true) -> DEFAULT_ENSURE_SUB_ISSUES_LIMIT
37
+ * - "forever" / "unlimited" / "infinite" / 0 / "0" -> Infinity
38
+ * - positive number / numeric string -> floor(value)
39
+ *
40
+ * @param {*} value
41
+ * @returns {number}
42
+ */
43
+ export function normalizeEnsureSubIssuesLimit(value) {
44
+ return normalizeKeepWorkingLimit(value, DEFAULT_ENSURE_SUB_ISSUES_LIMIT);
45
+ }
46
+
47
+ /**
48
+ * Human readable description of the limit for logs.
49
+ * @param {number} limit
50
+ * @returns {string}
51
+ */
52
+ export function formatEnsureSubIssuesLimit(limit) {
53
+ return limit === Infinity ? 'unlimited' : `${limit}`;
54
+ }
55
+
56
+ /**
57
+ * Normalize a raw sub-issue entry (REST `sub_issues` payload, or an already
58
+ * normalized entry) into `{number, title, owner, repo, url}`.
59
+ *
60
+ * The owner/repo are derived from `repository_url` when present so cross
61
+ * repository sub-issues are matched with their fully qualified reference.
62
+ *
63
+ * @param {object} entry
64
+ * @param {{owner?: string, repo?: string}} [fallbackRepository]
65
+ * @returns {{number: number, title: string, owner: string|null, repo: string|null, url: string}|null}
66
+ */
67
+ export function normalizeSubIssueEntry(entry, fallbackRepository = {}) {
68
+ if (!entry || typeof entry !== 'object') return null;
69
+ const number = Number(entry.number);
70
+ if (!Number.isInteger(number) || number <= 0) return null;
71
+
72
+ let owner = entry.owner || fallbackRepository.owner || null;
73
+ let repo = entry.repo || fallbackRepository.repo || null;
74
+
75
+ const repositoryUrl = String(entry.repository_url || '');
76
+ const match = repositoryUrl.match(/repos\/([^/]+)\/([^/]+)$/);
77
+ if (match) {
78
+ owner = match[1];
79
+ repo = match[2];
80
+ }
81
+
82
+ return {
83
+ number,
84
+ title: String(entry.title || '').trim(),
85
+ owner,
86
+ repo,
87
+ url: String(entry.html_url || entry.url || '').trim(),
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Find the sub-issues whose closing reference is missing from `text`.
93
+ *
94
+ * A sub-issue counts as referenced when the text contains a GitHub recognized
95
+ * closing keyword for it (`closes #12`, `fixes owner/repo#12`,
96
+ * `resolves https://github.com/owner/repo/issues/12`, ...).
97
+ *
98
+ * @param {object} params
99
+ * @param {string} params.text - pull request description (and optionally title)
100
+ * @param {Array<object>} params.subIssues
101
+ * @param {string} [params.owner] - repository of the parent issue
102
+ * @param {string} [params.repo]
103
+ * @returns {{missing: Array<object>, referenced: Array<object>, total: number}}
104
+ */
105
+ export function findMissingSubIssueReferences({ text, subIssues, owner = null, repo = null }) {
106
+ const normalized = (Array.isArray(subIssues) ? subIssues : []).map(entry => normalizeSubIssueEntry(entry, { owner, repo })).filter(Boolean);
107
+
108
+ const missing = [];
109
+ const referenced = [];
110
+
111
+ for (const subIssue of normalized) {
112
+ if (prClosesIssue(text, subIssue.number, subIssue.owner, subIssue.repo)) {
113
+ referenced.push(subIssue);
114
+ } else {
115
+ missing.push(subIssue);
116
+ }
117
+ }
118
+
119
+ return { missing, referenced, total: normalized.length };
120
+ }
121
+
122
+ function formatSubIssueReference(subIssue, owner, repo) {
123
+ const crossRepository = subIssue.owner && subIssue.repo && (subIssue.owner !== owner || subIssue.repo !== repo);
124
+ return crossRepository ? `${subIssue.owner}/${subIssue.repo}#${subIssue.number}` : `#${subIssue.number}`;
125
+ }
126
+
127
+ /**
128
+ * Build the closing-reference block the AI is asked to add, one keyword per
129
+ * issue as GitHub requires.
130
+ *
131
+ * @param {Array<object>} subIssues
132
+ * @param {object} [params]
133
+ * @returns {string}
134
+ */
135
+ export function buildMissingReferenceBlock(subIssues, { owner = null, repo = null, keyword = 'Fixes' } = {}) {
136
+ return (Array.isArray(subIssues) ? subIssues : []).map(subIssue => `${keyword} ${formatSubIssueReference(subIssue, owner, repo)}`).join('\n');
137
+ }
138
+
139
+ /**
140
+ * Build the feedback lines injected into the restart iteration.
141
+ *
142
+ * @param {object} params
143
+ * @param {Array<object>} params.missing
144
+ * @param {number} params.total
145
+ * @param {number} params.iteration
146
+ * @param {number} params.limit
147
+ * @param {string} [params.owner]
148
+ * @param {string} [params.repo]
149
+ * @param {string|number} [params.issueNumber] - parent issue
150
+ * @returns {string[]}
151
+ */
152
+ export function buildEnsureSubIssuesFeedback({ missing, total, iteration, limit, owner = null, repo = null, issueNumber = null }) {
153
+ const missingList = Array.isArray(missing) ? missing : [];
154
+ const lines = ['', '='.repeat(60), '🧩 ENSURE ALL SUB-ISSUES ADDRESSED:', '='.repeat(60), '', `Restart ${iteration}/${formatEnsureSubIssuesLimit(limit)}.`, '', `This issue${issueNumber ? ` (#${issueNumber})` : ''} has ${total} sub-issue(s). The pull request description is missing a GitHub recognized closing reference for ${missingList.length} of them:`, ''];
155
+
156
+ for (const subIssue of missingList) {
157
+ const reference = formatSubIssueReference(subIssue, owner, repo);
158
+ lines.push(` • ${reference}${subIssue.title ? ` — ${subIssue.title}` : ''}${subIssue.url ? ` (${subIssue.url})` : ''}`);
159
+ }
160
+
161
+ lines.push('', 'For each sub-issue above:', ' 1. Verify it was really addressed by the changes in this pull request. If it was not, implement it now — in this same pull request.', ' 2. Then update the pull request description so it closes the sub-issue.', '', 'GitHub only recognizes the full closing syntax repeated per issue: "Fixes #1, #2" closes only #1.', 'Add these lines to the pull request description (keep any existing closing references):', '', buildMissingReferenceBlock(missingList, { owner, repo }), '', ENSURE_SUB_ISSUES_PROMPT, '');
162
+
163
+ return lines;
164
+ }
165
+
166
+ export default {
167
+ DEFAULT_ENSURE_SUB_ISSUES_LIMIT,
168
+ ENSURE_SUB_ISSUES_PROMPT,
169
+ normalizeEnsureSubIssuesLimit,
170
+ formatEnsureSubIssuesLimit,
171
+ normalizeSubIssueEntry,
172
+ findMissingSubIssueReferences,
173
+ buildMissingReferenceBlock,
174
+ buildEnsureSubIssuesFeedback,
175
+ };