@blockspool/cli 0.3.3 → 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.
Files changed (149) hide show
  1. package/package.json +1 -1
  2. package/dist/bin/blockspool.d.ts +0 -16
  3. package/dist/bin/blockspool.d.ts.map +0 -1
  4. package/dist/bin/blockspool.js +0 -45
  5. package/dist/bin/blockspool.js.map +0 -1
  6. package/dist/commands/solo-auto.d.ts +0 -6
  7. package/dist/commands/solo-auto.d.ts.map +0 -1
  8. package/dist/commands/solo-auto.js +0 -418
  9. package/dist/commands/solo-auto.js.map +0 -1
  10. package/dist/commands/solo-exec.d.ts +0 -6
  11. package/dist/commands/solo-exec.d.ts.map +0 -1
  12. package/dist/commands/solo-exec.js +0 -656
  13. package/dist/commands/solo-exec.js.map +0 -1
  14. package/dist/commands/solo-inspect.d.ts +0 -6
  15. package/dist/commands/solo-inspect.d.ts.map +0 -1
  16. package/dist/commands/solo-inspect.js +0 -690
  17. package/dist/commands/solo-inspect.js.map +0 -1
  18. package/dist/commands/solo-lifecycle.d.ts +0 -6
  19. package/dist/commands/solo-lifecycle.d.ts.map +0 -1
  20. package/dist/commands/solo-lifecycle.js +0 -188
  21. package/dist/commands/solo-lifecycle.js.map +0 -1
  22. package/dist/commands/solo-nudge.d.ts +0 -6
  23. package/dist/commands/solo-nudge.d.ts.map +0 -1
  24. package/dist/commands/solo-nudge.js +0 -49
  25. package/dist/commands/solo-nudge.js.map +0 -1
  26. package/dist/commands/solo-qa.d.ts +0 -6
  27. package/dist/commands/solo-qa.d.ts.map +0 -1
  28. package/dist/commands/solo-qa.js +0 -254
  29. package/dist/commands/solo-qa.js.map +0 -1
  30. package/dist/commands/solo.d.ts +0 -11
  31. package/dist/commands/solo.d.ts.map +0 -1
  32. package/dist/commands/solo.js +0 -43
  33. package/dist/commands/solo.js.map +0 -1
  34. package/dist/index.d.ts +0 -18
  35. package/dist/index.d.ts.map +0 -1
  36. package/dist/index.js +0 -18
  37. package/dist/index.js.map +0 -1
  38. package/dist/lib/artifacts.d.ts +0 -136
  39. package/dist/lib/artifacts.d.ts.map +0 -1
  40. package/dist/lib/artifacts.js +0 -146
  41. package/dist/lib/artifacts.js.map +0 -1
  42. package/dist/lib/doctor.d.ts +0 -45
  43. package/dist/lib/doctor.d.ts.map +0 -1
  44. package/dist/lib/doctor.js +0 -383
  45. package/dist/lib/doctor.js.map +0 -1
  46. package/dist/lib/exec.d.ts +0 -24
  47. package/dist/lib/exec.d.ts.map +0 -1
  48. package/dist/lib/exec.js +0 -295
  49. package/dist/lib/exec.js.map +0 -1
  50. package/dist/lib/formulas.d.ts +0 -78
  51. package/dist/lib/formulas.d.ts.map +0 -1
  52. package/dist/lib/formulas.js +0 -295
  53. package/dist/lib/formulas.js.map +0 -1
  54. package/dist/lib/git.d.ts +0 -9
  55. package/dist/lib/git.d.ts.map +0 -1
  56. package/dist/lib/git.js +0 -60
  57. package/dist/lib/git.js.map +0 -1
  58. package/dist/lib/guidelines.d.ts +0 -43
  59. package/dist/lib/guidelines.d.ts.map +0 -1
  60. package/dist/lib/guidelines.js +0 -195
  61. package/dist/lib/guidelines.js.map +0 -1
  62. package/dist/lib/logger.d.ts +0 -17
  63. package/dist/lib/logger.d.ts.map +0 -1
  64. package/dist/lib/logger.js +0 -42
  65. package/dist/lib/logger.js.map +0 -1
  66. package/dist/lib/retention.d.ts +0 -62
  67. package/dist/lib/retention.d.ts.map +0 -1
  68. package/dist/lib/retention.js +0 -285
  69. package/dist/lib/retention.js.map +0 -1
  70. package/dist/lib/run-history.d.ts +0 -52
  71. package/dist/lib/run-history.d.ts.map +0 -1
  72. package/dist/lib/run-history.js +0 -116
  73. package/dist/lib/run-history.js.map +0 -1
  74. package/dist/lib/run-state.d.ts +0 -58
  75. package/dist/lib/run-state.d.ts.map +0 -1
  76. package/dist/lib/run-state.js +0 -119
  77. package/dist/lib/run-state.js.map +0 -1
  78. package/dist/lib/scope.d.ts +0 -95
  79. package/dist/lib/scope.d.ts.map +0 -1
  80. package/dist/lib/scope.js +0 -291
  81. package/dist/lib/scope.js.map +0 -1
  82. package/dist/lib/selection.d.ts +0 -35
  83. package/dist/lib/selection.d.ts.map +0 -1
  84. package/dist/lib/selection.js +0 -110
  85. package/dist/lib/selection.js.map +0 -1
  86. package/dist/lib/solo-auto.d.ts +0 -87
  87. package/dist/lib/solo-auto.d.ts.map +0 -1
  88. package/dist/lib/solo-auto.js +0 -1230
  89. package/dist/lib/solo-auto.js.map +0 -1
  90. package/dist/lib/solo-ci.d.ts +0 -84
  91. package/dist/lib/solo-ci.d.ts.map +0 -1
  92. package/dist/lib/solo-ci.js +0 -300
  93. package/dist/lib/solo-ci.js.map +0 -1
  94. package/dist/lib/solo-config.d.ts +0 -153
  95. package/dist/lib/solo-config.d.ts.map +0 -1
  96. package/dist/lib/solo-config.js +0 -236
  97. package/dist/lib/solo-config.js.map +0 -1
  98. package/dist/lib/solo-git.d.ts +0 -44
  99. package/dist/lib/solo-git.d.ts.map +0 -1
  100. package/dist/lib/solo-git.js +0 -174
  101. package/dist/lib/solo-git.js.map +0 -1
  102. package/dist/lib/solo-hints.d.ts +0 -32
  103. package/dist/lib/solo-hints.d.ts.map +0 -1
  104. package/dist/lib/solo-hints.js +0 -98
  105. package/dist/lib/solo-hints.js.map +0 -1
  106. package/dist/lib/solo-remote.d.ts +0 -14
  107. package/dist/lib/solo-remote.d.ts.map +0 -1
  108. package/dist/lib/solo-remote.js +0 -48
  109. package/dist/lib/solo-remote.js.map +0 -1
  110. package/dist/lib/solo-stdin.d.ts +0 -13
  111. package/dist/lib/solo-stdin.d.ts.map +0 -1
  112. package/dist/lib/solo-stdin.js +0 -33
  113. package/dist/lib/solo-stdin.js.map +0 -1
  114. package/dist/lib/solo-ticket.d.ts +0 -213
  115. package/dist/lib/solo-ticket.d.ts.map +0 -1
  116. package/dist/lib/solo-ticket.js +0 -850
  117. package/dist/lib/solo-ticket.js.map +0 -1
  118. package/dist/lib/solo-utils.d.ts +0 -133
  119. package/dist/lib/solo-utils.d.ts.map +0 -1
  120. package/dist/lib/solo-utils.js +0 -300
  121. package/dist/lib/solo-utils.js.map +0 -1
  122. package/dist/lib/spindle.d.ts +0 -144
  123. package/dist/lib/spindle.d.ts.map +0 -1
  124. package/dist/lib/spindle.js +0 -388
  125. package/dist/lib/spindle.js.map +0 -1
  126. package/dist/tui/app.d.ts +0 -17
  127. package/dist/tui/app.d.ts.map +0 -1
  128. package/dist/tui/app.js +0 -139
  129. package/dist/tui/app.js.map +0 -1
  130. package/dist/tui/index.d.ts +0 -8
  131. package/dist/tui/index.d.ts.map +0 -1
  132. package/dist/tui/index.js +0 -7
  133. package/dist/tui/index.js.map +0 -1
  134. package/dist/tui/poller.d.ts +0 -42
  135. package/dist/tui/poller.d.ts.map +0 -1
  136. package/dist/tui/poller.js +0 -62
  137. package/dist/tui/poller.js.map +0 -1
  138. package/dist/tui/screens/overview.d.ts +0 -9
  139. package/dist/tui/screens/overview.d.ts.map +0 -1
  140. package/dist/tui/screens/overview.js +0 -189
  141. package/dist/tui/screens/overview.js.map +0 -1
  142. package/dist/tui/state.d.ts +0 -93
  143. package/dist/tui/state.d.ts.map +0 -1
  144. package/dist/tui/state.js +0 -169
  145. package/dist/tui/state.js.map +0 -1
  146. package/dist/tui/types.d.ts +0 -18
  147. package/dist/tui/types.d.ts.map +0 -1
  148. package/dist/tui/types.js +0 -5
  149. package/dist/tui/types.js.map +0 -1
@@ -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