@blockspool/cli 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/dist/bin/blockspool.d.ts +0 -16
- package/dist/bin/blockspool.d.ts.map +0 -1
- package/dist/bin/blockspool.js +0 -45
- package/dist/bin/blockspool.js.map +0 -1
- package/dist/commands/solo-auto.d.ts +0 -6
- package/dist/commands/solo-auto.d.ts.map +0 -1
- package/dist/commands/solo-auto.js +0 -418
- package/dist/commands/solo-auto.js.map +0 -1
- package/dist/commands/solo-exec.d.ts +0 -6
- package/dist/commands/solo-exec.d.ts.map +0 -1
- package/dist/commands/solo-exec.js +0 -656
- package/dist/commands/solo-exec.js.map +0 -1
- package/dist/commands/solo-inspect.d.ts +0 -6
- package/dist/commands/solo-inspect.d.ts.map +0 -1
- package/dist/commands/solo-inspect.js +0 -690
- package/dist/commands/solo-inspect.js.map +0 -1
- package/dist/commands/solo-lifecycle.d.ts +0 -6
- package/dist/commands/solo-lifecycle.d.ts.map +0 -1
- package/dist/commands/solo-lifecycle.js +0 -188
- package/dist/commands/solo-lifecycle.js.map +0 -1
- package/dist/commands/solo-nudge.d.ts +0 -6
- package/dist/commands/solo-nudge.d.ts.map +0 -1
- package/dist/commands/solo-nudge.js +0 -49
- package/dist/commands/solo-nudge.js.map +0 -1
- package/dist/commands/solo-qa.d.ts +0 -6
- package/dist/commands/solo-qa.d.ts.map +0 -1
- package/dist/commands/solo-qa.js +0 -254
- package/dist/commands/solo-qa.js.map +0 -1
- package/dist/commands/solo.d.ts +0 -11
- package/dist/commands/solo.d.ts.map +0 -1
- package/dist/commands/solo.js +0 -43
- package/dist/commands/solo.js.map +0 -1
- package/dist/index.d.ts +0 -18
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -18
- package/dist/index.js.map +0 -1
- package/dist/lib/artifacts.d.ts +0 -136
- package/dist/lib/artifacts.d.ts.map +0 -1
- package/dist/lib/artifacts.js +0 -146
- package/dist/lib/artifacts.js.map +0 -1
- package/dist/lib/doctor.d.ts +0 -45
- package/dist/lib/doctor.d.ts.map +0 -1
- package/dist/lib/doctor.js +0 -383
- package/dist/lib/doctor.js.map +0 -1
- package/dist/lib/exec.d.ts +0 -24
- package/dist/lib/exec.d.ts.map +0 -1
- package/dist/lib/exec.js +0 -295
- package/dist/lib/exec.js.map +0 -1
- package/dist/lib/formulas.d.ts +0 -78
- package/dist/lib/formulas.d.ts.map +0 -1
- package/dist/lib/formulas.js +0 -295
- package/dist/lib/formulas.js.map +0 -1
- package/dist/lib/git.d.ts +0 -9
- package/dist/lib/git.d.ts.map +0 -1
- package/dist/lib/git.js +0 -60
- package/dist/lib/git.js.map +0 -1
- package/dist/lib/guidelines.d.ts +0 -43
- package/dist/lib/guidelines.d.ts.map +0 -1
- package/dist/lib/guidelines.js +0 -195
- package/dist/lib/guidelines.js.map +0 -1
- package/dist/lib/logger.d.ts +0 -17
- package/dist/lib/logger.d.ts.map +0 -1
- package/dist/lib/logger.js +0 -42
- package/dist/lib/logger.js.map +0 -1
- package/dist/lib/retention.d.ts +0 -62
- package/dist/lib/retention.d.ts.map +0 -1
- package/dist/lib/retention.js +0 -285
- package/dist/lib/retention.js.map +0 -1
- package/dist/lib/run-history.d.ts +0 -52
- package/dist/lib/run-history.d.ts.map +0 -1
- package/dist/lib/run-history.js +0 -116
- package/dist/lib/run-history.js.map +0 -1
- package/dist/lib/run-state.d.ts +0 -58
- package/dist/lib/run-state.d.ts.map +0 -1
- package/dist/lib/run-state.js +0 -119
- package/dist/lib/run-state.js.map +0 -1
- package/dist/lib/scope.d.ts +0 -95
- package/dist/lib/scope.d.ts.map +0 -1
- package/dist/lib/scope.js +0 -291
- package/dist/lib/scope.js.map +0 -1
- package/dist/lib/selection.d.ts +0 -35
- package/dist/lib/selection.d.ts.map +0 -1
- package/dist/lib/selection.js +0 -110
- package/dist/lib/selection.js.map +0 -1
- package/dist/lib/solo-auto.d.ts +0 -87
- package/dist/lib/solo-auto.d.ts.map +0 -1
- package/dist/lib/solo-auto.js +0 -1230
- package/dist/lib/solo-auto.js.map +0 -1
- package/dist/lib/solo-ci.d.ts +0 -84
- package/dist/lib/solo-ci.d.ts.map +0 -1
- package/dist/lib/solo-ci.js +0 -300
- package/dist/lib/solo-ci.js.map +0 -1
- package/dist/lib/solo-config.d.ts +0 -155
- package/dist/lib/solo-config.d.ts.map +0 -1
- package/dist/lib/solo-config.js +0 -236
- package/dist/lib/solo-config.js.map +0 -1
- package/dist/lib/solo-git.d.ts +0 -44
- package/dist/lib/solo-git.d.ts.map +0 -1
- package/dist/lib/solo-git.js +0 -174
- package/dist/lib/solo-git.js.map +0 -1
- package/dist/lib/solo-hints.d.ts +0 -32
- package/dist/lib/solo-hints.d.ts.map +0 -1
- package/dist/lib/solo-hints.js +0 -98
- package/dist/lib/solo-hints.js.map +0 -1
- package/dist/lib/solo-remote.d.ts +0 -14
- package/dist/lib/solo-remote.d.ts.map +0 -1
- package/dist/lib/solo-remote.js +0 -48
- package/dist/lib/solo-remote.js.map +0 -1
- package/dist/lib/solo-stdin.d.ts +0 -13
- package/dist/lib/solo-stdin.d.ts.map +0 -1
- package/dist/lib/solo-stdin.js +0 -33
- package/dist/lib/solo-stdin.js.map +0 -1
- package/dist/lib/solo-ticket.d.ts +0 -213
- package/dist/lib/solo-ticket.d.ts.map +0 -1
- package/dist/lib/solo-ticket.js +0 -850
- package/dist/lib/solo-ticket.js.map +0 -1
- package/dist/lib/solo-utils.d.ts +0 -133
- package/dist/lib/solo-utils.d.ts.map +0 -1
- package/dist/lib/solo-utils.js +0 -300
- package/dist/lib/solo-utils.js.map +0 -1
- package/dist/lib/spindle.d.ts +0 -144
- package/dist/lib/spindle.d.ts.map +0 -1
- package/dist/lib/spindle.js +0 -388
- package/dist/lib/spindle.js.map +0 -1
- package/dist/tui/app.d.ts +0 -17
- package/dist/tui/app.d.ts.map +0 -1
- package/dist/tui/app.js +0 -139
- package/dist/tui/app.js.map +0 -1
- package/dist/tui/index.d.ts +0 -8
- package/dist/tui/index.d.ts.map +0 -1
- package/dist/tui/index.js +0 -7
- package/dist/tui/index.js.map +0 -1
- package/dist/tui/poller.d.ts +0 -42
- package/dist/tui/poller.d.ts.map +0 -1
- package/dist/tui/poller.js +0 -62
- package/dist/tui/poller.js.map +0 -1
- package/dist/tui/screens/overview.d.ts +0 -9
- package/dist/tui/screens/overview.d.ts.map +0 -1
- package/dist/tui/screens/overview.js +0 -189
- package/dist/tui/screens/overview.js.map +0 -1
- package/dist/tui/state.d.ts +0 -93
- package/dist/tui/state.d.ts.map +0 -1
- package/dist/tui/state.js +0 -169
- package/dist/tui/state.js.map +0 -1
- package/dist/tui/types.d.ts +0 -18
- package/dist/tui/types.d.ts.map +0 -1
- package/dist/tui/types.js +0 -5
- package/dist/tui/types.js.map +0 -1
package/dist/lib/solo-auto.js
DELETED
|
@@ -1,1230 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Solo mode continuous execution
|
|
3
|
-
*/
|
|
4
|
-
import * as path from 'node:path';
|
|
5
|
-
import chalk from 'chalk';
|
|
6
|
-
import { spawnSync } from 'node:child_process';
|
|
7
|
-
import { scoutRepo, } from '@blockspool/core/services';
|
|
8
|
-
import { projects, tickets, runs } from '@blockspool/core/repos';
|
|
9
|
-
import { createGitService } from './git.js';
|
|
10
|
-
import { getAdapter, isInitialized, initSolo, loadConfig, createScoutDeps, formatProgress, } from './solo-config.js';
|
|
11
|
-
import { pathsOverlap, runPreflightChecks } from './solo-utils.js';
|
|
12
|
-
import { recordCycle, isDocsAuditDue, recordDocsAudit, deferProposal, popDeferredForScope } from './run-state.js';
|
|
13
|
-
import { soloRunTicket, CodexExecutionBackend } from './solo-ticket.js';
|
|
14
|
-
import { createMilestoneBranch, mergeTicketToMilestone, pushAndPrMilestone, cleanupMilestone, } from './solo-git.js';
|
|
15
|
-
import { consumePendingHints } from './solo-hints.js';
|
|
16
|
-
import { startStdinListener } from './solo-stdin.js';
|
|
17
|
-
import { loadGuidelines, formatGuidelinesForPrompt } from './guidelines.js';
|
|
18
|
-
/**
|
|
19
|
-
* Sleep helper
|
|
20
|
-
*/
|
|
21
|
-
export function sleep(ms) {
|
|
22
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
23
|
-
}
|
|
24
|
-
/**
|
|
25
|
-
* Normalize a title for comparison (lowercase, remove punctuation, collapse whitespace)
|
|
26
|
-
*/
|
|
27
|
-
export function normalizeTitle(title) {
|
|
28
|
-
return title
|
|
29
|
-
.toLowerCase()
|
|
30
|
-
.replace(/[^\w\s]/g, ' ')
|
|
31
|
-
.replace(/\s+/g, ' ')
|
|
32
|
-
.trim();
|
|
33
|
-
}
|
|
34
|
-
/**
|
|
35
|
-
* Calculate simple word overlap similarity between two titles (0-1)
|
|
36
|
-
*/
|
|
37
|
-
export function titleSimilarity(a, b) {
|
|
38
|
-
const wordsA = new Set(normalizeTitle(a).split(' ').filter(w => w.length > 2));
|
|
39
|
-
const wordsB = new Set(normalizeTitle(b).split(' ').filter(w => w.length > 2));
|
|
40
|
-
if (wordsA.size === 0 || wordsB.size === 0)
|
|
41
|
-
return 0;
|
|
42
|
-
let overlap = 0;
|
|
43
|
-
for (const word of wordsA) {
|
|
44
|
-
if (wordsB.has(word))
|
|
45
|
-
overlap++;
|
|
46
|
-
}
|
|
47
|
-
const union = new Set([...wordsA, ...wordsB]).size;
|
|
48
|
-
return overlap / union;
|
|
49
|
-
}
|
|
50
|
-
/**
|
|
51
|
-
* Check if a proposal is a duplicate of existing tickets or open PRs
|
|
52
|
-
*/
|
|
53
|
-
export async function isDuplicateProposal(proposal, existingTitles, openPrBranches, similarityThreshold = 0.6) {
|
|
54
|
-
const normalizedProposal = normalizeTitle(proposal.title);
|
|
55
|
-
for (const existing of existingTitles) {
|
|
56
|
-
if (normalizeTitle(existing) === normalizedProposal) {
|
|
57
|
-
return { isDuplicate: true, reason: `Exact match: "${existing}"` };
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
for (const existing of existingTitles) {
|
|
61
|
-
const sim = titleSimilarity(proposal.title, existing);
|
|
62
|
-
if (sim >= similarityThreshold) {
|
|
63
|
-
return { isDuplicate: true, reason: `Similar (${Math.round(sim * 100)}%): "${existing}"` };
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
for (const branch of openPrBranches) {
|
|
67
|
-
const branchTitle = branch.replace(/^blockspool\/tkt_[a-z0-9]+$/, '').replace(/-/g, ' ');
|
|
68
|
-
if (branchTitle && titleSimilarity(proposal.title, branchTitle) >= similarityThreshold) {
|
|
69
|
-
return { isDuplicate: true, reason: `Open PR branch: ${branch}` };
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
return { isDuplicate: false };
|
|
73
|
-
}
|
|
74
|
-
/**
|
|
75
|
-
* Get existing ticket titles and open PR branches for deduplication
|
|
76
|
-
*/
|
|
77
|
-
export async function getDeduplicationContext(adapter, projectId, repoRoot) {
|
|
78
|
-
const allTickets = await tickets.listByProject(adapter, projectId, {
|
|
79
|
-
limit: 200,
|
|
80
|
-
});
|
|
81
|
-
const existingTitles = allTickets
|
|
82
|
-
.filter(t => t.status !== 'ready')
|
|
83
|
-
.map(t => t.title);
|
|
84
|
-
let openPrBranches = [];
|
|
85
|
-
try {
|
|
86
|
-
const result = spawnSync('git', ['branch', '-r', '--list', 'origin/blockspool/*'], {
|
|
87
|
-
cwd: repoRoot,
|
|
88
|
-
encoding: 'utf-8',
|
|
89
|
-
});
|
|
90
|
-
if (result.stdout) {
|
|
91
|
-
openPrBranches = result.stdout
|
|
92
|
-
.split('\n')
|
|
93
|
-
.map(b => b.trim().replace('origin/', ''))
|
|
94
|
-
.filter(Boolean);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
catch {
|
|
98
|
-
// Ignore git errors
|
|
99
|
-
}
|
|
100
|
-
return { existingTitles, openPrBranches };
|
|
101
|
-
}
|
|
102
|
-
/**
|
|
103
|
-
* Determine adaptive parallel count based on ticket complexity.
|
|
104
|
-
* When --parallel is NOT explicitly set, scale based on proposal mix.
|
|
105
|
-
*/
|
|
106
|
-
export function getAdaptiveParallelCount(proposals) {
|
|
107
|
-
const heavy = proposals.filter(p => p.estimated_complexity === 'moderate' || p.estimated_complexity === 'complex').length;
|
|
108
|
-
const light = proposals.filter(p => p.estimated_complexity === 'trivial' || p.estimated_complexity === 'simple').length;
|
|
109
|
-
if (heavy === 0)
|
|
110
|
-
return 5; // all light → go wide
|
|
111
|
-
if (light === 0)
|
|
112
|
-
return 2; // all heavy → conservative
|
|
113
|
-
const ratio = light / (light + heavy);
|
|
114
|
-
return Math.max(2, Math.min(5, Math.round(2 + ratio * 3))); // 2-5
|
|
115
|
-
}
|
|
116
|
-
/**
|
|
117
|
-
* Run auto work mode - process ready tickets in parallel
|
|
118
|
-
*/
|
|
119
|
-
export async function runAutoWorkMode(options) {
|
|
120
|
-
const parallelCount = Math.max(1, parseInt(options.parallel || '1', 10));
|
|
121
|
-
console.log(chalk.blue('🧵 BlockSpool Auto - Work Mode'));
|
|
122
|
-
console.log(chalk.gray(` Parallel workers: ${parallelCount}`));
|
|
123
|
-
console.log();
|
|
124
|
-
const git = createGitService();
|
|
125
|
-
const repoRoot = await git.findRepoRoot(process.cwd());
|
|
126
|
-
if (!repoRoot) {
|
|
127
|
-
console.error(chalk.red('✗ Not a git repository'));
|
|
128
|
-
process.exit(1);
|
|
129
|
-
}
|
|
130
|
-
if (!isInitialized(repoRoot)) {
|
|
131
|
-
console.error(chalk.red('✗ BlockSpool not initialized'));
|
|
132
|
-
console.error(chalk.gray(' Run: blockspool solo init'));
|
|
133
|
-
process.exit(1);
|
|
134
|
-
}
|
|
135
|
-
const adapter = await getAdapter(repoRoot);
|
|
136
|
-
const projectList = await projects.list(adapter);
|
|
137
|
-
if (projectList.length === 0) {
|
|
138
|
-
console.log(chalk.yellow('No projects found.'));
|
|
139
|
-
console.log(chalk.gray(' Run: blockspool solo scout . to create proposals'));
|
|
140
|
-
await adapter.close();
|
|
141
|
-
process.exit(0);
|
|
142
|
-
}
|
|
143
|
-
const readyTickets = [];
|
|
144
|
-
for (const project of projectList) {
|
|
145
|
-
const projectTickets = await tickets.listByProject(adapter, project.id, { status: 'ready' });
|
|
146
|
-
readyTickets.push(...projectTickets);
|
|
147
|
-
}
|
|
148
|
-
if (readyTickets.length === 0) {
|
|
149
|
-
console.log(chalk.yellow('No ready tickets found.'));
|
|
150
|
-
console.log(chalk.gray(' Create tickets with: blockspool solo approve'));
|
|
151
|
-
await adapter.close();
|
|
152
|
-
process.exit(0);
|
|
153
|
-
}
|
|
154
|
-
console.log(chalk.bold(`Found ${readyTickets.length} ready ticket(s)`));
|
|
155
|
-
for (const ticket of readyTickets) {
|
|
156
|
-
console.log(chalk.gray(` • ${ticket.id}: ${ticket.title}`));
|
|
157
|
-
}
|
|
158
|
-
console.log();
|
|
159
|
-
if (options.dryRun) {
|
|
160
|
-
console.log(chalk.yellow('Dry run - no changes made'));
|
|
161
|
-
console.log();
|
|
162
|
-
console.log(`Would process ${Math.min(readyTickets.length, parallelCount)} ticket(s) concurrently.`);
|
|
163
|
-
await adapter.close();
|
|
164
|
-
process.exit(0);
|
|
165
|
-
}
|
|
166
|
-
const config = loadConfig(repoRoot);
|
|
167
|
-
// Load project guidelines for execution prompts
|
|
168
|
-
const guidelines = loadGuidelines(repoRoot, {
|
|
169
|
-
customPath: config?.auto?.guidelinesPath ?? undefined,
|
|
170
|
-
});
|
|
171
|
-
if (guidelines) {
|
|
172
|
-
console.log(chalk.gray(` Guidelines loaded: ${guidelines.source}`));
|
|
173
|
-
}
|
|
174
|
-
const inFlight = new Map();
|
|
175
|
-
const results = [];
|
|
176
|
-
let ticketIndex = 0;
|
|
177
|
-
async function runNextTicket() {
|
|
178
|
-
if (ticketIndex >= readyTickets.length) {
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
const ticket = readyTickets[ticketIndex++];
|
|
182
|
-
inFlight.set(ticket.id, { ticket, startTime: Date.now() });
|
|
183
|
-
updateProgressDisplay();
|
|
184
|
-
let run;
|
|
185
|
-
try {
|
|
186
|
-
await tickets.updateStatus(adapter, ticket.id, 'in_progress');
|
|
187
|
-
run = await runs.create(adapter, {
|
|
188
|
-
projectId: ticket.projectId,
|
|
189
|
-
type: 'worker',
|
|
190
|
-
ticketId: ticket.id,
|
|
191
|
-
metadata: {
|
|
192
|
-
parallel: true,
|
|
193
|
-
createPr: options.pr ?? false,
|
|
194
|
-
},
|
|
195
|
-
});
|
|
196
|
-
const result = await soloRunTicket({
|
|
197
|
-
ticket,
|
|
198
|
-
repoRoot: repoRoot,
|
|
199
|
-
config,
|
|
200
|
-
adapter,
|
|
201
|
-
runId: run.id,
|
|
202
|
-
skipQa: false,
|
|
203
|
-
createPr: options.pr ?? false,
|
|
204
|
-
timeoutMs: 600000,
|
|
205
|
-
verbose: options.verbose ?? false,
|
|
206
|
-
onProgress: (msg) => {
|
|
207
|
-
if (options.verbose) {
|
|
208
|
-
console.log(chalk.gray(` [${ticket.id}] ${msg}`));
|
|
209
|
-
}
|
|
210
|
-
},
|
|
211
|
-
guidelinesContext: guidelines ? formatGuidelinesForPrompt(guidelines) : undefined,
|
|
212
|
-
});
|
|
213
|
-
if (result.success) {
|
|
214
|
-
await runs.markSuccess(adapter, run.id);
|
|
215
|
-
await tickets.updateStatus(adapter, ticket.id, result.prUrl ? 'in_review' : 'done');
|
|
216
|
-
results.push({ ticketId: ticket.id, title: ticket.title, result });
|
|
217
|
-
}
|
|
218
|
-
else if (result.scopeExpanded) {
|
|
219
|
-
await runs.markFailure(adapter, run.id, `Scope expanded: retry ${result.scopeExpanded.newRetryCount}`);
|
|
220
|
-
const updatedTicket = await tickets.getById(adapter, ticket.id);
|
|
221
|
-
if (updatedTicket && updatedTicket.status === 'ready') {
|
|
222
|
-
readyTickets.push(updatedTicket);
|
|
223
|
-
console.log(chalk.yellow(`↻ Scope expanded for ${ticket.id}, re-queued (retry ${result.scopeExpanded.newRetryCount})`));
|
|
224
|
-
}
|
|
225
|
-
results.push({ ticketId: ticket.id, title: ticket.title, result });
|
|
226
|
-
}
|
|
227
|
-
else {
|
|
228
|
-
await runs.markFailure(adapter, run.id, result.error ?? 'Unknown error');
|
|
229
|
-
await tickets.updateStatus(adapter, ticket.id, 'blocked');
|
|
230
|
-
results.push({ ticketId: ticket.id, title: ticket.title, result });
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
catch (error) {
|
|
234
|
-
if (run) {
|
|
235
|
-
await runs.markFailure(adapter, run.id, error instanceof Error ? error.message : String(error));
|
|
236
|
-
}
|
|
237
|
-
await tickets.updateStatus(adapter, ticket.id, 'blocked');
|
|
238
|
-
results.push({
|
|
239
|
-
ticketId: ticket.id,
|
|
240
|
-
title: ticket.title,
|
|
241
|
-
result: {
|
|
242
|
-
success: false,
|
|
243
|
-
durationMs: Date.now() - (inFlight.get(ticket.id)?.startTime ?? Date.now()),
|
|
244
|
-
error: error instanceof Error ? error.message : String(error),
|
|
245
|
-
failureReason: 'agent_error',
|
|
246
|
-
},
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
finally {
|
|
250
|
-
inFlight.delete(ticket.id);
|
|
251
|
-
updateProgressDisplay();
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
function updateProgressDisplay() {
|
|
255
|
-
if (inFlight.size > 0) {
|
|
256
|
-
const ticketIds = Array.from(inFlight.keys()).join(', ');
|
|
257
|
-
process.stdout.write(`\r${chalk.cyan('⏳ In-flight:')} ${ticketIds}${' '.repeat(20)}`);
|
|
258
|
-
}
|
|
259
|
-
else {
|
|
260
|
-
process.stdout.write('\r' + ' '.repeat(80) + '\r');
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
console.log(chalk.bold('Processing tickets...'));
|
|
264
|
-
console.log();
|
|
265
|
-
while (ticketIndex < readyTickets.length || inFlight.size > 0) {
|
|
266
|
-
const startPromises = [];
|
|
267
|
-
while (inFlight.size < parallelCount && ticketIndex < readyTickets.length) {
|
|
268
|
-
startPromises.push(runNextTicket());
|
|
269
|
-
}
|
|
270
|
-
if (startPromises.length > 0) {
|
|
271
|
-
await Promise.race(startPromises);
|
|
272
|
-
}
|
|
273
|
-
else if (inFlight.size > 0) {
|
|
274
|
-
await new Promise(resolve => setTimeout(resolve, 100));
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
while (inFlight.size > 0) {
|
|
278
|
-
await new Promise(resolve => setTimeout(resolve, 100));
|
|
279
|
-
}
|
|
280
|
-
await adapter.close();
|
|
281
|
-
console.log();
|
|
282
|
-
console.log(chalk.bold('Results:'));
|
|
283
|
-
console.log();
|
|
284
|
-
const successful = results.filter(r => r.result.success);
|
|
285
|
-
const failed = results.filter(r => !r.result.success);
|
|
286
|
-
for (const { ticketId, title, result } of successful) {
|
|
287
|
-
console.log(chalk.green(`✓ ${ticketId}: ${title}`));
|
|
288
|
-
if (result.prUrl) {
|
|
289
|
-
console.log(chalk.gray(` PR: ${result.prUrl}`));
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
for (const { ticketId, title, result } of failed) {
|
|
293
|
-
console.log(chalk.red(`✗ ${ticketId}: ${title}`));
|
|
294
|
-
if (result.error) {
|
|
295
|
-
console.log(chalk.gray(` Error: ${result.error}`));
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
console.log();
|
|
299
|
-
console.log(chalk.bold('Summary:'));
|
|
300
|
-
console.log(chalk.green(` Successful: ${successful.length}`));
|
|
301
|
-
console.log(chalk.red(` Failed: ${failed.length}`));
|
|
302
|
-
process.exit(failed.length > 0 ? 1 : 0);
|
|
303
|
-
}
|
|
304
|
-
/**
|
|
305
|
-
* Partition proposals into conflict-free waves.
|
|
306
|
-
* Proposals with overlapping file paths go into separate waves
|
|
307
|
-
* so they run sequentially, avoiding merge conflicts.
|
|
308
|
-
*/
|
|
309
|
-
export function partitionIntoWaves(proposals) {
|
|
310
|
-
const waves = [];
|
|
311
|
-
for (const proposal of proposals) {
|
|
312
|
-
let placed = false;
|
|
313
|
-
for (const wave of waves) {
|
|
314
|
-
const conflicts = wave.some(existing => existing.files.some(fA => proposal.files.some(fB => pathsOverlap(fA, fB))));
|
|
315
|
-
if (!conflicts) {
|
|
316
|
-
wave.push(proposal);
|
|
317
|
-
placed = true;
|
|
318
|
-
break;
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
if (!placed) {
|
|
322
|
-
waves.push([proposal]);
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
return waves;
|
|
326
|
-
}
|
|
327
|
-
/**
|
|
328
|
-
* Run auto mode - the full "just run it" experience
|
|
329
|
-
* Scout → auto-approve safe changes → run → create draft PRs
|
|
330
|
-
*/
|
|
331
|
-
export async function runAutoMode(options) {
|
|
332
|
-
// Load formula if specified
|
|
333
|
-
let activeFormula = null;
|
|
334
|
-
if (options.formula) {
|
|
335
|
-
const { loadFormula, listFormulas } = await import('./formulas.js');
|
|
336
|
-
activeFormula = loadFormula(options.formula);
|
|
337
|
-
if (!activeFormula) {
|
|
338
|
-
const available = listFormulas();
|
|
339
|
-
console.error(chalk.red(`✗ Formula not found: ${options.formula}`));
|
|
340
|
-
console.error(chalk.gray(` Available formulas: ${available.map(f => f.name).join(', ')}`));
|
|
341
|
-
process.exit(1);
|
|
342
|
-
}
|
|
343
|
-
console.log(chalk.cyan(`📜 Using formula: ${activeFormula.name}`));
|
|
344
|
-
console.log(chalk.gray(` ${activeFormula.description}`));
|
|
345
|
-
if (activeFormula.prompt) {
|
|
346
|
-
console.log(chalk.gray(` Prompt: ${activeFormula.prompt.slice(0, 80)}...`));
|
|
347
|
-
}
|
|
348
|
-
console.log();
|
|
349
|
-
}
|
|
350
|
-
const maxCycles = options.cycles ? parseInt(options.cycles, 10) : 1;
|
|
351
|
-
const isContinuous = options.continuous || options.hours !== undefined || options.minutes !== undefined || maxCycles > 1;
|
|
352
|
-
const totalMinutes = (options.hours ? parseFloat(options.hours) * 60 : 0)
|
|
353
|
-
+ (options.minutes ? parseFloat(options.minutes) : 0) || undefined;
|
|
354
|
-
const endTime = totalMinutes ? Date.now() + (totalMinutes * 60 * 1000) : undefined;
|
|
355
|
-
const defaultMaxPrs = isContinuous ? 20 : 3;
|
|
356
|
-
const maxPrs = parseInt(options.maxPrs || String(activeFormula?.maxPrs ?? defaultMaxPrs), 10);
|
|
357
|
-
const minConfidence = parseInt(options.minConfidence || String(activeFormula?.minConfidence ?? 70), 10);
|
|
358
|
-
const useDraft = options.draft !== false;
|
|
359
|
-
// Milestone mode state (declared early so header can reference)
|
|
360
|
-
const batchSize = options.batchSize ? parseInt(options.batchSize, 10) : undefined;
|
|
361
|
-
const milestoneMode = batchSize !== undefined && batchSize > 0;
|
|
362
|
-
const defaultScopes = ['src', 'lib', 'packages', 'app', 'tests', 'scripts'];
|
|
363
|
-
const userScope = options.scope || activeFormula?.scope;
|
|
364
|
-
let scopeIndex = 0;
|
|
365
|
-
const DEEP_SCAN_INTERVAL = 5;
|
|
366
|
-
let deepFormula = null;
|
|
367
|
-
let docsAuditFormula = null;
|
|
368
|
-
if (!activeFormula) {
|
|
369
|
-
const { loadFormula: loadF } = await import('./formulas.js');
|
|
370
|
-
if (isContinuous) {
|
|
371
|
-
deepFormula = loadF('deep');
|
|
372
|
-
}
|
|
373
|
-
// Docs-audit loaded here; enabled/interval resolved after config is loaded
|
|
374
|
-
if (options.docsAudit !== false) {
|
|
375
|
-
docsAuditFormula = loadF('docs-audit');
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
const getCycleFormula = (cycle) => {
|
|
379
|
-
if (activeFormula)
|
|
380
|
-
return activeFormula;
|
|
381
|
-
if (deepFormula && isContinuous && cycle % DEEP_SCAN_INTERVAL === 0)
|
|
382
|
-
return deepFormula;
|
|
383
|
-
// Auto docs-audit every N cycles (persisted across sessions)
|
|
384
|
-
if (docsAuditFormula && repoRoot) {
|
|
385
|
-
const interval = options.docsAuditInterval
|
|
386
|
-
? parseInt(options.docsAuditInterval, 10)
|
|
387
|
-
: config?.auto?.docsAuditInterval ?? 3;
|
|
388
|
-
// Config can also disable docs-audit
|
|
389
|
-
const enabled = config?.auto?.docsAudit !== false;
|
|
390
|
-
if (enabled && isDocsAuditDue(repoRoot, interval))
|
|
391
|
-
return docsAuditFormula;
|
|
392
|
-
}
|
|
393
|
-
return null;
|
|
394
|
-
};
|
|
395
|
-
const getCycleCategories = (formula) => {
|
|
396
|
-
const allow = formula?.categories
|
|
397
|
-
? formula.categories
|
|
398
|
-
: options.aggressive
|
|
399
|
-
? ['refactor', 'test', 'docs', 'types', 'perf', 'security']
|
|
400
|
-
: ['refactor', 'test', 'docs', 'types', 'perf'];
|
|
401
|
-
const block = formula?.categories
|
|
402
|
-
? []
|
|
403
|
-
: options.aggressive
|
|
404
|
-
? ['deps', 'migration', 'config']
|
|
405
|
-
: ['deps', 'migration', 'config', 'security'];
|
|
406
|
-
return { allow, block };
|
|
407
|
-
};
|
|
408
|
-
let shutdownRequested = false;
|
|
409
|
-
let currentlyProcessing = false;
|
|
410
|
-
const shutdownHandler = () => {
|
|
411
|
-
if (shutdownRequested) {
|
|
412
|
-
console.log(chalk.red('\nForce quit. Exiting immediately.'));
|
|
413
|
-
process.exit(1);
|
|
414
|
-
}
|
|
415
|
-
shutdownRequested = true;
|
|
416
|
-
if (currentlyProcessing) {
|
|
417
|
-
console.log(chalk.yellow('\nShutdown requested. Finishing current ticket, then finalizing milestone...'));
|
|
418
|
-
}
|
|
419
|
-
else {
|
|
420
|
-
console.log(chalk.yellow('\nShutdown requested. Exiting...'));
|
|
421
|
-
process.exit(0);
|
|
422
|
-
}
|
|
423
|
-
};
|
|
424
|
-
process.on('SIGINT', shutdownHandler);
|
|
425
|
-
process.on('SIGTERM', shutdownHandler);
|
|
426
|
-
// Print header — need initial categories for display
|
|
427
|
-
const initialCategories = getCycleCategories(getCycleFormula(1));
|
|
428
|
-
console.log(chalk.blue('🧵 BlockSpool Auto'));
|
|
429
|
-
console.log();
|
|
430
|
-
if (isContinuous) {
|
|
431
|
-
console.log(chalk.gray(' Mode: Continuous (Ctrl+C to stop gracefully)'));
|
|
432
|
-
if (totalMinutes) {
|
|
433
|
-
const endDate = new Date(endTime);
|
|
434
|
-
const budgetLabel = totalMinutes < 60
|
|
435
|
-
? `${Math.round(totalMinutes)} minutes`
|
|
436
|
-
: totalMinutes % 60 === 0
|
|
437
|
-
? `${totalMinutes / 60} hours`
|
|
438
|
-
: `${Math.floor(totalMinutes / 60)}h ${Math.round(totalMinutes % 60)}m`;
|
|
439
|
-
console.log(chalk.gray(` Time budget: ${budgetLabel} (until ${endDate.toLocaleTimeString()})`));
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
else {
|
|
443
|
-
console.log(chalk.gray(' Mode: Scout → Auto-approve → Run → PR'));
|
|
444
|
-
}
|
|
445
|
-
console.log(chalk.gray(` Scope: ${userScope || (isContinuous ? 'rotating' : 'src')}`));
|
|
446
|
-
console.log(chalk.gray(` Max PRs: ${maxPrs}`));
|
|
447
|
-
console.log(chalk.gray(` Min confidence: ${minConfidence}%`));
|
|
448
|
-
console.log(chalk.gray(` Categories: ${initialCategories.allow.join(', ')}`));
|
|
449
|
-
console.log(chalk.gray(` Draft PRs: ${useDraft ? 'yes' : 'no'}`));
|
|
450
|
-
if (milestoneMode) {
|
|
451
|
-
console.log(chalk.gray(` Milestone mode: batch size ${batchSize}`));
|
|
452
|
-
}
|
|
453
|
-
console.log();
|
|
454
|
-
const git = createGitService();
|
|
455
|
-
const repoRoot = await git.findRepoRoot(process.cwd());
|
|
456
|
-
if (!repoRoot) {
|
|
457
|
-
console.error(chalk.red('✗ Not a git repository'));
|
|
458
|
-
process.exit(1);
|
|
459
|
-
}
|
|
460
|
-
// Start stdin listener for live hints in continuous/hours mode
|
|
461
|
-
let stopStdinListener;
|
|
462
|
-
if (isContinuous) {
|
|
463
|
-
stopStdinListener = startStdinListener(repoRoot);
|
|
464
|
-
}
|
|
465
|
-
const statusResult = spawnSync('git', ['status', '--porcelain'], { cwd: repoRoot });
|
|
466
|
-
const statusLines = statusResult.stdout?.toString().trim().split('\n').filter(Boolean) || [];
|
|
467
|
-
const modifiedFiles = statusLines.filter(line => !line.startsWith('??'));
|
|
468
|
-
if (modifiedFiles.length > 0 && !options.dryRun) {
|
|
469
|
-
console.error(chalk.red('✗ Working tree has uncommitted changes'));
|
|
470
|
-
console.error(chalk.gray(' Commit or stash your changes first'));
|
|
471
|
-
process.exit(1);
|
|
472
|
-
}
|
|
473
|
-
if (!isInitialized(repoRoot)) {
|
|
474
|
-
console.log(chalk.gray('Initializing BlockSpool...'));
|
|
475
|
-
await initSolo(repoRoot);
|
|
476
|
-
}
|
|
477
|
-
// Validate remote matches what was recorded at init
|
|
478
|
-
const preflight = await runPreflightChecks(repoRoot, { needsPr: true });
|
|
479
|
-
if (!preflight.ok) {
|
|
480
|
-
console.error(chalk.red(`✗ ${preflight.error}`));
|
|
481
|
-
process.exit(1);
|
|
482
|
-
}
|
|
483
|
-
for (const warning of preflight.warnings) {
|
|
484
|
-
console.log(chalk.yellow(`⚠ ${warning}`));
|
|
485
|
-
}
|
|
486
|
-
const config = loadConfig(repoRoot);
|
|
487
|
-
const adapter = await getAdapter(repoRoot);
|
|
488
|
-
// Determine guidelines backend based on execution/scout backend
|
|
489
|
-
const guidelinesBackend = (options.executeBackend === 'codex' || options.scoutBackend === 'codex') ? 'codex' : 'claude';
|
|
490
|
-
const guidelinesOpts = {
|
|
491
|
-
backend: guidelinesBackend,
|
|
492
|
-
autoCreate: config?.auto?.autoCreateGuidelines !== false,
|
|
493
|
-
customPath: config?.auto?.guidelinesPath ?? undefined,
|
|
494
|
-
};
|
|
495
|
-
// Load project guidelines (CLAUDE.md for Claude, AGENTS.md for Codex)
|
|
496
|
-
let guidelines = loadGuidelines(repoRoot, guidelinesOpts);
|
|
497
|
-
const guidelinesRefreshInterval = config?.auto?.guidelinesRefreshCycles ?? 10;
|
|
498
|
-
if (guidelines) {
|
|
499
|
-
console.log(chalk.gray(` Guidelines loaded: ${guidelines.source}`));
|
|
500
|
-
}
|
|
501
|
-
// Auto-prune stale state on session start (includes DB ticket cleanup)
|
|
502
|
-
try {
|
|
503
|
-
const { pruneAllAsync: pruneAllAsyncFn, getRetentionConfig } = await import('./retention.js');
|
|
504
|
-
const retentionConfig = getRetentionConfig(config);
|
|
505
|
-
const pruneReport = await pruneAllAsyncFn(repoRoot, retentionConfig, adapter);
|
|
506
|
-
if (pruneReport.totalPruned > 0) {
|
|
507
|
-
console.log(chalk.gray(` Pruned ${pruneReport.totalPruned} stale item(s)`));
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
catch {
|
|
511
|
-
// Non-fatal — prune failure shouldn't block the session
|
|
512
|
-
}
|
|
513
|
-
let totalPrsCreated = 0;
|
|
514
|
-
let totalFailed = 0;
|
|
515
|
-
let cycleCount = 0;
|
|
516
|
-
const allPrUrls = [];
|
|
517
|
-
const startTime = Date.now();
|
|
518
|
-
// Milestone mode mutable state
|
|
519
|
-
let milestoneBranch;
|
|
520
|
-
let milestoneWorktreePath;
|
|
521
|
-
let milestoneTicketCount = 0;
|
|
522
|
-
let milestoneNumber = 0;
|
|
523
|
-
let totalMilestonePrs = 0;
|
|
524
|
-
const milestoneTicketSummaries = [];
|
|
525
|
-
const getNextScope = () => {
|
|
526
|
-
if (userScope)
|
|
527
|
-
return userScope;
|
|
528
|
-
const scope = defaultScopes[scopeIndex % defaultScopes.length];
|
|
529
|
-
scopeIndex++;
|
|
530
|
-
return scope;
|
|
531
|
-
};
|
|
532
|
-
const shouldContinue = () => {
|
|
533
|
-
if (shutdownRequested)
|
|
534
|
-
return false;
|
|
535
|
-
if (milestoneMode) {
|
|
536
|
-
if (totalMilestonePrs >= maxPrs)
|
|
537
|
-
return false;
|
|
538
|
-
}
|
|
539
|
-
else {
|
|
540
|
-
if (totalPrsCreated >= maxPrs)
|
|
541
|
-
return false;
|
|
542
|
-
}
|
|
543
|
-
if (endTime && Date.now() >= endTime)
|
|
544
|
-
return false;
|
|
545
|
-
if (cycleCount >= maxCycles && !options.continuous && !options.hours && !options.minutes)
|
|
546
|
-
return false;
|
|
547
|
-
return true;
|
|
548
|
-
};
|
|
549
|
-
const formatElapsed = (ms) => {
|
|
550
|
-
const hours = Math.floor(ms / 3600000);
|
|
551
|
-
const minutes = Math.floor((ms % 3600000) / 60000);
|
|
552
|
-
if (hours > 0)
|
|
553
|
-
return `${hours}h ${minutes}m`;
|
|
554
|
-
return `${minutes}m`;
|
|
555
|
-
};
|
|
556
|
-
// Track whether --parallel was explicitly provided
|
|
557
|
-
const parallelExplicit = options.parallel !== undefined && options.parallel !== '3';
|
|
558
|
-
try {
|
|
559
|
-
const project = await projects.ensureForRepo(adapter, {
|
|
560
|
-
name: path.basename(repoRoot),
|
|
561
|
-
rootPath: repoRoot,
|
|
562
|
-
});
|
|
563
|
-
const deps = createScoutDeps(adapter, { verbose: options.verbose });
|
|
564
|
-
// Instantiate backends based on --scout-backend / --execute-backend
|
|
565
|
-
let scoutBackend;
|
|
566
|
-
let executionBackend;
|
|
567
|
-
if (options.scoutBackend === 'codex') {
|
|
568
|
-
const { CodexScoutBackend } = await import('@blockspool/core/scout');
|
|
569
|
-
scoutBackend = new CodexScoutBackend({ apiKey: process.env.CODEX_API_KEY, model: options.codexModel });
|
|
570
|
-
}
|
|
571
|
-
if (options.executeBackend === 'codex') {
|
|
572
|
-
executionBackend = new CodexExecutionBackend({
|
|
573
|
-
apiKey: process.env.CODEX_API_KEY,
|
|
574
|
-
model: options.codexModel,
|
|
575
|
-
unsafeBypassSandbox: options.codexUnsafeFullAccess,
|
|
576
|
-
});
|
|
577
|
-
}
|
|
578
|
-
// Detect base branch for milestone mode
|
|
579
|
-
let detectedBaseBranch = 'master';
|
|
580
|
-
try {
|
|
581
|
-
const remoteHead = (await (await import('./solo-git.js')).gitExec('git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || echo "refs/remotes/origin/master"', { cwd: repoRoot })).trim();
|
|
582
|
-
detectedBaseBranch = remoteHead.replace('refs/remotes/origin/', '');
|
|
583
|
-
}
|
|
584
|
-
catch {
|
|
585
|
-
// Fall back to master
|
|
586
|
-
}
|
|
587
|
-
// Initialize milestone branch if in milestone mode
|
|
588
|
-
if (milestoneMode && !options.dryRun) {
|
|
589
|
-
const ms = await createMilestoneBranch(repoRoot, detectedBaseBranch);
|
|
590
|
-
milestoneBranch = ms.milestoneBranch;
|
|
591
|
-
milestoneWorktreePath = ms.milestoneWorktreePath;
|
|
592
|
-
milestoneNumber = 1;
|
|
593
|
-
console.log(chalk.cyan(`Milestone branch: ${milestoneBranch}`));
|
|
594
|
-
console.log();
|
|
595
|
-
}
|
|
596
|
-
// Helper to finalize current milestone (push + PR)
|
|
597
|
-
const finalizeMilestone = async () => {
|
|
598
|
-
if (!milestoneMode || !milestoneBranch || !milestoneWorktreePath)
|
|
599
|
-
return;
|
|
600
|
-
if (milestoneTicketCount === 0)
|
|
601
|
-
return;
|
|
602
|
-
console.log(chalk.cyan(`\nFinalizing milestone #${milestoneNumber} (${milestoneTicketCount} tickets)...`));
|
|
603
|
-
const prUrl = await pushAndPrMilestone(repoRoot, milestoneBranch, milestoneWorktreePath, milestoneNumber, milestoneTicketCount, [...milestoneTicketSummaries]);
|
|
604
|
-
if (prUrl) {
|
|
605
|
-
allPrUrls.push(prUrl);
|
|
606
|
-
console.log(chalk.green(` ✓ Milestone PR: ${prUrl}`));
|
|
607
|
-
}
|
|
608
|
-
else {
|
|
609
|
-
console.log(chalk.yellow(` ⚠ Milestone pushed but PR creation failed`));
|
|
610
|
-
}
|
|
611
|
-
totalMilestonePrs++;
|
|
612
|
-
};
|
|
613
|
-
// Helper to start a new milestone branch
|
|
614
|
-
const startNewMilestone = async () => {
|
|
615
|
-
if (!milestoneMode)
|
|
616
|
-
return;
|
|
617
|
-
// Clean old milestone worktree
|
|
618
|
-
await cleanupMilestone(repoRoot);
|
|
619
|
-
milestoneTicketCount = 0;
|
|
620
|
-
milestoneTicketSummaries.length = 0;
|
|
621
|
-
const ms = await createMilestoneBranch(repoRoot, detectedBaseBranch);
|
|
622
|
-
milestoneBranch = ms.milestoneBranch;
|
|
623
|
-
milestoneWorktreePath = ms.milestoneWorktreePath;
|
|
624
|
-
milestoneNumber++;
|
|
625
|
-
console.log(chalk.cyan(`New milestone branch: ${milestoneBranch}`));
|
|
626
|
-
};
|
|
627
|
-
// Periodic pull settings
|
|
628
|
-
const pullInterval = config?.auto?.pullEveryNCycles ?? 5;
|
|
629
|
-
const pullPolicy = config?.auto?.pullPolicy ?? 'halt';
|
|
630
|
-
let cyclesSinceLastPull = 0;
|
|
631
|
-
do {
|
|
632
|
-
cycleCount++;
|
|
633
|
-
const scope = getNextScope();
|
|
634
|
-
// Periodic pull to stay current with team changes
|
|
635
|
-
if (pullInterval > 0 && isContinuous) {
|
|
636
|
-
cyclesSinceLastPull++;
|
|
637
|
-
if (cyclesSinceLastPull >= pullInterval) {
|
|
638
|
-
cyclesSinceLastPull = 0;
|
|
639
|
-
try {
|
|
640
|
-
// First fetch so we can detect divergence before attempting merge
|
|
641
|
-
const fetchResult = spawnSync('git', ['fetch', 'origin', detectedBaseBranch], { cwd: repoRoot, encoding: 'utf-8', timeout: 30000 });
|
|
642
|
-
if (fetchResult.status === 0) {
|
|
643
|
-
// Try fast-forward merge — fails if diverged
|
|
644
|
-
const mergeResult = spawnSync('git', ['merge', '--ff-only', `origin/${detectedBaseBranch}`], { cwd: repoRoot, encoding: 'utf-8' });
|
|
645
|
-
if (mergeResult.status === 0) {
|
|
646
|
-
const summary = mergeResult.stdout?.trim();
|
|
647
|
-
if (summary && !summary.includes('Already up to date')) {
|
|
648
|
-
console.log(chalk.cyan(` ⬇ Pulled latest from origin/${detectedBaseBranch}`));
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
else {
|
|
652
|
-
// Divergence detected — ff-only failed
|
|
653
|
-
const errMsg = mergeResult.stderr?.trim() || 'fast-forward not possible';
|
|
654
|
-
if (pullPolicy === 'halt') {
|
|
655
|
-
// Finalize any in-progress milestone before stopping
|
|
656
|
-
if (milestoneMode && milestoneTicketCount > 0) {
|
|
657
|
-
console.log(chalk.yellow(`\n⚠ Base branch diverged — finalizing current milestone before stopping...`));
|
|
658
|
-
await finalizeMilestone();
|
|
659
|
-
}
|
|
660
|
-
console.log();
|
|
661
|
-
console.log(chalk.red(`✗ HCF — Base branch has diverged from origin/${detectedBaseBranch}`));
|
|
662
|
-
console.log(chalk.gray(` ${errMsg}`));
|
|
663
|
-
console.log();
|
|
664
|
-
console.log(chalk.bold('Resolution:'));
|
|
665
|
-
console.log(` 1. Resolve the divergence (rebase, merge, or reset)`);
|
|
666
|
-
console.log(` 2. Re-run: blockspool --hours ... --continuous`);
|
|
667
|
-
console.log();
|
|
668
|
-
console.log(chalk.gray(` To keep going despite divergence, set pullPolicy: "warn" in config.`));
|
|
669
|
-
if (milestoneMode)
|
|
670
|
-
await cleanupMilestone(repoRoot);
|
|
671
|
-
stopStdinListener?.();
|
|
672
|
-
await adapter.close();
|
|
673
|
-
process.exit(1);
|
|
674
|
-
}
|
|
675
|
-
else {
|
|
676
|
-
// warn policy — log and continue on stale base
|
|
677
|
-
console.log(chalk.yellow(` ⚠ Base branch diverged from origin/${detectedBaseBranch} — continuing on stale base`));
|
|
678
|
-
console.log(chalk.gray(` ${errMsg}`));
|
|
679
|
-
console.log(chalk.gray(` Subsequent work may produce merge conflicts`));
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
else if (options.verbose) {
|
|
684
|
-
console.log(chalk.yellow(` ⚠ Fetch failed (network?): ${fetchResult.stderr?.trim()}`));
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
catch {
|
|
688
|
-
// Network unavailable — non-fatal, keep going
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
// Periodic guidelines refresh
|
|
693
|
-
if (guidelinesRefreshInterval > 0 && cycleCount > 1 && cycleCount % guidelinesRefreshInterval === 0) {
|
|
694
|
-
guidelines = loadGuidelines(repoRoot, guidelinesOpts);
|
|
695
|
-
if (guidelines && options.verbose) {
|
|
696
|
-
console.log(chalk.gray(` Refreshed project guidelines (${guidelines.source})`));
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
if (isContinuous && cycleCount > 1) {
|
|
700
|
-
console.log();
|
|
701
|
-
console.log(chalk.blue(`━━━ Cycle ${cycleCount} ━━━`));
|
|
702
|
-
console.log(chalk.gray(` Elapsed: ${formatElapsed(Date.now() - startTime)}`));
|
|
703
|
-
if (milestoneMode) {
|
|
704
|
-
console.log(chalk.gray(` Milestone PRs: ${totalMilestonePrs}/${maxPrs} (${totalPrsCreated} tickets merged)`));
|
|
705
|
-
}
|
|
706
|
-
else {
|
|
707
|
-
console.log(chalk.gray(` PRs created: ${totalPrsCreated}/${maxPrs}`));
|
|
708
|
-
}
|
|
709
|
-
if (endTime) {
|
|
710
|
-
const remaining = Math.max(0, endTime - Date.now());
|
|
711
|
-
console.log(chalk.gray(` Time remaining: ${formatElapsed(remaining)}`));
|
|
712
|
-
}
|
|
713
|
-
console.log();
|
|
714
|
-
}
|
|
715
|
-
const dedupContext = await getDeduplicationContext(adapter, project.id, repoRoot);
|
|
716
|
-
const cycleFormula = getCycleFormula(cycleCount);
|
|
717
|
-
const { allow: allowCategories, block: blockCategories } = getCycleCategories(cycleFormula);
|
|
718
|
-
const isDeepCycle = cycleFormula?.name === 'deep' && cycleFormula !== activeFormula;
|
|
719
|
-
const isDocsAuditCycle = cycleFormula?.name === 'docs-audit' && cycleFormula !== activeFormula;
|
|
720
|
-
const cycleSuffix = isDeepCycle ? ' 🔬 deep' : isDocsAuditCycle ? ' 📄 docs-audit' : '';
|
|
721
|
-
const cycleLabel = isContinuous ? `[Cycle ${cycleCount}]${cycleSuffix} ` : 'Step 1: ';
|
|
722
|
-
console.log(chalk.bold(`${cycleLabel}Scouting ${scope}...`));
|
|
723
|
-
// Consume any pending user hints
|
|
724
|
-
const hintBlock = consumePendingHints(repoRoot);
|
|
725
|
-
if (hintBlock) {
|
|
726
|
-
const hintCount = hintBlock.split('\n').filter(l => l.startsWith('- ')).length;
|
|
727
|
-
console.log(chalk.yellow(`[Hints] Applying ${hintCount} user hint(s) to this scout cycle`));
|
|
728
|
-
}
|
|
729
|
-
let lastProgress = '';
|
|
730
|
-
const scoutPath = (milestoneMode && milestoneWorktreePath) ? milestoneWorktreePath : repoRoot;
|
|
731
|
-
const guidelinesPrefix = guidelines ? formatGuidelinesForPrompt(guidelines) + '\n\n' : '';
|
|
732
|
-
const basePrompt = guidelinesPrefix + (cycleFormula?.prompt || '');
|
|
733
|
-
const effectivePrompt = hintBlock ? (basePrompt + hintBlock) : (basePrompt || undefined);
|
|
734
|
-
const scoutResult = await scoutRepo(deps, {
|
|
735
|
-
path: scoutPath,
|
|
736
|
-
scope,
|
|
737
|
-
maxProposals: 20,
|
|
738
|
-
minConfidence: Math.max((cycleFormula?.minConfidence ?? minConfidence) - 20, 30),
|
|
739
|
-
model: options.eco ? 'sonnet' : (cycleFormula?.model ?? 'opus'),
|
|
740
|
-
customPrompt: effectivePrompt,
|
|
741
|
-
autoApprove: false,
|
|
742
|
-
backend: scoutBackend,
|
|
743
|
-
protectedFiles: options.includeClaudeMd ? undefined : ['CLAUDE.md', '.claude/**'],
|
|
744
|
-
onProgress: (progress) => {
|
|
745
|
-
if (options.verbose) {
|
|
746
|
-
const formatted = formatProgress(progress);
|
|
747
|
-
if (formatted !== lastProgress) {
|
|
748
|
-
process.stdout.write(`\r ${formatted.padEnd(70)}`);
|
|
749
|
-
lastProgress = formatted;
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
},
|
|
753
|
-
});
|
|
754
|
-
if (options.verbose) {
|
|
755
|
-
process.stdout.write('\r' + ' '.repeat(80) + '\r');
|
|
756
|
-
}
|
|
757
|
-
const proposals = scoutResult.proposals;
|
|
758
|
-
if (proposals.length === 0) {
|
|
759
|
-
console.log(chalk.gray(` No improvements found in ${scope}`));
|
|
760
|
-
if (scoutResult.errors.length > 0) {
|
|
761
|
-
for (const err of scoutResult.errors) {
|
|
762
|
-
console.log(chalk.yellow(` ⚠ ${err}`));
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
if (isContinuous) {
|
|
766
|
-
await sleep(2000);
|
|
767
|
-
continue;
|
|
768
|
-
}
|
|
769
|
-
else {
|
|
770
|
-
console.log(chalk.green('✓ Your code looks great!'));
|
|
771
|
-
break;
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
console.log(chalk.gray(` Found ${proposals.length} potential improvements`));
|
|
775
|
-
// Re-inject deferred proposals that now match this cycle's scope
|
|
776
|
-
const deferred = popDeferredForScope(repoRoot, scope);
|
|
777
|
-
if (deferred.length > 0) {
|
|
778
|
-
console.log(chalk.cyan(` ♻ ${deferred.length} deferred proposal(s) now in scope`));
|
|
779
|
-
for (const dp of deferred) {
|
|
780
|
-
proposals.push({
|
|
781
|
-
id: `deferred-${Date.now()}`,
|
|
782
|
-
category: dp.category,
|
|
783
|
-
title: dp.title,
|
|
784
|
-
description: dp.description,
|
|
785
|
-
files: dp.files,
|
|
786
|
-
allowed_paths: dp.allowed_paths,
|
|
787
|
-
confidence: dp.confidence,
|
|
788
|
-
impact_score: dp.impact_score,
|
|
789
|
-
acceptance_criteria: [],
|
|
790
|
-
verification_commands: ['npm run build'],
|
|
791
|
-
rationale: `(deferred from scope ${dp.original_scope})`,
|
|
792
|
-
estimated_complexity: 'simple',
|
|
793
|
-
});
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
const categoryFiltered = proposals.filter((p) => {
|
|
797
|
-
const category = (p.category || 'refactor').toLowerCase();
|
|
798
|
-
const confidence = p.confidence || 50;
|
|
799
|
-
if (blockCategories.some(blocked => category.includes(blocked)))
|
|
800
|
-
return false;
|
|
801
|
-
if (!allowCategories.some(allowed => category.includes(allowed)))
|
|
802
|
-
return false;
|
|
803
|
-
if (confidence < minConfidence)
|
|
804
|
-
return false;
|
|
805
|
-
return true;
|
|
806
|
-
});
|
|
807
|
-
// Scope filter — defer proposals with files outside current scope
|
|
808
|
-
const normalizedScope = scope.replace(/\*\*$/, '').replace(/\*$/, '').replace(/\/$/, '');
|
|
809
|
-
const scopeFiltered = normalizedScope
|
|
810
|
-
? categoryFiltered.filter(p => {
|
|
811
|
-
const files = (p.files?.length ? p.files : p.allowed_paths) || [];
|
|
812
|
-
const allInScope = files.length === 0 || files.every(f => f.startsWith(normalizedScope) || f.startsWith(normalizedScope + '/'));
|
|
813
|
-
if (!allInScope) {
|
|
814
|
-
deferProposal(repoRoot, {
|
|
815
|
-
category: p.category,
|
|
816
|
-
title: p.title,
|
|
817
|
-
description: p.description,
|
|
818
|
-
files: p.files || [],
|
|
819
|
-
allowed_paths: p.allowed_paths || [],
|
|
820
|
-
confidence: p.confidence || 50,
|
|
821
|
-
impact_score: p.impact_score ?? 5,
|
|
822
|
-
original_scope: scope,
|
|
823
|
-
deferredAt: Date.now(),
|
|
824
|
-
});
|
|
825
|
-
if (options.verbose) {
|
|
826
|
-
console.log(chalk.gray(` Deferred (out of scope): ${p.title}`));
|
|
827
|
-
}
|
|
828
|
-
return false;
|
|
829
|
-
}
|
|
830
|
-
return true;
|
|
831
|
-
})
|
|
832
|
-
: categoryFiltered;
|
|
833
|
-
const approvedProposals = [];
|
|
834
|
-
let duplicateCount = 0;
|
|
835
|
-
for (const p of scopeFiltered) {
|
|
836
|
-
const dupCheck = await isDuplicateProposal(p, dedupContext.existingTitles, dedupContext.openPrBranches);
|
|
837
|
-
if (dupCheck.isDuplicate) {
|
|
838
|
-
duplicateCount++;
|
|
839
|
-
if (options.verbose) {
|
|
840
|
-
console.log(chalk.gray(` Skipping duplicate: ${p.title}`));
|
|
841
|
-
console.log(chalk.gray(` Reason: ${dupCheck.reason}`));
|
|
842
|
-
}
|
|
843
|
-
}
|
|
844
|
-
else {
|
|
845
|
-
approvedProposals.push(p);
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
if (approvedProposals.length === 0) {
|
|
849
|
-
const reason = duplicateCount > 0
|
|
850
|
-
? `No new proposals (${duplicateCount} duplicates filtered)`
|
|
851
|
-
: 'No proposals passed trust filter';
|
|
852
|
-
console.log(chalk.gray(` ${reason}`));
|
|
853
|
-
if (isContinuous) {
|
|
854
|
-
await sleep(2000);
|
|
855
|
-
continue;
|
|
856
|
-
}
|
|
857
|
-
else {
|
|
858
|
-
break;
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
const prsRemaining = milestoneMode
|
|
862
|
-
? (batchSize - milestoneTicketCount + (maxPrs - totalMilestonePrs - 1) * batchSize)
|
|
863
|
-
: (maxPrs - totalPrsCreated);
|
|
864
|
-
const defaultBatch = milestoneMode ? 10 : (isContinuous ? 5 : 3);
|
|
865
|
-
const toProcess = approvedProposals.slice(0, Math.min(prsRemaining, defaultBatch));
|
|
866
|
-
const statsMsg = duplicateCount > 0
|
|
867
|
-
? `Auto-approved: ${approvedProposals.length} (${duplicateCount} duplicates skipped), processing: ${toProcess.length}`
|
|
868
|
-
: `Auto-approved: ${approvedProposals.length}, processing: ${toProcess.length}`;
|
|
869
|
-
console.log(chalk.gray(` ${statsMsg}`));
|
|
870
|
-
console.log();
|
|
871
|
-
if (!isContinuous || cycleCount === 1) {
|
|
872
|
-
console.log(chalk.bold('Will process:'));
|
|
873
|
-
for (const p of toProcess) {
|
|
874
|
-
const confidenceStr = p.confidence ? `${p.confidence}%` : '?';
|
|
875
|
-
const complexity = p.estimated_complexity || 'simple';
|
|
876
|
-
console.log(chalk.cyan(` • ${p.title}`));
|
|
877
|
-
console.log(chalk.gray(` ${p.category || 'refactor'} | ${complexity} | ${confidenceStr}`));
|
|
878
|
-
}
|
|
879
|
-
console.log();
|
|
880
|
-
}
|
|
881
|
-
if (options.dryRun) {
|
|
882
|
-
console.log(chalk.yellow('Dry run - no changes made'));
|
|
883
|
-
break;
|
|
884
|
-
}
|
|
885
|
-
if (cycleCount === 1 && !options.yes) {
|
|
886
|
-
const readline = await import('readline');
|
|
887
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
888
|
-
const confirmMsg = isContinuous
|
|
889
|
-
? `Start continuous auto? [Y/n] `
|
|
890
|
-
: `Proceed with ${toProcess.length} improvement(s)? [Y/n] `;
|
|
891
|
-
const answer = await new Promise((resolve) => {
|
|
892
|
-
rl.question(chalk.bold(confirmMsg), resolve);
|
|
893
|
-
});
|
|
894
|
-
rl.close();
|
|
895
|
-
if (answer.toLowerCase() === 'n' || answer.toLowerCase() === 'no') {
|
|
896
|
-
console.log(chalk.gray('Cancelled.'));
|
|
897
|
-
await adapter.close();
|
|
898
|
-
process.exit(0);
|
|
899
|
-
}
|
|
900
|
-
console.log();
|
|
901
|
-
}
|
|
902
|
-
// Step 3: Run tickets (parallel or sequential)
|
|
903
|
-
currentlyProcessing = true;
|
|
904
|
-
// Adaptive parallelism: if --parallel was not explicitly set, use adaptive count
|
|
905
|
-
let parallelCount;
|
|
906
|
-
if (parallelExplicit) {
|
|
907
|
-
parallelCount = Math.max(1, parseInt(options.parallel, 10));
|
|
908
|
-
}
|
|
909
|
-
else {
|
|
910
|
-
parallelCount = getAdaptiveParallelCount(toProcess);
|
|
911
|
-
const heavy = toProcess.filter(p => p.estimated_complexity === 'moderate' || p.estimated_complexity === 'complex').length;
|
|
912
|
-
const light = toProcess.length - heavy;
|
|
913
|
-
console.log(chalk.gray(` Parallel: ${parallelCount} (adaptive — ${light} simple, ${heavy} complex)`));
|
|
914
|
-
}
|
|
915
|
-
// In milestone mode, reduce parallelism when near batch limit to avoid merge conflicts
|
|
916
|
-
if (milestoneMode && batchSize) {
|
|
917
|
-
const remaining = batchSize - milestoneTicketCount;
|
|
918
|
-
if (remaining <= 3 && parallelCount > 2) {
|
|
919
|
-
parallelCount = 2;
|
|
920
|
-
console.log(chalk.gray(` Parallel reduced to ${parallelCount} (milestone ${milestoneTicketCount}/${batchSize}, near full)`));
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
|
-
const processOneProposal = async (proposal, slotLabel) => {
|
|
924
|
-
console.log(chalk.cyan(`[${slotLabel}] ${proposal.title}`));
|
|
925
|
-
const ticket = await tickets.create(adapter, {
|
|
926
|
-
projectId: project.id,
|
|
927
|
-
title: proposal.title,
|
|
928
|
-
description: proposal.description || proposal.title,
|
|
929
|
-
priority: 2,
|
|
930
|
-
allowedPaths: proposal.files,
|
|
931
|
-
forbiddenPaths: ['node_modules', '.git', 'dist', 'build'],
|
|
932
|
-
});
|
|
933
|
-
await tickets.updateStatus(adapter, ticket.id, 'in_progress');
|
|
934
|
-
const run = await runs.create(adapter, {
|
|
935
|
-
projectId: project.id,
|
|
936
|
-
type: 'worker',
|
|
937
|
-
ticketId: ticket.id,
|
|
938
|
-
metadata: { auto: true },
|
|
939
|
-
});
|
|
940
|
-
let currentTicket = ticket;
|
|
941
|
-
let currentRun = run;
|
|
942
|
-
let retryCount = 0;
|
|
943
|
-
const maxScopeRetries = 2;
|
|
944
|
-
while (retryCount <= maxScopeRetries) {
|
|
945
|
-
try {
|
|
946
|
-
const result = await soloRunTicket({
|
|
947
|
-
ticket: currentTicket,
|
|
948
|
-
repoRoot,
|
|
949
|
-
config,
|
|
950
|
-
adapter,
|
|
951
|
-
runId: currentRun.id,
|
|
952
|
-
skipQa: false,
|
|
953
|
-
createPr: !milestoneMode,
|
|
954
|
-
draftPr: useDraft,
|
|
955
|
-
timeoutMs: 600000,
|
|
956
|
-
verbose: options.verbose ?? false,
|
|
957
|
-
onProgress: (msg) => {
|
|
958
|
-
if (options.verbose) {
|
|
959
|
-
console.log(chalk.gray(` ${msg}`));
|
|
960
|
-
}
|
|
961
|
-
},
|
|
962
|
-
executionBackend,
|
|
963
|
-
guidelinesContext: guidelines ? formatGuidelinesForPrompt(guidelines) : undefined,
|
|
964
|
-
...(milestoneMode && milestoneBranch ? {
|
|
965
|
-
baseBranch: milestoneBranch,
|
|
966
|
-
skipPush: true,
|
|
967
|
-
skipPr: true,
|
|
968
|
-
} : {}),
|
|
969
|
-
});
|
|
970
|
-
if (result.success) {
|
|
971
|
-
// In milestone mode, merge ticket branch into milestone
|
|
972
|
-
if (milestoneMode && milestoneWorktreePath) {
|
|
973
|
-
if (!result.branchName) {
|
|
974
|
-
// No changes produced (e.g. no_changes_needed) — skip silently
|
|
975
|
-
await runs.markSuccess(adapter, currentRun.id);
|
|
976
|
-
await tickets.updateStatus(adapter, currentTicket.id, 'done');
|
|
977
|
-
console.log(chalk.gray(` — No changes needed, skipping`));
|
|
978
|
-
return { success: true };
|
|
979
|
-
}
|
|
980
|
-
const mergeResult = await mergeTicketToMilestone(repoRoot, result.branchName, milestoneWorktreePath);
|
|
981
|
-
if (!mergeResult.success) {
|
|
982
|
-
await runs.markFailure(adapter, currentRun.id, 'Merge conflict with milestone branch');
|
|
983
|
-
await tickets.updateStatus(adapter, currentTicket.id, 'blocked');
|
|
984
|
-
console.log(chalk.yellow(` ⚠ Merge conflict — ticket blocked`));
|
|
985
|
-
return { success: false };
|
|
986
|
-
}
|
|
987
|
-
milestoneTicketCount++;
|
|
988
|
-
milestoneTicketSummaries.push(currentTicket.title);
|
|
989
|
-
await runs.markSuccess(adapter, currentRun.id);
|
|
990
|
-
await tickets.updateStatus(adapter, currentTicket.id, 'done');
|
|
991
|
-
console.log(chalk.green(` ✓ Merged to milestone (${milestoneTicketCount}/${batchSize})`));
|
|
992
|
-
// Finalize mid-batch if full (prevents overflow when running in parallel)
|
|
993
|
-
if (batchSize && milestoneTicketCount >= batchSize) {
|
|
994
|
-
await finalizeMilestone();
|
|
995
|
-
if (shouldContinue()) {
|
|
996
|
-
await startNewMilestone();
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
return { success: true };
|
|
1000
|
-
}
|
|
1001
|
-
await runs.markSuccess(adapter, currentRun.id, { prUrl: result.prUrl });
|
|
1002
|
-
await tickets.updateStatus(adapter, currentTicket.id, 'done');
|
|
1003
|
-
console.log(chalk.green(` ✓ PR created`));
|
|
1004
|
-
if (result.prUrl) {
|
|
1005
|
-
console.log(chalk.cyan(` ${result.prUrl}`));
|
|
1006
|
-
}
|
|
1007
|
-
return { success: true, prUrl: result.prUrl };
|
|
1008
|
-
}
|
|
1009
|
-
else if (result.scopeExpanded && retryCount < maxScopeRetries) {
|
|
1010
|
-
retryCount++;
|
|
1011
|
-
console.log(chalk.yellow(` ↻ Scope expanded, retrying (${retryCount}/${maxScopeRetries})...`));
|
|
1012
|
-
if (options.verbose) {
|
|
1013
|
-
console.log(chalk.gray(` Added: ${result.scopeExpanded.addedPaths.join(', ')}`));
|
|
1014
|
-
}
|
|
1015
|
-
const updatedTicket = await tickets.getById(adapter, currentTicket.id);
|
|
1016
|
-
if (!updatedTicket) {
|
|
1017
|
-
throw new Error('Failed to fetch updated ticket after scope expansion');
|
|
1018
|
-
}
|
|
1019
|
-
currentTicket = updatedTicket;
|
|
1020
|
-
await runs.markFailure(adapter, currentRun.id, `Scope expanded: retry ${retryCount}`);
|
|
1021
|
-
currentRun = await runs.create(adapter, {
|
|
1022
|
-
projectId: project.id,
|
|
1023
|
-
type: 'worker',
|
|
1024
|
-
ticketId: currentTicket.id,
|
|
1025
|
-
metadata: { auto: true, scopeRetry: retryCount },
|
|
1026
|
-
});
|
|
1027
|
-
continue;
|
|
1028
|
-
}
|
|
1029
|
-
else {
|
|
1030
|
-
await runs.markFailure(adapter, currentRun.id, result.error || result.failureReason || 'unknown');
|
|
1031
|
-
await tickets.updateStatus(adapter, currentTicket.id, 'blocked');
|
|
1032
|
-
const failReason = result.scopeExpanded
|
|
1033
|
-
? `Scope expansion failed after ${maxScopeRetries} retries`
|
|
1034
|
-
: (result.error || result.failureReason || 'unknown');
|
|
1035
|
-
console.log(chalk.red(` ✗ Failed: ${failReason}`));
|
|
1036
|
-
return { success: false };
|
|
1037
|
-
}
|
|
1038
|
-
}
|
|
1039
|
-
catch (err) {
|
|
1040
|
-
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1041
|
-
await runs.markFailure(adapter, currentRun.id, errorMsg);
|
|
1042
|
-
await tickets.updateStatus(adapter, currentTicket.id, 'blocked');
|
|
1043
|
-
console.log(chalk.red(` ✗ Error: ${errorMsg}`));
|
|
1044
|
-
return { success: false };
|
|
1045
|
-
}
|
|
1046
|
-
}
|
|
1047
|
-
return { success: false };
|
|
1048
|
-
};
|
|
1049
|
-
if (parallelCount <= 1) {
|
|
1050
|
-
for (let i = 0; i < toProcess.length && shouldContinue(); i++) {
|
|
1051
|
-
const result = await processOneProposal(toProcess[i], `${totalPrsCreated + 1}/${maxPrs}`);
|
|
1052
|
-
if (result.success) {
|
|
1053
|
-
totalPrsCreated++;
|
|
1054
|
-
if (result.prUrl)
|
|
1055
|
-
allPrUrls.push(result.prUrl);
|
|
1056
|
-
}
|
|
1057
|
-
else {
|
|
1058
|
-
totalFailed++;
|
|
1059
|
-
}
|
|
1060
|
-
console.log();
|
|
1061
|
-
if (i < toProcess.length - 1 && shouldContinue()) {
|
|
1062
|
-
await sleep(1000);
|
|
1063
|
-
}
|
|
1064
|
-
}
|
|
1065
|
-
}
|
|
1066
|
-
else {
|
|
1067
|
-
// In milestone mode, partition proposals into conflict-free waves
|
|
1068
|
-
// to avoid merge conflicts from overlapping file paths
|
|
1069
|
-
let waves;
|
|
1070
|
-
if (milestoneMode) {
|
|
1071
|
-
waves = partitionIntoWaves(toProcess);
|
|
1072
|
-
if (waves.length > 1) {
|
|
1073
|
-
console.log(chalk.gray(` Conflict-aware scheduling: ${waves.length} waves (avoiding overlapping file paths)`));
|
|
1074
|
-
}
|
|
1075
|
-
}
|
|
1076
|
-
else {
|
|
1077
|
-
waves = [toProcess];
|
|
1078
|
-
}
|
|
1079
|
-
let prCounter = totalPrsCreated;
|
|
1080
|
-
for (const wave of waves) {
|
|
1081
|
-
if (!shouldContinue())
|
|
1082
|
-
break;
|
|
1083
|
-
let semaphorePermits = parallelCount;
|
|
1084
|
-
const semaphoreWaiting = [];
|
|
1085
|
-
const semAcquire = async () => {
|
|
1086
|
-
if (semaphorePermits > 0) {
|
|
1087
|
-
semaphorePermits--;
|
|
1088
|
-
return;
|
|
1089
|
-
}
|
|
1090
|
-
return new Promise((resolve) => { semaphoreWaiting.push(resolve); });
|
|
1091
|
-
};
|
|
1092
|
-
const semRelease = () => {
|
|
1093
|
-
if (semaphoreWaiting.length > 0) {
|
|
1094
|
-
semaphoreWaiting.shift()();
|
|
1095
|
-
}
|
|
1096
|
-
else {
|
|
1097
|
-
semaphorePermits++;
|
|
1098
|
-
}
|
|
1099
|
-
};
|
|
1100
|
-
const tasks = wave.map(async (proposal) => {
|
|
1101
|
-
await semAcquire();
|
|
1102
|
-
if (!shouldContinue()) {
|
|
1103
|
-
semRelease();
|
|
1104
|
-
return { success: false };
|
|
1105
|
-
}
|
|
1106
|
-
const label = `${++prCounter}/${maxPrs}`;
|
|
1107
|
-
try {
|
|
1108
|
-
return await processOneProposal(proposal, label);
|
|
1109
|
-
}
|
|
1110
|
-
finally {
|
|
1111
|
-
semRelease();
|
|
1112
|
-
}
|
|
1113
|
-
});
|
|
1114
|
-
const taskResults = await Promise.allSettled(tasks);
|
|
1115
|
-
for (const r of taskResults) {
|
|
1116
|
-
if (r.status === 'fulfilled' && r.value.success) {
|
|
1117
|
-
totalPrsCreated++;
|
|
1118
|
-
if (r.value.prUrl)
|
|
1119
|
-
allPrUrls.push(r.value.prUrl);
|
|
1120
|
-
}
|
|
1121
|
-
else if (r.status === 'fulfilled') {
|
|
1122
|
-
totalFailed++;
|
|
1123
|
-
}
|
|
1124
|
-
else {
|
|
1125
|
-
totalFailed++;
|
|
1126
|
-
}
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1129
|
-
console.log();
|
|
1130
|
-
}
|
|
1131
|
-
currentlyProcessing = false;
|
|
1132
|
-
// Record cycle completion for cross-session tracking
|
|
1133
|
-
recordCycle(repoRoot);
|
|
1134
|
-
if (isDocsAuditCycle) {
|
|
1135
|
-
recordDocsAudit(repoRoot);
|
|
1136
|
-
}
|
|
1137
|
-
if (isContinuous && shouldContinue()) {
|
|
1138
|
-
console.log(chalk.gray('Pausing before next cycle...'));
|
|
1139
|
-
await sleep(5000);
|
|
1140
|
-
}
|
|
1141
|
-
} while (isContinuous && shouldContinue());
|
|
1142
|
-
// Finalize any partial milestone
|
|
1143
|
-
if (milestoneMode && milestoneTicketCount > 0) {
|
|
1144
|
-
await finalizeMilestone();
|
|
1145
|
-
}
|
|
1146
|
-
if (milestoneMode) {
|
|
1147
|
-
await cleanupMilestone(repoRoot);
|
|
1148
|
-
}
|
|
1149
|
-
const elapsed = Date.now() - startTime;
|
|
1150
|
-
// Record run history
|
|
1151
|
-
try {
|
|
1152
|
-
const { appendRunHistory } = await import('./run-history.js');
|
|
1153
|
-
const elapsed = Date.now() - startTime;
|
|
1154
|
-
const stoppedReason = shutdownRequested ? 'user_shutdown'
|
|
1155
|
-
: totalPrsCreated >= maxPrs ? 'pr_limit'
|
|
1156
|
-
: (endTime && Date.now() >= endTime) ? 'time_limit'
|
|
1157
|
-
: 'completed';
|
|
1158
|
-
appendRunHistory({
|
|
1159
|
-
timestamp: new Date().toISOString(),
|
|
1160
|
-
mode: 'auto',
|
|
1161
|
-
scope: userScope || 'src',
|
|
1162
|
-
formula: activeFormula?.name,
|
|
1163
|
-
ticketsProposed: 0,
|
|
1164
|
-
ticketsApproved: totalPrsCreated + totalFailed,
|
|
1165
|
-
ticketsCompleted: totalPrsCreated,
|
|
1166
|
-
ticketsFailed: totalFailed,
|
|
1167
|
-
prsCreated: allPrUrls.length,
|
|
1168
|
-
prsMerged: 0,
|
|
1169
|
-
durationMs: elapsed,
|
|
1170
|
-
parallel: parallelExplicit ? parseInt(options.parallel, 10) : -1,
|
|
1171
|
-
stoppedReason,
|
|
1172
|
-
}, repoRoot || undefined);
|
|
1173
|
-
}
|
|
1174
|
-
catch {
|
|
1175
|
-
// Non-fatal
|
|
1176
|
-
}
|
|
1177
|
-
console.log();
|
|
1178
|
-
console.log(chalk.bold('━'.repeat(50)));
|
|
1179
|
-
console.log(chalk.bold('Final Summary'));
|
|
1180
|
-
console.log();
|
|
1181
|
-
console.log(chalk.gray(` Duration: ${formatElapsed(elapsed)}`));
|
|
1182
|
-
console.log(chalk.gray(` Cycles: ${cycleCount}`));
|
|
1183
|
-
if (milestoneMode) {
|
|
1184
|
-
console.log(chalk.gray(` Milestone PRs: ${totalMilestonePrs}`));
|
|
1185
|
-
console.log(chalk.gray(` Total tickets merged: ${totalPrsCreated}`));
|
|
1186
|
-
}
|
|
1187
|
-
if (allPrUrls.length > 0) {
|
|
1188
|
-
console.log(chalk.green(`\n✓ ${allPrUrls.length} PR(s) created:`));
|
|
1189
|
-
for (const url of allPrUrls) {
|
|
1190
|
-
console.log(chalk.cyan(` ${url}`));
|
|
1191
|
-
}
|
|
1192
|
-
}
|
|
1193
|
-
if (totalFailed > 0) {
|
|
1194
|
-
console.log(chalk.red(`\n✗ ${totalFailed} failed`));
|
|
1195
|
-
}
|
|
1196
|
-
if (allPrUrls.length > 0) {
|
|
1197
|
-
console.log();
|
|
1198
|
-
console.log(chalk.bold('Next steps:'));
|
|
1199
|
-
console.log(' • Review the draft PRs on GitHub');
|
|
1200
|
-
console.log(' • Mark as ready for review when satisfied');
|
|
1201
|
-
console.log(' • Merge after CI passes');
|
|
1202
|
-
}
|
|
1203
|
-
if (isContinuous) {
|
|
1204
|
-
console.log();
|
|
1205
|
-
if (shutdownRequested) {
|
|
1206
|
-
console.log(chalk.gray('Stopped: User requested shutdown'));
|
|
1207
|
-
}
|
|
1208
|
-
else if (totalPrsCreated >= maxPrs) {
|
|
1209
|
-
console.log(chalk.gray(`Stopped: Reached PR limit (${maxPrs})`));
|
|
1210
|
-
}
|
|
1211
|
-
else if (endTime && Date.now() >= endTime) {
|
|
1212
|
-
const exhaustedLabel = totalMinutes < 60
|
|
1213
|
-
? `${Math.round(totalMinutes)}m`
|
|
1214
|
-
: totalMinutes % 60 === 0
|
|
1215
|
-
? `${totalMinutes / 60}h`
|
|
1216
|
-
: `${Math.floor(totalMinutes / 60)}h ${Math.round(totalMinutes % 60)}m`;
|
|
1217
|
-
console.log(chalk.gray(`Stopped: Time budget exhausted (${exhaustedLabel})`));
|
|
1218
|
-
}
|
|
1219
|
-
}
|
|
1220
|
-
stopStdinListener?.();
|
|
1221
|
-
await adapter.close();
|
|
1222
|
-
process.exit(totalFailed > 0 && allPrUrls.length === 0 ? 1 : 0);
|
|
1223
|
-
}
|
|
1224
|
-
catch (err) {
|
|
1225
|
-
stopStdinListener?.();
|
|
1226
|
-
await adapter.close();
|
|
1227
|
-
throw err;
|
|
1228
|
-
}
|
|
1229
|
-
}
|
|
1230
|
-
//# sourceMappingURL=solo-auto.js.map
|