@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 +28 -0
- package/README.hi.md +5 -0
- package/README.md +5 -0
- package/README.ru.md +5 -0
- package/README.zh.md +5 -0
- package/package.json +1 -1
- package/src/pull-request-changes.lib.mjs +147 -18
- package/src/solve.auto-merge-guards.lib.mjs +28 -0
- package/src/solve.auto-merge.lib.mjs +13 -1
- package/src/solve.config.lib.mjs +26 -0
- package/src/solve.ensure-sub-issues.detect.lib.mjs +175 -0
- package/src/solve.ensure-sub-issues.lib.mjs +276 -0
- package/src/solve.mjs +38 -2
- package/src/solve.repository-mode.lib.mjs +233 -0
- package/src/solve.repository-mode.run.lib.mjs +326 -0
- package/src/telegram-bot.mjs +5 -2
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
|
@@ -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
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* `.gitkeep`
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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) {
|
package/src/solve.config.lib.mjs
CHANGED
|
@@ -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
|
+
};
|