@link-assistant/hive-mind 2.11.2 → 2.11.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/agent-commander.lib.mjs +33 -12
- package/src/agent-token-usage.lib.mjs +5 -9
- package/src/agent.lib.mjs +110 -152
- package/src/ai-tool-scratch.lib.mjs +143 -0
- package/src/anthropic-cost-accumulator.lib.mjs +36 -0
- package/src/auto-restart-budget.lib.mjs +107 -0
- package/src/auto-restart-exhaustion.lib.mjs +122 -0
- package/src/claude.lib.mjs +19 -15
- package/src/claude.runtime-switch.lib.mjs +4 -4
- package/src/codex.lib.mjs +46 -8
- package/src/credential-sanitization-core.lib.mjs +27 -3
- package/src/formal-ai-pricing.lib.mjs +110 -0
- package/src/gemini.lib.mjs +34 -43
- package/src/github-cost-info.lib.mjs +5 -0
- package/src/json-stream.lib.mjs +219 -0
- package/src/opencode.lib.mjs +56 -74
- package/src/pull-request-changes.lib.mjs +166 -0
- package/src/qwen.lib.mjs +33 -32
- package/src/reviewers-hive.mjs +2 -2
- package/src/solve.auto-merge.lib.mjs +68 -44
- package/src/solve.auto-pr.lib.mjs +11 -3
- package/src/solve.finalize.lib.mjs +15 -0
- package/src/solve.repository.lib.mjs +21 -4
- package/src/solve.restart-shared.lib.mjs +6 -2
- package/src/solve.results.lib.mjs +29 -16
- package/src/solve.watch.lib.mjs +47 -18
- package/src/use-m-bootstrap.lib.mjs +23 -4
- package/src/use-m-single-flight.lib.mjs +350 -0
- package/src/use-with-retry.lib.mjs +8 -2
- package/src/working-session-summary.lib.mjs +65 -0
- package/src/youtrack/youtrack-sync.mjs +2 -2
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Issue #2119: does the pull request actually contain any changes?
|
|
5
|
+
*
|
|
6
|
+
* Nobody asked that question before, and two separate false positives followed
|
|
7
|
+
* from it. In the reproduction runs the AI tool produced nothing, so the branch
|
|
8
|
+
* ended up with the solver's own scaffolding commit and a revert of it - a net
|
|
9
|
+
* diff of zero files:
|
|
10
|
+
*
|
|
11
|
+
* https://github.com/konard/test-hello-world-019fb330-00e1-73b9-955e-f357a1600d5b/pull/2
|
|
12
|
+
* https://github.com/konard/test-hello-world-019fb330-fa49-7c9d-a664-b7ea33bb698a/pull/2
|
|
13
|
+
*
|
|
14
|
+
* Yet the published pull request bodies claimed
|
|
15
|
+
*
|
|
16
|
+
* ### Changes
|
|
17
|
+
* - 1 file(s) modified
|
|
18
|
+
* - 1 line(s) added
|
|
19
|
+
*
|
|
20
|
+
* (the stats were measured while the scaffolding commit was still in the diff
|
|
21
|
+
* and never revisited), and the Kotlin run went on to post "✅ Ready to merge -
|
|
22
|
+
* No pending changes" for a pull request that changed nothing at all.
|
|
23
|
+
*
|
|
24
|
+
* The third reproduction run failed before the AI committed anything, so its
|
|
25
|
+
* pull request kept the scaffolding file itself:
|
|
26
|
+
*
|
|
27
|
+
* https://github.com/konard/test-hello-world-019fb331-c107-78c7-8ff6-9f127a3c593c/pull/2
|
|
28
|
+
* .gitkeep | 1 +
|
|
29
|
+
*
|
|
30
|
+
* That is the same "nothing was implemented" state wearing a file count, so the
|
|
31
|
+
* solver's own placeholder is excluded from the counts here rather than being
|
|
32
|
+
* reported as the AI's work.
|
|
33
|
+
*
|
|
34
|
+
* This module is the single place that answers the question, so both the
|
|
35
|
+
* description writer and the mergeability watcher agree.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { ghWithRateLimitRetry } from './github-rate-limit.lib.mjs';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The solver's own scaffolding files, recognised by the content it writes into
|
|
42
|
+
* them (`src/solve.auto-pr.lib.mjs`). A pull request whose whole diff is one of
|
|
43
|
+
* these contains no solution: the placeholder exists only to give an empty
|
|
44
|
+
* branch something to open a pull request from, and is reverted once the AI
|
|
45
|
+
* commits real work.
|
|
46
|
+
*
|
|
47
|
+
* Matching on content, not on the file name, keeps a repository's own
|
|
48
|
+
* `.gitkeep` or `CLAUDE.md` edits counted as the real changes they are.
|
|
49
|
+
*/
|
|
50
|
+
const PLACEHOLDER_CONTENT_PATTERNS = new Map([
|
|
51
|
+
['.gitkeep', [/^\+#\s*\.gitkeep file auto-generated at .+ for PR creation at branch /m]],
|
|
52
|
+
['CLAUDE.md', [/^\+Issue to solve: \S+/m, /^\+Your prepared branch: \S+/m]],
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
/** Split a unified diff into one section per file. */
|
|
56
|
+
const splitDiffByFile = diff => {
|
|
57
|
+
const sections = [];
|
|
58
|
+
for (const line of diff.split('\n')) {
|
|
59
|
+
if (line.startsWith('diff --git ')) {
|
|
60
|
+
const match = /^diff --git a\/(.+) b\/(.+)$/.exec(line);
|
|
61
|
+
sections.push({ path: match ? match[2] : '', body: '' });
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (sections.length > 0) sections[sections.length - 1].body += `${line}\n`;
|
|
65
|
+
}
|
|
66
|
+
return sections;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** True when this file section is nothing but the solver's own placeholder. */
|
|
70
|
+
const isPlaceholderSection = section => {
|
|
71
|
+
const patterns = PLACEHOLDER_CONTENT_PATTERNS.get(section.path);
|
|
72
|
+
return Boolean(patterns) && patterns.every(pattern => pattern.test(section.body));
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Measure the net diff of a pull request.
|
|
77
|
+
*
|
|
78
|
+
* The counts come from the unified diff rather than from the PR's `additions` /
|
|
79
|
+
* `deletions` fields because those are per-commit sums: a commit and its revert
|
|
80
|
+
* report 1 addition and 1 deletion while the net diff is empty.
|
|
81
|
+
*
|
|
82
|
+
* @param {Object} params
|
|
83
|
+
* @param {string} params.owner
|
|
84
|
+
* @param {string} params.repo
|
|
85
|
+
* @param {number} params.prNumber
|
|
86
|
+
* @param {Function} params.$ command-stream tagged-template executor
|
|
87
|
+
* @returns {Promise<{hasChanges: boolean, filesChanged: number, additions: number, deletions: number, placeholderOnly: boolean, measured: boolean}>}
|
|
88
|
+
* The counts cover the AI's own work: the solver's placeholder file is
|
|
89
|
+
* excluded and reported through `placeholderOnly` instead. `measured` is
|
|
90
|
+
* false when the diff could not be fetched, in which case callers must not
|
|
91
|
+
* treat the pull request as empty.
|
|
92
|
+
*/
|
|
93
|
+
export const getPullRequestChangeStats = async ({ owner, repo, prNumber, $ }) => {
|
|
94
|
+
let diffOutput = '';
|
|
95
|
+
let measured = false;
|
|
96
|
+
try {
|
|
97
|
+
const result = await ghWithRateLimitRetry(() => $`gh pr diff ${prNumber} --repo ${owner}/${repo}`, { label: `pr diff ${owner}/${repo}#${prNumber}` });
|
|
98
|
+
if (result.code === 0) {
|
|
99
|
+
diffOutput = result.stdout.toString();
|
|
100
|
+
measured = true;
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// Leave measured false: an unreachable API must not read as "no changes".
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const sections = splitDiffByFile(diffOutput);
|
|
107
|
+
const placeholderSections = sections.filter(isPlaceholderSection);
|
|
108
|
+
const realSections = sections.filter(section => !isPlaceholderSection(section));
|
|
109
|
+
|
|
110
|
+
const countMatches = (pattern, text) => (text.match(pattern) || []).length;
|
|
111
|
+
const filesChanged = realSections.length;
|
|
112
|
+
const additions = realSections.reduce((total, section) => total + countMatches(/^\+[^+]/gm, section.body), 0);
|
|
113
|
+
const deletions = realSections.reduce((total, section) => total + countMatches(/^-[^-]/gm, section.body), 0);
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
hasChanges: filesChanged > 0,
|
|
117
|
+
filesChanged,
|
|
118
|
+
additions,
|
|
119
|
+
deletions,
|
|
120
|
+
placeholderOnly: filesChanged === 0 && placeholderSections.length > 0,
|
|
121
|
+
measured,
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Render the "### Changes" section of a generated pull request description.
|
|
127
|
+
*
|
|
128
|
+
* When the diff is empty this says so instead of inventing a file count, so a
|
|
129
|
+
* reviewer reading the description learns the same thing the diff would tell
|
|
130
|
+
* them.
|
|
131
|
+
*
|
|
132
|
+
* @param {{hasChanges: boolean, filesChanged: number, additions: number, deletions: number, measured: boolean}} stats
|
|
133
|
+
* @returns {string}
|
|
134
|
+
*/
|
|
135
|
+
export const formatChangeSummary = stats => {
|
|
136
|
+
if (!stats.measured) {
|
|
137
|
+
return '- The diff could not be read, so the change summary is unavailable';
|
|
138
|
+
}
|
|
139
|
+
if (!stats.hasChanges) {
|
|
140
|
+
if (stats.placeholderOnly) {
|
|
141
|
+
return '- No files were changed by this pull request yet (it contains only the placeholder file the solver commits to open a pull request)';
|
|
142
|
+
}
|
|
143
|
+
return '- No files were changed by this pull request yet';
|
|
144
|
+
}
|
|
145
|
+
return [`- ${stats.filesChanged} file(s) modified`, `- ${stats.additions} line(s) added`, `- ${stats.deletions} line(s) removed`].join('\n');
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The blocker to report when a pull request is otherwise mergeable but empty.
|
|
150
|
+
*
|
|
151
|
+
* Merging it would close the issue without changing anything, so this is
|
|
152
|
+
* treated as a reason to restart the AI rather than as success. The shared
|
|
153
|
+
* auto-restart budget bounds the retries and fails the run visibly once it is
|
|
154
|
+
* exhausted.
|
|
155
|
+
*/
|
|
156
|
+
export const EMPTY_PULL_REQUEST_BLOCKER = 'The pull request contains no changes (its net diff is empty), so there is nothing to merge';
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The same blocker, naming the placeholder when that is all the diff contains.
|
|
160
|
+
*
|
|
161
|
+
* @param {{placeholderOnly?: boolean}|null} stats
|
|
162
|
+
* @returns {string}
|
|
163
|
+
*/
|
|
164
|
+
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);
|
|
165
|
+
|
|
166
|
+
export default { getPullRequestChangeStats, formatChangeSummary, EMPTY_PULL_REQUEST_BLOCKER, buildEmptyPullRequestBlocker };
|
package/src/qwen.lib.mjs
CHANGED
|
@@ -18,12 +18,15 @@ import { reportError } from './sentry.lib.mjs';
|
|
|
18
18
|
import { timeouts, retryLimits } from './config.lib.mjs';
|
|
19
19
|
import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs';
|
|
20
20
|
import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
|
|
21
|
-
import { qwenModels, defaultModels } from './models/index.mjs';
|
|
21
|
+
import { qwenModels, defaultModels, isFormalAiModel } from './models/index.mjs';
|
|
22
22
|
import { logPreparedToolCommand, resolveFormalAiToolInvocation } from './formal-ai.lib.mjs';
|
|
23
|
+
import { buildFormalAiPricingInfo } from './formal-ai-pricing.lib.mjs'; // Issue #2119
|
|
23
24
|
import { checkPlaywrightMcpPackageAvailability } from './playwright-mcp.lib.mjs';
|
|
24
25
|
import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
|
|
25
26
|
import { getCumulativeContextInputTokens, getRestoredContextInputTokens, toTokenCount } from './context-fill.lib.mjs';
|
|
27
|
+
import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
|
|
26
28
|
import { getTerminalEventCompletionHealth } from './tool-run-health.lib.mjs'; // Issue #1990
|
|
29
|
+
import { takeJsonRecords } from './json-stream.lib.mjs'; // Issue #2119
|
|
27
30
|
|
|
28
31
|
export const mapModelToId = model => qwenModels[model] || model;
|
|
29
32
|
|
|
@@ -249,7 +252,7 @@ const applyQwenUsageToState = (state, event) => {
|
|
|
249
252
|
applyQwenUsageObject(state, rawUsage, findFirstValue(event, QWEN_USAGE_PATHS.model));
|
|
250
253
|
};
|
|
251
254
|
|
|
252
|
-
const buildQwenPricingInfo = (state, mappedModel) => {
|
|
255
|
+
export const buildQwenPricingInfo = (state, mappedModel) => {
|
|
253
256
|
const tokenUsage = cloneQwenTokenUsage(state?.tokenUsage);
|
|
254
257
|
if (!tokenUsage || tokenUsage.stepCount === 0) {
|
|
255
258
|
return {
|
|
@@ -264,6 +267,17 @@ const buildQwenPricingInfo = (state, mappedModel) => {
|
|
|
264
267
|
tokenUsage.respondedModelId ||= tokenUsage.requestedModelId;
|
|
265
268
|
const modelId = tokenUsage.respondedModelId || tokenUsage.requestedModelId;
|
|
266
269
|
|
|
270
|
+
// Issue #2119: `--model formal-ai` is served by the local Link.Assistant model
|
|
271
|
+
// server, so the session belongs to Link.Assistant at $0.00 - not to Qwen Code.
|
|
272
|
+
if (isFormalAiModel(mappedModel) || isFormalAiModel(modelId)) {
|
|
273
|
+
return {
|
|
274
|
+
pricingInfo: { ...buildFormalAiPricingInfo(modelId, tokenUsage), source: 'qwen-stream-json' },
|
|
275
|
+
publicPricingEstimate: 0,
|
|
276
|
+
tokenUsage,
|
|
277
|
+
resultModelUsage: buildQwenResultModelUsage(tokenUsage),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
267
281
|
return {
|
|
268
282
|
pricingInfo: {
|
|
269
283
|
provider: 'Qwen Code',
|
|
@@ -315,35 +329,19 @@ export const parseQwenStreamJsonOutput = (output, state = {}) => {
|
|
|
315
329
|
const text = output?.toString?.() ?? String(output || '');
|
|
316
330
|
nextState.plainText += text;
|
|
317
331
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
return false;
|
|
332
|
-
}
|
|
333
|
-
};
|
|
334
|
-
|
|
335
|
-
const combined = `${nextState.buffer}${text}`;
|
|
336
|
-
nextState.buffer = '';
|
|
337
|
-
|
|
338
|
-
const lines = combined.split(/\r?\n/);
|
|
339
|
-
for (let index = 0; index < lines.length; index++) {
|
|
340
|
-
const line = lines[index];
|
|
341
|
-
const isLastLine = index === lines.length - 1;
|
|
342
|
-
if (!line.trim()) continue;
|
|
343
|
-
|
|
344
|
-
const parsed = parseCandidate(line);
|
|
345
|
-
if (!parsed && isLastLine) {
|
|
346
|
-
nextState.buffer = line;
|
|
332
|
+
// Issue #2119: frame the stream by balanced JSON values instead of by lines.
|
|
333
|
+
// `formal-ai with qwen` emits pretty-printed, multi-line records, so every
|
|
334
|
+
// line failed to parse and every event - including the token usage - was
|
|
335
|
+
// dropped. Scanning for balanced values also covers records concatenated
|
|
336
|
+
// without a separator and records split across two process chunks.
|
|
337
|
+
const { records, rest } = takeJsonRecords(`${nextState.buffer}${text}`);
|
|
338
|
+
nextState.buffer = rest;
|
|
339
|
+
|
|
340
|
+
for (const record of records) {
|
|
341
|
+
if (Array.isArray(record)) {
|
|
342
|
+
for (const item of record) addQwenEventToState(nextState, item);
|
|
343
|
+
} else {
|
|
344
|
+
addQwenEventToState(nextState, record);
|
|
347
345
|
}
|
|
348
346
|
}
|
|
349
347
|
|
|
@@ -739,11 +737,14 @@ export const executeQwenCommand = async params => {
|
|
|
739
737
|
|
|
740
738
|
export const checkForUncommittedChanges = async (tempDir, owner, repo, branchName, $, log, autoCommit = false, autoRestartEnabled = true) => {
|
|
741
739
|
await log('\n🔍 Checking for uncommitted changes...');
|
|
740
|
+
// Issue #2119: AI tools leave scratch state (.formal-ai/, .playwright-mcp/) in
|
|
741
|
+
// the workspace. Ignoring it here keeps it out of both this check and 'git add -A'.
|
|
742
|
+
await ensureAiToolScratchIgnored(tempDir, log);
|
|
742
743
|
try {
|
|
743
744
|
const gitStatusResult = await $({ cwd: tempDir })`git status --porcelain 2>&1`;
|
|
744
745
|
|
|
745
746
|
if (gitStatusResult.code === 0) {
|
|
746
|
-
const statusOutput = gitStatusResult.stdout.toString().trim();
|
|
747
|
+
const statusOutput = filterAiToolScratchFromStatus(gitStatusResult.stdout.toString().trim());
|
|
747
748
|
|
|
748
749
|
if (statusOutput) {
|
|
749
750
|
await log('📝 Found uncommitted changes');
|
package/src/reviewers-hive.mjs
CHANGED
|
@@ -322,10 +322,10 @@ async function reviewer(reviewerId) {
|
|
|
322
322
|
await log(` 🚀 Executing review.mjs for ${prUrl}...`);
|
|
323
323
|
|
|
324
324
|
const startTime = Date.now();
|
|
325
|
-
let reviewCommand = $`./review.mjs
|
|
325
|
+
let reviewCommand = $`./review.mjs ${prUrl} --model ${argv.model} --focus ${argv.focus}`;
|
|
326
326
|
|
|
327
327
|
if (argv.autoApprove) {
|
|
328
|
-
reviewCommand = $`./review.mjs
|
|
328
|
+
reviewCommand = $`./review.mjs ${prUrl} --model ${argv.model} --focus ${argv.focus} --approve`;
|
|
329
329
|
}
|
|
330
330
|
|
|
331
331
|
// Stream output and capture result
|
|
@@ -67,7 +67,7 @@ const { buildCancelledCIReviewComment, getRetriggerableWorkflowRuns, shouldStopF
|
|
|
67
67
|
|
|
68
68
|
// Issue #1625: Shared marker constants + posting/tracking helpers
|
|
69
69
|
const toolComments = await import('./tool-comments.lib.mjs');
|
|
70
|
-
const { READY_TO_MERGE_MARKER, READY_FOR_REVIEW_MARKER, AUTO_RESTART_MARKER, AUTO_MERGED_MARKER, postTrackedComment } = toolComments;
|
|
70
|
+
const { READY_TO_MERGE_MARKER, READY_FOR_REVIEW_MARKER, AUTO_RESTART_MARKER, AUTO_RESTART_UNTIL_MERGEABLE_LOG_MARKER, AUTO_MERGED_MARKER, postTrackedComment } = toolComments;
|
|
71
71
|
|
|
72
72
|
const externalReviewLimitLib = await import('./external-review-limit.lib.mjs');
|
|
73
73
|
const { buildReadyForReviewComment } = externalReviewLimitLib;
|
|
@@ -81,8 +81,18 @@ const { maybeAttachWorkingSessionSummary, ensurePullRequestIssueLink } = results
|
|
|
81
81
|
// Issue #1574: Interruptible sleep so CTRL+C is never blocked by a lingering timer
|
|
82
82
|
const { interruptibleSleep } = await import('./interruptible-sleep.lib.mjs');
|
|
83
83
|
const { formatAutoIterationLimit, hasReachedAutoIterationLimit, normalizeAutoIterationLimit, shouldSyncBeforeRestart } = await import('./auto-iteration-limits.lib.mjs');
|
|
84
|
+
// Issue #2119: one auto-restart budget shared with solve.watch.lib.mjs. solve.mjs
|
|
85
|
+
// runs both loops in the same process, so before this a limit of 5 allowed 10 AI
|
|
86
|
+
// sessions and the two loops published incompatible progress labels
|
|
87
|
+
// ("Auto-restart triggered (iteration 1)" vs "Auto-restart 1/5 Log").
|
|
88
|
+
const autoRestartBudget = await import('./auto-restart-budget.lib.mjs');
|
|
89
|
+
const { beginAutoRestartBudget, consumeAutoRestartIteration, formatAutoRestartLabel, formatAutoRestartLimit, hasExhaustedAutoRestartBudget } = autoRestartBudget;
|
|
90
|
+
const { failOnAutoRestartBudgetExhausted } = await import('./auto-restart-exhaustion.lib.mjs');
|
|
84
91
|
const { ensurePullRequestBaseBranch } = await import('./solve.pr-base-guard.lib.mjs');
|
|
85
92
|
|
|
93
|
+
// Issue #2119: an empty pull request must not be reported as ready to merge.
|
|
94
|
+
const { buildEmptyPullRequestBlocker, getPullRequestChangeStats } = await import('./pull-request-changes.lib.mjs');
|
|
95
|
+
|
|
86
96
|
// Issue #1895: explicitly close linked issues after merging a PR into a
|
|
87
97
|
// non-default branch, where GitHub does not auto-close them.
|
|
88
98
|
const { ensureLinkedIssueClosedAfterMerge } = await import('./github-issue-auto-close.lib.mjs');
|
|
@@ -101,7 +111,8 @@ export const watchUntilMergeable = async params => {
|
|
|
101
111
|
const MIN_CI_CHECK_INTERVAL_SECONDS = 120;
|
|
102
112
|
const watchInterval = Math.max(rawWatchInterval, MIN_CI_CHECK_INTERVAL_SECONDS);
|
|
103
113
|
const isAutoMerge = argv.autoMerge || false;
|
|
104
|
-
|
|
114
|
+
// Issue #2119: join the shared budget instead of starting a second counter.
|
|
115
|
+
const maxAutoRestartIterations = beginAutoRestartBudget({ maxIterations: argv.autoRestartMaxIterations });
|
|
105
116
|
const maxAutoResumeIterations = normalizeAutoIterationLimit(argv.autoResumeMaxIterations);
|
|
106
117
|
// Issue #1503/#1573/#1612: repo-wide action gating is opt-in strict mode.
|
|
107
118
|
// The config default may be bypassed when this module is reused directly, so normalize here.
|
|
@@ -112,7 +123,8 @@ export const watchUntilMergeable = async params => {
|
|
|
112
123
|
let latestAnthropicCost = null;
|
|
113
124
|
|
|
114
125
|
// Issue #1323: Track actual AI restarts separately from check cycle iterations
|
|
115
|
-
|
|
126
|
+
// Issue #2119: the count now lives in the shared budget module, so restarts
|
|
127
|
+
// already spent by the watch loop earlier in this run are counted here too.
|
|
116
128
|
let limitResumeCount = 0;
|
|
117
129
|
|
|
118
130
|
// Issue #1371: In-memory dedup for "Ready to merge" comment (per-session, not all-time)
|
|
@@ -133,7 +145,7 @@ export const watchUntilMergeable = async params => {
|
|
|
133
145
|
await log(formatAligned('', 'Mode:', isAutoMerge ? 'Auto-merge (will merge when ready)' : 'Auto-restart-until-mergeable (will NOT auto-merge)', 2));
|
|
134
146
|
await log(formatAligned('', 'Checking interval:', `${watchInterval} seconds (minimum: ${MIN_CI_CHECK_INTERVAL_SECONDS}s)`, 2));
|
|
135
147
|
await log(formatAligned('', 'Initial cooldown:', `${INITIAL_COOLDOWN_SECONDS} seconds`, 2));
|
|
136
|
-
await log(formatAligned('', 'Max restart iterations:',
|
|
148
|
+
await log(formatAligned('', 'Max restart iterations:', formatAutoRestartLimit(), 2));
|
|
137
149
|
await log(formatAligned('', 'Max limit resumes:', formatAutoIterationLimit(maxAutoResumeIterations), 2));
|
|
138
150
|
await log(formatAligned('', 'Wait for all repo actions:', waitForAllRepoActionsFlag ? 'Yes (strict repo-wide safety)' : 'No (PR-scoped CI only)', 2));
|
|
139
151
|
await log(formatAligned('', 'Stop conditions:', 'PR merged, PR closed, or becomes mergeable', 2));
|
|
@@ -277,9 +289,20 @@ export const watchUntilMergeable = async params => {
|
|
|
277
289
|
}
|
|
278
290
|
}
|
|
279
291
|
|
|
292
|
+
// Issue #2119: an empty pull request is not "ready to merge". The Kotlin
|
|
293
|
+
// reproduction run posted "✅ Ready to merge - No pending changes" for a
|
|
294
|
+
// pull request whose net diff was empty, so merging it would have closed
|
|
295
|
+
// the issue without implementing anything.
|
|
296
|
+
const changeStats = await getPullRequestChangeStats({ owner, repo, prNumber, $ });
|
|
297
|
+
const isEmptyPullRequest = changeStats.measured && !changeStats.hasChanges;
|
|
298
|
+
const emptyPullRequestBlocker = buildEmptyPullRequestBlocker(changeStats);
|
|
299
|
+
if (isEmptyPullRequest) {
|
|
300
|
+
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' });
|
|
301
|
+
}
|
|
302
|
+
|
|
280
303
|
// If PR is mergeable, no blockers, no new comments, no issue metadata
|
|
281
|
-
// edits,
|
|
282
|
-
if (blockers.length === 0 && !hasNewComments && !hasIssueMetadataChanges && !hasUncommittedChanges) {
|
|
304
|
+
// edits, no uncommitted changes and it actually changes something
|
|
305
|
+
if (blockers.length === 0 && !hasNewComments && !hasIssueMetadataChanges && !hasUncommittedChanges && !isEmptyPullRequest) {
|
|
283
306
|
// Issue #1503 (enhanced): Multi-mechanism consensus + repo-wide action check.
|
|
284
307
|
// Before declaring PR mergeable, run multiple independent CI detection mechanisms
|
|
285
308
|
// and require all to agree. This catches race conditions where CI starts between
|
|
@@ -434,6 +457,15 @@ export const watchUntilMergeable = async params => {
|
|
|
434
457
|
feedbackLines.push('Please review and address the feedback from these comments.');
|
|
435
458
|
}
|
|
436
459
|
|
|
460
|
+
// Issue #2119: Reason 1a: the pull request does not change anything yet.
|
|
461
|
+
if (isEmptyPullRequest) {
|
|
462
|
+
shouldRestart = true;
|
|
463
|
+
restartReason = restartReason ? `${restartReason}; ${emptyPullRequestBlocker}` : emptyPullRequestBlocker;
|
|
464
|
+
feedbackLines.push(`📭 ${emptyPullRequestBlocker}.`);
|
|
465
|
+
feedbackLines.push('');
|
|
466
|
+
feedbackLines.push('Implement the requested change and commit it to the pull request branch. Do not report the work as done while the diff is empty.');
|
|
467
|
+
}
|
|
468
|
+
|
|
437
469
|
// Issue #2007: Reason 1b: Issue title/description edited by the user.
|
|
438
470
|
if (hasIssueMetadataChanges) {
|
|
439
471
|
shouldRestart = true;
|
|
@@ -691,38 +723,24 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
|
|
|
691
723
|
}
|
|
692
724
|
|
|
693
725
|
if (shouldRestart) {
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
await
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
*Auto-restart-until-mergeable stopped by the safety limit.*`;
|
|
713
|
-
await postTrackedComment({ $, owner, repo, targetNumber: prNumber, body: limitComment });
|
|
714
|
-
} catch (commentError) {
|
|
715
|
-
reportError(commentError, {
|
|
716
|
-
context: 'post_auto_restart_limit_comment',
|
|
717
|
-
owner,
|
|
718
|
-
repo,
|
|
719
|
-
prNumber,
|
|
720
|
-
operation: 'comment_on_pr',
|
|
721
|
-
});
|
|
722
|
-
await log(formatAligned('', '⚠️ Could not post auto-restart limit comment to PR', '', 2));
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
return { success: false, reason: 'auto_restart_limit_reached', latestSessionId, latestAnthropicCost };
|
|
726
|
+
// Issue #2119: the run-wide budget is exhausted (it may already have been
|
|
727
|
+
// spent by the watch loop). Fail and auto-commit through the same shared
|
|
728
|
+
// exhaustion path the uncommitted-changes loop uses, so the outcome and
|
|
729
|
+
// the published comment are identical no matter which loop hit the limit.
|
|
730
|
+
if (hasExhaustedAutoRestartBudget()) {
|
|
731
|
+
const exhaustion = await failOnAutoRestartBudgetExhausted({
|
|
732
|
+
owner,
|
|
733
|
+
repo,
|
|
734
|
+
prNumber,
|
|
735
|
+
tempDir,
|
|
736
|
+
branchName: prBranch || branchName,
|
|
737
|
+
$,
|
|
738
|
+
log,
|
|
739
|
+
formatAligned,
|
|
740
|
+
blocker: restartReason,
|
|
741
|
+
subsystem: 'auto-restart-until-mergeable',
|
|
742
|
+
});
|
|
743
|
+
return { success: false, reason: exhaustion.reason, latestSessionId, latestAnthropicCost };
|
|
726
744
|
}
|
|
727
745
|
|
|
728
746
|
// Add standard instructions for auto-restart-until-mergeable mode using shared utility
|
|
@@ -759,17 +777,21 @@ No further AI sessions will be started automatically for this run. Please review
|
|
|
759
777
|
}
|
|
760
778
|
|
|
761
779
|
// Issue #1323: Increment restart count only when a tool execution is about to start.
|
|
762
|
-
|
|
780
|
+
// Issue #2119: claim it from the run-wide shared budget.
|
|
781
|
+
const restartCount = consumeAutoRestartIteration();
|
|
763
782
|
|
|
764
783
|
await log(formatAligned('🔄', 'RESTART TRIGGERED:', restartReason));
|
|
765
|
-
await log(formatAligned('', 'Restart iteration:',
|
|
784
|
+
await log(formatAligned('', 'Restart iteration:', formatAutoRestartLabel(restartCount), 2));
|
|
766
785
|
await log('');
|
|
767
786
|
|
|
768
787
|
// Post a comment to PR about the restart after preflight succeeds, so every
|
|
769
788
|
// posted restart notification corresponds to an actual tool session.
|
|
770
789
|
try {
|
|
771
|
-
const limitText = maxAutoRestartIterations === 0 ? 'No automatic restart limit is configured.' : `This run will stop after ${maxAutoRestartIterations} restart iteration${maxAutoRestartIterations !== 1 ? 's' : ''}.`;
|
|
772
|
-
|
|
790
|
+
const limitText = maxAutoRestartIterations === 0 ? 'No automatic restart limit is configured.' : `This run will stop after ${maxAutoRestartIterations} restart iteration${maxAutoRestartIterations !== 1 ? 's' : ''} in total.`;
|
|
791
|
+
// Issue #2119: the same `N/M` heading the uncommitted-changes loop posts.
|
|
792
|
+
// "triggered (iteration N)" hid the limit and made one auto-restart
|
|
793
|
+
// system look like two.
|
|
794
|
+
const commentBody = `## 🔄 ${AUTO_RESTART_MARKER} ${formatAutoRestartLabel(restartCount)}\n\n**Reason:** ${restartReason}\n\nStarting new session to address the issues.\n\n---\n*Auto-restart-until-mergeable mode is active. ${limitText}*`;
|
|
773
795
|
// Issue #1625: Track so this doesn't falsely count as an AI-authored comment
|
|
774
796
|
await postTrackedComment({ $, owner, repo, targetNumber: prNumber, body: commentBody });
|
|
775
797
|
await log(formatAligned('', '💬 Posted auto-restart notification to PR', '', 2));
|
|
@@ -1093,8 +1115,10 @@ No further AI sessions will be started automatically for this run. Please review
|
|
|
1093
1115
|
try {
|
|
1094
1116
|
const logFile = getLogFile();
|
|
1095
1117
|
if (logFile) {
|
|
1096
|
-
// Issue #1323: Use
|
|
1097
|
-
|
|
1118
|
+
// Issue #1323: Use the restart count (actual AI executions) instead of iteration (check cycles)
|
|
1119
|
+
// Issue #2119: `N/M` like every other auto-restart label, so the
|
|
1120
|
+
// limit is visible in the log title too.
|
|
1121
|
+
const customTitle = `🔄 ${AUTO_RESTART_UNTIL_MERGEABLE_LOG_MARKER} ${formatAutoRestartLabel()}`;
|
|
1098
1122
|
await attachLogToGitHub({
|
|
1099
1123
|
logFile,
|
|
1100
1124
|
targetType: 'pr',
|
|
@@ -1126,8 +1126,14 @@ ${prBody}`,
|
|
|
1126
1126
|
// Link the issue to the PR in GitHub's Development section using GraphQL API
|
|
1127
1127
|
await log(formatAligned('🔗', 'Linking:', `Issue #${issueNumber} to PR #${localPrNumber}...`));
|
|
1128
1128
|
try {
|
|
1129
|
+
// Issue #2119: the double quotes below are GraphQL string syntax,
|
|
1130
|
+
// not shell quoting. command-stream escapes interpolated values, so
|
|
1131
|
+
// the queries are assembled in JS and passed as one argument.
|
|
1132
|
+
const repositorySelector = `repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(repo)})`;
|
|
1133
|
+
|
|
1129
1134
|
// First, get the node IDs for both the issue and the PR
|
|
1130
|
-
const
|
|
1135
|
+
const issueNodeQuery = `query { ${repositorySelector} { issue(number: ${issueNumber}) { id } } }`;
|
|
1136
|
+
const issueNodeResult = await $`gh api graphql -f query=${issueNodeQuery} --jq .data.repository.issue.id`;
|
|
1131
1137
|
|
|
1132
1138
|
if (issueNodeResult.code !== 0) {
|
|
1133
1139
|
throw new Error(`Failed to get issue node ID: ${issueNodeResult.stderr}`);
|
|
@@ -1136,7 +1142,8 @@ ${prBody}`,
|
|
|
1136
1142
|
const issueNodeId = issueNodeResult.stdout.toString().trim();
|
|
1137
1143
|
await log(` Issue node ID: ${issueNodeId}`, { verbose: true });
|
|
1138
1144
|
|
|
1139
|
-
const
|
|
1145
|
+
const prNodeQuery = `query { ${repositorySelector} { pullRequest(number: ${localPrNumber}) { id } } }`;
|
|
1146
|
+
const prNodeResult = await $`gh api graphql -f query=${prNodeQuery} --jq .data.repository.pullRequest.id`;
|
|
1140
1147
|
|
|
1141
1148
|
if (prNodeResult.code !== 0) {
|
|
1142
1149
|
throw new Error(`Failed to get PR node ID: ${prNodeResult.stderr}`);
|
|
@@ -1152,7 +1159,8 @@ ${prBody}`,
|
|
|
1152
1159
|
// 2. For cross-repo (fork) PRs, we need "Fixes owner/repo#N"
|
|
1153
1160
|
|
|
1154
1161
|
// Let's verify the link was created
|
|
1155
|
-
const
|
|
1162
|
+
const linkCheckQuery = `query { ${repositorySelector} { pullRequest(number: ${localPrNumber}) { closingIssuesReferences(first: 10) { nodes { number } } } } }`;
|
|
1163
|
+
const linkCheckResult = await $`gh api graphql -f query=${linkCheckQuery} --jq '.data.repository.pullRequest.closingIssuesReferences.nodes[].number'`;
|
|
1156
1164
|
|
|
1157
1165
|
if (linkCheckResult.code === 0) {
|
|
1158
1166
|
const linkedIssues = parseClosingIssueNumbers(linkCheckResult.stdout);
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
// Issue #2119: "after 5 we must actually stop (fail + auto-commit on fail
|
|
2
|
+
// recovery). So the result will be actually visible." Both auto-restart loops
|
|
3
|
+
// record their exhaustion in this shared module, so the run exits non-zero
|
|
4
|
+
// instead of reporting success with the blocker still unresolved.
|
|
5
|
+
import { getAutoRestartLimitFailure, hasAutoRestartLimitFailure } from './auto-restart-exhaustion.lib.mjs';
|
|
6
|
+
|
|
1
7
|
export async function finalizeSolveProcess({ tempDir, argv, limitReached, path, getLogFile, log, closeSentry, logActiveHandles, cleanupTempDirectory, safeExit }) {
|
|
2
8
|
const runFinalizationStep = async (label, step) => {
|
|
3
9
|
try {
|
|
@@ -31,6 +37,15 @@ export async function finalizeSolveProcess({ tempDir, argv, limitReached, path,
|
|
|
31
37
|
// drainHandles() inside safeExit() will unref/close these before process.exit().
|
|
32
38
|
await runFinalizationStep('active handle diagnostics', () => logActiveHandles(msg => log(msg)));
|
|
33
39
|
|
|
40
|
+
// Issue #2119: an exhausted auto-restart budget is a failure, not a completed run.
|
|
41
|
+
if (hasAutoRestartLimitFailure()) {
|
|
42
|
+
const failure = getAutoRestartLimitFailure();
|
|
43
|
+
await log(`\n❌ Auto-restart limit reached after ${failure.iterationsUsed} iteration${failure.iterationsUsed !== 1 ? 's' : ''} - the blocker was never resolved.`, { level: 'error' });
|
|
44
|
+
await log(failure.committed ? ' Uncommitted work was auto-committed before exit, so the partial result is visible.' : ' No uncommitted work was left to preserve.', { level: 'error' });
|
|
45
|
+
await safeExit(1, 'Auto-restart limit reached');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
34
49
|
// Issue #1431: safeExit() unrefs handles so the event loop exits naturally, then calls process.exit(0)
|
|
35
50
|
await safeExit(0, 'Process completed');
|
|
36
51
|
}
|
|
@@ -29,6 +29,7 @@ const { log, formatAligned } = lib;
|
|
|
29
29
|
|
|
30
30
|
// Import exit handler
|
|
31
31
|
import { safeExit } from './exit-handler.lib.mjs';
|
|
32
|
+
import { ensureAiToolScratchIgnored } from './ai-tool-scratch.lib.mjs';
|
|
32
33
|
import { parseForkFullNameFromGhOutput } from './github-repository-names.lib.mjs';
|
|
33
34
|
import { checkReplacementRepositoryBranchSafety } from './solve.repository-safety.lib.mjs';
|
|
34
35
|
import { buildForkReplacementBlockedReason, buildForkReplacementSafetyCheckDescription } from './solve.repository-recovery-message.lib.mjs';
|
|
@@ -59,7 +60,11 @@ export const checkExistingForkOfRoot = async rootRepo => {
|
|
|
59
60
|
const userResult = await lib.ghCmdRetry(() => $`gh api user --jq .login`, { label: 'get user (fork check)' });
|
|
60
61
|
if (userResult.code !== 0) return null;
|
|
61
62
|
const currentUser = userResult.stdout.toString().trim();
|
|
62
|
-
|
|
63
|
+
// Issue #2119: build the jq expression in JS. Its double quotes belong to jq,
|
|
64
|
+
// not to the shell, and command-stream quotes interpolated values itself - so
|
|
65
|
+
// interpolating inside the quotes would leak shell quotes into the comparison.
|
|
66
|
+
const forkFilter = `.[] | select(.owner.login == ${JSON.stringify(currentUser)}) | .full_name`;
|
|
67
|
+
const forksResult = await lib.ghCmdRetry(() => $`gh api repos/${rootRepo}/forks --paginate --jq ${forkFilter}`, { label: `check forks of ${rootRepo}` });
|
|
63
68
|
if (forksResult.code !== 0) return null;
|
|
64
69
|
|
|
65
70
|
const forks = forksResult.stdout
|
|
@@ -324,9 +329,13 @@ export const tryInitializeEmptyRepository = async (owner, repo) => {
|
|
|
324
329
|
const base64Content = Buffer.from(readmeContent).toString('base64');
|
|
325
330
|
|
|
326
331
|
// Try to create README.md using GitHub API
|
|
332
|
+
// Issue #2119: `--field content="${base64Content}"` would leak literal quotes
|
|
333
|
+
// into the field value as soon as command-stream decides the value needs
|
|
334
|
+
// quoting, so the whole `key=value` token is built in JS instead.
|
|
335
|
+
const contentField = `content=${base64Content}`;
|
|
327
336
|
const createResult = await $`gh api repos/${owner}/${repo}/contents/README.md --method PUT --silent \
|
|
328
|
-
--field message
|
|
329
|
-
--field
|
|
337
|
+
--field message=${'Initialize repository with README'} \
|
|
338
|
+
--field ${contentField} 2>&1`;
|
|
330
339
|
|
|
331
340
|
if (createResult.code === 0) {
|
|
332
341
|
await log(`${formatAligned('✅', 'Success:', 'README.md created successfully')}`);
|
|
@@ -1062,6 +1071,11 @@ export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) =
|
|
|
1062
1071
|
if (cloneResult.code === 0 && repoIsValid) {
|
|
1063
1072
|
await log(`${formatAligned('✅', 'Cloned to:', tempDir)}`);
|
|
1064
1073
|
|
|
1074
|
+
// Issue #2119: AI tools drop scratch state (`.formal-ai/`, `.playwright-mcp/`)
|
|
1075
|
+
// into the workspace. Exclude it here, once, so every later `git status` and
|
|
1076
|
+
// `git add -A` agrees instead of reading it as the AI's uncommitted work.
|
|
1077
|
+
await ensureAiToolScratchIgnored(tempDir, log);
|
|
1078
|
+
|
|
1065
1079
|
// Verify and fix remote configuration
|
|
1066
1080
|
const remoteCheckResult = await $({ cwd: tempDir })`git remote -v 2>&1`;
|
|
1067
1081
|
if (!remoteCheckResult.stdout || !remoteCheckResult.stdout.toString().includes('origin')) {
|
|
@@ -1207,7 +1221,10 @@ export const setupPrForkRemote = async (tempDir, argv, prForkOwner, repo, isCont
|
|
|
1207
1221
|
// Strategy 1: Query the upstream repo's forks to find this user's fork
|
|
1208
1222
|
if (owner) {
|
|
1209
1223
|
await log(`${formatAligned('🔍', 'Discovering fork name:', `Searching ${owner}/${repo}/forks for ${prForkOwner}'s fork...`)}`);
|
|
1210
|
-
|
|
1224
|
+
// Issue #2119: the double quotes here are jq syntax, so the expression is
|
|
1225
|
+
// built in JS and interpolated as one already-escaped argument.
|
|
1226
|
+
const forkNameFilter = `.[] | select(.owner.login == ${JSON.stringify(prForkOwner)}) | .name`;
|
|
1227
|
+
const forksResult = await $`gh api repos/${owner}/${repo}/forks --paginate --jq ${forkNameFilter}`;
|
|
1211
1228
|
if (forksResult.code === 0 && forksResult.stdout) {
|
|
1212
1229
|
const forkName = forksResult.stdout.toString().trim().split('\n')[0]; // Take first match
|
|
1213
1230
|
if (forkName) {
|
|
@@ -32,6 +32,7 @@ const fs = (await use('fs')).promises;
|
|
|
32
32
|
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
|
+
const { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } = await import('./ai-tool-scratch.lib.mjs');
|
|
35
36
|
const { RESOURCE_PHASE_RESTART_AFTER, RESOURCE_PHASE_RESTART_BEFORE, recordResourceSnapshot } = await import('./solve.resource-diagnostics.lib.mjs');
|
|
36
37
|
// Issue #2123: shared draft/ready transitions for working sessions.
|
|
37
38
|
const { ensurePullRequestIsDraft } = await import('./pr-draft-state.lib.mjs');
|
|
@@ -128,11 +129,14 @@ export const cleanupPlaywrightMcpFolder = async (tempDir, argv = {}) => {
|
|
|
128
129
|
export const checkForUncommittedChanges = async (tempDir, argv = {}) => {
|
|
129
130
|
// First, clean up .playwright-mcp/ folder to prevent false positives (Issue #1124)
|
|
130
131
|
await cleanupPlaywrightMcpFolder(tempDir, argv);
|
|
132
|
+
// Issue #2119: the same false positive, generalized - `.formal-ai/` and any
|
|
133
|
+
// other AI tool scratch directory must not read as the AI's uncommitted work.
|
|
134
|
+
await ensureAiToolScratchIgnored(tempDir, log);
|
|
131
135
|
|
|
132
136
|
try {
|
|
133
137
|
const gitStatusResult = await $({ cwd: tempDir })`git status --porcelain 2>&1`;
|
|
134
138
|
if (gitStatusResult.code === 0) {
|
|
135
|
-
const statusOutput = gitStatusResult.stdout.toString().trim();
|
|
139
|
+
const statusOutput = filterAiToolScratchFromStatus(gitStatusResult.stdout.toString().trim());
|
|
136
140
|
return statusOutput.length > 0;
|
|
137
141
|
}
|
|
138
142
|
} catch (error) {
|
|
@@ -156,7 +160,7 @@ export const getUncommittedChangesDetails = async tempDir => {
|
|
|
156
160
|
try {
|
|
157
161
|
const gitStatusResult = await $({ cwd: tempDir })`git status --porcelain 2>&1`;
|
|
158
162
|
if (gitStatusResult.code === 0) {
|
|
159
|
-
const statusOutput = gitStatusResult.stdout.toString().trim();
|
|
163
|
+
const statusOutput = filterAiToolScratchFromStatus(gitStatusResult.stdout.toString().trim());
|
|
160
164
|
if (statusOutput) {
|
|
161
165
|
changes.push(...statusOutput.split('\n'));
|
|
162
166
|
}
|