switchman-dev 0.1.7 → 0.1.9

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.
@@ -0,0 +1,549 @@
1
+ export function registerQueueCommands(program, {
2
+ buildQueueStatusSummary,
3
+ chalk,
4
+ colorForHealth,
5
+ escalateMergeQueueItem,
6
+ enqueueMergeItem,
7
+ evaluatePipelinePolicyGate,
8
+ getDb,
9
+ getRepo,
10
+ healthLabel,
11
+ listMergeQueue,
12
+ listMergeQueueEvents,
13
+ listWorktrees,
14
+ maybeCaptureTelemetry,
15
+ preparePipelineLandingTarget,
16
+ printErrorWithNext,
17
+ pushSyncEvent,
18
+ renderChip,
19
+ renderMetricRow,
20
+ renderPanel,
21
+ renderSignalStrip,
22
+ removeMergeQueueItem,
23
+ retryMergeQueueItem,
24
+ runMergeQueue,
25
+ sleepSync,
26
+ statusBadge,
27
+ }) {
28
+ const queueCmd = program.command('queue').alias('land').description('Land finished work safely back onto main, one item at a time');
29
+ queueCmd.addHelpText('after', `
30
+ Examples:
31
+ switchman queue add --worktree agent1
32
+ switchman queue status
33
+ switchman queue run --watch
34
+ `);
35
+
36
+ queueCmd
37
+ .command('add [branch]')
38
+ .description('Add a branch, workspace, or pipeline to the landing queue')
39
+ .option('--worktree <name>', 'Queue a registered workspace by name')
40
+ .option('--pipeline <pipelineId>', 'Queue a pipeline by id')
41
+ .option('--target <branch>', 'Target branch to merge into', 'main')
42
+ .option('--max-retries <n>', 'Maximum automatic retries', '1')
43
+ .option('--submitted-by <name>', 'Operator or automation name')
44
+ .option('--json', 'Output raw JSON')
45
+ .addHelpText('after', `
46
+ Examples:
47
+ switchman queue add feature/auth-hardening
48
+ switchman queue add --worktree agent2
49
+ switchman queue add --pipeline pipe-123
50
+
51
+ Pipeline landing rule:
52
+ switchman queue add --pipeline <id>
53
+ lands the pipeline's inferred landing branch.
54
+ If completed work spans multiple branches, Switchman creates one synthetic landing branch first.
55
+ `)
56
+ .action(async (branch, opts) => {
57
+ const repoRoot = getRepo();
58
+ const db = getDb(repoRoot);
59
+
60
+ try {
61
+ let payload;
62
+ if (opts.worktree) {
63
+ const worktree = listWorktrees(db).find((entry) => entry.name === opts.worktree);
64
+ if (!worktree) {
65
+ throw new Error(`Workspace ${opts.worktree} is not registered.`);
66
+ }
67
+ payload = {
68
+ sourceType: 'worktree',
69
+ sourceRef: worktree.branch,
70
+ sourceWorktree: worktree.name,
71
+ targetBranch: opts.target,
72
+ maxRetries: opts.maxRetries,
73
+ submittedBy: opts.submittedBy || null,
74
+ };
75
+ } else if (opts.pipeline) {
76
+ const policyGate = await evaluatePipelinePolicyGate(db, repoRoot, opts.pipeline);
77
+ if (!policyGate.ok) {
78
+ throw new Error(`${policyGate.summary} Next: ${policyGate.next_action}`);
79
+ }
80
+ const landingTarget = preparePipelineLandingTarget(db, repoRoot, opts.pipeline, {
81
+ baseBranch: opts.target || 'main',
82
+ requireCompleted: true,
83
+ allowCurrentBranchFallback: false,
84
+ });
85
+ payload = {
86
+ sourceType: 'pipeline',
87
+ sourceRef: landingTarget.branch,
88
+ sourcePipelineId: opts.pipeline,
89
+ sourceWorktree: landingTarget.worktree || null,
90
+ targetBranch: opts.target,
91
+ maxRetries: opts.maxRetries,
92
+ submittedBy: opts.submittedBy || null,
93
+ eventDetails: policyGate.override_applied
94
+ ? {
95
+ policy_override_summary: policyGate.override_summary,
96
+ overridden_task_types: policyGate.policy_state?.overridden_task_types || [],
97
+ }
98
+ : null,
99
+ };
100
+ } else if (branch) {
101
+ payload = {
102
+ sourceType: 'branch',
103
+ sourceRef: branch,
104
+ targetBranch: opts.target,
105
+ maxRetries: opts.maxRetries,
106
+ submittedBy: opts.submittedBy || null,
107
+ };
108
+ } else {
109
+ throw new Error('Choose one source to land: a branch name, `--worktree`, or `--pipeline`.');
110
+ }
111
+
112
+ const result = enqueueMergeItem(db, payload);
113
+ db.close();
114
+ pushSyncEvent('queue_added', {
115
+ item_id: result.id,
116
+ source_type: result.source_type,
117
+ source_ref: result.source_ref,
118
+ source_worktree: result.source_worktree || null,
119
+ target_branch: result.target_branch,
120
+ }, { worktree: result.source_worktree || null }).catch(() => {});
121
+
122
+ if (opts.json) {
123
+ console.log(JSON.stringify(result, null, 2));
124
+ return;
125
+ }
126
+
127
+ console.log(`${chalk.green('✓')} Queued ${chalk.cyan(result.id)} for ${chalk.bold(result.target_branch)}`);
128
+ console.log(` ${chalk.dim('source:')} ${result.source_type} ${result.source_ref}`);
129
+ if (result.source_worktree) console.log(` ${chalk.dim('worktree:')} ${result.source_worktree}`);
130
+ if (payload.eventDetails?.policy_override_summary) {
131
+ console.log(` ${chalk.dim('policy override:')} ${payload.eventDetails.policy_override_summary}`);
132
+ }
133
+ } catch (err) {
134
+ db.close();
135
+ printErrorWithNext(err.message, 'switchman queue add --help');
136
+ process.exitCode = 1;
137
+ }
138
+ });
139
+
140
+ queueCmd
141
+ .command('list')
142
+ .description('List merge queue items')
143
+ .option('--status <status>', 'Filter by queue status')
144
+ .option('--json', 'Output raw JSON')
145
+ .action((opts) => {
146
+ const repoRoot = getRepo();
147
+ const db = getDb(repoRoot);
148
+ const items = listMergeQueue(db, { status: opts.status || null });
149
+ db.close();
150
+
151
+ if (opts.json) {
152
+ console.log(JSON.stringify(items, null, 2));
153
+ return;
154
+ }
155
+
156
+ if (items.length === 0) {
157
+ console.log(chalk.dim('Merge queue is empty.'));
158
+ return;
159
+ }
160
+
161
+ for (const item of items) {
162
+ const retryInfo = chalk.dim(`retries:${item.retry_count}/${item.max_retries}`);
163
+ const attemptInfo = item.last_attempt_at ? ` ${chalk.dim(`last-attempt:${item.last_attempt_at}`)}` : '';
164
+ const backoffInfo = item.backoff_until ? ` ${chalk.dim(`backoff-until:${item.backoff_until}`)}` : '';
165
+ const escalationInfo = item.escalated_at ? ` ${chalk.dim(`escalated:${item.escalated_at}`)}` : '';
166
+ console.log(` ${statusBadge(item.status)} ${item.id} ${item.source_type}:${item.source_ref} ${chalk.dim(`→ ${item.target_branch}`)} ${retryInfo}${attemptInfo}${backoffInfo}${escalationInfo}`);
167
+ if (item.last_error_summary) {
168
+ console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
169
+ }
170
+ if (item.next_action) {
171
+ console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
172
+ }
173
+ }
174
+ });
175
+
176
+ queueCmd
177
+ .command('status')
178
+ .description('Show an operator-friendly merge queue summary')
179
+ .option('--json', 'Output raw JSON')
180
+ .addHelpText('after', `
181
+ Plain English:
182
+ Use this when finished branches are waiting to land and you want one safe queue view.
183
+
184
+ Examples:
185
+ switchman queue status
186
+ switchman queue status --json
187
+
188
+ What it helps you answer:
189
+ - what lands next
190
+ - what is blocked
191
+ - what command should I run now
192
+ `)
193
+ .action((opts) => {
194
+ const repoRoot = getRepo();
195
+ const db = getDb(repoRoot);
196
+ const items = listMergeQueue(db);
197
+ const summary = buildQueueStatusSummary(items, { db, repoRoot });
198
+ const recentEvents = items.slice(0, 5).flatMap((item) =>
199
+ listMergeQueueEvents(db, item.id, { limit: 3 }).map((event) => ({ ...event, queue_item_id: item.id })),
200
+ ).sort((a, b) => b.id - a.id).slice(0, 8);
201
+ db.close();
202
+
203
+ if (opts.json) {
204
+ console.log(JSON.stringify({ items, summary, recent_events: recentEvents }, null, 2));
205
+ return;
206
+ }
207
+
208
+ if (items.length === 0) {
209
+ console.log('');
210
+ console.log(chalk.bold('switchman queue status'));
211
+ console.log('');
212
+ console.log('Queue is empty.');
213
+ console.log(`Add finished work with: ${chalk.cyan('switchman queue add --worktree agent1')}`);
214
+ return;
215
+ }
216
+
217
+ const queueHealth = summary.counts.blocked > 0
218
+ ? 'block'
219
+ : summary.counts.retrying > 0 || summary.counts.held > 0 || summary.counts.wave_blocked > 0 || summary.counts.escalated > 0
220
+ ? 'warn'
221
+ : 'healthy';
222
+ const queueHealthColor = colorForHealth(queueHealth);
223
+ const retryingItems = items.filter((item) => item.status === 'retrying');
224
+ const focus = summary.blocked[0] || retryingItems[0] || summary.next || null;
225
+ const focusLine = focus
226
+ ? `${focus.id} ${focus.source_type}:${focus.source_ref}${focus.last_error_summary ? ` ${chalk.dim(`• ${focus.last_error_summary}`)}` : ''}`
227
+ : 'Nothing waiting. Landing queue is clear.';
228
+
229
+ console.log('');
230
+ console.log(queueHealthColor('='.repeat(72)));
231
+ console.log(`${queueHealthColor(healthLabel(queueHealth))} ${chalk.bold('switchman queue status')} ${chalk.dim('• landing mission control')}`);
232
+ console.log(queueHealthColor('='.repeat(72)));
233
+ console.log(renderSignalStrip([
234
+ renderChip('queued', summary.counts.queued, summary.counts.queued > 0 ? chalk.yellow : chalk.green),
235
+ renderChip('retrying', summary.counts.retrying, summary.counts.retrying > 0 ? chalk.yellow : chalk.green),
236
+ renderChip('held', summary.counts.held, summary.counts.held > 0 ? chalk.yellow : chalk.green),
237
+ renderChip('wave blocked', summary.counts.wave_blocked, summary.counts.wave_blocked > 0 ? chalk.yellow : chalk.green),
238
+ renderChip('escalated', summary.counts.escalated, summary.counts.escalated > 0 ? chalk.red : chalk.green),
239
+ renderChip('blocked', summary.counts.blocked, summary.counts.blocked > 0 ? chalk.red : chalk.green),
240
+ renderChip('merging', summary.counts.merging, summary.counts.merging > 0 ? chalk.blue : chalk.green),
241
+ renderChip('merged', summary.counts.merged, summary.counts.merged > 0 ? chalk.green : chalk.white),
242
+ ]));
243
+ console.log(renderMetricRow([
244
+ { label: 'items', value: items.length, color: chalk.white },
245
+ { label: 'validating', value: summary.counts.validating, color: chalk.blue },
246
+ { label: 'rebasing', value: summary.counts.rebasing, color: chalk.blue },
247
+ { label: 'target', value: summary.next?.target_branch || 'main', color: chalk.cyan },
248
+ ]));
249
+ console.log(`${chalk.bold('Focus now:')} ${focusLine}`);
250
+
251
+ const queueFocusLines = summary.next
252
+ ? [
253
+ `${renderChip(summary.next.recommendation?.action === 'retry' ? 'RETRY' : summary.next.recommendation?.action === 'escalate' ? 'ESCALATE' : 'NEXT', summary.next.id, summary.next.recommendation?.action === 'retry' ? chalk.yellow : summary.next.recommendation?.action === 'escalate' ? chalk.red : chalk.green)} ${summary.next.source_type}:${summary.next.source_ref} ${chalk.dim(`retries:${summary.next.retry_count}/${summary.next.max_retries}`)}${summary.next.queue_assessment?.goal_priority ? ` ${chalk.dim(`priority:${summary.next.queue_assessment.goal_priority}`)}` : ''}${summary.next.queue_assessment?.integration_risk && summary.next.queue_assessment.integration_risk !== 'normal' ? ` ${chalk.dim(`risk:${summary.next.queue_assessment.integration_risk}`)}` : ''}${summary.next.queue_assessment?.freshness ? ` ${chalk.dim(`freshness:${summary.next.queue_assessment.freshness}`)}` : ''}${summary.next.queue_assessment?.stale_invalidation_count ? ` ${chalk.dim(`stale:${summary.next.queue_assessment.stale_invalidation_count}`)}` : ''}`,
254
+ ...(summary.next.queue_assessment?.reason ? [` ${chalk.dim('why next:')} ${summary.next.queue_assessment.reason}`] : []),
255
+ ...(summary.next.recommendation?.summary ? [` ${chalk.dim('decision:')} ${summary.next.recommendation.summary}`] : []),
256
+ ` ${chalk.yellow('run:')} ${summary.next.recommendation?.command || 'switchman queue run'}`,
257
+ ]
258
+ : [chalk.dim('No queued landing work right now.')];
259
+
260
+ const queueHeldBackLines = summary.held_back.length > 0
261
+ ? summary.held_back.flatMap((item) => {
262
+ const lines = [`${renderChip(item.recommendation?.action === 'escalate' ? 'ESCALATE' : 'HOLD', item.id, item.recommendation?.action === 'escalate' ? chalk.red : chalk.yellow)} ${item.source_type}:${item.source_ref}${item.queue_assessment?.goal_priority ? ` ${chalk.dim(`priority:${item.queue_assessment.goal_priority}`)}` : ''} ${chalk.dim(`freshness:${item.queue_assessment?.freshness || 'unknown'}`)}${item.queue_assessment?.integration_risk && item.queue_assessment.integration_risk !== 'normal' ? ` ${chalk.dim(`risk:${item.queue_assessment.integration_risk}`)}` : ''}${item.queue_assessment?.stale_invalidation_count ? ` ${chalk.dim(`stale:${item.queue_assessment.stale_invalidation_count}`)}` : ''}`];
263
+ if (item.queue_assessment?.reason) lines.push(` ${chalk.dim('why later:')} ${item.queue_assessment.reason}`);
264
+ if (item.recommendation?.summary) lines.push(` ${chalk.dim('decision:')} ${item.recommendation.summary}`);
265
+ if (item.queue_assessment?.next_action) lines.push(` ${chalk.yellow('next:')} ${item.queue_assessment.next_action}`);
266
+ return lines;
267
+ })
268
+ : [chalk.green('Nothing significant is being held back.')];
269
+
270
+ const queueBlockedLines = summary.blocked.length > 0
271
+ ? summary.blocked.slice(0, 4).flatMap((item) => {
272
+ const lines = [`${renderChip('BLOCKED', item.id, chalk.red)} ${item.source_type}:${item.source_ref} ${chalk.dim(`retries:${item.retry_count}/${item.max_retries}`)}`];
273
+ if (item.last_error_summary) lines.push(` ${chalk.red('why:')} ${item.last_error_summary}`);
274
+ if (item.next_action) lines.push(` ${chalk.yellow('next:')} ${item.next_action}`);
275
+ return lines;
276
+ })
277
+ : [chalk.green('Nothing blocked.')];
278
+
279
+ const queueWatchLines = items.filter((item) => ['retrying', 'held', 'wave_blocked', 'escalated', 'merging', 'rebasing', 'validating'].includes(item.status)).length > 0
280
+ ? items
281
+ .filter((item) => ['retrying', 'held', 'wave_blocked', 'escalated', 'merging', 'rebasing', 'validating'].includes(item.status))
282
+ .slice(0, 4)
283
+ .flatMap((item) => {
284
+ const lines = [`${renderChip(item.status.toUpperCase(), item.id, item.status === 'retrying' || item.status === 'held' || item.status === 'wave_blocked' ? chalk.yellow : item.status === 'escalated' ? chalk.red : chalk.blue)} ${item.source_type}:${item.source_ref}`];
285
+ if (item.last_error_summary) lines.push(` ${chalk.dim(item.last_error_summary)}`);
286
+ if (item.next_action) lines.push(` ${chalk.yellow('next:')} ${item.next_action}`);
287
+ return lines;
288
+ })
289
+ : [chalk.green('No in-flight queue items right now.')];
290
+
291
+ const queueCommandLines = [
292
+ `${chalk.cyan('$')} switchman queue run`,
293
+ `${chalk.cyan('$')} switchman queue status --json`,
294
+ ...(summary.blocked[0] ? [`${chalk.cyan('$')} switchman queue retry ${summary.blocked[0].id}`] : []),
295
+ ];
296
+
297
+ const queuePlanLines = [
298
+ ...(summary.plan?.land_now?.slice(0, 2).map((item) => `${renderChip('LAND NOW', item.item_id, chalk.green)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
299
+ ...(summary.plan?.prepare_next?.slice(0, 2).map((item) => `${renderChip('PREP NEXT', item.item_id, chalk.cyan)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
300
+ ...(summary.plan?.unblock_first?.slice(0, 2).map((item) => `${renderChip('UNBLOCK', item.item_id, chalk.yellow)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
301
+ ...(summary.plan?.escalate?.slice(0, 2).map((item) => `${renderChip('ESCALATE', item.item_id, chalk.red)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
302
+ ...(summary.plan?.defer?.slice(0, 2).map((item) => `${renderChip('DEFER', item.item_id, chalk.white)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
303
+ ];
304
+ const queueSequenceLines = summary.recommended_sequence?.length > 0
305
+ ? summary.recommended_sequence.map((item) => `${chalk.bold(`${item.stage}.`)} ${item.source_type}:${item.source_ref} ${chalk.dim(`[${item.lane}]`)} ${item.summary}`)
306
+ : [chalk.green('No recommended sequence beyond the current landing focus.')];
307
+
308
+ console.log('');
309
+ for (const block of [
310
+ renderPanel('Landing focus', queueFocusLines, chalk.green),
311
+ renderPanel('Recommended sequence', queueSequenceLines, summary.recommended_sequence?.length > 0 ? chalk.cyan : chalk.green),
312
+ renderPanel('Queue plan', queuePlanLines.length > 0 ? queuePlanLines : [chalk.green('Nothing else needs planning right now.')], queuePlanLines.length > 0 ? chalk.cyan : chalk.green),
313
+ renderPanel('Held back', queueHeldBackLines, summary.held_back.length > 0 ? chalk.yellow : chalk.green),
314
+ renderPanel('Blocked', queueBlockedLines, summary.counts.blocked > 0 ? chalk.red : chalk.green),
315
+ renderPanel('In flight', queueWatchLines, queueWatchLines[0] === 'No in-flight queue items right now.' ? chalk.green : chalk.blue),
316
+ renderPanel('Next commands', queueCommandLines, chalk.cyan),
317
+ ]) {
318
+ for (const line of block) console.log(line);
319
+ console.log('');
320
+ }
321
+
322
+ if (recentEvents.length > 0) {
323
+ console.log(chalk.bold('Recent Queue Events:'));
324
+ for (const event of recentEvents) {
325
+ console.log(` ${chalk.cyan(event.queue_item_id)} ${chalk.dim(event.event_type)} ${chalk.dim(event.status || '')} ${chalk.dim(event.created_at)}`.trim());
326
+ }
327
+ }
328
+ });
329
+
330
+ queueCmd
331
+ .command('retry <itemId>')
332
+ .description('Retry a blocked merge queue item')
333
+ .option('--json', 'Output raw JSON')
334
+ .action((itemId, opts) => {
335
+ const repoRoot = getRepo();
336
+ const db = getDb(repoRoot);
337
+ const item = retryMergeQueueItem(db, itemId);
338
+ db.close();
339
+
340
+ if (!item) {
341
+ printErrorWithNext(`Queue item ${itemId} is not retryable.`, 'switchman queue status');
342
+ process.exitCode = 1;
343
+ return;
344
+ }
345
+
346
+ if (opts.json) {
347
+ console.log(JSON.stringify(item, null, 2));
348
+ return;
349
+ }
350
+
351
+ console.log(`${chalk.green('✓')} Queue item ${chalk.cyan(item.id)} reset to retrying`);
352
+ });
353
+
354
+ queueCmd
355
+ .command('escalate <itemId>')
356
+ .description('Mark a queue item as needing explicit operator review before landing')
357
+ .option('--reason <text>', 'Why this item is being escalated')
358
+ .option('--json', 'Output raw JSON')
359
+ .action((itemId, opts) => {
360
+ const repoRoot = getRepo();
361
+ const db = getDb(repoRoot);
362
+ const item = escalateMergeQueueItem(db, itemId, {
363
+ summary: opts.reason || null,
364
+ nextAction: `Run \`switchman explain queue ${itemId}\` to review the landing risk, then \`switchman queue retry ${itemId}\` when it is ready again.`,
365
+ });
366
+ db.close();
367
+
368
+ if (!item) {
369
+ printErrorWithNext(`Queue item ${itemId} cannot be escalated.`, 'switchman queue status');
370
+ process.exitCode = 1;
371
+ return;
372
+ }
373
+
374
+ if (opts.json) {
375
+ console.log(JSON.stringify(item, null, 2));
376
+ return;
377
+ }
378
+
379
+ console.log(`${chalk.yellow('!')} Queue item ${chalk.cyan(item.id)} marked escalated for operator review`);
380
+ if (item.last_error_summary) {
381
+ console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
382
+ }
383
+ if (item.next_action) {
384
+ console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
385
+ }
386
+ });
387
+
388
+ queueCmd
389
+ .command('remove <itemId>')
390
+ .description('Remove a merge queue item')
391
+ .action((itemId) => {
392
+ const repoRoot = getRepo();
393
+ const db = getDb(repoRoot);
394
+ const item = removeMergeQueueItem(db, itemId);
395
+ db.close();
396
+
397
+ if (!item) {
398
+ printErrorWithNext(`Queue item ${itemId} does not exist.`, 'switchman queue status');
399
+ process.exitCode = 1;
400
+ return;
401
+ }
402
+
403
+ console.log(`${chalk.green('✓')} Removed ${chalk.cyan(item.id)} from the merge queue`);
404
+ });
405
+
406
+ queueCmd
407
+ .command('run')
408
+ .description('Process landing-queue items one at a time')
409
+ .option('--max-items <n>', 'Maximum queue items to process', '1')
410
+ .option('--follow-plan', 'Only run queue items that are currently in the land_now lane')
411
+ .option('--merge-budget <n>', 'Maximum successful merges to allow in this run')
412
+ .option('--target <branch>', 'Default target branch', 'main')
413
+ .option('--watch', 'Keep polling for new queue items')
414
+ .option('--watch-interval-ms <n>', 'Polling interval for --watch mode', '1000')
415
+ .option('--max-cycles <n>', 'Maximum watch cycles before exiting (mainly for tests)')
416
+ .option('--json', 'Output raw JSON')
417
+ .addHelpText('after', `
418
+ Examples:
419
+ switchman queue run
420
+ switchman queue run --follow-plan --merge-budget 2
421
+ switchman queue run --watch
422
+ switchman queue run --watch --watch-interval-ms 1000
423
+ `)
424
+ .action(async (opts) => {
425
+ const repoRoot = getRepo();
426
+
427
+ try {
428
+ const watch = Boolean(opts.watch);
429
+ const followPlan = Boolean(opts.followPlan);
430
+ const watchIntervalMs = Math.max(0, Number.parseInt(opts.watchIntervalMs, 10) || 1000);
431
+ const maxCycles = opts.maxCycles ? Math.max(1, Number.parseInt(opts.maxCycles, 10) || 1) : null;
432
+ const mergeBudget = opts.mergeBudget !== undefined
433
+ ? Math.max(0, Number.parseInt(opts.mergeBudget, 10) || 0)
434
+ : null;
435
+ const aggregate = {
436
+ processed: [],
437
+ cycles: 0,
438
+ watch,
439
+ execution_policy: {
440
+ follow_plan: followPlan,
441
+ merge_budget: mergeBudget,
442
+ merged_count: 0,
443
+ },
444
+ };
445
+
446
+ while (true) {
447
+ const db = getDb(repoRoot);
448
+ const result = await runMergeQueue(db, repoRoot, {
449
+ maxItems: Number.parseInt(opts.maxItems, 10) || 1,
450
+ targetBranch: opts.target || 'main',
451
+ followPlan,
452
+ mergeBudget,
453
+ });
454
+ db.close();
455
+
456
+ aggregate.processed.push(...result.processed);
457
+ aggregate.summary = result.summary;
458
+ aggregate.deferred = result.deferred || aggregate.deferred || null;
459
+ aggregate.execution_policy = result.execution_policy || aggregate.execution_policy;
460
+ aggregate.cycles += 1;
461
+
462
+ if (!watch) break;
463
+ if (maxCycles && aggregate.cycles >= maxCycles) break;
464
+ if (mergeBudget !== null && aggregate.execution_policy.merged_count >= mergeBudget) break;
465
+ if (result.processed.length === 0) {
466
+ sleepSync(watchIntervalMs);
467
+ }
468
+ }
469
+
470
+ if (opts.json) {
471
+ console.log(JSON.stringify(aggregate, null, 2));
472
+ return;
473
+ }
474
+
475
+ if (aggregate.processed.length === 0) {
476
+ const deferredFocus = aggregate.deferred || aggregate.summary?.next || null;
477
+ if (deferredFocus?.recommendation?.action) {
478
+ console.log(chalk.yellow('No landing candidate is ready to run right now.'));
479
+ console.log(` ${chalk.dim('focus:')} ${deferredFocus.id} ${deferredFocus.source_type}:${deferredFocus.source_ref}`);
480
+ if (followPlan) {
481
+ console.log(` ${chalk.dim('policy:')} following the queue plan, so only land_now items will run automatically`);
482
+ }
483
+ if (deferredFocus.recommendation?.summary) {
484
+ console.log(` ${chalk.dim('decision:')} ${deferredFocus.recommendation.summary}`);
485
+ }
486
+ if (deferredFocus.recommendation?.command) {
487
+ console.log(` ${chalk.yellow('next:')} ${deferredFocus.recommendation.command}`);
488
+ }
489
+ } else {
490
+ console.log(chalk.dim('No queued merge items.'));
491
+ }
492
+ await maybeCaptureTelemetry('queue_used', {
493
+ watch,
494
+ cycles: aggregate.cycles,
495
+ processed_count: 0,
496
+ merged_count: 0,
497
+ blocked_count: 0,
498
+ });
499
+ return;
500
+ }
501
+
502
+ for (const entry of aggregate.processed) {
503
+ const item = entry.item;
504
+ if (entry.status === 'merged') {
505
+ pushSyncEvent('queue_merged', {
506
+ item_id: item.id,
507
+ source_type: item.source_type,
508
+ source_ref: item.source_ref,
509
+ source_worktree: item.source_worktree || null,
510
+ target_branch: item.target_branch,
511
+ merged_commit: item.merged_commit || null,
512
+ }, { worktree: item.source_worktree || null }).catch(() => {});
513
+ console.log(`${chalk.green('✓')} Merged ${chalk.cyan(item.id)} into ${chalk.bold(item.target_branch)}`);
514
+ console.log(` ${chalk.dim('commit:')} ${item.merged_commit}`);
515
+ } else {
516
+ pushSyncEvent('queue_blocked', {
517
+ item_id: item.id,
518
+ source_type: item.source_type,
519
+ source_ref: item.source_ref,
520
+ source_worktree: item.source_worktree || null,
521
+ target_branch: item.target_branch,
522
+ error_code: item.last_error_code || null,
523
+ error_summary: item.last_error_summary || null,
524
+ }, { worktree: item.source_worktree || null }).catch(() => {});
525
+ console.log(`${chalk.red('✗')} Blocked ${chalk.cyan(item.id)}`);
526
+ console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
527
+ if (item.next_action) console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
528
+ }
529
+ }
530
+
531
+ if (aggregate.execution_policy.follow_plan) {
532
+ console.log(`${chalk.dim('plan-aware run:')} merged ${aggregate.execution_policy.merged_count}${aggregate.execution_policy.merge_budget !== null ? ` of ${aggregate.execution_policy.merge_budget}` : ''} budgeted item(s)`);
533
+ }
534
+
535
+ await maybeCaptureTelemetry('queue_used', {
536
+ watch,
537
+ cycles: aggregate.cycles,
538
+ processed_count: aggregate.processed.length,
539
+ merged_count: aggregate.processed.filter((entry) => entry.status === 'merged').length,
540
+ blocked_count: aggregate.processed.filter((entry) => entry.status !== 'merged').length,
541
+ });
542
+ } catch (err) {
543
+ console.error(chalk.red(err.message));
544
+ process.exitCode = 1;
545
+ }
546
+ });
547
+
548
+ return queueCmd;
549
+ }