switchman-dev 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -495
- package/package.json +3 -3
- package/src/cli/index.js +728 -204
- package/src/core/db.js +252 -2
- package/src/core/git.js +74 -1
- package/src/core/ignore.js +2 -0
- package/src/core/mcp.js +39 -10
- package/src/core/outcome.js +48 -11
- package/src/core/policy.js +49 -0
- package/src/core/queue.js +225 -0
package/src/cli/index.js
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
* switchman init - Initialize in current repo
|
|
8
8
|
* switchman task add - Add a task to the queue
|
|
9
9
|
* switchman task list - List all tasks
|
|
10
|
-
* switchman task assign - Assign task to a
|
|
10
|
+
* switchman task assign - Assign task to a workspace
|
|
11
11
|
* switchman task done - Mark task complete
|
|
12
|
-
* switchman worktree add - Register a
|
|
13
|
-
* switchman worktree list - List registered
|
|
14
|
-
* switchman scan - Scan for conflicts across
|
|
12
|
+
* switchman worktree add - Register a workspace
|
|
13
|
+
* switchman worktree list - List registered workspaces
|
|
14
|
+
* switchman scan - Scan for conflicts across workspaces
|
|
15
15
|
* switchman claim - Claim files for a task
|
|
16
|
-
* switchman status - Show
|
|
16
|
+
* switchman status - Show the repo dashboard
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { program } from 'commander';
|
|
@@ -29,20 +29,23 @@ import {
|
|
|
29
29
|
createTask, startTaskLease, completeTask, failTask, getBoundaryValidationState, getTaskSpec, listTasks, getTask, getNextPendingTask,
|
|
30
30
|
listDependencyInvalidations, listLeases, listScopeReservations, heartbeatLease, getStaleLeases, reapStaleLeases,
|
|
31
31
|
registerWorktree, listWorktrees,
|
|
32
|
+
enqueueMergeItem, getMergeQueueItem, listMergeQueue, listMergeQueueEvents, removeMergeQueueItem, retryMergeQueueItem,
|
|
32
33
|
claimFiles, releaseFileClaims, getActiveFileClaims, checkFileConflicts,
|
|
33
34
|
verifyAuditTrail,
|
|
34
35
|
} from '../core/db.js';
|
|
35
36
|
import { scanAllWorktrees } from '../core/detector.js';
|
|
36
|
-
import {
|
|
37
|
+
import { getWindsurfMcpConfigPath, upsertAllProjectMcpConfigs, upsertWindsurfMcpConfig } from '../core/mcp.js';
|
|
37
38
|
import { gatewayAppendFile, gatewayMakeDirectory, gatewayMovePath, gatewayRemovePath, gatewayWriteFile, installGateHooks, monitorWorktreesOnce, runCommitGate, runWrappedCommand, writeEnforcementPolicy } from '../core/enforcement.js';
|
|
38
39
|
import { runAiMergeGate } from '../core/merge-gate.js';
|
|
39
40
|
import { clearMonitorState, getMonitorStatePath, isProcessRunning, readMonitorState, writeMonitorState } from '../core/monitor.js';
|
|
40
41
|
import { buildPipelinePrSummary, createPipelineFollowupTasks, executePipeline, exportPipelinePrBundle, getPipelineStatus, publishPipelinePr, runPipeline, startPipeline } from '../core/pipeline.js';
|
|
41
42
|
import { installGitHubActionsWorkflow, resolveGitHubOutputTargets, writeGitHubCiStatus } from '../core/ci.js';
|
|
42
43
|
import { importCodeObjectsToStore, listCodeObjects, materializeCodeObjects, materializeSemanticIndex, updateCodeObjectSource } from '../core/semantic.js';
|
|
44
|
+
import { buildQueueStatusSummary, runMergeQueue } from '../core/queue.js';
|
|
45
|
+
import { DEFAULT_LEASE_POLICY, loadLeasePolicy, writeLeasePolicy } from '../core/policy.js';
|
|
43
46
|
|
|
44
47
|
function installMcpConfig(targetDirs) {
|
|
45
|
-
return targetDirs.
|
|
48
|
+
return targetDirs.flatMap((targetDir) => upsertAllProjectMcpConfigs(targetDir));
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
@@ -80,6 +83,14 @@ function statusBadge(status) {
|
|
|
80
83
|
observed: chalk.yellow,
|
|
81
84
|
non_compliant: chalk.red,
|
|
82
85
|
stale: chalk.red,
|
|
86
|
+
queued: chalk.yellow,
|
|
87
|
+
validating: chalk.blue,
|
|
88
|
+
rebasing: chalk.blue,
|
|
89
|
+
retrying: chalk.yellow,
|
|
90
|
+
blocked: chalk.red,
|
|
91
|
+
merging: chalk.blue,
|
|
92
|
+
merged: chalk.green,
|
|
93
|
+
canceled: chalk.gray,
|
|
83
94
|
};
|
|
84
95
|
return (colors[status] || chalk.white)(status.toUpperCase().padEnd(11));
|
|
85
96
|
}
|
|
@@ -117,6 +128,52 @@ function printTable(rows, columns) {
|
|
|
117
128
|
}
|
|
118
129
|
}
|
|
119
130
|
|
|
131
|
+
function padRight(value, width) {
|
|
132
|
+
return String(value).padEnd(width);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function stripAnsi(text) {
|
|
136
|
+
return String(text).replace(/\x1B\[[0-9;]*m/g, '');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function colorForHealth(health) {
|
|
140
|
+
if (health === 'healthy') return chalk.green;
|
|
141
|
+
if (health === 'warn') return chalk.yellow;
|
|
142
|
+
return chalk.red;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function healthLabel(health) {
|
|
146
|
+
if (health === 'healthy') return 'HEALTHY';
|
|
147
|
+
if (health === 'warn') return 'ATTENTION';
|
|
148
|
+
return 'BLOCKED';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function renderPanel(title, lines, color = chalk.cyan) {
|
|
152
|
+
const content = lines.length > 0 ? lines : [chalk.dim('No items.')];
|
|
153
|
+
const width = Math.max(
|
|
154
|
+
stripAnsi(title).length + 2,
|
|
155
|
+
...content.map((line) => stripAnsi(line).length),
|
|
156
|
+
);
|
|
157
|
+
const top = color(`+${'-'.repeat(width + 2)}+`);
|
|
158
|
+
const titleLine = color(`| ${padRight(title, width)} |`);
|
|
159
|
+
const body = content.map((line) => `| ${padRight(line, width)} |`);
|
|
160
|
+
const bottom = color(`+${'-'.repeat(width + 2)}+`);
|
|
161
|
+
return [top, titleLine, top, ...body, bottom];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function renderMetricRow(metrics) {
|
|
165
|
+
return metrics.map(({ label, value, color = chalk.white }) => `${chalk.dim(label)} ${color(String(value))}`).join(chalk.dim(' | '));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function renderMiniBar(items) {
|
|
169
|
+
if (!items.length) return chalk.dim('none');
|
|
170
|
+
return items.map(({ label, value, color = chalk.white }) => `${color('■')} ${label}:${value}`).join(chalk.dim(' '));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function formatRelativePolicy(policy) {
|
|
174
|
+
return `stale ${policy.stale_after_minutes}m • heartbeat ${policy.heartbeat_interval_seconds}s • auto-reap ${policy.reap_on_status_check ? 'on' : 'off'}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
120
177
|
function sleepSync(ms) {
|
|
121
178
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
122
179
|
}
|
|
@@ -454,6 +511,278 @@ function buildDoctorReport({ db, repoRoot, tasks, activeLeases, staleLeases, sca
|
|
|
454
511
|
};
|
|
455
512
|
}
|
|
456
513
|
|
|
514
|
+
function buildUnifiedStatusReport({
|
|
515
|
+
repoRoot,
|
|
516
|
+
leasePolicy,
|
|
517
|
+
tasks,
|
|
518
|
+
claims,
|
|
519
|
+
doctorReport,
|
|
520
|
+
queueItems,
|
|
521
|
+
queueSummary,
|
|
522
|
+
recentQueueEvents,
|
|
523
|
+
}) {
|
|
524
|
+
const queueAttention = [
|
|
525
|
+
...queueItems
|
|
526
|
+
.filter((item) => item.status === 'blocked')
|
|
527
|
+
.map((item) => ({
|
|
528
|
+
kind: 'queue_blocked',
|
|
529
|
+
title: `${item.id} is blocked from landing`,
|
|
530
|
+
detail: item.last_error_summary || `${item.source_type}:${item.source_ref}`,
|
|
531
|
+
next_step: item.next_action || `Run \`switchman queue retry ${item.id}\` after fixing the branch state.`,
|
|
532
|
+
command: item.next_action?.includes('queue retry') ? `switchman queue retry ${item.id}` : 'switchman queue status',
|
|
533
|
+
severity: 'block',
|
|
534
|
+
})),
|
|
535
|
+
...queueItems
|
|
536
|
+
.filter((item) => item.status === 'retrying')
|
|
537
|
+
.map((item) => ({
|
|
538
|
+
kind: 'queue_retrying',
|
|
539
|
+
title: `${item.id} is waiting for another landing attempt`,
|
|
540
|
+
detail: item.last_error_summary || `${item.source_type}:${item.source_ref}`,
|
|
541
|
+
next_step: item.next_action || 'Run `switchman queue run` again to continue landing queued work.',
|
|
542
|
+
command: 'switchman queue run',
|
|
543
|
+
severity: 'warn',
|
|
544
|
+
})),
|
|
545
|
+
];
|
|
546
|
+
|
|
547
|
+
const attention = [...doctorReport.attention, ...queueAttention];
|
|
548
|
+
const nextUp = tasks
|
|
549
|
+
.filter((task) => task.status === 'pending')
|
|
550
|
+
.sort((a, b) => Number(b.priority || 0) - Number(a.priority || 0))
|
|
551
|
+
.slice(0, 3)
|
|
552
|
+
.map((task) => ({
|
|
553
|
+
id: task.id,
|
|
554
|
+
title: task.title,
|
|
555
|
+
priority: task.priority,
|
|
556
|
+
}));
|
|
557
|
+
const failedTasks = tasks
|
|
558
|
+
.filter((task) => task.status === 'failed')
|
|
559
|
+
.slice(0, 5)
|
|
560
|
+
.map((task) => ({
|
|
561
|
+
id: task.id,
|
|
562
|
+
title: task.title,
|
|
563
|
+
failure: latestTaskFailure(task),
|
|
564
|
+
}));
|
|
565
|
+
|
|
566
|
+
const suggestedCommands = [
|
|
567
|
+
...doctorReport.suggested_commands,
|
|
568
|
+
...(queueItems.length > 0 ? ['switchman queue status'] : []),
|
|
569
|
+
...(queueSummary.next ? ['switchman queue run'] : []),
|
|
570
|
+
].filter(Boolean);
|
|
571
|
+
|
|
572
|
+
return {
|
|
573
|
+
generated_at: new Date().toISOString(),
|
|
574
|
+
repo_root: repoRoot,
|
|
575
|
+
health: attention.some((item) => item.severity === 'block')
|
|
576
|
+
? 'block'
|
|
577
|
+
: attention.some((item) => item.severity === 'warn')
|
|
578
|
+
? 'warn'
|
|
579
|
+
: doctorReport.health,
|
|
580
|
+
summary: attention.some((item) => item.severity === 'block')
|
|
581
|
+
? 'Repo needs attention before more work or merge.'
|
|
582
|
+
: attention.some((item) => item.severity === 'warn')
|
|
583
|
+
? 'Repo is running, but a few items need review.'
|
|
584
|
+
: 'Repo looks healthy. Agents are coordinated and merge checks are clear.',
|
|
585
|
+
lease_policy: leasePolicy,
|
|
586
|
+
counts: {
|
|
587
|
+
...doctorReport.counts,
|
|
588
|
+
queue: queueSummary.counts,
|
|
589
|
+
active_claims: claims.length,
|
|
590
|
+
},
|
|
591
|
+
active_work: doctorReport.active_work,
|
|
592
|
+
attention,
|
|
593
|
+
next_up: nextUp,
|
|
594
|
+
failed_tasks: failedTasks,
|
|
595
|
+
queue: {
|
|
596
|
+
items: queueItems,
|
|
597
|
+
summary: queueSummary,
|
|
598
|
+
recent_events: recentQueueEvents,
|
|
599
|
+
},
|
|
600
|
+
merge_readiness: doctorReport.merge_readiness,
|
|
601
|
+
claims: claims.map((claim) => ({
|
|
602
|
+
worktree: claim.worktree,
|
|
603
|
+
task_id: claim.task_id,
|
|
604
|
+
file_path: claim.file_path,
|
|
605
|
+
})),
|
|
606
|
+
next_steps: [...new Set([
|
|
607
|
+
...doctorReport.next_steps,
|
|
608
|
+
...queueAttention.map((item) => item.next_step),
|
|
609
|
+
])].slice(0, 6),
|
|
610
|
+
suggested_commands: [...new Set(suggestedCommands)].slice(0, 6),
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async function collectStatusSnapshot(repoRoot) {
|
|
615
|
+
const db = getDb(repoRoot);
|
|
616
|
+
try {
|
|
617
|
+
const leasePolicy = loadLeasePolicy(repoRoot);
|
|
618
|
+
|
|
619
|
+
if (leasePolicy.reap_on_status_check) {
|
|
620
|
+
reapStaleLeases(db, leasePolicy.stale_after_minutes, {
|
|
621
|
+
requeueTask: leasePolicy.requeue_task_on_reap,
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const tasks = listTasks(db);
|
|
626
|
+
const activeLeases = listLeases(db, 'active');
|
|
627
|
+
const staleLeases = getStaleLeases(db, leasePolicy.stale_after_minutes);
|
|
628
|
+
const claims = getActiveFileClaims(db);
|
|
629
|
+
const queueItems = listMergeQueue(db);
|
|
630
|
+
const queueSummary = buildQueueStatusSummary(queueItems);
|
|
631
|
+
const recentQueueEvents = queueItems
|
|
632
|
+
.slice(0, 5)
|
|
633
|
+
.flatMap((item) => listMergeQueueEvents(db, item.id, { limit: 3 }).map((event) => ({ ...event, queue_item_id: item.id })))
|
|
634
|
+
.sort((a, b) => b.id - a.id)
|
|
635
|
+
.slice(0, 8);
|
|
636
|
+
const scanReport = await scanAllWorktrees(db, repoRoot);
|
|
637
|
+
const aiGate = await runAiMergeGate(db, repoRoot);
|
|
638
|
+
const doctorReport = buildDoctorReport({
|
|
639
|
+
db,
|
|
640
|
+
repoRoot,
|
|
641
|
+
tasks,
|
|
642
|
+
activeLeases,
|
|
643
|
+
staleLeases,
|
|
644
|
+
scanReport,
|
|
645
|
+
aiGate,
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
return buildUnifiedStatusReport({
|
|
649
|
+
repoRoot,
|
|
650
|
+
leasePolicy,
|
|
651
|
+
tasks,
|
|
652
|
+
claims,
|
|
653
|
+
doctorReport,
|
|
654
|
+
queueItems,
|
|
655
|
+
queueSummary,
|
|
656
|
+
recentQueueEvents,
|
|
657
|
+
});
|
|
658
|
+
} finally {
|
|
659
|
+
db.close();
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function renderUnifiedStatusReport(report) {
|
|
664
|
+
const healthColor = colorForHealth(report.health);
|
|
665
|
+
const badge = healthColor(healthLabel(report.health));
|
|
666
|
+
const mergeColor = report.merge_readiness.ci_gate_ok ? chalk.green : chalk.red;
|
|
667
|
+
const queueCounts = report.counts.queue;
|
|
668
|
+
|
|
669
|
+
console.log('');
|
|
670
|
+
console.log(healthColor('='.repeat(64)));
|
|
671
|
+
console.log(`${badge} ${chalk.bold('switchman status')} ${chalk.dim('• user-centred repo overview')}`);
|
|
672
|
+
console.log(`${chalk.dim(report.repo_root)}`);
|
|
673
|
+
console.log(`${chalk.dim(report.summary)}`);
|
|
674
|
+
console.log(healthColor('='.repeat(64)));
|
|
675
|
+
console.log(renderMetricRow([
|
|
676
|
+
{ label: 'tasks', value: `${report.counts.pending}/${report.counts.in_progress}/${report.counts.done}/${report.counts.failed}`, color: chalk.white },
|
|
677
|
+
{ label: 'leases', value: `${report.counts.active_leases} active`, color: chalk.blue },
|
|
678
|
+
{ label: 'claims', value: report.counts.active_claims, color: chalk.cyan },
|
|
679
|
+
{ label: 'merge', value: report.merge_readiness.ci_gate_ok ? 'clear' : 'blocked', color: mergeColor },
|
|
680
|
+
]));
|
|
681
|
+
console.log(renderMiniBar([
|
|
682
|
+
{ label: 'queued', value: queueCounts.queued, color: chalk.yellow },
|
|
683
|
+
{ label: 'retrying', value: queueCounts.retrying, color: chalk.yellow },
|
|
684
|
+
{ label: 'blocked', value: queueCounts.blocked, color: chalk.red },
|
|
685
|
+
{ label: 'merging', value: queueCounts.merging, color: chalk.blue },
|
|
686
|
+
{ label: 'merged', value: queueCounts.merged, color: chalk.green },
|
|
687
|
+
]));
|
|
688
|
+
console.log(chalk.dim(`policy: ${formatRelativePolicy(report.lease_policy)} • requeue on reap ${report.lease_policy.requeue_task_on_reap ? 'on' : 'off'}`));
|
|
689
|
+
|
|
690
|
+
const runningLines = report.active_work.length > 0
|
|
691
|
+
? report.active_work.slice(0, 5).map((item) => {
|
|
692
|
+
const boundary = item.boundary_validation ? ` validation:${item.boundary_validation.status}` : '';
|
|
693
|
+
const stale = (item.dependency_invalidations?.length || 0) > 0 ? ` stale:${item.dependency_invalidations.length}` : '';
|
|
694
|
+
return `${chalk.cyan(item.worktree)} -> ${item.task_title} ${chalk.dim(item.task_id)}${item.scope_summary ? ` ${chalk.dim(item.scope_summary)}` : ''}${chalk.dim(boundary)}${chalk.dim(stale)}`;
|
|
695
|
+
})
|
|
696
|
+
: [chalk.dim('Nothing active right now.')];
|
|
697
|
+
|
|
698
|
+
const blockedItems = report.attention.filter((item) => item.severity === 'block');
|
|
699
|
+
const warningItems = report.attention.filter((item) => item.severity !== 'block');
|
|
700
|
+
|
|
701
|
+
const blockedLines = blockedItems.length > 0
|
|
702
|
+
? blockedItems.slice(0, 4).flatMap((item) => {
|
|
703
|
+
const lines = [`${chalk.red('BLOCK')} ${item.title}`];
|
|
704
|
+
if (item.detail) lines.push(` ${chalk.dim(item.detail)}`);
|
|
705
|
+
lines.push(` ${chalk.yellow('next:')} ${item.next_step}`);
|
|
706
|
+
if (item.command) lines.push(` ${chalk.cyan('run:')} ${item.command}`);
|
|
707
|
+
return lines;
|
|
708
|
+
})
|
|
709
|
+
: [chalk.green('Nothing blocked.')];
|
|
710
|
+
|
|
711
|
+
const warningLines = warningItems.length > 0
|
|
712
|
+
? warningItems.slice(0, 4).flatMap((item) => {
|
|
713
|
+
const tone = chalk.yellow('WARN ');
|
|
714
|
+
const lines = [`${tone} ${item.title}`];
|
|
715
|
+
if (item.detail) lines.push(` ${chalk.dim(item.detail)}`);
|
|
716
|
+
lines.push(` ${chalk.yellow('next:')} ${item.next_step}`);
|
|
717
|
+
if (item.command) lines.push(` ${chalk.cyan('run:')} ${item.command}`);
|
|
718
|
+
return lines;
|
|
719
|
+
})
|
|
720
|
+
: [chalk.green('Nothing warning-worthy right now.')];
|
|
721
|
+
|
|
722
|
+
const queueLines = report.queue.items.length > 0
|
|
723
|
+
? [
|
|
724
|
+
...(report.queue.summary.next
|
|
725
|
+
? [`${chalk.dim('next:')} ${report.queue.summary.next.id} ${report.queue.summary.next.source_type}:${report.queue.summary.next.source_ref} ${chalk.dim(`retries:${report.queue.summary.next.retry_count}/${report.queue.summary.next.max_retries}`)}`]
|
|
726
|
+
: []),
|
|
727
|
+
...report.queue.items
|
|
728
|
+
.filter((entry) => ['blocked', 'retrying', 'merging'].includes(entry.status))
|
|
729
|
+
.slice(0, 4)
|
|
730
|
+
.flatMap((item) => {
|
|
731
|
+
const lines = [`${statusBadge(item.status)} ${item.id} ${item.source_type}:${item.source_ref} ${chalk.dim(`retries:${item.retry_count}/${item.max_retries}`)}`];
|
|
732
|
+
if (item.last_error_summary) lines.push(` ${chalk.red('why:')} ${item.last_error_summary}`);
|
|
733
|
+
if (item.next_action) lines.push(` ${chalk.yellow('next:')} ${item.next_action}`);
|
|
734
|
+
return lines;
|
|
735
|
+
}),
|
|
736
|
+
]
|
|
737
|
+
: [chalk.dim('No queued merges.')];
|
|
738
|
+
|
|
739
|
+
const nextActionLines = [
|
|
740
|
+
...(report.next_up.length > 0
|
|
741
|
+
? report.next_up.map((task) => `[p${task.priority}] ${task.title} ${chalk.dim(task.id)}`)
|
|
742
|
+
: [chalk.dim('No pending tasks waiting right now.')]),
|
|
743
|
+
'',
|
|
744
|
+
...report.suggested_commands.slice(0, 4).map((command) => `${chalk.cyan('$')} ${command}`),
|
|
745
|
+
];
|
|
746
|
+
|
|
747
|
+
const panelBlocks = [
|
|
748
|
+
renderPanel('Running now', runningLines, chalk.cyan),
|
|
749
|
+
renderPanel('Blocked', blockedLines, blockedItems.length > 0 ? chalk.red : chalk.green),
|
|
750
|
+
renderPanel('Warnings', warningLines, warningItems.length > 0 ? chalk.yellow : chalk.green),
|
|
751
|
+
renderPanel('Landing queue', queueLines, queueCounts.blocked > 0 ? chalk.red : chalk.blue),
|
|
752
|
+
renderPanel('Next action', nextActionLines, chalk.green),
|
|
753
|
+
];
|
|
754
|
+
|
|
755
|
+
console.log('');
|
|
756
|
+
for (const block of panelBlocks) {
|
|
757
|
+
for (const line of block) console.log(line);
|
|
758
|
+
console.log('');
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (report.failed_tasks.length > 0) {
|
|
762
|
+
console.log(chalk.bold('Recent failed tasks:'));
|
|
763
|
+
for (const task of report.failed_tasks) {
|
|
764
|
+
const reason = humanizeReasonCode(task.failure?.reason_code);
|
|
765
|
+
const summary = task.failure?.summary || 'unknown failure';
|
|
766
|
+
console.log(` ${chalk.red(task.title)} ${chalk.dim(task.id)}`);
|
|
767
|
+
console.log(` ${chalk.red('why:')} ${summary} ${chalk.dim(`(${reason})`)}`);
|
|
768
|
+
}
|
|
769
|
+
console.log('');
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (report.queue.recent_events.length > 0) {
|
|
773
|
+
console.log(chalk.bold('Recent queue events:'));
|
|
774
|
+
for (const event of report.queue.recent_events.slice(0, 5)) {
|
|
775
|
+
console.log(` ${chalk.cyan(event.queue_item_id)} ${chalk.dim(event.event_type)} ${chalk.dim(event.status || '')} ${chalk.dim(event.created_at)}`.trim());
|
|
776
|
+
}
|
|
777
|
+
console.log('');
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
console.log(chalk.bold('Recommended next steps:'));
|
|
781
|
+
for (const step of report.next_steps) {
|
|
782
|
+
console.log(` - ${step}`);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
457
786
|
function acquireNextTaskLease(db, worktreeName, agent, attempts = 20) {
|
|
458
787
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
459
788
|
try {
|
|
@@ -570,8 +899,8 @@ program
|
|
|
570
899
|
|
|
571
900
|
program
|
|
572
901
|
.command('setup')
|
|
573
|
-
.description('One-command setup: create agent
|
|
574
|
-
.option('-a, --agents <n>', 'Number of agent
|
|
902
|
+
.description('One-command setup: create agent workspaces and initialise Switchman')
|
|
903
|
+
.option('-a, --agents <n>', 'Number of agent workspaces to create (default: 3)', '3')
|
|
575
904
|
.option('--prefix <prefix>', 'Branch prefix (default: switchman)', 'switchman')
|
|
576
905
|
.action((opts) => {
|
|
577
906
|
const agentCount = parseInt(opts.agents);
|
|
@@ -592,7 +921,7 @@ program
|
|
|
592
921
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
593
922
|
});
|
|
594
923
|
} catch {
|
|
595
|
-
spinner.fail('Your repo needs at least one commit before
|
|
924
|
+
spinner.fail('Your repo needs at least one commit before agent workspaces can be created.');
|
|
596
925
|
console.log(chalk.dim(' Run: git commit --allow-empty -m "init" then try again'));
|
|
597
926
|
process.exit(1);
|
|
598
927
|
}
|
|
@@ -600,7 +929,7 @@ program
|
|
|
600
929
|
// Init the switchman database
|
|
601
930
|
const db = initDb(repoRoot);
|
|
602
931
|
|
|
603
|
-
// Create one worktree per agent
|
|
932
|
+
// Create one workspace (git worktree) per agent
|
|
604
933
|
const created = [];
|
|
605
934
|
for (let i = 1; i <= agentCount; i++) {
|
|
606
935
|
const name = `agent${i}`;
|
|
@@ -650,7 +979,7 @@ program
|
|
|
650
979
|
console.log(chalk.bold('Next steps:'));
|
|
651
980
|
console.log(` 1. Add your tasks:`);
|
|
652
981
|
console.log(` ${chalk.cyan('switchman task add "Your first task" --priority 8')}`);
|
|
653
|
-
console.log(` 2. Open Claude Code in each folder above — the local
|
|
982
|
+
console.log(` 2. Open Claude Code or Cursor in each folder above — the local MCP config will attach Switchman automatically`);
|
|
654
983
|
console.log(` 3. Check status at any time:`);
|
|
655
984
|
console.log(` ${chalk.cyan('switchman status')}`);
|
|
656
985
|
console.log('');
|
|
@@ -662,9 +991,45 @@ program
|
|
|
662
991
|
});
|
|
663
992
|
|
|
664
993
|
|
|
994
|
+
// ── mcp ───────────────────────────────────────────────────────────────────────
|
|
995
|
+
|
|
996
|
+
const mcpCmd = program.command('mcp').description('Manage editor connections for Switchman');
|
|
997
|
+
|
|
998
|
+
mcpCmd
|
|
999
|
+
.command('install')
|
|
1000
|
+
.description('Install editor-specific MCP config for Switchman')
|
|
1001
|
+
.option('--windsurf', 'Write Windsurf MCP config to ~/.codeium/mcp_config.json')
|
|
1002
|
+
.option('--home <path>', 'Override the home directory for config writes (useful for testing)')
|
|
1003
|
+
.option('--json', 'Output raw JSON')
|
|
1004
|
+
.action((opts) => {
|
|
1005
|
+
if (!opts.windsurf) {
|
|
1006
|
+
console.error(chalk.red('Choose an editor install target, for example `switchman mcp install --windsurf`.'));
|
|
1007
|
+
process.exitCode = 1;
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
const result = upsertWindsurfMcpConfig(opts.home);
|
|
1012
|
+
|
|
1013
|
+
if (opts.json) {
|
|
1014
|
+
console.log(JSON.stringify({
|
|
1015
|
+
editor: 'windsurf',
|
|
1016
|
+
path: result.path,
|
|
1017
|
+
created: result.created,
|
|
1018
|
+
changed: result.changed,
|
|
1019
|
+
}, null, 2));
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
console.log(`${chalk.green('✓')} Windsurf MCP config ${result.changed ? 'written' : 'already up to date'}`);
|
|
1024
|
+
console.log(` ${chalk.dim('path:')} ${chalk.cyan(result.path)}`);
|
|
1025
|
+
console.log(` ${chalk.dim('open:')} Windsurf -> Settings -> Cascade -> MCP Servers`);
|
|
1026
|
+
console.log(` ${chalk.dim('note:')} Windsurf reads the shared config from ${getWindsurfMcpConfigPath(opts.home)}`);
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
|
|
665
1030
|
// ── task ──────────────────────────────────────────────────────────────────────
|
|
666
1031
|
|
|
667
|
-
const taskCmd = program.command('task').description('Manage the task
|
|
1032
|
+
const taskCmd = program.command('task').description('Manage the task list');
|
|
668
1033
|
|
|
669
1034
|
taskCmd
|
|
670
1035
|
.command('add <title>')
|
|
@@ -720,7 +1085,7 @@ taskCmd
|
|
|
720
1085
|
|
|
721
1086
|
taskCmd
|
|
722
1087
|
.command('assign <taskId> <worktree>')
|
|
723
|
-
.description('Assign a task to a
|
|
1088
|
+
.description('Assign a task to a workspace (compatibility shim for lease acquire)')
|
|
724
1089
|
.option('--agent <name>', 'Agent name (e.g. claude-code)')
|
|
725
1090
|
.action((taskId, worktree, opts) => {
|
|
726
1091
|
const repoRoot = getRepo();
|
|
@@ -764,7 +1129,7 @@ taskCmd
|
|
|
764
1129
|
.command('next')
|
|
765
1130
|
.description('Get and assign the next pending task (compatibility shim for lease next)')
|
|
766
1131
|
.option('--json', 'Output as JSON')
|
|
767
|
-
.option('--worktree <name>', '
|
|
1132
|
+
.option('--worktree <name>', 'Workspace to assign the task to (defaults to the current folder name)')
|
|
768
1133
|
.option('--agent <name>', 'Agent identifier for logging (e.g. claude-code)')
|
|
769
1134
|
.action((opts) => {
|
|
770
1135
|
const repoRoot = getRepo();
|
|
@@ -792,6 +1157,259 @@ taskCmd
|
|
|
792
1157
|
}
|
|
793
1158
|
});
|
|
794
1159
|
|
|
1160
|
+
// ── queue ─────────────────────────────────────────────────────────────────────
|
|
1161
|
+
|
|
1162
|
+
const queueCmd = program.command('queue').description('Land finished work safely back onto main, one item at a time');
|
|
1163
|
+
|
|
1164
|
+
queueCmd
|
|
1165
|
+
.command('add [branch]')
|
|
1166
|
+
.description('Add a branch, workspace, or pipeline to the landing queue')
|
|
1167
|
+
.option('--worktree <name>', 'Queue a registered workspace by name')
|
|
1168
|
+
.option('--pipeline <pipelineId>', 'Queue a pipeline by id')
|
|
1169
|
+
.option('--target <branch>', 'Target branch to merge into', 'main')
|
|
1170
|
+
.option('--max-retries <n>', 'Maximum automatic retries', '1')
|
|
1171
|
+
.option('--submitted-by <name>', 'Operator or automation name')
|
|
1172
|
+
.option('--json', 'Output raw JSON')
|
|
1173
|
+
.action((branch, opts) => {
|
|
1174
|
+
const repoRoot = getRepo();
|
|
1175
|
+
const db = getDb(repoRoot);
|
|
1176
|
+
|
|
1177
|
+
try {
|
|
1178
|
+
let payload;
|
|
1179
|
+
if (opts.worktree) {
|
|
1180
|
+
const worktree = listWorktrees(db).find((entry) => entry.name === opts.worktree);
|
|
1181
|
+
if (!worktree) {
|
|
1182
|
+
throw new Error(`Worktree ${opts.worktree} is not registered.`);
|
|
1183
|
+
}
|
|
1184
|
+
payload = {
|
|
1185
|
+
sourceType: 'worktree',
|
|
1186
|
+
sourceRef: worktree.branch,
|
|
1187
|
+
sourceWorktree: worktree.name,
|
|
1188
|
+
targetBranch: opts.target,
|
|
1189
|
+
maxRetries: opts.maxRetries,
|
|
1190
|
+
submittedBy: opts.submittedBy || null,
|
|
1191
|
+
};
|
|
1192
|
+
} else if (opts.pipeline) {
|
|
1193
|
+
payload = {
|
|
1194
|
+
sourceType: 'pipeline',
|
|
1195
|
+
sourceRef: opts.pipeline,
|
|
1196
|
+
sourcePipelineId: opts.pipeline,
|
|
1197
|
+
targetBranch: opts.target,
|
|
1198
|
+
maxRetries: opts.maxRetries,
|
|
1199
|
+
submittedBy: opts.submittedBy || null,
|
|
1200
|
+
};
|
|
1201
|
+
} else if (branch) {
|
|
1202
|
+
payload = {
|
|
1203
|
+
sourceType: 'branch',
|
|
1204
|
+
sourceRef: branch,
|
|
1205
|
+
targetBranch: opts.target,
|
|
1206
|
+
maxRetries: opts.maxRetries,
|
|
1207
|
+
submittedBy: opts.submittedBy || null,
|
|
1208
|
+
};
|
|
1209
|
+
} else {
|
|
1210
|
+
throw new Error('Provide a branch, --worktree, or --pipeline.');
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const result = enqueueMergeItem(db, payload);
|
|
1214
|
+
db.close();
|
|
1215
|
+
|
|
1216
|
+
if (opts.json) {
|
|
1217
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
console.log(`${chalk.green('✓')} Queued ${chalk.cyan(result.id)} for ${chalk.bold(result.target_branch)}`);
|
|
1222
|
+
console.log(` ${chalk.dim('source:')} ${result.source_type} ${result.source_ref}`);
|
|
1223
|
+
if (result.source_worktree) console.log(` ${chalk.dim('worktree:')} ${result.source_worktree}`);
|
|
1224
|
+
} catch (err) {
|
|
1225
|
+
db.close();
|
|
1226
|
+
console.error(chalk.red(err.message));
|
|
1227
|
+
process.exitCode = 1;
|
|
1228
|
+
}
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
queueCmd
|
|
1232
|
+
.command('list')
|
|
1233
|
+
.description('List merge queue items')
|
|
1234
|
+
.option('--status <status>', 'Filter by queue status')
|
|
1235
|
+
.option('--json', 'Output raw JSON')
|
|
1236
|
+
.action((opts) => {
|
|
1237
|
+
const repoRoot = getRepo();
|
|
1238
|
+
const db = getDb(repoRoot);
|
|
1239
|
+
const items = listMergeQueue(db, { status: opts.status || null });
|
|
1240
|
+
db.close();
|
|
1241
|
+
|
|
1242
|
+
if (opts.json) {
|
|
1243
|
+
console.log(JSON.stringify(items, null, 2));
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
if (items.length === 0) {
|
|
1248
|
+
console.log(chalk.dim('Merge queue is empty.'));
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
for (const item of items) {
|
|
1253
|
+
const retryInfo = chalk.dim(`retries:${item.retry_count}/${item.max_retries}`);
|
|
1254
|
+
const attemptInfo = item.last_attempt_at ? ` ${chalk.dim(`last-attempt:${item.last_attempt_at}`)}` : '';
|
|
1255
|
+
console.log(` ${statusBadge(item.status)} ${item.id} ${item.source_type}:${item.source_ref} ${chalk.dim(`→ ${item.target_branch}`)} ${retryInfo}${attemptInfo}`);
|
|
1256
|
+
if (item.last_error_summary) {
|
|
1257
|
+
console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
|
|
1258
|
+
}
|
|
1259
|
+
if (item.next_action) {
|
|
1260
|
+
console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
queueCmd
|
|
1266
|
+
.command('status')
|
|
1267
|
+
.description('Show an operator-friendly merge queue summary')
|
|
1268
|
+
.option('--json', 'Output raw JSON')
|
|
1269
|
+
.action((opts) => {
|
|
1270
|
+
const repoRoot = getRepo();
|
|
1271
|
+
const db = getDb(repoRoot);
|
|
1272
|
+
const items = listMergeQueue(db);
|
|
1273
|
+
const summary = buildQueueStatusSummary(items);
|
|
1274
|
+
const recentEvents = items.slice(0, 5).flatMap((item) =>
|
|
1275
|
+
listMergeQueueEvents(db, item.id, { limit: 3 }).map((event) => ({ ...event, queue_item_id: item.id })),
|
|
1276
|
+
).sort((a, b) => b.id - a.id).slice(0, 8);
|
|
1277
|
+
db.close();
|
|
1278
|
+
|
|
1279
|
+
if (opts.json) {
|
|
1280
|
+
console.log(JSON.stringify({ items, summary, recent_events: recentEvents }, null, 2));
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
console.log(`Queue: ${items.length} item(s)`);
|
|
1285
|
+
console.log(` ${chalk.dim('queued')} ${summary.counts.queued} ${chalk.dim('validating')} ${summary.counts.validating} ${chalk.dim('rebasing')} ${summary.counts.rebasing} ${chalk.dim('merging')} ${summary.counts.merging} ${chalk.dim('retrying')} ${summary.counts.retrying} ${chalk.dim('blocked')} ${summary.counts.blocked} ${chalk.dim('merged')} ${summary.counts.merged}`);
|
|
1286
|
+
if (summary.next) {
|
|
1287
|
+
console.log(` ${chalk.dim('next:')} ${summary.next.id} ${summary.next.source_type}:${summary.next.source_ref} ${chalk.dim(`retries:${summary.next.retry_count}/${summary.next.max_retries}`)}`);
|
|
1288
|
+
}
|
|
1289
|
+
for (const item of summary.blocked) {
|
|
1290
|
+
console.log(` ${statusBadge(item.status)} ${item.id} ${item.source_type}:${item.source_ref} ${chalk.dim(`retries:${item.retry_count}/${item.max_retries}`)}`);
|
|
1291
|
+
if (item.last_error_summary) console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
|
|
1292
|
+
if (item.next_action) console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
|
|
1293
|
+
}
|
|
1294
|
+
if (recentEvents.length > 0) {
|
|
1295
|
+
console.log('');
|
|
1296
|
+
console.log(chalk.bold('Recent Queue Events:'));
|
|
1297
|
+
for (const event of recentEvents) {
|
|
1298
|
+
console.log(` ${chalk.cyan(event.queue_item_id)} ${chalk.dim(event.event_type)} ${chalk.dim(event.status || '')} ${chalk.dim(event.created_at)}`.trim());
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
queueCmd
|
|
1304
|
+
.command('run')
|
|
1305
|
+
.description('Process queued merge items serially')
|
|
1306
|
+
.option('--max-items <n>', 'Maximum queue items to process', '1')
|
|
1307
|
+
.option('--target <branch>', 'Default target branch', 'main')
|
|
1308
|
+
.option('--watch', 'Keep polling for new queue items')
|
|
1309
|
+
.option('--watch-interval-ms <n>', 'Polling interval for --watch mode', '1000')
|
|
1310
|
+
.option('--max-cycles <n>', 'Maximum watch cycles before exiting (mainly for tests)')
|
|
1311
|
+
.option('--json', 'Output raw JSON')
|
|
1312
|
+
.action(async (opts) => {
|
|
1313
|
+
const repoRoot = getRepo();
|
|
1314
|
+
|
|
1315
|
+
try {
|
|
1316
|
+
const watch = Boolean(opts.watch);
|
|
1317
|
+
const watchIntervalMs = Math.max(0, Number.parseInt(opts.watchIntervalMs, 10) || 1000);
|
|
1318
|
+
const maxCycles = opts.maxCycles ? Math.max(1, Number.parseInt(opts.maxCycles, 10) || 1) : null;
|
|
1319
|
+
const aggregate = {
|
|
1320
|
+
processed: [],
|
|
1321
|
+
cycles: 0,
|
|
1322
|
+
watch,
|
|
1323
|
+
};
|
|
1324
|
+
|
|
1325
|
+
while (true) {
|
|
1326
|
+
const db = getDb(repoRoot);
|
|
1327
|
+
const result = await runMergeQueue(db, repoRoot, {
|
|
1328
|
+
maxItems: Number.parseInt(opts.maxItems, 10) || 1,
|
|
1329
|
+
targetBranch: opts.target || 'main',
|
|
1330
|
+
});
|
|
1331
|
+
db.close();
|
|
1332
|
+
|
|
1333
|
+
aggregate.processed.push(...result.processed);
|
|
1334
|
+
aggregate.summary = result.summary;
|
|
1335
|
+
aggregate.cycles += 1;
|
|
1336
|
+
|
|
1337
|
+
if (!watch) break;
|
|
1338
|
+
if (maxCycles && aggregate.cycles >= maxCycles) break;
|
|
1339
|
+
if (result.processed.length === 0) {
|
|
1340
|
+
sleepSync(watchIntervalMs);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
if (opts.json) {
|
|
1345
|
+
console.log(JSON.stringify(aggregate, null, 2));
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
if (aggregate.processed.length === 0) {
|
|
1350
|
+
console.log(chalk.dim('No queued merge items.'));
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
for (const entry of aggregate.processed) {
|
|
1355
|
+
const item = entry.item;
|
|
1356
|
+
if (entry.status === 'merged') {
|
|
1357
|
+
console.log(`${chalk.green('✓')} Merged ${chalk.cyan(item.id)} into ${chalk.bold(item.target_branch)}`);
|
|
1358
|
+
console.log(` ${chalk.dim('commit:')} ${item.merged_commit}`);
|
|
1359
|
+
} else {
|
|
1360
|
+
console.log(`${chalk.red('✗')} Blocked ${chalk.cyan(item.id)}`);
|
|
1361
|
+
console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
|
|
1362
|
+
if (item.next_action) console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
} catch (err) {
|
|
1366
|
+
console.error(chalk.red(err.message));
|
|
1367
|
+
process.exitCode = 1;
|
|
1368
|
+
}
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
queueCmd
|
|
1372
|
+
.command('retry <itemId>')
|
|
1373
|
+
.description('Retry a blocked merge queue item')
|
|
1374
|
+
.option('--json', 'Output raw JSON')
|
|
1375
|
+
.action((itemId, opts) => {
|
|
1376
|
+
const repoRoot = getRepo();
|
|
1377
|
+
const db = getDb(repoRoot);
|
|
1378
|
+
const item = retryMergeQueueItem(db, itemId);
|
|
1379
|
+
db.close();
|
|
1380
|
+
|
|
1381
|
+
if (!item) {
|
|
1382
|
+
console.error(chalk.red(`Queue item ${itemId} is not retryable.`));
|
|
1383
|
+
process.exitCode = 1;
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
if (opts.json) {
|
|
1388
|
+
console.log(JSON.stringify(item, null, 2));
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
console.log(`${chalk.green('✓')} Queue item ${chalk.cyan(item.id)} reset to retrying`);
|
|
1393
|
+
});
|
|
1394
|
+
|
|
1395
|
+
queueCmd
|
|
1396
|
+
.command('remove <itemId>')
|
|
1397
|
+
.description('Remove a merge queue item')
|
|
1398
|
+
.action((itemId) => {
|
|
1399
|
+
const repoRoot = getRepo();
|
|
1400
|
+
const db = getDb(repoRoot);
|
|
1401
|
+
const item = removeMergeQueueItem(db, itemId);
|
|
1402
|
+
db.close();
|
|
1403
|
+
|
|
1404
|
+
if (!item) {
|
|
1405
|
+
console.error(chalk.red(`Queue item ${itemId} does not exist.`));
|
|
1406
|
+
process.exitCode = 1;
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
console.log(`${chalk.green('✓')} Removed ${chalk.cyan(item.id)} from the merge queue`);
|
|
1411
|
+
});
|
|
1412
|
+
|
|
795
1413
|
// ── pipeline ──────────────────────────────────────────────────────────────────
|
|
796
1414
|
|
|
797
1415
|
const pipelineCmd = program.command('pipeline').description('Create and summarize issue-to-PR execution pipelines');
|
|
@@ -1215,13 +1833,18 @@ leaseCmd
|
|
|
1215
1833
|
leaseCmd
|
|
1216
1834
|
.command('reap')
|
|
1217
1835
|
.description('Expire stale leases, release their claims, and return their tasks to pending')
|
|
1218
|
-
.option('--stale-after-minutes <minutes>', 'Age threshold for staleness'
|
|
1836
|
+
.option('--stale-after-minutes <minutes>', 'Age threshold for staleness')
|
|
1219
1837
|
.option('--json', 'Output as JSON')
|
|
1220
1838
|
.action((opts) => {
|
|
1221
1839
|
const repoRoot = getRepo();
|
|
1222
1840
|
const db = getDb(repoRoot);
|
|
1223
|
-
const
|
|
1224
|
-
const
|
|
1841
|
+
const leasePolicy = loadLeasePolicy(repoRoot);
|
|
1842
|
+
const staleAfterMinutes = opts.staleAfterMinutes
|
|
1843
|
+
? Number.parseInt(opts.staleAfterMinutes, 10)
|
|
1844
|
+
: leasePolicy.stale_after_minutes;
|
|
1845
|
+
const expired = reapStaleLeases(db, staleAfterMinutes, {
|
|
1846
|
+
requeueTask: leasePolicy.requeue_task_on_reap,
|
|
1847
|
+
});
|
|
1225
1848
|
db.close();
|
|
1226
1849
|
|
|
1227
1850
|
if (opts.json) {
|
|
@@ -1240,6 +1863,60 @@ leaseCmd
|
|
|
1240
1863
|
}
|
|
1241
1864
|
});
|
|
1242
1865
|
|
|
1866
|
+
const leasePolicyCmd = leaseCmd.command('policy').description('Inspect or update the stale-lease policy for this repo');
|
|
1867
|
+
|
|
1868
|
+
leasePolicyCmd
|
|
1869
|
+
.command('set')
|
|
1870
|
+
.description('Persist a stale-lease policy for this repo')
|
|
1871
|
+
.option('--heartbeat-interval-seconds <seconds>', 'Recommended heartbeat interval')
|
|
1872
|
+
.option('--stale-after-minutes <minutes>', 'Age threshold for staleness')
|
|
1873
|
+
.option('--reap-on-status-check <boolean>', 'Automatically reap stale leases during `switchman status`')
|
|
1874
|
+
.option('--requeue-task-on-reap <boolean>', 'Return stale tasks to pending instead of failing them')
|
|
1875
|
+
.option('--json', 'Output as JSON')
|
|
1876
|
+
.action((opts) => {
|
|
1877
|
+
const repoRoot = getRepo();
|
|
1878
|
+
const current = loadLeasePolicy(repoRoot);
|
|
1879
|
+
const next = {
|
|
1880
|
+
...current,
|
|
1881
|
+
...(opts.heartbeatIntervalSeconds ? { heartbeat_interval_seconds: Number.parseInt(opts.heartbeatIntervalSeconds, 10) } : {}),
|
|
1882
|
+
...(opts.staleAfterMinutes ? { stale_after_minutes: Number.parseInt(opts.staleAfterMinutes, 10) } : {}),
|
|
1883
|
+
...(opts.reapOnStatusCheck ? { reap_on_status_check: opts.reapOnStatusCheck === 'true' } : {}),
|
|
1884
|
+
...(opts.requeueTaskOnReap ? { requeue_task_on_reap: opts.requeueTaskOnReap === 'true' } : {}),
|
|
1885
|
+
};
|
|
1886
|
+
const path = writeLeasePolicy(repoRoot, next);
|
|
1887
|
+
const saved = loadLeasePolicy(repoRoot);
|
|
1888
|
+
|
|
1889
|
+
if (opts.json) {
|
|
1890
|
+
console.log(JSON.stringify({ path, policy: saved }, null, 2));
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
console.log(`${chalk.green('✓')} Lease policy updated`);
|
|
1895
|
+
console.log(` ${chalk.dim(path)}`);
|
|
1896
|
+
console.log(` ${chalk.dim('heartbeat_interval_seconds:')} ${saved.heartbeat_interval_seconds}`);
|
|
1897
|
+
console.log(` ${chalk.dim('stale_after_minutes:')} ${saved.stale_after_minutes}`);
|
|
1898
|
+
console.log(` ${chalk.dim('reap_on_status_check:')} ${saved.reap_on_status_check}`);
|
|
1899
|
+
console.log(` ${chalk.dim('requeue_task_on_reap:')} ${saved.requeue_task_on_reap}`);
|
|
1900
|
+
});
|
|
1901
|
+
|
|
1902
|
+
leasePolicyCmd
|
|
1903
|
+
.description('Show the active stale-lease policy for this repo')
|
|
1904
|
+
.option('--json', 'Output as JSON')
|
|
1905
|
+
.action((opts) => {
|
|
1906
|
+
const repoRoot = getRepo();
|
|
1907
|
+
const policy = loadLeasePolicy(repoRoot);
|
|
1908
|
+
if (opts.json) {
|
|
1909
|
+
console.log(JSON.stringify({ policy }, null, 2));
|
|
1910
|
+
return;
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
console.log(chalk.bold('Lease policy'));
|
|
1914
|
+
console.log(` ${chalk.dim('heartbeat_interval_seconds:')} ${policy.heartbeat_interval_seconds}`);
|
|
1915
|
+
console.log(` ${chalk.dim('stale_after_minutes:')} ${policy.stale_after_minutes}`);
|
|
1916
|
+
console.log(` ${chalk.dim('reap_on_status_check:')} ${policy.reap_on_status_check}`);
|
|
1917
|
+
console.log(` ${chalk.dim('requeue_task_on_reap:')} ${policy.requeue_task_on_reap}`);
|
|
1918
|
+
});
|
|
1919
|
+
|
|
1243
1920
|
// ── worktree ───────────────────────────────────────────────────────────────────
|
|
1244
1921
|
|
|
1245
1922
|
const wtCmd = program.command('worktree').description('Manage worktrees');
|
|
@@ -1501,13 +2178,13 @@ program
|
|
|
1501
2178
|
|
|
1502
2179
|
program
|
|
1503
2180
|
.command('scan')
|
|
1504
|
-
.description('Scan all
|
|
2181
|
+
.description('Scan all workspaces for conflicts')
|
|
1505
2182
|
.option('--json', 'Output raw JSON')
|
|
1506
2183
|
.option('--quiet', 'Only show conflicts')
|
|
1507
2184
|
.action(async (opts) => {
|
|
1508
2185
|
const repoRoot = getRepo();
|
|
1509
2186
|
const db = getDb(repoRoot);
|
|
1510
|
-
const spinner = ora('Scanning
|
|
2187
|
+
const spinner = ora('Scanning workspaces for conflicts...').start();
|
|
1511
2188
|
|
|
1512
2189
|
try {
|
|
1513
2190
|
const report = await scanAllWorktrees(db, repoRoot);
|
|
@@ -1600,7 +2277,7 @@ program
|
|
|
1600
2277
|
|
|
1601
2278
|
// All clear
|
|
1602
2279
|
if (report.conflicts.length === 0 && report.fileConflicts.length === 0 && (report.ownershipConflicts?.length || 0) === 0 && (report.semanticConflicts?.length || 0) === 0 && report.unclaimedChanges.length === 0) {
|
|
1603
|
-
console.log(chalk.green(`✓ No conflicts detected across ${report.worktrees.length}
|
|
2280
|
+
console.log(chalk.green(`✓ No conflicts detected across ${report.worktrees.length} workspace(s)`));
|
|
1604
2281
|
}
|
|
1605
2282
|
|
|
1606
2283
|
} catch (err) {
|
|
@@ -1614,194 +2291,41 @@ program
|
|
|
1614
2291
|
|
|
1615
2292
|
program
|
|
1616
2293
|
.command('status')
|
|
1617
|
-
.description('Show
|
|
1618
|
-
.
|
|
2294
|
+
.description('Show one dashboard view of what is running, blocked, and ready next')
|
|
2295
|
+
.option('--json', 'Output raw JSON')
|
|
2296
|
+
.option('--watch', 'Keep refreshing status in the terminal')
|
|
2297
|
+
.option('--watch-interval-ms <n>', 'Polling interval for --watch mode', '2000')
|
|
2298
|
+
.option('--max-cycles <n>', 'Maximum refresh cycles before exiting', '0')
|
|
2299
|
+
.action(async (opts) => {
|
|
1619
2300
|
const repoRoot = getRepo();
|
|
1620
|
-
const
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
console.log(chalk.dim(`Repo: ${repoRoot}`));
|
|
1625
|
-
console.log('');
|
|
1626
|
-
|
|
1627
|
-
// Tasks
|
|
1628
|
-
const tasks = listTasks(db);
|
|
1629
|
-
const pending = tasks.filter(t => t.status === 'pending');
|
|
1630
|
-
const inProgress = tasks.filter(t => t.status === 'in_progress');
|
|
1631
|
-
const done = tasks.filter(t => t.status === 'done');
|
|
1632
|
-
const failed = tasks.filter(t => t.status === 'failed');
|
|
1633
|
-
const activeLeases = listLeases(db, 'active');
|
|
1634
|
-
const staleLeases = getStaleLeases(db);
|
|
2301
|
+
const watch = Boolean(opts.watch);
|
|
2302
|
+
const watchIntervalMs = Math.max(100, Number.parseInt(opts.watchIntervalMs, 10) || 2000);
|
|
2303
|
+
const maxCycles = Math.max(0, Number.parseInt(opts.maxCycles, 10) || 0);
|
|
2304
|
+
let cycles = 0;
|
|
1635
2305
|
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
console.log(` ${chalk.green('Done')} ${done.length}`);
|
|
1640
|
-
console.log(` ${chalk.red('Failed')} ${failed.length}`);
|
|
1641
|
-
|
|
1642
|
-
if (activeLeases.length > 0) {
|
|
1643
|
-
console.log('');
|
|
1644
|
-
console.log(chalk.bold('Active Leases:'));
|
|
1645
|
-
for (const lease of activeLeases) {
|
|
1646
|
-
const agent = lease.agent ? ` ${chalk.dim(`agent:${lease.agent}`)}` : '';
|
|
1647
|
-
const scope = summarizeLeaseScope(db, lease);
|
|
1648
|
-
const boundaryValidation = getBoundaryValidationState(db, lease.id);
|
|
1649
|
-
const dependencyInvalidations = listDependencyInvalidations(db, { affectedTaskId: lease.task_id });
|
|
1650
|
-
const boundary = boundaryValidation ? ` ${chalk.dim(`validation:${boundaryValidation.status}`)}` : '';
|
|
1651
|
-
const staleMarker = dependencyInvalidations.length > 0 ? ` ${chalk.dim(`stale:${dependencyInvalidations.length}`)}` : '';
|
|
1652
|
-
console.log(` ${chalk.cyan(lease.worktree)} → ${lease.task_title} ${chalk.dim(lease.id)} ${chalk.dim(`task:${lease.task_id}`)}${agent}${scope ? ` ${chalk.dim(scope)}` : ''}${boundary}${staleMarker}`);
|
|
1653
|
-
}
|
|
1654
|
-
} else if (inProgress.length > 0) {
|
|
1655
|
-
console.log('');
|
|
1656
|
-
console.log(chalk.bold('In-Progress Tasks Without Lease:'));
|
|
1657
|
-
for (const t of inProgress) {
|
|
1658
|
-
console.log(` ${chalk.cyan(t.worktree || 'unassigned')} → ${t.title}`);
|
|
2306
|
+
while (true) {
|
|
2307
|
+
if (watch && process.stdout.isTTY && !opts.json) {
|
|
2308
|
+
console.clear();
|
|
1659
2309
|
}
|
|
1660
|
-
}
|
|
1661
2310
|
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
console.log(chalk.bold('Stale Leases:'));
|
|
1665
|
-
for (const lease of staleLeases) {
|
|
1666
|
-
console.log(` ${chalk.red(lease.worktree)} → ${lease.task_title} ${chalk.dim(lease.id)} ${chalk.dim(lease.heartbeat_at)}`);
|
|
1667
|
-
}
|
|
1668
|
-
}
|
|
2311
|
+
const report = await collectStatusSnapshot(repoRoot);
|
|
2312
|
+
cycles += 1;
|
|
1669
2313
|
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
console.log(chalk.bold('Next Up:'));
|
|
1673
|
-
const next = pending.slice(0, 3);
|
|
1674
|
-
for (const t of next) {
|
|
1675
|
-
console.log(` [p${t.priority}] ${t.title} ${chalk.dim(t.id)}`);
|
|
1676
|
-
}
|
|
1677
|
-
}
|
|
1678
|
-
|
|
1679
|
-
if (failed.length > 0) {
|
|
1680
|
-
console.log('');
|
|
1681
|
-
console.log(chalk.bold('Failed Tasks:'));
|
|
1682
|
-
for (const task of failed.slice(0, 5)) {
|
|
1683
|
-
const failureLine = String(task.description || '')
|
|
1684
|
-
.split('\n')
|
|
1685
|
-
.map((line) => line.trim())
|
|
1686
|
-
.filter(Boolean)
|
|
1687
|
-
.reverse()
|
|
1688
|
-
.find((line) => line.startsWith('FAILED: '));
|
|
1689
|
-
const failureText = failureLine ? failureLine.slice('FAILED: '.length) : 'unknown failure';
|
|
1690
|
-
const reasonMatch = failureText.match(/^([a-z0-9_]+):\s*(.+)$/i);
|
|
1691
|
-
const reasonCode = reasonMatch ? reasonMatch[1] : null;
|
|
1692
|
-
const summary = reasonMatch ? reasonMatch[2] : failureText;
|
|
1693
|
-
const nextStep = nextStepForReason(reasonCode);
|
|
1694
|
-
console.log(` ${chalk.red(task.title)} ${chalk.dim(task.id)}`);
|
|
1695
|
-
console.log(` ${chalk.red('why:')} ${summary} ${chalk.dim(`(${humanizeReasonCode(reasonCode)})`)}`);
|
|
1696
|
-
if (nextStep) {
|
|
1697
|
-
console.log(` ${chalk.yellow('next:')} ${nextStep}`);
|
|
1698
|
-
}
|
|
1699
|
-
}
|
|
1700
|
-
}
|
|
1701
|
-
|
|
1702
|
-
// File Claims
|
|
1703
|
-
const claims = getActiveFileClaims(db);
|
|
1704
|
-
if (claims.length > 0) {
|
|
1705
|
-
console.log('');
|
|
1706
|
-
console.log(chalk.bold(`Active File Claims (${claims.length}):`));
|
|
1707
|
-
const byWorktree = {};
|
|
1708
|
-
for (const c of claims) {
|
|
1709
|
-
if (!byWorktree[c.worktree]) byWorktree[c.worktree] = [];
|
|
1710
|
-
byWorktree[c.worktree].push(c.file_path);
|
|
1711
|
-
}
|
|
1712
|
-
for (const [wt, files] of Object.entries(byWorktree)) {
|
|
1713
|
-
console.log(` ${chalk.cyan(wt)}: ${files.slice(0, 5).join(', ')}${files.length > 5 ? ` +${files.length - 5} more` : ''}`);
|
|
1714
|
-
}
|
|
1715
|
-
}
|
|
1716
|
-
|
|
1717
|
-
// Quick conflict scan
|
|
1718
|
-
console.log('');
|
|
1719
|
-
const spinner = ora('Running conflict scan...').start();
|
|
1720
|
-
try {
|
|
1721
|
-
const report = await scanAllWorktrees(db, repoRoot);
|
|
1722
|
-
spinner.stop();
|
|
1723
|
-
|
|
1724
|
-
const totalConflicts = report.conflicts.length + report.fileConflicts.length + (report.ownershipConflicts?.length || 0) + (report.semanticConflicts?.length || 0) + report.unclaimedChanges.length;
|
|
1725
|
-
if (totalConflicts === 0) {
|
|
1726
|
-
console.log(chalk.green(`✓ No conflicts across ${report.worktrees.length} worktree(s)`));
|
|
2314
|
+
if (opts.json) {
|
|
2315
|
+
console.log(JSON.stringify(watch ? { ...report, watch: true, cycles } : report, null, 2));
|
|
1727
2316
|
} else {
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
console.log(chalk.bold('Compliance:'));
|
|
1733
|
-
console.log(` ${chalk.green('Managed')} ${report.complianceSummary.managed}`);
|
|
1734
|
-
console.log(` ${chalk.yellow('Observed')} ${report.complianceSummary.observed}`);
|
|
1735
|
-
console.log(` ${chalk.red('Non-Compliant')} ${report.complianceSummary.non_compliant}`);
|
|
1736
|
-
console.log(` ${chalk.red('Stale')} ${report.complianceSummary.stale}`);
|
|
1737
|
-
|
|
1738
|
-
if (report.unclaimedChanges.length > 0) {
|
|
1739
|
-
console.log('');
|
|
1740
|
-
console.log(chalk.bold('Unclaimed Changed Paths:'));
|
|
1741
|
-
for (const entry of report.unclaimedChanges) {
|
|
1742
|
-
const reasonCode = entry.reasons?.[0]?.reason_code || null;
|
|
1743
|
-
const nextStep = nextStepForReason(reasonCode);
|
|
1744
|
-
console.log(` ${chalk.cyan(entry.worktree)}: ${entry.files.slice(0, 5).join(', ')}${entry.files.length > 5 ? ` +${entry.files.length - 5} more` : ''}`);
|
|
1745
|
-
console.log(` ${chalk.dim(humanizeReasonCode(reasonCode))}${nextStep ? ` — ${nextStep}` : ''}`);
|
|
1746
|
-
}
|
|
1747
|
-
}
|
|
1748
|
-
|
|
1749
|
-
if ((report.ownershipConflicts?.length || 0) > 0) {
|
|
1750
|
-
console.log('');
|
|
1751
|
-
console.log(chalk.bold('Ownership Boundary Overlaps:'));
|
|
1752
|
-
for (const conflict of report.ownershipConflicts.slice(0, 5)) {
|
|
1753
|
-
if (conflict.type === 'subsystem_overlap') {
|
|
1754
|
-
console.log(` ${chalk.cyan(conflict.worktreeA)}: ${chalk.dim(`subsystem:${conflict.subsystemTag}`)} ${chalk.dim('vs')} ${chalk.cyan(conflict.worktreeB)}`);
|
|
1755
|
-
} else {
|
|
1756
|
-
console.log(` ${chalk.cyan(conflict.worktreeA)}: ${chalk.dim(conflict.scopeA)} ${chalk.dim('vs')} ${chalk.cyan(conflict.worktreeB)} ${chalk.dim(conflict.scopeB)}`);
|
|
1757
|
-
}
|
|
1758
|
-
}
|
|
1759
|
-
}
|
|
1760
|
-
|
|
1761
|
-
if ((report.semanticConflicts?.length || 0) > 0) {
|
|
1762
|
-
console.log('');
|
|
1763
|
-
console.log(chalk.bold('Semantic Overlaps:'));
|
|
1764
|
-
for (const conflict of report.semanticConflicts.slice(0, 5)) {
|
|
1765
|
-
console.log(` ${chalk.cyan(conflict.worktreeA)}: ${chalk.dim(conflict.object_name)} ${chalk.dim('vs')} ${chalk.cyan(conflict.worktreeB)} ${chalk.dim(`${conflict.fileA} ↔ ${conflict.fileB}`)}`);
|
|
1766
|
-
}
|
|
1767
|
-
}
|
|
1768
|
-
|
|
1769
|
-
const staleInvalidations = listDependencyInvalidations(db, { status: 'stale' });
|
|
1770
|
-
if (staleInvalidations.length > 0) {
|
|
1771
|
-
console.log('');
|
|
1772
|
-
console.log(chalk.bold('Stale For Revalidation:'));
|
|
1773
|
-
for (const invalidation of staleInvalidations.slice(0, 5)) {
|
|
1774
|
-
const staleArea = invalidation.reason_type === 'subsystem_overlap'
|
|
1775
|
-
? `subsystem:${invalidation.subsystem_tag}`
|
|
1776
|
-
: `${invalidation.source_scope_pattern} ↔ ${invalidation.affected_scope_pattern}`;
|
|
1777
|
-
console.log(` ${chalk.cyan(invalidation.affected_worktree || 'unknown')}: ${chalk.dim(staleArea)} ${chalk.dim('because')} ${chalk.cyan(invalidation.source_worktree || 'unknown')} changed it`);
|
|
2317
|
+
renderUnifiedStatusReport(report);
|
|
2318
|
+
if (watch) {
|
|
2319
|
+
console.log('');
|
|
2320
|
+
console.log(chalk.dim(`Watching every ${watchIntervalMs}ms${maxCycles > 0 ? ` • cycle ${cycles}/${maxCycles}` : ''}`));
|
|
1778
2321
|
}
|
|
1779
2322
|
}
|
|
1780
2323
|
|
|
1781
|
-
if (
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
console.log(` ${chalk.red(failure.worktree || 'unknown')} ${chalk.dim(humanizeReasonCode(failure.reason_code || 'rejected'))} ${chalk.dim(failure.created_at)}`);
|
|
1786
|
-
}
|
|
1787
|
-
}
|
|
1788
|
-
|
|
1789
|
-
if (report.deniedWrites.length > 0) {
|
|
1790
|
-
console.log('');
|
|
1791
|
-
console.log(chalk.bold('Recent Denied Events:'));
|
|
1792
|
-
for (const event of report.deniedWrites.slice(0, 5)) {
|
|
1793
|
-
console.log(` ${chalk.red(event.event_type)} ${chalk.cyan(event.worktree || 'repo')} ${chalk.dim(humanizeReasonCode(event.reason_code || event.status))}`);
|
|
1794
|
-
}
|
|
1795
|
-
}
|
|
1796
|
-
} catch {
|
|
1797
|
-
spinner.stop();
|
|
1798
|
-
console.log(chalk.dim('Could not run conflict scan'));
|
|
2324
|
+
if (!watch) break;
|
|
2325
|
+
if (maxCycles > 0 && cycles >= maxCycles) break;
|
|
2326
|
+
if (opts.json) break;
|
|
2327
|
+
sleepSync(watchIntervalMs);
|
|
1799
2328
|
}
|
|
1800
|
-
|
|
1801
|
-
db.close();
|
|
1802
|
-
console.log('');
|
|
1803
|
-
console.log(chalk.dim('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
|
|
1804
|
-
console.log('');
|
|
1805
2329
|
});
|
|
1806
2330
|
|
|
1807
2331
|
program
|
|
@@ -1891,7 +2415,7 @@ program
|
|
|
1891
2415
|
|
|
1892
2416
|
// ── gate ─────────────────────────────────────────────────────────────────────
|
|
1893
2417
|
|
|
1894
|
-
const gateCmd = program.command('gate').description('
|
|
2418
|
+
const gateCmd = program.command('gate').description('Safety checks for edits, merges, and CI');
|
|
1895
2419
|
|
|
1896
2420
|
const auditCmd = program.command('audit').description('Inspect and verify the tamper-evident audit trail');
|
|
1897
2421
|
|
|
@@ -2103,7 +2627,7 @@ gateCmd
|
|
|
2103
2627
|
|
|
2104
2628
|
gateCmd
|
|
2105
2629
|
.command('ai')
|
|
2106
|
-
.description('Run the AI-style merge
|
|
2630
|
+
.description('Run the AI-style merge check to assess risky overlap across workspaces')
|
|
2107
2631
|
.option('--json', 'Output raw JSON')
|
|
2108
2632
|
.action(async (opts) => {
|
|
2109
2633
|
const repoRoot = getRepo();
|
|
@@ -2270,7 +2794,7 @@ objectCmd
|
|
|
2270
2794
|
|
|
2271
2795
|
// ── monitor ──────────────────────────────────────────────────────────────────
|
|
2272
2796
|
|
|
2273
|
-
const monitorCmd = program.command('monitor').description('Observe
|
|
2797
|
+
const monitorCmd = program.command('monitor').description('Observe workspaces for runtime file changes');
|
|
2274
2798
|
|
|
2275
2799
|
monitorCmd
|
|
2276
2800
|
.command('once')
|
|
@@ -2304,7 +2828,7 @@ monitorCmd
|
|
|
2304
2828
|
|
|
2305
2829
|
monitorCmd
|
|
2306
2830
|
.command('watch')
|
|
2307
|
-
.description('Poll
|
|
2831
|
+
.description('Poll workspaces continuously and log observed file changes')
|
|
2308
2832
|
.option('--interval-ms <ms>', 'Polling interval in milliseconds', '2000')
|
|
2309
2833
|
.option('--quarantine', 'Move or restore denied runtime changes immediately after detection')
|
|
2310
2834
|
.option('--daemonized', 'Internal flag used by monitor start', false)
|
|
@@ -2317,7 +2841,7 @@ monitorCmd
|
|
|
2317
2841
|
process.exit(1);
|
|
2318
2842
|
}
|
|
2319
2843
|
|
|
2320
|
-
console.log(chalk.cyan(`Watching
|
|
2844
|
+
console.log(chalk.cyan(`Watching workspaces every ${intervalMs}ms. Press Ctrl+C to stop.`));
|
|
2321
2845
|
|
|
2322
2846
|
let stopped = false;
|
|
2323
2847
|
const stop = () => {
|