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/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 worktree
10
+ * switchman task assign - Assign task to a workspace
11
11
  * switchman task done - Mark task complete
12
- * switchman worktree add - Register a worktree
13
- * switchman worktree list - List registered worktrees
14
- * switchman scan - Scan for conflicts across worktrees
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 full system status
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 { upsertProjectMcpConfig } from '../core/mcp.js';
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.map((targetDir) => upsertProjectMcpConfig(targetDir));
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 worktrees and initialise switchman')
574
- .option('-a, --agents <n>', 'Number of agent worktrees to create (default: 3)', '3')
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 worktrees can be created.');
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 .mcp.json will attach Switchman automatically`);
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 queue');
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 worktree (compatibility shim for lease acquire)')
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>', 'Worktree to assign the task to (defaults to current 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', String(DEFAULT_STALE_LEASE_MINUTES))
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 staleAfterMinutes = Number.parseInt(opts.staleAfterMinutes, 10);
1224
- const expired = reapStaleLeases(db, staleAfterMinutes);
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 worktrees for conflicts')
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 worktrees for conflicts...').start();
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} worktree(s)`));
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 full system status: tasks, worktrees, claims, and conflicts')
1618
- .action(async () => {
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 db = getDb(repoRoot);
1621
-
1622
- console.log('');
1623
- console.log(chalk.bold.cyan('━━━ switchman status ━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
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
- console.log(chalk.bold('Tasks:'));
1637
- console.log(` ${chalk.yellow('Pending')} ${pending.length}`);
1638
- console.log(` ${chalk.blue('In Progress')} ${inProgress.length}`);
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
- if (staleLeases.length > 0) {
1663
- console.log('');
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
- if (pending.length > 0) {
1671
- console.log('');
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
- console.log(chalk.red(`⚠ ${totalConflicts} conflict(s) detected — run 'switchman scan' for details`));
1729
- }
1730
-
1731
- console.log('');
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 (report.commitGateFailures.length > 0) {
1782
- console.log('');
1783
- console.log(chalk.bold('Recent Commit Gate Failures:'));
1784
- for (const failure of report.commitGateFailures.slice(0, 5)) {
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('Enforcement and commit-gate helpers');
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 gate to assess semantic integration risk across worktrees')
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 worktrees for runtime file mutations');
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 worktrees continuously and log observed file changes')
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 worktrees every ${intervalMs}ms. Press Ctrl+C to stop.`));
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 = () => {