switchman-dev 0.1.2 → 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,35 +7,46 @@
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';
20
20
  import chalk from 'chalk';
21
21
  import ora from 'ora';
22
- import { readFileSync } from 'fs';
23
- import { join, dirname } from 'path';
24
- import { fileURLToPath } from 'url';
25
- import { execSync } from 'child_process';
22
+ import { join } from 'path';
23
+ import { execSync, spawn } from 'child_process';
26
24
 
27
25
  import { findRepoRoot, listGitWorktrees, createGitWorktree } from '../core/git.js';
28
26
  import {
29
27
  initDb, openDb,
30
28
  DEFAULT_STALE_LEASE_MINUTES,
31
- createTask, startTaskLease, completeTask, failTask, listTasks, getTask, getNextPendingTask,
32
- listLeases, heartbeatLease, getStaleLeases, reapStaleLeases,
29
+ createTask, startTaskLease, completeTask, failTask, getBoundaryValidationState, getTaskSpec, listTasks, getTask, getNextPendingTask,
30
+ listDependencyInvalidations, listLeases, listScopeReservations, heartbeatLease, getStaleLeases, reapStaleLeases,
33
31
  registerWorktree, listWorktrees,
32
+ enqueueMergeItem, getMergeQueueItem, listMergeQueue, listMergeQueueEvents, removeMergeQueueItem, retryMergeQueueItem,
34
33
  claimFiles, releaseFileClaims, getActiveFileClaims, checkFileConflicts,
34
+ verifyAuditTrail,
35
35
  } from '../core/db.js';
36
36
  import { scanAllWorktrees } from '../core/detector.js';
37
-
38
- const __dirname = dirname(fileURLToPath(import.meta.url));
37
+ import { getWindsurfMcpConfigPath, upsertAllProjectMcpConfigs, upsertWindsurfMcpConfig } from '../core/mcp.js';
38
+ import { gatewayAppendFile, gatewayMakeDirectory, gatewayMovePath, gatewayRemovePath, gatewayWriteFile, installGateHooks, monitorWorktreesOnce, runCommitGate, runWrappedCommand, writeEnforcementPolicy } from '../core/enforcement.js';
39
+ import { runAiMergeGate } from '../core/merge-gate.js';
40
+ import { clearMonitorState, getMonitorStatePath, isProcessRunning, readMonitorState, writeMonitorState } from '../core/monitor.js';
41
+ import { buildPipelinePrSummary, createPipelineFollowupTasks, executePipeline, exportPipelinePrBundle, getPipelineStatus, publishPipelinePr, runPipeline, startPipeline } from '../core/pipeline.js';
42
+ import { installGitHubActionsWorkflow, resolveGitHubOutputTargets, writeGitHubCiStatus } from '../core/ci.js';
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';
46
+
47
+ function installMcpConfig(targetDirs) {
48
+ return targetDirs.flatMap((targetDir) => upsertAllProjectMcpConfigs(targetDir));
49
+ }
39
50
 
40
51
  // ─── Helpers ─────────────────────────────────────────────────────────────────
41
52
 
@@ -68,6 +79,18 @@ function statusBadge(status) {
68
79
  expired: chalk.red,
69
80
  idle: chalk.gray,
70
81
  busy: chalk.blue,
82
+ managed: chalk.green,
83
+ observed: chalk.yellow,
84
+ non_compliant: chalk.red,
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,
71
94
  };
72
95
  return (colors[status] || chalk.white)(status.toUpperCase().padEnd(11));
73
96
  }
@@ -105,6 +128,730 @@ function printTable(rows, columns) {
105
128
  }
106
129
  }
107
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
+
177
+ function sleepSync(ms) {
178
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
179
+ }
180
+
181
+ function summarizeLeaseScope(db, lease) {
182
+ const reservations = listScopeReservations(db, { leaseId: lease.id });
183
+ const pathScopes = reservations
184
+ .filter((reservation) => reservation.ownership_level === 'path_scope' && reservation.scope_pattern)
185
+ .map((reservation) => reservation.scope_pattern);
186
+ if (pathScopes.length === 1) return `scope:${pathScopes[0]}`;
187
+ if (pathScopes.length > 1) return `scope:${pathScopes.length} paths`;
188
+
189
+ const subsystemScopes = reservations
190
+ .filter((reservation) => reservation.ownership_level === 'subsystem' && reservation.subsystem_tag)
191
+ .map((reservation) => reservation.subsystem_tag);
192
+ if (subsystemScopes.length === 1) return `subsystem:${subsystemScopes[0]}`;
193
+ if (subsystemScopes.length > 1) return `subsystem:${subsystemScopes.length}`;
194
+ return null;
195
+ }
196
+
197
+ function isBusyError(err) {
198
+ const message = String(err?.message || '').toLowerCase();
199
+ return message.includes('database is locked') || message.includes('sqlite_busy');
200
+ }
201
+
202
+ function humanizeReasonCode(reasonCode) {
203
+ const labels = {
204
+ no_active_lease: 'no active lease',
205
+ lease_expired: 'lease expired',
206
+ worktree_mismatch: 'wrong worktree',
207
+ path_not_claimed: 'path not claimed',
208
+ path_claimed_by_other_lease: 'claimed by another lease',
209
+ path_scoped_by_other_lease: 'scoped by another lease',
210
+ path_within_task_scope: 'within task scope',
211
+ policy_exception_required: 'policy exception required',
212
+ policy_exception_allowed: 'policy exception allowed',
213
+ changes_outside_claims: 'changed files outside claims',
214
+ changes_outside_task_scope: 'changed files outside task scope',
215
+ missing_expected_tests: 'missing expected tests',
216
+ missing_expected_docs: 'missing expected docs',
217
+ missing_expected_source_changes: 'missing expected source changes',
218
+ objective_not_evidenced: 'task objective not evidenced',
219
+ no_changes_detected: 'no changes detected',
220
+ task_execution_timeout: 'task execution timed out',
221
+ task_failed: 'task failed',
222
+ agent_command_failed: 'agent command failed',
223
+ rejected: 'rejected',
224
+ };
225
+ return labels[reasonCode] || String(reasonCode || 'unknown').replace(/_/g, ' ');
226
+ }
227
+
228
+ function nextStepForReason(reasonCode) {
229
+ const actions = {
230
+ no_active_lease: 'reacquire the task or lease before writing',
231
+ lease_expired: 'refresh or reacquire the lease, then retry',
232
+ worktree_mismatch: 'run the task from the assigned worktree',
233
+ path_not_claimed: 'claim the file before editing it',
234
+ path_claimed_by_other_lease: 'wait for the other task or pick a different file',
235
+ changes_outside_claims: 'claim all edited files or narrow the task scope',
236
+ changes_outside_task_scope: 'keep edits inside allowed paths or update the plan',
237
+ missing_expected_tests: 'add test coverage before rerunning',
238
+ missing_expected_docs: 'add the expected docs change before rerunning',
239
+ missing_expected_source_changes: 'make a source change inside the task scope',
240
+ objective_not_evidenced: 'align the output more closely to the task objective',
241
+ no_changes_detected: 'produce a tracked change or close the task differently',
242
+ task_execution_timeout: 'raise the timeout or reduce task size',
243
+ agent_command_failed: 'inspect stderr/stdout and rerun the agent',
244
+ };
245
+ return actions[reasonCode] || null;
246
+ }
247
+
248
+ function latestTaskFailure(task) {
249
+ const failureLine = String(task.description || '')
250
+ .split('\n')
251
+ .map((line) => line.trim())
252
+ .filter(Boolean)
253
+ .reverse()
254
+ .find((line) => line.startsWith('FAILED: '));
255
+ if (!failureLine) return null;
256
+ const failureText = failureLine.slice('FAILED: '.length);
257
+ const reasonMatch = failureText.match(/^([a-z0-9_]+):\s*(.+)$/i);
258
+ return {
259
+ reason_code: reasonMatch ? reasonMatch[1] : null,
260
+ summary: reasonMatch ? reasonMatch[2] : failureText,
261
+ };
262
+ }
263
+
264
+ function analyzeTaskScope(title, description = '') {
265
+ const text = `${title}\n${description}`.toLowerCase();
266
+ const broadPatterns = [
267
+ /\brefactor\b/,
268
+ /\bwhole repo\b/,
269
+ /\bentire repo\b/,
270
+ /\bacross the repo\b/,
271
+ /\bacross the codebase\b/,
272
+ /\bmultiple modules\b/,
273
+ /\ball routes\b/,
274
+ /\bevery route\b/,
275
+ /\ball files\b/,
276
+ /\bevery file\b/,
277
+ /\brename\b.*\bacross\b/,
278
+ /\bsweep(ing)?\b/,
279
+ /\bglobal\b/,
280
+ /\bwide\b/,
281
+ /\blarge\b/,
282
+ ];
283
+ const matches = broadPatterns.filter((pattern) => pattern.test(text));
284
+ if (matches.length === 0) return null;
285
+
286
+ return {
287
+ level: 'warn',
288
+ summary: 'This task looks broad and may fan out across many files or shared areas.',
289
+ next_step: 'Split it into smaller tasks or use `switchman pipeline start` so Switchman can plan and govern the work explicitly.',
290
+ command: `switchman pipeline start "${title.replace(/"/g, '\\"')}"`,
291
+ };
292
+ }
293
+
294
+ function commandForFailedTask(task, failure) {
295
+ if (!task?.id) return null;
296
+ switch (failure?.reason_code) {
297
+ case 'changes_outside_task_scope':
298
+ case 'objective_not_evidenced':
299
+ case 'missing_expected_tests':
300
+ case 'missing_expected_docs':
301
+ case 'missing_expected_source_changes':
302
+ case 'no_changes_detected':
303
+ return `switchman pipeline status ${task.id.split('-').slice(0, -1).join('-')}`;
304
+ default:
305
+ return null;
306
+ }
307
+ }
308
+
309
+ function buildDoctorReport({ db, repoRoot, tasks, activeLeases, staleLeases, scanReport, aiGate }) {
310
+ const failedTasks = tasks
311
+ .filter((task) => task.status === 'failed')
312
+ .map((task) => {
313
+ const failure = latestTaskFailure(task);
314
+ return {
315
+ id: task.id,
316
+ title: task.title,
317
+ worktree: task.worktree || null,
318
+ reason_code: failure?.reason_code || null,
319
+ summary: failure?.summary || 'task failed without a recorded summary',
320
+ next_step: nextStepForReason(failure?.reason_code) || 'inspect the task output and rerun with a narrower scope',
321
+ command: commandForFailedTask(task, failure),
322
+ };
323
+ });
324
+
325
+ const blockedWorktrees = scanReport.unclaimedChanges.map((entry) => ({
326
+ worktree: entry.worktree,
327
+ files: entry.files,
328
+ reason_code: entry.reasons?.[0]?.reason_code || null,
329
+ next_step: nextStepForReason(entry.reasons?.[0]?.reason_code) || 'inspect the changed files and bring them back under Switchman claims',
330
+ }));
331
+
332
+ const fileConflicts = scanReport.fileConflicts.map((conflict) => ({
333
+ file: conflict.file,
334
+ worktrees: conflict.worktrees,
335
+ next_step: 'let one task finish first or re-scope the conflicting work',
336
+ }));
337
+
338
+ const ownershipConflicts = (scanReport.ownershipConflicts || []).map((conflict) => ({
339
+ type: conflict.type,
340
+ worktree_a: conflict.worktreeA,
341
+ worktree_b: conflict.worktreeB,
342
+ subsystem_tag: conflict.subsystemTag || null,
343
+ scope_a: conflict.scopeA || null,
344
+ scope_b: conflict.scopeB || null,
345
+ next_step: 'split the task scopes or serialize work across the shared ownership boundary',
346
+ }));
347
+ const semanticConflicts = (scanReport.semanticConflicts || []).map((conflict) => ({
348
+ ...conflict,
349
+ next_step: 'review the overlapping exported object or split the work across different boundaries',
350
+ }));
351
+
352
+ const branchConflicts = scanReport.conflicts.map((conflict) => ({
353
+ worktree_a: conflict.worktreeA,
354
+ worktree_b: conflict.worktreeB,
355
+ files: conflict.conflictingFiles,
356
+ next_step: 'review the overlapping branches before merge',
357
+ }));
358
+
359
+ const attention = [
360
+ ...staleLeases.map((lease) => ({
361
+ kind: 'stale_lease',
362
+ title: `${lease.worktree} lost its active heartbeat`,
363
+ detail: lease.task_title,
364
+ next_step: 'run `switchman lease reap` to return the task to pending',
365
+ command: 'switchman lease reap',
366
+ severity: 'block',
367
+ })),
368
+ ...failedTasks.map((task) => ({
369
+ kind: 'failed_task',
370
+ title: task.title,
371
+ detail: task.summary,
372
+ next_step: task.next_step,
373
+ command: task.command,
374
+ severity: 'warn',
375
+ })),
376
+ ...blockedWorktrees.map((entry) => ({
377
+ kind: 'unmanaged_changes',
378
+ title: `${entry.worktree} has unmanaged changed files`,
379
+ detail: `${entry.files.slice(0, 3).join(', ')}${entry.files.length > 3 ? ` +${entry.files.length - 3} more` : ''}`,
380
+ next_step: entry.next_step,
381
+ command: 'switchman scan',
382
+ severity: 'block',
383
+ })),
384
+ ...fileConflicts.map((conflict) => ({
385
+ kind: 'file_conflict',
386
+ title: `${conflict.file} is being edited in multiple worktrees`,
387
+ detail: conflict.worktrees.join(', '),
388
+ next_step: conflict.next_step,
389
+ command: 'switchman scan',
390
+ severity: 'block',
391
+ })),
392
+ ...ownershipConflicts.map((conflict) => ({
393
+ kind: 'ownership_conflict',
394
+ title: conflict.type === 'subsystem_overlap'
395
+ ? `${conflict.worktree_a} and ${conflict.worktree_b} share subsystem ownership`
396
+ : `${conflict.worktree_a} and ${conflict.worktree_b} share scoped ownership`,
397
+ detail: conflict.type === 'subsystem_overlap'
398
+ ? `subsystem:${conflict.subsystem_tag}`
399
+ : `${conflict.scope_a} ↔ ${conflict.scope_b}`,
400
+ next_step: conflict.next_step,
401
+ command: 'switchman scan',
402
+ severity: 'block',
403
+ })),
404
+ ...semanticConflicts.map((conflict) => ({
405
+ kind: 'semantic_conflict',
406
+ title: conflict.type === 'semantic_object_overlap'
407
+ ? `${conflict.worktreeA} and ${conflict.worktreeB} changed the same exported object`
408
+ : `${conflict.worktreeA} and ${conflict.worktreeB} changed semantically similar objects`,
409
+ detail: `${conflict.object_name} (${conflict.fileA} ↔ ${conflict.fileB})`,
410
+ next_step: conflict.next_step,
411
+ command: 'switchman gate ai',
412
+ severity: conflict.severity === 'blocked' ? 'block' : 'warn',
413
+ })),
414
+ ...branchConflicts.map((conflict) => ({
415
+ kind: 'branch_conflict',
416
+ title: `${conflict.worktree_a} and ${conflict.worktree_b} have merge risk`,
417
+ detail: `${conflict.files.slice(0, 3).join(', ')}${conflict.files.length > 3 ? ` +${conflict.files.length - 3} more` : ''}`,
418
+ next_step: conflict.next_step,
419
+ command: 'switchman gate ai',
420
+ severity: 'block',
421
+ })),
422
+ ];
423
+
424
+ if (aiGate.status === 'warn' || aiGate.status === 'blocked') {
425
+ attention.push({
426
+ kind: 'ai_merge_gate',
427
+ title: aiGate.status === 'blocked' ? 'AI merge gate blocked the repo' : 'AI merge gate wants manual review',
428
+ detail: aiGate.summary,
429
+ next_step: 'run `switchman gate ai` and review the risky worktree pairs',
430
+ command: 'switchman gate ai',
431
+ severity: aiGate.status === 'blocked' ? 'block' : 'warn',
432
+ });
433
+ }
434
+
435
+ for (const validation of aiGate.boundary_validations || []) {
436
+ attention.push({
437
+ kind: 'boundary_validation',
438
+ title: validation.summary,
439
+ detail: validation.rationale?.[0] || `missing ${validation.missing_task_types.join(', ')}`,
440
+ next_step: 'complete the missing validation work before merge',
441
+ command: validation.pipeline_id ? `switchman pipeline status ${validation.pipeline_id}` : 'switchman gate ai',
442
+ severity: validation.severity === 'blocked' ? 'block' : 'warn',
443
+ });
444
+ }
445
+
446
+ for (const invalidation of aiGate.dependency_invalidations || []) {
447
+ attention.push({
448
+ kind: 'dependency_invalidation',
449
+ title: invalidation.summary,
450
+ detail: `${invalidation.source_worktree || 'unknown'} -> ${invalidation.affected_worktree || 'unknown'} (${invalidation.stale_area})`,
451
+ next_step: 'rerun or re-review the stale task before merge',
452
+ command: invalidation.affected_pipeline_id ? `switchman pipeline status ${invalidation.affected_pipeline_id}` : 'switchman gate ai',
453
+ severity: invalidation.severity === 'blocked' ? 'block' : 'warn',
454
+ });
455
+ }
456
+
457
+ const health = attention.some((item) => item.severity === 'block')
458
+ ? 'block'
459
+ : attention.some((item) => item.severity === 'warn')
460
+ ? 'warn'
461
+ : 'healthy';
462
+
463
+ return {
464
+ repo_root: repoRoot,
465
+ health,
466
+ summary: health === 'healthy'
467
+ ? 'Repo looks healthy. Agents are coordinated and merge checks are clear.'
468
+ : health === 'warn'
469
+ ? 'Repo is running, but there are issues that need review before merge.'
470
+ : 'Repo needs attention before more work or merge.',
471
+ counts: {
472
+ pending: tasks.filter((task) => task.status === 'pending').length,
473
+ in_progress: tasks.filter((task) => task.status === 'in_progress').length,
474
+ done: tasks.filter((task) => task.status === 'done').length,
475
+ failed: failedTasks.length,
476
+ active_leases: activeLeases.length,
477
+ stale_leases: staleLeases.length,
478
+ },
479
+ active_work: activeLeases.map((lease) => ({
480
+ worktree: lease.worktree,
481
+ task_id: lease.task_id,
482
+ task_title: lease.task_title,
483
+ heartbeat_at: lease.heartbeat_at,
484
+ scope_summary: summarizeLeaseScope(db, lease),
485
+ boundary_validation: getBoundaryValidationState(db, lease.id),
486
+ dependency_invalidations: listDependencyInvalidations(db, { affectedTaskId: lease.task_id }),
487
+ })),
488
+ attention,
489
+ merge_readiness: {
490
+ ci_gate_ok: scanReport.conflicts.length === 0
491
+ && scanReport.fileConflicts.length === 0
492
+ && (scanReport.ownershipConflicts?.length || 0) === 0
493
+ && (scanReport.semanticConflicts?.length || 0) === 0
494
+ && scanReport.unclaimedChanges.length === 0
495
+ && scanReport.complianceSummary.non_compliant === 0
496
+ && scanReport.complianceSummary.stale === 0
497
+ && aiGate.status !== 'blocked'
498
+ && (aiGate.dependency_invalidations || []).filter((item) => item.severity === 'blocked').length === 0,
499
+ ai_gate_status: aiGate.status,
500
+ boundary_validations: aiGate.boundary_validations || [],
501
+ dependency_invalidations: aiGate.dependency_invalidations || [],
502
+ compliance: scanReport.complianceSummary,
503
+ semantic_conflicts: scanReport.semanticConflicts || [],
504
+ },
505
+ next_steps: attention.length > 0
506
+ ? [...new Set(attention.map((item) => item.next_step))].slice(0, 5)
507
+ : ['run `switchman gate ci` before merge', 'run `switchman scan` after major parallel work'],
508
+ suggested_commands: attention.length > 0
509
+ ? [...new Set(attention.map((item) => item.command).filter(Boolean))].slice(0, 5)
510
+ : ['switchman gate ci', 'switchman scan'],
511
+ };
512
+ }
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
+
786
+ function acquireNextTaskLease(db, worktreeName, agent, attempts = 20) {
787
+ for (let attempt = 1; attempt <= attempts; attempt++) {
788
+ try {
789
+ const task = getNextPendingTask(db);
790
+ if (!task) {
791
+ return { task: null, lease: null, exhausted: true };
792
+ }
793
+
794
+ const lease = startTaskLease(db, task.id, worktreeName, agent || null);
795
+ if (lease) {
796
+ return { task, lease, exhausted: false };
797
+ }
798
+ } catch (err) {
799
+ if (!isBusyError(err) || attempt === attempts) {
800
+ throw err;
801
+ }
802
+ }
803
+
804
+ if (attempt < attempts) {
805
+ sleepSync(75 * attempt);
806
+ }
807
+ }
808
+
809
+ return { task: null, lease: null, exhausted: false };
810
+ }
811
+
812
+ function acquireNextTaskLeaseWithRetries(repoRoot, worktreeName, agent, attempts = 20) {
813
+ let lastError = null;
814
+ for (let attempt = 1; attempt <= attempts; attempt++) {
815
+ let db = null;
816
+ try {
817
+ db = openDb(repoRoot);
818
+ const result = acquireNextTaskLease(db, worktreeName, agent, attempts);
819
+ db.close();
820
+ return result;
821
+ } catch (err) {
822
+ lastError = err;
823
+ try { db?.close(); } catch { /* no-op */ }
824
+ if (!isBusyError(err) || attempt === attempts) {
825
+ throw err;
826
+ }
827
+ sleepSync(100 * attempt);
828
+ }
829
+ }
830
+ throw lastError;
831
+ }
832
+
833
+ function completeTaskWithRetries(repoRoot, taskId, attempts = 20) {
834
+ let lastError = null;
835
+ for (let attempt = 1; attempt <= attempts; attempt++) {
836
+ let db = null;
837
+ try {
838
+ db = openDb(repoRoot);
839
+ completeTask(db, taskId);
840
+ releaseFileClaims(db, taskId);
841
+ db.close();
842
+ return;
843
+ } catch (err) {
844
+ lastError = err;
845
+ try { db?.close(); } catch { /* no-op */ }
846
+ if (!isBusyError(err) || attempt === attempts) {
847
+ throw err;
848
+ }
849
+ sleepSync(100 * attempt);
850
+ }
851
+ }
852
+ throw lastError;
853
+ }
854
+
108
855
  // ─── Program ──────────────────────────────────────────────────────────────────
109
856
 
110
857
  program
@@ -129,10 +876,13 @@ program
129
876
  registerWorktree(db, { name: wt.name, path: wt.path, branch: wt.branch || 'unknown' });
130
877
  }
131
878
 
879
+ const mcpConfigWrites = installMcpConfig([...new Set([repoRoot, ...gitWorktrees.map((wt) => wt.path)])]);
880
+
132
881
  db.close();
133
882
  spinner.succeed(`Initialized in ${chalk.cyan(repoRoot)}`);
134
883
  console.log(chalk.dim(` Found and registered ${gitWorktrees.length} git worktree(s)`));
135
884
  console.log(chalk.dim(` Database: .switchman/switchman.db`));
885
+ console.log(chalk.dim(` MCP config: ${mcpConfigWrites.filter((result) => result.changed).length} file(s) written`));
136
886
  console.log('');
137
887
  console.log(`Next steps:`);
138
888
  console.log(` ${chalk.cyan('switchman task add "Fix the login bug"')} — add a task`);
@@ -149,8 +899,8 @@ program
149
899
 
150
900
  program
151
901
  .command('setup')
152
- .description('One-command setup: create agent worktrees and initialise switchman')
153
- .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')
154
904
  .option('--prefix <prefix>', 'Branch prefix (default: switchman)', 'switchman')
155
905
  .action((opts) => {
156
906
  const agentCount = parseInt(opts.agents);
@@ -171,7 +921,7 @@ program
171
921
  stdio: ['pipe', 'pipe', 'pipe'],
172
922
  });
173
923
  } catch {
174
- 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.');
175
925
  console.log(chalk.dim(' Run: git commit --allow-empty -m "init" then try again'));
176
926
  process.exit(1);
177
927
  }
@@ -179,7 +929,7 @@ program
179
929
  // Init the switchman database
180
930
  const db = initDb(repoRoot);
181
931
 
182
- // Create one worktree per agent
932
+ // Create one workspace (git worktree) per agent
183
933
  const created = [];
184
934
  for (let i = 1; i <= agentCount; i++) {
185
935
  const name = `agent${i}`;
@@ -204,6 +954,8 @@ program
204
954
  registerWorktree(db, { name: wt.name, path: wt.path, branch: wt.branch || 'unknown' });
205
955
  }
206
956
 
957
+ const mcpConfigWrites = installMcpConfig([...new Set([repoRoot, ...created.map((wt) => wt.path)])]);
958
+
207
959
  db.close();
208
960
 
209
961
  const label = agentCount === 1 ? 'workspace' : 'workspaces';
@@ -216,11 +968,18 @@ program
216
968
  console.log(` ${chalk.dim('branch:')} ${wt.branch}`);
217
969
  }
218
970
 
971
+ console.log('');
972
+ console.log(chalk.bold('MCP config:'));
973
+ for (const result of mcpConfigWrites) {
974
+ const status = result.created ? 'created' : result.changed ? 'updated' : 'unchanged';
975
+ console.log(` ${chalk.green('✓')} ${chalk.cyan(result.path)} ${chalk.dim(`(${status})`)}`);
976
+ }
977
+
219
978
  console.log('');
220
979
  console.log(chalk.bold('Next steps:'));
221
980
  console.log(` 1. Add your tasks:`);
222
981
  console.log(` ${chalk.cyan('switchman task add "Your first task" --priority 8')}`);
223
- console.log(` 2. Open Claude Code in each folder above — agents will coordinate automatically`);
982
+ console.log(` 2. Open Claude Code or Cursor in each folder above — the local MCP config will attach Switchman automatically`);
224
983
  console.log(` 3. Check status at any time:`);
225
984
  console.log(` ${chalk.cyan('switchman status')}`);
226
985
  console.log('');
@@ -232,9 +991,45 @@ program
232
991
  });
233
992
 
234
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
+
235
1030
  // ── task ──────────────────────────────────────────────────────────────────────
236
1031
 
237
- const taskCmd = program.command('task').description('Manage the task queue');
1032
+ const taskCmd = program.command('task').description('Manage the task list');
238
1033
 
239
1034
  taskCmd
240
1035
  .command('add <title>')
@@ -252,8 +1047,14 @@ taskCmd
252
1047
  priority: parseInt(opts.priority),
253
1048
  });
254
1049
  db.close();
1050
+ const scopeWarning = analyzeTaskScope(title, opts.description || '');
255
1051
  console.log(`${chalk.green('✓')} Task created: ${chalk.cyan(taskId)}`);
256
1052
  console.log(` ${chalk.dim(title)}`);
1053
+ if (scopeWarning) {
1054
+ console.log(chalk.yellow(` warning: ${scopeWarning.summary}`));
1055
+ console.log(chalk.yellow(` next: ${scopeWarning.next_step}`));
1056
+ console.log(chalk.cyan(` try: ${scopeWarning.command}`));
1057
+ }
257
1058
  });
258
1059
 
259
1060
  taskCmd
@@ -284,7 +1085,7 @@ taskCmd
284
1085
 
285
1086
  taskCmd
286
1087
  .command('assign <taskId> <worktree>')
287
- .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)')
288
1089
  .option('--agent <name>', 'Agent name (e.g. claude-code)')
289
1090
  .action((taskId, worktree, opts) => {
290
1091
  const repoRoot = getRepo();
@@ -303,11 +1104,13 @@ taskCmd
303
1104
  .description('Mark a task as complete and release all file claims')
304
1105
  .action((taskId) => {
305
1106
  const repoRoot = getRepo();
306
- const db = getDb(repoRoot);
307
- completeTask(db, taskId);
308
- releaseFileClaims(db, taskId);
309
- db.close();
310
- console.log(`${chalk.green('✓')} Task ${chalk.cyan(taskId)} marked done — file claims released`);
1107
+ try {
1108
+ completeTaskWithRetries(repoRoot, taskId);
1109
+ console.log(`${chalk.green('✓')} Task ${chalk.cyan(taskId)} marked done — file claims released`);
1110
+ } catch (err) {
1111
+ console.error(chalk.red(err.message));
1112
+ process.exitCode = 1;
1113
+ }
311
1114
  });
312
1115
 
313
1116
  taskCmd
@@ -326,27 +1129,21 @@ taskCmd
326
1129
  .command('next')
327
1130
  .description('Get and assign the next pending task (compatibility shim for lease next)')
328
1131
  .option('--json', 'Output as JSON')
329
- .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)')
330
1133
  .option('--agent <name>', 'Agent identifier for logging (e.g. claude-code)')
331
1134
  .action((opts) => {
332
1135
  const repoRoot = getRepo();
333
- const db = getDb(repoRoot);
334
- const task = getNextPendingTask(db);
1136
+ const worktreeName = getCurrentWorktreeName(opts.worktree);
1137
+ const { task, lease, exhausted } = acquireNextTaskLeaseWithRetries(repoRoot, worktreeName, opts.agent || null);
335
1138
 
336
1139
  if (!task) {
337
- db.close();
338
1140
  if (opts.json) console.log(JSON.stringify({ task: null }));
339
- else console.log(chalk.dim('No pending tasks.'));
1141
+ else if (exhausted) console.log(chalk.dim('No pending tasks.'));
1142
+ else console.log(chalk.yellow('Tasks were claimed by other agents during assignment. Run again to get the next one.'));
340
1143
  return;
341
1144
  }
342
1145
 
343
- // Determine worktree name: explicit flag, or derive from cwd
344
- const worktreeName = getCurrentWorktreeName(opts.worktree);
345
- const lease = startTaskLease(db, task.id, worktreeName, opts.agent || null);
346
- db.close();
347
-
348
1146
  if (!lease) {
349
- // Race condition: another agent grabbed it between get and assign
350
1147
  if (opts.json) console.log(JSON.stringify({ task: null, message: 'Task claimed by another agent — try again' }));
351
1148
  else console.log(chalk.yellow('Task was just claimed by another agent. Run again to get the next one.'));
352
1149
  return;
@@ -360,442 +1157,1831 @@ taskCmd
360
1157
  }
361
1158
  });
362
1159
 
363
- // ── lease ────────────────────────────────────────────────────────────────────
1160
+ // ── queue ─────────────────────────────────────────────────────────────────────
364
1161
 
365
- const leaseCmd = program.command('lease').description('Manage active work leases');
1162
+ const queueCmd = program.command('queue').description('Land finished work safely back onto main, one item at a time');
366
1163
 
367
- leaseCmd
368
- .command('acquire <taskId> <worktree>')
369
- .description('Acquire a lease for a pending task')
370
- .option('--agent <name>', 'Agent identifier for logging')
371
- .option('--json', 'Output as JSON')
372
- .action((taskId, worktree, opts) => {
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) => {
373
1174
  const repoRoot = getRepo();
374
1175
  const db = getDb(repoRoot);
375
- const task = getTask(db, taskId);
376
- const lease = startTaskLease(db, taskId, worktree, opts.agent || null);
377
- db.close();
378
1176
 
379
- if (!lease || !task) {
380
- if (opts.json) console.log(JSON.stringify({ lease: null, task: null }));
381
- else console.log(chalk.red(`Could not acquire lease. The task may not exist or is not pending.`));
382
- process.exitCode = 1;
383
- return;
384
- }
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
+ }
385
1212
 
386
- if (opts.json) {
387
- console.log(JSON.stringify({
388
- lease,
389
- task: taskJsonWithLease(task, worktree, lease).task,
390
- }, null, 2));
391
- return;
392
- }
1213
+ const result = enqueueMergeItem(db, payload);
1214
+ db.close();
393
1215
 
394
- console.log(`${chalk.green('✓')} Lease acquired ${chalk.dim(lease.id)}`);
395
- console.log(` ${chalk.dim('task:')} ${chalk.bold(task.title)}`);
396
- console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(worktree)}`);
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
+ }
397
1229
  });
398
1230
 
399
- leaseCmd
400
- .command('next')
401
- .description('Claim the next pending task and acquire its lease')
402
- .option('--json', 'Output as JSON')
403
- .option('--worktree <name>', 'Worktree to assign the task to (defaults to current worktree name)')
404
- .option('--agent <name>', 'Agent identifier for logging')
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')
405
1236
  .action((opts) => {
406
1237
  const repoRoot = getRepo();
407
1238
  const db = getDb(repoRoot);
408
- const task = getNextPendingTask(db);
409
-
410
- if (!task) {
411
- db.close();
412
- if (opts.json) console.log(JSON.stringify({ task: null, lease: null }));
413
- else console.log(chalk.dim('No pending tasks.'));
414
- return;
415
- }
416
-
417
- const worktreeName = getCurrentWorktreeName(opts.worktree);
418
- const lease = startTaskLease(db, task.id, worktreeName, opts.agent || null);
1239
+ const items = listMergeQueue(db, { status: opts.status || null });
419
1240
  db.close();
420
1241
 
421
- if (!lease) {
422
- if (opts.json) console.log(JSON.stringify({ task: null, lease: null, message: 'Task claimed by another agent — try again' }));
423
- else console.log(chalk.yellow('Task was just claimed by another agent. Run again to get the next one.'));
1242
+ if (opts.json) {
1243
+ console.log(JSON.stringify(items, null, 2));
424
1244
  return;
425
1245
  }
426
1246
 
427
- if (opts.json) {
428
- console.log(JSON.stringify({
429
- lease,
430
- ...taskJsonWithLease(task, worktreeName, lease),
431
- }, null, 2));
1247
+ if (items.length === 0) {
1248
+ console.log(chalk.dim('Merge queue is empty.'));
432
1249
  return;
433
1250
  }
434
1251
 
435
- console.log(`${chalk.green('✓')} Lease acquired: ${chalk.bold(task.title)}`);
436
- console.log(` ${chalk.dim('task:')} ${task.id} ${chalk.dim('lease:')} ${lease.id}`);
437
- console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(worktreeName)} ${chalk.dim('priority:')} ${task.priority}`);
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
+ }
438
1263
  });
439
1264
 
440
- leaseCmd
441
- .command('list')
442
- .description('List leases, newest first')
443
- .option('-s, --status <status>', 'Filter by status (active|completed|failed|expired)')
1265
+ queueCmd
1266
+ .command('status')
1267
+ .description('Show an operator-friendly merge queue summary')
1268
+ .option('--json', 'Output raw JSON')
444
1269
  .action((opts) => {
445
1270
  const repoRoot = getRepo();
446
1271
  const db = getDb(repoRoot);
447
- const leases = listLeases(db, opts.status);
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);
448
1277
  db.close();
449
1278
 
450
- if (!leases.length) {
451
- console.log(chalk.dim('No leases found.'));
1279
+ if (opts.json) {
1280
+ console.log(JSON.stringify({ items, summary, recent_events: recentEvents }, null, 2));
452
1281
  return;
453
1282
  }
454
1283
 
455
- console.log('');
456
- for (const lease of leases) {
457
- console.log(`${statusBadge(lease.status)} ${chalk.bold(lease.task_title)}`);
458
- console.log(` ${chalk.dim('lease:')} ${lease.id} ${chalk.dim('task:')} ${lease.task_id}`);
459
- console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(lease.worktree)} ${chalk.dim('agent:')} ${lease.agent || 'unknown'}`);
460
- console.log(` ${chalk.dim('started:')} ${lease.started_at} ${chalk.dim('heartbeat:')} ${lease.heartbeat_at}`);
461
- if (lease.failure_reason) console.log(` ${chalk.red(lease.failure_reason)}`);
462
- console.log('');
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
+ }
463
1300
  }
464
1301
  });
465
1302
 
466
- leaseCmd
467
- .command('heartbeat <leaseId>')
468
- .description('Refresh the heartbeat timestamp for an active lease')
469
- .option('--agent <name>', 'Agent identifier for logging')
470
- .option('--json', 'Output as JSON')
471
- .action((leaseId, opts) => {
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) => {
472
1376
  const repoRoot = getRepo();
473
1377
  const db = getDb(repoRoot);
474
- const lease = heartbeatLease(db, leaseId, opts.agent || null);
1378
+ const item = retryMergeQueueItem(db, itemId);
475
1379
  db.close();
476
1380
 
477
- if (!lease) {
478
- if (opts.json) console.log(JSON.stringify({ lease: null }));
479
- else console.log(chalk.red(`No active lease found for ${leaseId}`));
1381
+ if (!item) {
1382
+ console.error(chalk.red(`Queue item ${itemId} is not retryable.`));
480
1383
  process.exitCode = 1;
481
1384
  return;
482
1385
  }
483
1386
 
484
1387
  if (opts.json) {
485
- console.log(JSON.stringify({ lease }, null, 2));
1388
+ console.log(JSON.stringify(item, null, 2));
486
1389
  return;
487
1390
  }
488
1391
 
489
- console.log(`${chalk.green('✓')} Heartbeat refreshed for ${chalk.dim(lease.id)}`);
490
- console.log(` ${chalk.dim('task:')} ${lease.task_title} ${chalk.dim('worktree:')} ${chalk.cyan(lease.worktree)}`);
1392
+ console.log(`${chalk.green('✓')} Queue item ${chalk.cyan(item.id)} reset to retrying`);
491
1393
  });
492
1394
 
493
- leaseCmd
494
- .command('reap')
495
- .description('Expire stale leases, release their claims, and return their tasks to pending')
496
- .option('--stale-after-minutes <minutes>', 'Age threshold for staleness', String(DEFAULT_STALE_LEASE_MINUTES))
497
- .option('--json', 'Output as JSON')
498
- .action((opts) => {
1395
+ queueCmd
1396
+ .command('remove <itemId>')
1397
+ .description('Remove a merge queue item')
1398
+ .action((itemId) => {
499
1399
  const repoRoot = getRepo();
500
1400
  const db = getDb(repoRoot);
501
- const staleAfterMinutes = Number.parseInt(opts.staleAfterMinutes, 10);
502
- const expired = reapStaleLeases(db, staleAfterMinutes);
1401
+ const item = removeMergeQueueItem(db, itemId);
503
1402
  db.close();
504
1403
 
505
- if (opts.json) {
506
- console.log(JSON.stringify({ stale_after_minutes: staleAfterMinutes, expired }, null, 2));
507
- return;
508
- }
509
-
510
- if (!expired.length) {
511
- console.log(chalk.dim(`No stale leases older than ${staleAfterMinutes} minute(s).`));
1404
+ if (!item) {
1405
+ console.error(chalk.red(`Queue item ${itemId} does not exist.`));
1406
+ process.exitCode = 1;
512
1407
  return;
513
1408
  }
514
1409
 
515
- console.log(`${chalk.green('✓')} Reaped ${expired.length} stale lease(s)`);
516
- for (const lease of expired) {
517
- console.log(` ${chalk.dim(lease.id)} ${chalk.cyan(lease.worktree)} → ${lease.task_title}`);
518
- }
1410
+ console.log(`${chalk.green('✓')} Removed ${chalk.cyan(item.id)} from the merge queue`);
519
1411
  });
520
1412
 
521
- // ── worktree ───────────────────────────────────────────────────────────────────
522
-
523
- const wtCmd = program.command('worktree').description('Manage worktrees');
1413
+ // ── pipeline ──────────────────────────────────────────────────────────────────
524
1414
 
525
- wtCmd
526
- .command('add <name> <path> <branch>')
527
- .description('Register a worktree with switchman')
528
- .option('--agent <name>', 'Agent assigned to this worktree')
529
- .action((name, path, branch, opts) => {
530
- const repoRoot = getRepo();
531
- const db = getDb(repoRoot);
532
- registerWorktree(db, { name, path, branch, agent: opts.agent });
533
- db.close();
534
- console.log(`${chalk.green('✓')} Registered worktree: ${chalk.cyan(name)}`);
535
- });
1415
+ const pipelineCmd = program.command('pipeline').description('Create and summarize issue-to-PR execution pipelines');
536
1416
 
537
- wtCmd
538
- .command('list')
539
- .description('List all registered worktrees')
540
- .action(() => {
1417
+ pipelineCmd
1418
+ .command('start <title>')
1419
+ .description('Create a pipeline from one issue title and split it into execution subtasks')
1420
+ .option('-d, --description <desc>', 'Issue description or markdown checklist')
1421
+ .option('-p, --priority <n>', 'Priority 1-10 (default 5)', '5')
1422
+ .option('--id <id>', 'Custom pipeline ID')
1423
+ .option('--json', 'Output raw JSON')
1424
+ .action((title, opts) => {
541
1425
  const repoRoot = getRepo();
542
1426
  const db = getDb(repoRoot);
543
- const worktrees = listWorktrees(db);
544
- const gitWorktrees = listGitWorktrees(repoRoot);
1427
+ const result = startPipeline(db, {
1428
+ title,
1429
+ description: opts.description || null,
1430
+ priority: Number.parseInt(opts.priority, 10),
1431
+ pipelineId: opts.id || null,
1432
+ });
545
1433
  db.close();
546
1434
 
547
- if (!worktrees.length && !gitWorktrees.length) {
548
- console.log(chalk.dim('No worktrees found.'));
1435
+ if (opts.json) {
1436
+ console.log(JSON.stringify(result, null, 2));
549
1437
  return;
550
1438
  }
551
1439
 
552
- // Show git worktrees (source of truth) annotated with db info
553
- console.log('');
554
- console.log(chalk.bold('Git Worktrees:'));
555
- for (const wt of gitWorktrees) {
556
- const dbInfo = worktrees.find(d => d.path === wt.path);
557
- const agent = dbInfo?.agent ? chalk.cyan(dbInfo.agent) : chalk.dim('no agent');
558
- const status = dbInfo?.status ? statusBadge(dbInfo.status) : chalk.dim('unregistered');
559
- console.log(` ${chalk.bold(wt.name.padEnd(20))} ${status} branch: ${chalk.cyan(wt.branch || 'unknown')} agent: ${agent}`);
560
- console.log(` ${chalk.dim(wt.path)}`);
1440
+ console.log(`${chalk.green('✓')} Pipeline created ${chalk.cyan(result.pipeline_id)}`);
1441
+ console.log(` ${chalk.bold(result.title)}`);
1442
+ for (const task of result.tasks) {
1443
+ const suggested = task.suggested_worktree ? ` ${chalk.dim(`→ ${task.suggested_worktree}`)}` : '';
1444
+ const type = task.task_spec?.task_type ? ` ${chalk.dim(`[${task.task_spec.task_type}]`)}` : '';
1445
+ console.log(` ${chalk.cyan(task.id)} ${task.title}${type}${suggested}`);
561
1446
  }
562
- console.log('');
563
1447
  });
564
1448
 
565
- wtCmd
566
- .command('sync')
567
- .description('Sync git worktrees into the switchman database')
568
- .action(() => {
1449
+ pipelineCmd
1450
+ .command('status <pipelineId>')
1451
+ .description('Show task status for a pipeline')
1452
+ .option('--json', 'Output raw JSON')
1453
+ .action((pipelineId, opts) => {
569
1454
  const repoRoot = getRepo();
570
1455
  const db = getDb(repoRoot);
571
- const gitWorktrees = listGitWorktrees(repoRoot);
572
- for (const wt of gitWorktrees) {
573
- registerWorktree(db, { name: wt.name, path: wt.path, branch: wt.branch || 'unknown' });
574
- }
575
- db.close();
576
- console.log(`${chalk.green('✓')} Synced ${gitWorktrees.length} worktree(s) from git`);
577
- });
578
1456
 
579
- // ── claim ──────────────────────────────────────────────────────────────────────
1457
+ try {
1458
+ const result = getPipelineStatus(db, pipelineId);
1459
+ db.close();
580
1460
 
581
- program
582
- .command('claim <taskId> <worktree> [files...]')
583
- .description('Claim files for a task (warns if conflicts exist)')
584
- .option('--agent <name>', 'Agent name')
585
- .option('--force', 'Claim even if conflicts exist')
586
- .action((taskId, worktree, files, opts) => {
587
- if (!files.length) {
588
- console.log(chalk.yellow('No files specified. Use: switchman claim <taskId> <worktree> file1 file2 ...'));
589
- return;
1461
+ if (opts.json) {
1462
+ console.log(JSON.stringify(result, null, 2));
1463
+ return;
1464
+ }
1465
+
1466
+ console.log(`${chalk.bold(result.title)} ${chalk.dim(result.pipeline_id)}`);
1467
+ console.log(` ${chalk.dim('done')} ${result.counts.done} ${chalk.dim('in_progress')} ${result.counts.in_progress} ${chalk.dim('pending')} ${result.counts.pending} ${chalk.dim('failed')} ${result.counts.failed}`);
1468
+ for (const task of result.tasks) {
1469
+ const worktree = task.worktree || task.suggested_worktree || 'unassigned';
1470
+ const blocked = task.blocked_by?.length ? ` ${chalk.dim(`blocked by ${task.blocked_by.join(', ')}`)}` : '';
1471
+ const type = task.task_spec?.task_type ? ` ${chalk.dim(`[${task.task_spec.task_type}]`)}` : '';
1472
+ console.log(` ${statusBadge(task.status)} ${task.id} ${task.title}${type} ${chalk.dim(worktree)}${blocked}`);
1473
+ if (task.failure?.summary) {
1474
+ const reasonLabel = humanizeReasonCode(task.failure.reason_code);
1475
+ console.log(` ${chalk.red('why:')} ${task.failure.summary} ${chalk.dim(`(${reasonLabel})`)}`);
1476
+ }
1477
+ if (task.next_action) {
1478
+ console.log(` ${chalk.yellow('next:')} ${task.next_action}`);
1479
+ }
1480
+ }
1481
+ } catch (err) {
1482
+ db.close();
1483
+ console.error(chalk.red(err.message));
1484
+ process.exitCode = 1;
590
1485
  }
1486
+ });
1487
+
1488
+ pipelineCmd
1489
+ .command('pr <pipelineId>')
1490
+ .description('Generate a PR-ready summary for a pipeline using the repo and AI gates')
1491
+ .option('--json', 'Output raw JSON')
1492
+ .action(async (pipelineId, opts) => {
591
1493
  const repoRoot = getRepo();
592
1494
  const db = getDb(repoRoot);
593
1495
 
594
1496
  try {
595
- const conflicts = checkFileConflicts(db, files, worktree);
1497
+ const result = await buildPipelinePrSummary(db, repoRoot, pipelineId);
1498
+ db.close();
596
1499
 
597
- if (conflicts.length > 0 && !opts.force) {
598
- console.log(chalk.red(`\n⚠ Claim conflicts detected:`));
599
- for (const c of conflicts) {
600
- console.log(` ${chalk.yellow(c.file)} → already claimed by worktree ${chalk.cyan(c.claimedBy.worktree)} (task: ${c.claimedBy.task_title})`);
601
- }
602
- console.log(chalk.dim('\nUse --force to claim anyway, or resolve conflicts first.'));
1500
+ if (opts.json) {
1501
+ console.log(JSON.stringify(result, null, 2));
603
1502
  return;
604
1503
  }
605
1504
 
606
- const lease = claimFiles(db, taskId, worktree, files, opts.agent);
607
- console.log(`${chalk.green('✓')} Claimed ${files.length} file(s) for task ${chalk.cyan(taskId)} (${chalk.dim(lease.id)})`);
608
- files.forEach(f => console.log(` ${chalk.dim(f)}`));
1505
+ console.log(result.markdown);
609
1506
  } catch (err) {
1507
+ db.close();
610
1508
  console.error(chalk.red(err.message));
611
1509
  process.exitCode = 1;
612
- } finally {
613
- db.close();
614
1510
  }
615
1511
  });
616
1512
 
617
- program
618
- .command('release <taskId>')
619
- .description('Release all file claims for a task')
620
- .action((taskId) => {
1513
+ pipelineCmd
1514
+ .command('bundle <pipelineId> [outputDir]')
1515
+ .description('Export a reviewer-ready PR bundle for a pipeline to disk')
1516
+ .option('--json', 'Output raw JSON')
1517
+ .action(async (pipelineId, outputDir, opts) => {
621
1518
  const repoRoot = getRepo();
622
1519
  const db = getDb(repoRoot);
623
- releaseFileClaims(db, taskId);
624
- db.close();
625
- console.log(`${chalk.green('✓')} Released all claims for task ${chalk.cyan(taskId)}`);
626
- });
627
1520
 
628
- // ── scan ───────────────────────────────────────────────────────────────────────
1521
+ try {
1522
+ const result = await exportPipelinePrBundle(db, repoRoot, pipelineId, outputDir || null);
1523
+ db.close();
629
1524
 
630
- program
631
- .command('scan')
632
- .description('Scan all worktrees for conflicts')
1525
+ if (opts.json) {
1526
+ console.log(JSON.stringify(result, null, 2));
1527
+ return;
1528
+ }
1529
+
1530
+ console.log(`${chalk.green('✓')} Exported PR bundle for ${chalk.cyan(result.pipeline_id)}`);
1531
+ console.log(` ${chalk.dim(result.output_dir)}`);
1532
+ console.log(` ${chalk.dim('json:')} ${result.files.summary_json}`);
1533
+ console.log(` ${chalk.dim('summary:')} ${result.files.summary_markdown}`);
1534
+ console.log(` ${chalk.dim('body:')} ${result.files.pr_body_markdown}`);
1535
+ } catch (err) {
1536
+ db.close();
1537
+ console.error(chalk.red(err.message));
1538
+ process.exitCode = 1;
1539
+ }
1540
+ });
1541
+
1542
+ pipelineCmd
1543
+ .command('publish <pipelineId> [outputDir]')
1544
+ .description('Create a hosted GitHub pull request for a pipeline using gh')
1545
+ .option('--base <branch>', 'Base branch for the pull request', 'main')
1546
+ .option('--head <branch>', 'Head branch for the pull request (defaults to inferred pipeline branch)')
1547
+ .option('--draft', 'Create the pull request as a draft')
1548
+ .option('--gh-command <command>', 'Executable to use for GitHub CLI', 'gh')
633
1549
  .option('--json', 'Output raw JSON')
634
- .option('--quiet', 'Only show conflicts')
635
- .action(async (opts) => {
1550
+ .action(async (pipelineId, outputDir, opts) => {
636
1551
  const repoRoot = getRepo();
637
1552
  const db = getDb(repoRoot);
638
- const spinner = ora('Scanning worktrees for conflicts...').start();
639
1553
 
640
1554
  try {
641
- const report = await scanAllWorktrees(db, repoRoot);
1555
+ const result = await publishPipelinePr(db, repoRoot, pipelineId, {
1556
+ baseBranch: opts.base,
1557
+ headBranch: opts.head || null,
1558
+ draft: Boolean(opts.draft),
1559
+ ghCommand: opts.ghCommand,
1560
+ outputDir: outputDir || null,
1561
+ });
642
1562
  db.close();
643
- spinner.stop();
644
1563
 
645
1564
  if (opts.json) {
646
- console.log(JSON.stringify(report, null, 2));
1565
+ console.log(JSON.stringify(result, null, 2));
647
1566
  return;
648
1567
  }
649
1568
 
650
- console.log('');
651
- console.log(chalk.bold(`Conflict Scan Report`));
652
- console.log(chalk.dim(`${report.scannedAt}`));
653
- console.log('');
654
-
655
- // Worktrees summary
656
- if (!opts.quiet) {
657
- console.log(chalk.bold('Worktrees:'));
658
- for (const wt of report.worktrees) {
659
- const files = report.fileMap?.[wt.name] || [];
660
- console.log(` ${chalk.cyan(wt.name.padEnd(20))} branch: ${(wt.branch || 'unknown').padEnd(30)} ${chalk.dim(files.length + ' changed file(s)')}`);
661
- }
662
- console.log('');
1569
+ console.log(`${chalk.green('')} Published PR for ${chalk.cyan(result.pipeline_id)}`);
1570
+ console.log(` ${chalk.dim('base:')} ${result.base_branch}`);
1571
+ console.log(` ${chalk.dim('head:')} ${result.head_branch}`);
1572
+ if (result.output) {
1573
+ console.log(` ${chalk.dim(result.output)}`);
663
1574
  }
1575
+ } catch (err) {
1576
+ db.close();
1577
+ console.error(chalk.red(err.message));
1578
+ process.exitCode = 1;
1579
+ }
1580
+ });
664
1581
 
665
- // File-level overlaps (uncommitted)
666
- if (report.fileConflicts.length > 0) {
667
- console.log(chalk.yellow(`⚠ Files being edited in multiple worktrees (uncommitted):`));
668
- for (const fc of report.fileConflicts) {
669
- console.log(` ${chalk.yellow(fc.file)}`);
670
- console.log(` ${chalk.dim('edited in:')} ${fc.worktrees.join(', ')}`);
671
- }
672
- console.log('');
673
- }
1582
+ pipelineCmd
1583
+ .command('run <pipelineId> [agentCommand...]')
1584
+ .description('Dispatch pending pipeline tasks onto available worktrees and optionally launch an agent command in each one')
1585
+ .option('--agent <name>', 'Agent name to record on acquired leases', 'pipeline-runner')
1586
+ .option('--detached', 'Launch agent commands as detached background processes')
1587
+ .option('--json', 'Output raw JSON')
1588
+ .action((pipelineId, agentCommand, opts) => {
1589
+ const repoRoot = getRepo();
1590
+ const db = getDb(repoRoot);
674
1591
 
675
- // Branch-level conflicts
676
- if (report.conflicts.length > 0) {
677
- console.log(chalk.red(`✗ Branch conflicts detected:`));
678
- for (const c of report.conflicts) {
679
- const icon = c.type === 'merge_conflict' ? chalk.red('MERGE CONFLICT') : chalk.yellow('FILE OVERLAP');
680
- console.log(` ${icon}`);
681
- console.log(` ${chalk.cyan(c.worktreeA)} (${c.branchA}) ↔ ${chalk.cyan(c.worktreeB)} (${c.branchB})`);
682
- if (c.conflictingFiles.length) {
683
- console.log(` Conflicting files:`);
684
- c.conflictingFiles.forEach(f => console.log(` ${chalk.yellow(f)}`));
685
- }
686
- }
687
- console.log('');
1592
+ try {
1593
+ const result = runPipeline(db, repoRoot, {
1594
+ pipelineId,
1595
+ agentCommand,
1596
+ agentName: opts.agent,
1597
+ detached: Boolean(opts.detached),
1598
+ });
1599
+ db.close();
1600
+
1601
+ if (opts.json) {
1602
+ console.log(JSON.stringify(result, null, 2));
1603
+ return;
688
1604
  }
689
1605
 
690
- // All clear
691
- if (report.conflicts.length === 0 && report.fileConflicts.length === 0) {
692
- console.log(chalk.green(`✓ No conflicts detected across ${report.worktrees.length} worktree(s)`));
1606
+ if (result.assigned.length === 0) {
1607
+ console.log(chalk.dim('No pending pipeline tasks were assigned. All worktrees may already be busy.'));
1608
+ return;
693
1609
  }
694
1610
 
1611
+ console.log(`${chalk.green('✓')} Dispatched ${result.assigned.length} pipeline task(s)`);
1612
+ for (const assignment of result.assigned) {
1613
+ const launch = result.launched.find((item) => item.task_id === assignment.task_id);
1614
+ const launchInfo = launch ? ` ${chalk.dim(`pid=${launch.pid}`)}` : '';
1615
+ console.log(` ${chalk.cyan(assignment.task_id)} → ${chalk.cyan(assignment.worktree)} ${chalk.dim(assignment.lease_id)}${launchInfo}`);
1616
+ }
1617
+ if (result.remaining_pending > 0) {
1618
+ console.log(chalk.dim(`${result.remaining_pending} pipeline task(s) remain pending due to unavailable worktrees.`));
1619
+ }
695
1620
  } catch (err) {
696
- spinner.fail(err.message);
697
1621
  db.close();
698
- process.exit(1);
1622
+ console.error(chalk.red(err.message));
1623
+ process.exitCode = 1;
699
1624
  }
700
1625
  });
701
1626
 
702
- // ── status ─────────────────────────────────────────────────────────────────────
703
-
704
- program
705
- .command('status')
706
- .description('Show full system status: tasks, worktrees, claims, and conflicts')
707
- .action(async () => {
1627
+ pipelineCmd
1628
+ .command('review <pipelineId>')
1629
+ .description('Inspect repo and AI gate failures for a pipeline and create follow-up fix tasks')
1630
+ .option('--json', 'Output raw JSON')
1631
+ .action(async (pipelineId, opts) => {
708
1632
  const repoRoot = getRepo();
709
1633
  const db = getDb(repoRoot);
710
1634
 
711
- console.log('');
712
- console.log(chalk.bold.cyan('━━━ switchman status ━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
713
- console.log(chalk.dim(`Repo: ${repoRoot}`));
714
- console.log('');
1635
+ try {
1636
+ const result = await createPipelineFollowupTasks(db, repoRoot, pipelineId);
1637
+ db.close();
715
1638
 
716
- // Tasks
717
- const tasks = listTasks(db);
718
- const pending = tasks.filter(t => t.status === 'pending');
719
- const inProgress = tasks.filter(t => t.status === 'in_progress');
720
- const done = tasks.filter(t => t.status === 'done');
721
- const failed = tasks.filter(t => t.status === 'failed');
722
- const activeLeases = listLeases(db, 'active');
723
- const staleLeases = getStaleLeases(db);
1639
+ if (opts.json) {
1640
+ console.log(JSON.stringify(result, null, 2));
1641
+ return;
1642
+ }
724
1643
 
725
- console.log(chalk.bold('Tasks:'));
726
- console.log(` ${chalk.yellow('Pending')} ${pending.length}`);
727
- console.log(` ${chalk.blue('In Progress')} ${inProgress.length}`);
728
- console.log(` ${chalk.green('Done')} ${done.length}`);
729
- console.log(` ${chalk.red('Failed')} ${failed.length}`);
1644
+ if (result.created_count === 0) {
1645
+ console.log(chalk.dim('No follow-up tasks were created. The pipeline gates did not surface new actionable items.'));
1646
+ return;
1647
+ }
730
1648
 
731
- if (activeLeases.length > 0) {
732
- console.log('');
733
- console.log(chalk.bold('Active Leases:'));
734
- for (const lease of activeLeases) {
735
- console.log(` ${chalk.cyan(lease.worktree)} → ${lease.task_title} ${chalk.dim(lease.id)}`);
1649
+ console.log(`${chalk.green('✓')} Created ${result.created_count} follow-up task(s)`);
1650
+ for (const task of result.created) {
1651
+ console.log(` ${chalk.cyan(task.id)} ${task.title}`);
736
1652
  }
737
- } else if (inProgress.length > 0) {
738
- console.log('');
739
- console.log(chalk.bold('In-Progress Tasks Without Lease:'));
740
- for (const t of inProgress) {
741
- console.log(` ${chalk.cyan(t.worktree || 'unassigned')} → ${t.title}`);
1653
+ } catch (err) {
1654
+ db.close();
1655
+ console.error(chalk.red(err.message));
1656
+ process.exitCode = 1;
1657
+ }
1658
+ });
1659
+
1660
+ pipelineCmd
1661
+ .command('exec <pipelineId> [agentCommand...]')
1662
+ .description('Run a bounded autonomous loop: dispatch, execute, review, and stop when ready or blocked')
1663
+ .option('--agent <name>', 'Agent name to record on acquired leases', 'pipeline-runner')
1664
+ .option('--max-iterations <n>', 'Maximum execution/review iterations', '3')
1665
+ .option('--max-retries <n>', 'Retry a failed pipeline task up to this many times', '1')
1666
+ .option('--retry-backoff-ms <ms>', 'Base backoff in milliseconds between retry attempts', '0')
1667
+ .option('--timeout-ms <ms>', 'Default command timeout in milliseconds when a task spec does not provide one', '0')
1668
+ .option('--json', 'Output raw JSON')
1669
+ .action(async (pipelineId, agentCommand, opts) => {
1670
+ const repoRoot = getRepo();
1671
+ const db = getDb(repoRoot);
1672
+
1673
+ try {
1674
+ const result = await executePipeline(db, repoRoot, {
1675
+ pipelineId,
1676
+ agentCommand,
1677
+ agentName: opts.agent,
1678
+ maxIterations: Number.parseInt(opts.maxIterations, 10),
1679
+ maxRetries: Number.parseInt(opts.maxRetries, 10),
1680
+ retryBackoffMs: Number.parseInt(opts.retryBackoffMs, 10),
1681
+ timeoutMs: Number.parseInt(opts.timeoutMs, 10),
1682
+ });
1683
+ db.close();
1684
+
1685
+ if (opts.json) {
1686
+ console.log(JSON.stringify(result, null, 2));
1687
+ return;
1688
+ }
1689
+
1690
+ const badge = result.status === 'ready'
1691
+ ? chalk.green('READY')
1692
+ : result.status === 'blocked'
1693
+ ? chalk.red('BLOCKED')
1694
+ : chalk.yellow('MAX');
1695
+ console.log(`${badge} Pipeline ${chalk.cyan(result.pipeline_id)} ${chalk.dim(result.status)}`);
1696
+ for (const iteration of result.iterations) {
1697
+ console.log(` iter ${iteration.iteration}: resumed=${iteration.resumed_retries} dispatched=${iteration.dispatched} executed=${iteration.executed} retries=${iteration.retries_scheduled} followups=${iteration.followups_created} ai=${iteration.ai_gate_status} ready=${iteration.ready}`);
1698
+ }
1699
+ console.log(chalk.dim(result.pr.markdown.split('\n')[0]));
1700
+ } catch (err) {
1701
+ db.close();
1702
+ console.error(chalk.red(err.message));
1703
+ process.exitCode = 1;
1704
+ }
1705
+ });
1706
+
1707
+ // ── lease ────────────────────────────────────────────────────────────────────
1708
+
1709
+ const leaseCmd = program.command('lease').description('Manage active work leases');
1710
+
1711
+ leaseCmd
1712
+ .command('acquire <taskId> <worktree>')
1713
+ .description('Acquire a lease for a pending task')
1714
+ .option('--agent <name>', 'Agent identifier for logging')
1715
+ .option('--json', 'Output as JSON')
1716
+ .action((taskId, worktree, opts) => {
1717
+ const repoRoot = getRepo();
1718
+ const db = getDb(repoRoot);
1719
+ const task = getTask(db, taskId);
1720
+ const lease = startTaskLease(db, taskId, worktree, opts.agent || null);
1721
+ db.close();
1722
+
1723
+ if (!lease || !task) {
1724
+ if (opts.json) console.log(JSON.stringify({ lease: null, task: null }));
1725
+ else console.log(chalk.red(`Could not acquire lease. The task may not exist or is not pending.`));
1726
+ process.exitCode = 1;
1727
+ return;
1728
+ }
1729
+
1730
+ if (opts.json) {
1731
+ console.log(JSON.stringify({
1732
+ lease,
1733
+ task: taskJsonWithLease(task, worktree, lease).task,
1734
+ }, null, 2));
1735
+ return;
1736
+ }
1737
+
1738
+ console.log(`${chalk.green('✓')} Lease acquired ${chalk.dim(lease.id)}`);
1739
+ console.log(` ${chalk.dim('task:')} ${chalk.bold(task.title)}`);
1740
+ console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(worktree)}`);
1741
+ });
1742
+
1743
+ leaseCmd
1744
+ .command('next')
1745
+ .description('Claim the next pending task and acquire its lease')
1746
+ .option('--json', 'Output as JSON')
1747
+ .option('--worktree <name>', 'Worktree to assign the task to (defaults to current worktree name)')
1748
+ .option('--agent <name>', 'Agent identifier for logging')
1749
+ .action((opts) => {
1750
+ const repoRoot = getRepo();
1751
+ const worktreeName = getCurrentWorktreeName(opts.worktree);
1752
+ const { task, lease, exhausted } = acquireNextTaskLeaseWithRetries(repoRoot, worktreeName, opts.agent || null);
1753
+
1754
+ if (!task) {
1755
+ if (opts.json) console.log(JSON.stringify({ task: null, lease: null }));
1756
+ else if (exhausted) console.log(chalk.dim('No pending tasks.'));
1757
+ else console.log(chalk.yellow('Tasks were claimed by other agents during assignment. Run again to get the next one.'));
1758
+ return;
1759
+ }
1760
+
1761
+ if (!lease) {
1762
+ if (opts.json) console.log(JSON.stringify({ task: null, lease: null, message: 'Task claimed by another agent — try again' }));
1763
+ else console.log(chalk.yellow('Task was just claimed by another agent. Run again to get the next one.'));
1764
+ return;
1765
+ }
1766
+
1767
+ if (opts.json) {
1768
+ console.log(JSON.stringify({
1769
+ lease,
1770
+ ...taskJsonWithLease(task, worktreeName, lease),
1771
+ }, null, 2));
1772
+ return;
1773
+ }
1774
+
1775
+ console.log(`${chalk.green('✓')} Lease acquired: ${chalk.bold(task.title)}`);
1776
+ console.log(` ${chalk.dim('task:')} ${task.id} ${chalk.dim('lease:')} ${lease.id}`);
1777
+ console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(worktreeName)} ${chalk.dim('priority:')} ${task.priority}`);
1778
+ });
1779
+
1780
+ leaseCmd
1781
+ .command('list')
1782
+ .description('List leases, newest first')
1783
+ .option('-s, --status <status>', 'Filter by status (active|completed|failed|expired)')
1784
+ .action((opts) => {
1785
+ const repoRoot = getRepo();
1786
+ const db = getDb(repoRoot);
1787
+ const leases = listLeases(db, opts.status);
1788
+ db.close();
1789
+
1790
+ if (!leases.length) {
1791
+ console.log(chalk.dim('No leases found.'));
1792
+ return;
1793
+ }
1794
+
1795
+ console.log('');
1796
+ for (const lease of leases) {
1797
+ console.log(`${statusBadge(lease.status)} ${chalk.bold(lease.task_title)}`);
1798
+ console.log(` ${chalk.dim('lease:')} ${lease.id} ${chalk.dim('task:')} ${lease.task_id}`);
1799
+ console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(lease.worktree)} ${chalk.dim('agent:')} ${lease.agent || 'unknown'}`);
1800
+ console.log(` ${chalk.dim('started:')} ${lease.started_at} ${chalk.dim('heartbeat:')} ${lease.heartbeat_at}`);
1801
+ if (lease.failure_reason) console.log(` ${chalk.red(lease.failure_reason)}`);
1802
+ console.log('');
1803
+ }
1804
+ });
1805
+
1806
+ leaseCmd
1807
+ .command('heartbeat <leaseId>')
1808
+ .description('Refresh the heartbeat timestamp for an active lease')
1809
+ .option('--agent <name>', 'Agent identifier for logging')
1810
+ .option('--json', 'Output as JSON')
1811
+ .action((leaseId, opts) => {
1812
+ const repoRoot = getRepo();
1813
+ const db = getDb(repoRoot);
1814
+ const lease = heartbeatLease(db, leaseId, opts.agent || null);
1815
+ db.close();
1816
+
1817
+ if (!lease) {
1818
+ if (opts.json) console.log(JSON.stringify({ lease: null }));
1819
+ else console.log(chalk.red(`No active lease found for ${leaseId}`));
1820
+ process.exitCode = 1;
1821
+ return;
1822
+ }
1823
+
1824
+ if (opts.json) {
1825
+ console.log(JSON.stringify({ lease }, null, 2));
1826
+ return;
1827
+ }
1828
+
1829
+ console.log(`${chalk.green('✓')} Heartbeat refreshed for ${chalk.dim(lease.id)}`);
1830
+ console.log(` ${chalk.dim('task:')} ${lease.task_title} ${chalk.dim('worktree:')} ${chalk.cyan(lease.worktree)}`);
1831
+ });
1832
+
1833
+ leaseCmd
1834
+ .command('reap')
1835
+ .description('Expire stale leases, release their claims, and return their tasks to pending')
1836
+ .option('--stale-after-minutes <minutes>', 'Age threshold for staleness')
1837
+ .option('--json', 'Output as JSON')
1838
+ .action((opts) => {
1839
+ const repoRoot = getRepo();
1840
+ const db = getDb(repoRoot);
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
+ });
1848
+ db.close();
1849
+
1850
+ if (opts.json) {
1851
+ console.log(JSON.stringify({ stale_after_minutes: staleAfterMinutes, expired }, null, 2));
1852
+ return;
1853
+ }
1854
+
1855
+ if (!expired.length) {
1856
+ console.log(chalk.dim(`No stale leases older than ${staleAfterMinutes} minute(s).`));
1857
+ return;
1858
+ }
1859
+
1860
+ console.log(`${chalk.green('✓')} Reaped ${expired.length} stale lease(s)`);
1861
+ for (const lease of expired) {
1862
+ console.log(` ${chalk.dim(lease.id)} ${chalk.cyan(lease.worktree)} → ${lease.task_title}`);
1863
+ }
1864
+ });
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
+
1920
+ // ── worktree ───────────────────────────────────────────────────────────────────
1921
+
1922
+ const wtCmd = program.command('worktree').description('Manage worktrees');
1923
+
1924
+ wtCmd
1925
+ .command('add <name> <path> <branch>')
1926
+ .description('Register a worktree with switchman')
1927
+ .option('--agent <name>', 'Agent assigned to this worktree')
1928
+ .action((name, path, branch, opts) => {
1929
+ const repoRoot = getRepo();
1930
+ const db = getDb(repoRoot);
1931
+ registerWorktree(db, { name, path, branch, agent: opts.agent });
1932
+ db.close();
1933
+ console.log(`${chalk.green('✓')} Registered worktree: ${chalk.cyan(name)}`);
1934
+ });
1935
+
1936
+ wtCmd
1937
+ .command('list')
1938
+ .description('List all registered worktrees')
1939
+ .action(() => {
1940
+ const repoRoot = getRepo();
1941
+ const db = getDb(repoRoot);
1942
+ const worktrees = listWorktrees(db);
1943
+ const gitWorktrees = listGitWorktrees(repoRoot);
1944
+ db.close();
1945
+
1946
+ if (!worktrees.length && !gitWorktrees.length) {
1947
+ console.log(chalk.dim('No worktrees found.'));
1948
+ return;
1949
+ }
1950
+
1951
+ // Show git worktrees (source of truth) annotated with db info
1952
+ console.log('');
1953
+ console.log(chalk.bold('Git Worktrees:'));
1954
+ for (const wt of gitWorktrees) {
1955
+ const dbInfo = worktrees.find(d => d.path === wt.path);
1956
+ const agent = dbInfo?.agent ? chalk.cyan(dbInfo.agent) : chalk.dim('no agent');
1957
+ const status = dbInfo?.status ? statusBadge(dbInfo.status) : chalk.dim('unregistered');
1958
+ const compliance = dbInfo?.compliance_state ? statusBadge(dbInfo.compliance_state) : chalk.dim('unknown');
1959
+ console.log(` ${chalk.bold(wt.name.padEnd(20))} ${status} ${compliance} branch: ${chalk.cyan(wt.branch || 'unknown')} agent: ${agent}`);
1960
+ console.log(` ${chalk.dim(wt.path)}`);
1961
+ }
1962
+ console.log('');
1963
+ });
1964
+
1965
+ wtCmd
1966
+ .command('sync')
1967
+ .description('Sync git worktrees into the switchman database')
1968
+ .action(() => {
1969
+ const repoRoot = getRepo();
1970
+ const db = getDb(repoRoot);
1971
+ const gitWorktrees = listGitWorktrees(repoRoot);
1972
+ for (const wt of gitWorktrees) {
1973
+ registerWorktree(db, { name: wt.name, path: wt.path, branch: wt.branch || 'unknown' });
1974
+ }
1975
+ db.close();
1976
+ installMcpConfig([...new Set([repoRoot, ...gitWorktrees.map((wt) => wt.path)])]);
1977
+ console.log(`${chalk.green('✓')} Synced ${gitWorktrees.length} worktree(s) from git`);
1978
+ });
1979
+
1980
+ // ── claim ──────────────────────────────────────────────────────────────────────
1981
+
1982
+ program
1983
+ .command('claim <taskId> <worktree> [files...]')
1984
+ .description('Claim files for a task (warns if conflicts exist)')
1985
+ .option('--agent <name>', 'Agent name')
1986
+ .option('--force', 'Claim even if conflicts exist')
1987
+ .action((taskId, worktree, files, opts) => {
1988
+ if (!files.length) {
1989
+ console.log(chalk.yellow('No files specified. Use: switchman claim <taskId> <worktree> file1 file2 ...'));
1990
+ return;
1991
+ }
1992
+ const repoRoot = getRepo();
1993
+ const db = getDb(repoRoot);
1994
+
1995
+ try {
1996
+ const conflicts = checkFileConflicts(db, files, worktree);
1997
+
1998
+ if (conflicts.length > 0 && !opts.force) {
1999
+ console.log(chalk.red(`\n⚠ Claim conflicts detected:`));
2000
+ for (const c of conflicts) {
2001
+ console.log(` ${chalk.yellow(c.file)} → already claimed by worktree ${chalk.cyan(c.claimedBy.worktree)} (task: ${c.claimedBy.task_title})`);
2002
+ }
2003
+ console.log(chalk.dim('\nUse --force to claim anyway, or resolve conflicts first.'));
2004
+ process.exitCode = 1;
2005
+ return;
2006
+ }
2007
+
2008
+ const lease = claimFiles(db, taskId, worktree, files, opts.agent);
2009
+ console.log(`${chalk.green('✓')} Claimed ${files.length} file(s) for task ${chalk.cyan(taskId)} (${chalk.dim(lease.id)})`);
2010
+ files.forEach(f => console.log(` ${chalk.dim(f)}`));
2011
+ } catch (err) {
2012
+ console.error(chalk.red(err.message));
2013
+ process.exitCode = 1;
2014
+ } finally {
2015
+ db.close();
2016
+ }
2017
+ });
2018
+
2019
+ program
2020
+ .command('release <taskId>')
2021
+ .description('Release all file claims for a task')
2022
+ .action((taskId) => {
2023
+ const repoRoot = getRepo();
2024
+ const db = getDb(repoRoot);
2025
+ releaseFileClaims(db, taskId);
2026
+ db.close();
2027
+ console.log(`${chalk.green('✓')} Released all claims for task ${chalk.cyan(taskId)}`);
2028
+ });
2029
+
2030
+ program
2031
+ .command('write <leaseId> <path>')
2032
+ .description('Write a file through the Switchman enforcement gateway')
2033
+ .requiredOption('--text <content>', 'Replacement file content')
2034
+ .option('--worktree <name>', 'Expected worktree for lease validation')
2035
+ .action((leaseId, path, opts) => {
2036
+ const repoRoot = getRepo();
2037
+ const db = getDb(repoRoot);
2038
+ const result = gatewayWriteFile(db, repoRoot, {
2039
+ leaseId,
2040
+ path,
2041
+ content: opts.text,
2042
+ worktree: opts.worktree || null,
2043
+ });
2044
+ db.close();
2045
+
2046
+ if (!result.ok) {
2047
+ console.log(chalk.red(`✗ Write denied for ${chalk.cyan(result.file_path || path)} ${chalk.dim(result.reason_code)}`));
2048
+ process.exitCode = 1;
2049
+ return;
2050
+ }
2051
+
2052
+ console.log(`${chalk.green('✓')} Wrote ${chalk.cyan(result.file_path)} via lease ${chalk.dim(result.lease_id)}`);
2053
+ });
2054
+
2055
+ program
2056
+ .command('rm <leaseId> <path>')
2057
+ .description('Remove a file or directory through the Switchman enforcement gateway')
2058
+ .option('--worktree <name>', 'Expected worktree for lease validation')
2059
+ .action((leaseId, path, opts) => {
2060
+ const repoRoot = getRepo();
2061
+ const db = getDb(repoRoot);
2062
+ const result = gatewayRemovePath(db, repoRoot, {
2063
+ leaseId,
2064
+ path,
2065
+ worktree: opts.worktree || null,
2066
+ });
2067
+ db.close();
2068
+
2069
+ if (!result.ok) {
2070
+ console.log(chalk.red(`✗ Remove denied for ${chalk.cyan(result.file_path || path)} ${chalk.dim(result.reason_code)}`));
2071
+ process.exitCode = 1;
2072
+ return;
2073
+ }
2074
+
2075
+ console.log(`${chalk.green('✓')} Removed ${chalk.cyan(result.file_path)} via lease ${chalk.dim(result.lease_id)}`);
2076
+ });
2077
+
2078
+ program
2079
+ .command('append <leaseId> <path>')
2080
+ .description('Append to a file through the Switchman enforcement gateway')
2081
+ .requiredOption('--text <content>', 'Content to append')
2082
+ .option('--worktree <name>', 'Expected worktree for lease validation')
2083
+ .action((leaseId, path, opts) => {
2084
+ const repoRoot = getRepo();
2085
+ const db = getDb(repoRoot);
2086
+ const result = gatewayAppendFile(db, repoRoot, {
2087
+ leaseId,
2088
+ path,
2089
+ content: opts.text,
2090
+ worktree: opts.worktree || null,
2091
+ });
2092
+ db.close();
2093
+
2094
+ if (!result.ok) {
2095
+ console.log(chalk.red(`✗ Append denied for ${chalk.cyan(result.file_path || path)} ${chalk.dim(result.reason_code)}`));
2096
+ process.exitCode = 1;
2097
+ return;
2098
+ }
2099
+
2100
+ console.log(`${chalk.green('✓')} Appended to ${chalk.cyan(result.file_path)} via lease ${chalk.dim(result.lease_id)}`);
2101
+ });
2102
+
2103
+ program
2104
+ .command('mv <leaseId> <sourcePath> <destinationPath>')
2105
+ .description('Move a file through the Switchman enforcement gateway')
2106
+ .option('--worktree <name>', 'Expected worktree for lease validation')
2107
+ .action((leaseId, sourcePath, destinationPath, opts) => {
2108
+ const repoRoot = getRepo();
2109
+ const db = getDb(repoRoot);
2110
+ const result = gatewayMovePath(db, repoRoot, {
2111
+ leaseId,
2112
+ sourcePath,
2113
+ destinationPath,
2114
+ worktree: opts.worktree || null,
2115
+ });
2116
+ db.close();
2117
+
2118
+ if (!result.ok) {
2119
+ console.log(chalk.red(`✗ Move denied for ${chalk.cyan(result.file_path || destinationPath)} ${chalk.dim(result.reason_code)}`));
2120
+ process.exitCode = 1;
2121
+ return;
2122
+ }
2123
+
2124
+ console.log(`${chalk.green('✓')} Moved ${chalk.cyan(result.source_path)} → ${chalk.cyan(result.file_path)} via lease ${chalk.dim(result.lease_id)}`);
2125
+ });
2126
+
2127
+ program
2128
+ .command('mkdir <leaseId> <path>')
2129
+ .description('Create a directory through the Switchman enforcement gateway')
2130
+ .option('--worktree <name>', 'Expected worktree for lease validation')
2131
+ .action((leaseId, path, opts) => {
2132
+ const repoRoot = getRepo();
2133
+ const db = getDb(repoRoot);
2134
+ const result = gatewayMakeDirectory(db, repoRoot, {
2135
+ leaseId,
2136
+ path,
2137
+ worktree: opts.worktree || null,
2138
+ });
2139
+ db.close();
2140
+
2141
+ if (!result.ok) {
2142
+ console.log(chalk.red(`✗ Mkdir denied for ${chalk.cyan(result.file_path || path)} ${chalk.dim(result.reason_code)}`));
2143
+ process.exitCode = 1;
2144
+ return;
2145
+ }
2146
+
2147
+ console.log(`${chalk.green('✓')} Created ${chalk.cyan(result.file_path)} via lease ${chalk.dim(result.lease_id)}`);
2148
+ });
2149
+
2150
+ program
2151
+ .command('wrap <leaseId> <command...>')
2152
+ .description('Launch a CLI tool under an active Switchman lease with enforcement context env vars')
2153
+ .option('--worktree <name>', 'Expected worktree for lease validation')
2154
+ .option('--cwd <path>', 'Override working directory for the wrapped command')
2155
+ .action((leaseId, commandParts, opts) => {
2156
+ const repoRoot = getRepo();
2157
+ const db = getDb(repoRoot);
2158
+ const [command, ...args] = commandParts;
2159
+ const result = runWrappedCommand(db, repoRoot, {
2160
+ leaseId,
2161
+ command,
2162
+ args,
2163
+ worktree: opts.worktree || null,
2164
+ cwd: opts.cwd || null,
2165
+ });
2166
+ db.close();
2167
+
2168
+ if (!result.ok) {
2169
+ console.log(chalk.red(`✗ Wrapped command denied ${chalk.dim(result.reason_code || 'wrapped_command_failed')}`));
2170
+ process.exitCode = 1;
2171
+ return;
2172
+ }
2173
+
2174
+ console.log(`${chalk.green('✓')} Wrapped command completed under lease ${chalk.dim(result.lease_id)}`);
2175
+ });
2176
+
2177
+ // ── scan ───────────────────────────────────────────────────────────────────────
2178
+
2179
+ program
2180
+ .command('scan')
2181
+ .description('Scan all workspaces for conflicts')
2182
+ .option('--json', 'Output raw JSON')
2183
+ .option('--quiet', 'Only show conflicts')
2184
+ .action(async (opts) => {
2185
+ const repoRoot = getRepo();
2186
+ const db = getDb(repoRoot);
2187
+ const spinner = ora('Scanning workspaces for conflicts...').start();
2188
+
2189
+ try {
2190
+ const report = await scanAllWorktrees(db, repoRoot);
2191
+ db.close();
2192
+ spinner.stop();
2193
+
2194
+ if (opts.json) {
2195
+ console.log(JSON.stringify(report, null, 2));
2196
+ return;
2197
+ }
2198
+
2199
+ console.log('');
2200
+ console.log(chalk.bold(`Conflict Scan Report`));
2201
+ console.log(chalk.dim(`${report.scannedAt}`));
2202
+ console.log('');
2203
+
2204
+ // Worktrees summary
2205
+ if (!opts.quiet) {
2206
+ console.log(chalk.bold('Worktrees:'));
2207
+ for (const wt of report.worktrees) {
2208
+ const files = report.fileMap?.[wt.name] || [];
2209
+ const compliance = report.worktreeCompliance?.find((entry) => entry.worktree === wt.name)?.compliance_state || wt.compliance_state || 'observed';
2210
+ console.log(` ${chalk.cyan(wt.name.padEnd(20))} ${statusBadge(compliance)} branch: ${(wt.branch || 'unknown').padEnd(30)} ${chalk.dim(files.length + ' changed file(s)')}`);
2211
+ }
2212
+ console.log('');
742
2213
  }
2214
+
2215
+ // File-level overlaps (uncommitted)
2216
+ if (report.fileConflicts.length > 0) {
2217
+ console.log(chalk.yellow(`⚠ Files being edited in multiple worktrees (uncommitted):`));
2218
+ for (const fc of report.fileConflicts) {
2219
+ console.log(` ${chalk.yellow(fc.file)}`);
2220
+ console.log(` ${chalk.dim('edited in:')} ${fc.worktrees.join(', ')}`);
2221
+ }
2222
+ console.log('');
2223
+ }
2224
+
2225
+ if ((report.ownershipConflicts?.length || 0) > 0) {
2226
+ console.log(chalk.yellow(`⚠ Ownership boundary overlaps detected:`));
2227
+ for (const conflict of report.ownershipConflicts) {
2228
+ if (conflict.type === 'subsystem_overlap') {
2229
+ console.log(` ${chalk.yellow(`subsystem:${conflict.subsystemTag}`)}`);
2230
+ console.log(` ${chalk.dim('reserved by:')} ${conflict.worktreeA}, ${conflict.worktreeB}`);
2231
+ } else {
2232
+ console.log(` ${chalk.yellow(conflict.scopeA)}`);
2233
+ console.log(` ${chalk.dim('overlaps with:')} ${conflict.scopeB}`);
2234
+ console.log(` ${chalk.dim('reserved by:')} ${conflict.worktreeA}, ${conflict.worktreeB}`);
2235
+ }
2236
+ }
2237
+ console.log('');
2238
+ }
2239
+
2240
+ if ((report.semanticConflicts?.length || 0) > 0) {
2241
+ console.log(chalk.yellow(`⚠ Semantic overlaps detected:`));
2242
+ for (const conflict of report.semanticConflicts) {
2243
+ console.log(` ${chalk.yellow(conflict.object_name)}`);
2244
+ console.log(` ${chalk.dim('changed by:')} ${conflict.worktreeA}, ${conflict.worktreeB}`);
2245
+ console.log(` ${chalk.dim('files:')} ${conflict.fileA} ↔ ${conflict.fileB}`);
2246
+ }
2247
+ console.log('');
2248
+ }
2249
+
2250
+ // Branch-level conflicts
2251
+ if (report.conflicts.length > 0) {
2252
+ console.log(chalk.red(`✗ Branch conflicts detected:`));
2253
+ for (const c of report.conflicts) {
2254
+ const icon = c.type === 'merge_conflict' ? chalk.red('MERGE CONFLICT') : chalk.yellow('FILE OVERLAP');
2255
+ console.log(` ${icon}`);
2256
+ console.log(` ${chalk.cyan(c.worktreeA)} (${c.branchA}) ↔ ${chalk.cyan(c.worktreeB)} (${c.branchB})`);
2257
+ if (c.conflictingFiles.length) {
2258
+ console.log(` Conflicting files:`);
2259
+ c.conflictingFiles.forEach(f => console.log(` ${chalk.yellow(f)}`));
2260
+ }
2261
+ }
2262
+ console.log('');
2263
+ }
2264
+
2265
+ if (report.unclaimedChanges.length > 0) {
2266
+ console.log(chalk.red(`✗ Unclaimed or unmanaged changed files detected:`));
2267
+ for (const entry of report.unclaimedChanges) {
2268
+ console.log(` ${chalk.cyan(entry.worktree)} ${chalk.dim(entry.lease_id || 'no active lease')}`);
2269
+ entry.files.forEach((file) => {
2270
+ const reason = entry.reasons.find((item) => item.file === file)?.reason_code || 'path_not_claimed';
2271
+ const nextStep = nextStepForReason(reason);
2272
+ console.log(` ${chalk.yellow(file)} ${chalk.dim(humanizeReasonCode(reason))}${nextStep ? ` ${chalk.dim(`— ${nextStep}`)}` : ''}`);
2273
+ });
2274
+ }
2275
+ console.log('');
2276
+ }
2277
+
2278
+ // All clear
2279
+ if (report.conflicts.length === 0 && report.fileConflicts.length === 0 && (report.ownershipConflicts?.length || 0) === 0 && (report.semanticConflicts?.length || 0) === 0 && report.unclaimedChanges.length === 0) {
2280
+ console.log(chalk.green(`✓ No conflicts detected across ${report.worktrees.length} workspace(s)`));
2281
+ }
2282
+
2283
+ } catch (err) {
2284
+ spinner.fail(err.message);
2285
+ db.close();
2286
+ process.exit(1);
2287
+ }
2288
+ });
2289
+
2290
+ // ── status ─────────────────────────────────────────────────────────────────────
2291
+
2292
+ program
2293
+ .command('status')
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) => {
2300
+ const repoRoot = getRepo();
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;
2305
+
2306
+ while (true) {
2307
+ if (watch && process.stdout.isTTY && !opts.json) {
2308
+ console.clear();
2309
+ }
2310
+
2311
+ const report = await collectStatusSnapshot(repoRoot);
2312
+ cycles += 1;
2313
+
2314
+ if (opts.json) {
2315
+ console.log(JSON.stringify(watch ? { ...report, watch: true, cycles } : report, null, 2));
2316
+ } else {
2317
+ renderUnifiedStatusReport(report);
2318
+ if (watch) {
2319
+ console.log('');
2320
+ console.log(chalk.dim(`Watching every ${watchIntervalMs}ms${maxCycles > 0 ? ` • cycle ${cycles}/${maxCycles}` : ''}`));
2321
+ }
2322
+ }
2323
+
2324
+ if (!watch) break;
2325
+ if (maxCycles > 0 && cycles >= maxCycles) break;
2326
+ if (opts.json) break;
2327
+ sleepSync(watchIntervalMs);
2328
+ }
2329
+ });
2330
+
2331
+ program
2332
+ .command('doctor')
2333
+ .description('Show one operator-focused health view: what is running, what is blocked, and what to do next')
2334
+ .option('--json', 'Output raw JSON')
2335
+ .action(async (opts) => {
2336
+ const repoRoot = getRepo();
2337
+ const db = getDb(repoRoot);
2338
+ const tasks = listTasks(db);
2339
+ const activeLeases = listLeases(db, 'active');
2340
+ const staleLeases = getStaleLeases(db);
2341
+ const scanReport = await scanAllWorktrees(db, repoRoot);
2342
+ const aiGate = await runAiMergeGate(db, repoRoot);
2343
+ const report = buildDoctorReport({
2344
+ db,
2345
+ repoRoot,
2346
+ tasks,
2347
+ activeLeases,
2348
+ staleLeases,
2349
+ scanReport,
2350
+ aiGate,
2351
+ });
2352
+ db.close();
2353
+
2354
+ if (opts.json) {
2355
+ console.log(JSON.stringify(report, null, 2));
2356
+ return;
743
2357
  }
744
2358
 
745
- if (staleLeases.length > 0) {
2359
+ const badge = report.health === 'healthy'
2360
+ ? chalk.green('HEALTHY')
2361
+ : report.health === 'warn'
2362
+ ? chalk.yellow('ATTENTION')
2363
+ : chalk.red('BLOCKED');
2364
+ console.log(`${badge} ${report.summary}`);
2365
+ console.log(chalk.dim(repoRoot));
2366
+ console.log('');
2367
+
2368
+ console.log(chalk.bold('At a glance:'));
2369
+ console.log(` ${chalk.dim('tasks')} ${report.counts.pending} pending, ${report.counts.in_progress} in progress, ${report.counts.done} done, ${report.counts.failed} failed`);
2370
+ console.log(` ${chalk.dim('leases')} ${report.counts.active_leases} active, ${report.counts.stale_leases} stale`);
2371
+ console.log(` ${chalk.dim('merge')} CI ${report.merge_readiness.ci_gate_ok ? chalk.green('clear') : chalk.red('blocked')} AI ${report.merge_readiness.ai_gate_status}`);
2372
+
2373
+ if (report.active_work.length > 0) {
746
2374
  console.log('');
747
- console.log(chalk.bold('Stale Leases:'));
748
- for (const lease of staleLeases) {
749
- console.log(` ${chalk.red(lease.worktree)} ${lease.task_title} ${chalk.dim(lease.id)} ${chalk.dim(lease.heartbeat_at)}`);
2375
+ console.log(chalk.bold('Running now:'));
2376
+ for (const item of report.active_work.slice(0, 5)) {
2377
+ const leaseId = activeLeases.find((lease) => lease.task_id === item.task_id && lease.worktree === item.worktree)?.id || null;
2378
+ const boundary = item.boundary_validation
2379
+ ? ` ${chalk.dim(`validation:${item.boundary_validation.status}`)}`
2380
+ : '';
2381
+ const stale = (item.dependency_invalidations?.length || 0) > 0
2382
+ ? ` ${chalk.dim(`stale:${item.dependency_invalidations.length}`)}`
2383
+ : '';
2384
+ console.log(` ${chalk.cyan(item.worktree)} -> ${item.task_title} ${chalk.dim(item.task_id)}${leaseId ? ` ${chalk.dim(`lease:${leaseId}`)}` : ''}${item.scope_summary ? ` ${chalk.dim(item.scope_summary)}` : ''}${boundary}${stale}`);
750
2385
  }
751
2386
  }
752
2387
 
753
- if (pending.length > 0) {
754
- console.log('');
755
- console.log(chalk.bold('Next Up:'));
756
- const next = pending.slice(0, 3);
757
- for (const t of next) {
758
- console.log(` [p${t.priority}] ${t.title} ${chalk.dim(t.id)}`);
2388
+ console.log('');
2389
+ console.log(chalk.bold('Attention now:'));
2390
+ if (report.attention.length === 0) {
2391
+ console.log(` ${chalk.green('Nothing urgent.')}`);
2392
+ } else {
2393
+ for (const item of report.attention.slice(0, 6)) {
2394
+ const itemBadge = item.severity === 'block' ? chalk.red('block') : chalk.yellow('warn ');
2395
+ console.log(` ${itemBadge} ${item.title}`);
2396
+ if (item.detail) console.log(` ${chalk.dim(item.detail)}`);
2397
+ console.log(` ${chalk.yellow('next:')} ${item.next_step}`);
2398
+ if (item.command) console.log(` ${chalk.cyan('run:')} ${item.command}`);
759
2399
  }
760
2400
  }
761
2401
 
762
- // File Claims
763
- const claims = getActiveFileClaims(db);
764
- if (claims.length > 0) {
2402
+ console.log('');
2403
+ console.log(chalk.bold('Recommended next steps:'));
2404
+ for (const step of report.next_steps) {
2405
+ console.log(` - ${step}`);
2406
+ }
2407
+ if (report.suggested_commands.length > 0) {
765
2408
  console.log('');
766
- console.log(chalk.bold(`Active File Claims (${claims.length}):`));
767
- const byWorktree = {};
768
- for (const c of claims) {
769
- if (!byWorktree[c.worktree]) byWorktree[c.worktree] = [];
770
- byWorktree[c.worktree].push(c.file_path);
2409
+ console.log(chalk.bold('Suggested commands:'));
2410
+ for (const command of report.suggested_commands) {
2411
+ console.log(` ${chalk.cyan(command)}`);
771
2412
  }
772
- for (const [wt, files] of Object.entries(byWorktree)) {
773
- console.log(` ${chalk.cyan(wt)}: ${files.slice(0, 5).join(', ')}${files.length > 5 ? ` +${files.length - 5} more` : ''}`);
2413
+ }
2414
+ });
2415
+
2416
+ // ── gate ─────────────────────────────────────────────────────────────────────
2417
+
2418
+ const gateCmd = program.command('gate').description('Safety checks for edits, merges, and CI');
2419
+
2420
+ const auditCmd = program.command('audit').description('Inspect and verify the tamper-evident audit trail');
2421
+
2422
+ auditCmd
2423
+ .command('verify')
2424
+ .description('Verify the audit log hash chain and project signatures')
2425
+ .option('--json', 'Output verification details as JSON')
2426
+ .action((options) => {
2427
+ const repo = getRepo();
2428
+ const db = getDb(repo);
2429
+ const result = verifyAuditTrail(db);
2430
+
2431
+ if (options.json) {
2432
+ console.log(JSON.stringify(result, null, 2));
2433
+ process.exit(result.ok ? 0 : 1);
2434
+ }
2435
+
2436
+ if (result.ok) {
2437
+ console.log(chalk.green(`Audit trail verified: ${result.count} signed events in order.`));
2438
+ return;
2439
+ }
2440
+
2441
+ console.log(chalk.red(`Audit trail verification failed: ${result.failures.length} problem(s) across ${result.count} events.`));
2442
+ for (const failure of result.failures.slice(0, 10)) {
2443
+ const prefix = failure.sequence ? `#${failure.sequence}` : `event ${failure.id}`;
2444
+ console.log(` ${chalk.red(prefix)} ${failure.reason_code}: ${failure.message}`);
2445
+ }
2446
+ if (result.failures.length > 10) {
2447
+ console.log(chalk.dim(` ...and ${result.failures.length - 10} more`));
2448
+ }
2449
+ process.exit(1);
2450
+ });
2451
+
2452
+ gateCmd
2453
+ .command('commit')
2454
+ .description('Validate current worktree changes against the active lease and claims')
2455
+ .option('--json', 'Output raw JSON')
2456
+ .action((opts) => {
2457
+ const repoRoot = getRepo();
2458
+ const db = getDb(repoRoot);
2459
+ const result = runCommitGate(db, repoRoot);
2460
+ db.close();
2461
+
2462
+ if (opts.json) {
2463
+ console.log(JSON.stringify(result, null, 2));
2464
+ } else if (result.ok) {
2465
+ console.log(`${chalk.green('✓')} ${result.summary}`);
2466
+ } else {
2467
+ console.log(chalk.red(`✗ ${result.summary}`));
2468
+ for (const violation of result.violations) {
2469
+ const label = violation.file || '(worktree)';
2470
+ console.log(` ${chalk.yellow(label)} ${chalk.dim(violation.reason_code)}`);
774
2471
  }
775
2472
  }
776
2473
 
777
- // Quick conflict scan
778
- console.log('');
779
- const spinner = ora('Running conflict scan...').start();
780
- try {
781
- const report = await scanAllWorktrees(db, repoRoot);
782
- spinner.stop();
2474
+ if (!result.ok) process.exitCode = 1;
2475
+ });
783
2476
 
784
- const totalConflicts = report.conflicts.length + report.fileConflicts.length;
785
- if (totalConflicts === 0) {
786
- console.log(chalk.green(`✓ No conflicts across ${report.worktrees.length} worktree(s)`));
787
- } else {
788
- console.log(chalk.red(`⚠ ${totalConflicts} conflict(s) detected — run 'switchman scan' for details`));
2477
+ gateCmd
2478
+ .command('merge')
2479
+ .description('Validate current worktree changes before recording a merge commit')
2480
+ .option('--json', 'Output raw JSON')
2481
+ .action((opts) => {
2482
+ const repoRoot = getRepo();
2483
+ const db = getDb(repoRoot);
2484
+ const result = runCommitGate(db, repoRoot);
2485
+ db.close();
2486
+
2487
+ if (opts.json) {
2488
+ console.log(JSON.stringify(result, null, 2));
2489
+ } else if (result.ok) {
2490
+ console.log(`${chalk.green('✓')} Merge gate passed for ${chalk.cyan(result.worktree || 'current worktree')}.`);
2491
+ } else {
2492
+ console.log(chalk.red(`✗ Merge gate rejected changes in ${chalk.cyan(result.worktree || 'current worktree')}.`));
2493
+ for (const violation of result.violations) {
2494
+ const label = violation.file || '(worktree)';
2495
+ console.log(` ${chalk.yellow(label)} ${chalk.dim(violation.reason_code)}`);
2496
+ }
2497
+ }
2498
+
2499
+ if (!result.ok) process.exitCode = 1;
2500
+ });
2501
+
2502
+ gateCmd
2503
+ .command('install')
2504
+ .description('Install git hooks that run the Switchman commit and merge gates')
2505
+ .action(() => {
2506
+ const repoRoot = getRepo();
2507
+ const hookPaths = installGateHooks(repoRoot);
2508
+ console.log(`${chalk.green('✓')} Installed pre-commit hook at ${chalk.cyan(hookPaths.pre_commit)}`);
2509
+ console.log(`${chalk.green('✓')} Installed pre-merge-commit hook at ${chalk.cyan(hookPaths.pre_merge_commit)}`);
2510
+ });
2511
+
2512
+ gateCmd
2513
+ .command('ci')
2514
+ .description('Run a repo-level enforcement gate suitable for CI, merges, or PR validation')
2515
+ .option('--github', 'Write GitHub Actions step summary/output when GITHUB_* env vars are present')
2516
+ .option('--github-step-summary <path>', 'Path to write GitHub Actions step summary markdown')
2517
+ .option('--github-output <path>', 'Path to write GitHub Actions outputs')
2518
+ .option('--json', 'Output raw JSON')
2519
+ .action(async (opts) => {
2520
+ const repoRoot = getRepo();
2521
+ const db = getDb(repoRoot);
2522
+ const report = await scanAllWorktrees(db, repoRoot);
2523
+ const aiGate = await runAiMergeGate(db, repoRoot);
2524
+ db.close();
2525
+
2526
+ const ok = report.conflicts.length === 0
2527
+ && report.fileConflicts.length === 0
2528
+ && (report.ownershipConflicts?.length || 0) === 0
2529
+ && (report.semanticConflicts?.length || 0) === 0
2530
+ && report.unclaimedChanges.length === 0
2531
+ && report.complianceSummary.non_compliant === 0
2532
+ && report.complianceSummary.stale === 0
2533
+ && aiGate.status !== 'blocked'
2534
+ && (aiGate.dependency_invalidations?.filter((item) => item.severity === 'blocked').length || 0) === 0;
2535
+
2536
+ const result = {
2537
+ ok,
2538
+ summary: ok
2539
+ ? `Repo gate passed for ${report.worktrees.length} worktree(s).`
2540
+ : 'Repo gate rejected unmanaged changes, stale leases, ownership conflicts, stale dependency invalidations, or boundary validation failures.',
2541
+ compliance: report.complianceSummary,
2542
+ unclaimed_changes: report.unclaimedChanges,
2543
+ file_conflicts: report.fileConflicts,
2544
+ ownership_conflicts: report.ownershipConflicts || [],
2545
+ semantic_conflicts: report.semanticConflicts || [],
2546
+ branch_conflicts: report.conflicts,
2547
+ ai_gate_status: aiGate.status,
2548
+ boundary_validations: aiGate.boundary_validations || [],
2549
+ dependency_invalidations: aiGate.dependency_invalidations || [],
2550
+ };
2551
+
2552
+ const githubTargets = resolveGitHubOutputTargets(opts);
2553
+ if (githubTargets.stepSummaryPath || githubTargets.outputPath) {
2554
+ writeGitHubCiStatus({
2555
+ result,
2556
+ stepSummaryPath: githubTargets.stepSummaryPath,
2557
+ outputPath: githubTargets.outputPath,
2558
+ });
2559
+ }
2560
+
2561
+ if (opts.json) {
2562
+ console.log(JSON.stringify(result, null, 2));
2563
+ } else if (ok) {
2564
+ console.log(`${chalk.green('✓')} ${result.summary}`);
2565
+ } else {
2566
+ console.log(chalk.red(`✗ ${result.summary}`));
2567
+ if (result.unclaimed_changes.length > 0) {
2568
+ console.log(chalk.bold(' Unclaimed changes:'));
2569
+ for (const entry of result.unclaimed_changes) {
2570
+ console.log(` ${chalk.cyan(entry.worktree)}: ${entry.files.join(', ')}`);
2571
+ }
2572
+ }
2573
+ if (result.file_conflicts.length > 0) {
2574
+ console.log(chalk.bold(' File conflicts:'));
2575
+ for (const conflict of result.file_conflicts) {
2576
+ console.log(` ${chalk.yellow(conflict.file)} ${chalk.dim(conflict.worktrees.join(', '))}`);
2577
+ }
2578
+ }
2579
+ if (result.ownership_conflicts.length > 0) {
2580
+ console.log(chalk.bold(' Ownership conflicts:'));
2581
+ for (const conflict of result.ownership_conflicts) {
2582
+ if (conflict.type === 'subsystem_overlap') {
2583
+ console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)} ${chalk.dim(`subsystem:${conflict.subsystemTag}`)}`);
2584
+ } else {
2585
+ console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)} ${chalk.dim(`${conflict.scopeA} ↔ ${conflict.scopeB}`)}`);
2586
+ }
2587
+ }
2588
+ }
2589
+ if (result.semantic_conflicts.length > 0) {
2590
+ console.log(chalk.bold(' Semantic conflicts:'));
2591
+ for (const conflict of result.semantic_conflicts) {
2592
+ console.log(` ${chalk.yellow(conflict.object_name)} ${chalk.dim(`${conflict.worktreeA} vs ${conflict.worktreeB}`)}`);
2593
+ }
2594
+ }
2595
+ if (result.branch_conflicts.length > 0) {
2596
+ console.log(chalk.bold(' Branch conflicts:'));
2597
+ for (const conflict of result.branch_conflicts) {
2598
+ console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)}`);
2599
+ }
2600
+ }
2601
+ if (result.boundary_validations.length > 0) {
2602
+ console.log(chalk.bold(' Boundary validations:'));
2603
+ for (const validation of result.boundary_validations) {
2604
+ console.log(` ${chalk.yellow(validation.task_id)} ${chalk.dim(validation.missing_task_types.join(', '))}`);
2605
+ }
2606
+ }
2607
+ if (result.dependency_invalidations.length > 0) {
2608
+ console.log(chalk.bold(' Stale dependency invalidations:'));
2609
+ for (const invalidation of result.dependency_invalidations) {
2610
+ console.log(` ${chalk.yellow(invalidation.affected_task_id)} ${chalk.dim(invalidation.stale_area)}`);
2611
+ }
789
2612
  }
790
- } catch {
791
- spinner.stop();
792
- console.log(chalk.dim('Could not run conflict scan'));
793
2613
  }
794
2614
 
2615
+ if (!ok) process.exitCode = 1;
2616
+ });
2617
+
2618
+ gateCmd
2619
+ .command('install-ci')
2620
+ .description('Install a GitHub Actions workflow that runs the Switchman CI gate on PRs and pushes')
2621
+ .option('--workflow-name <name>', 'Workflow file name', 'switchman-gate.yml')
2622
+ .action((opts) => {
2623
+ const repoRoot = getRepo();
2624
+ const workflowPath = installGitHubActionsWorkflow(repoRoot, opts.workflowName);
2625
+ console.log(`${chalk.green('✓')} Installed GitHub Actions workflow at ${chalk.cyan(workflowPath)}`);
2626
+ });
2627
+
2628
+ gateCmd
2629
+ .command('ai')
2630
+ .description('Run the AI-style merge check to assess risky overlap across workspaces')
2631
+ .option('--json', 'Output raw JSON')
2632
+ .action(async (opts) => {
2633
+ const repoRoot = getRepo();
2634
+ const db = getDb(repoRoot);
2635
+ const result = await runAiMergeGate(db, repoRoot);
795
2636
  db.close();
796
- console.log('');
797
- console.log(chalk.dim('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
798
- console.log('');
2637
+
2638
+ if (opts.json) {
2639
+ console.log(JSON.stringify(result, null, 2));
2640
+ } else {
2641
+ const badge = result.status === 'pass'
2642
+ ? chalk.green('PASS')
2643
+ : result.status === 'warn'
2644
+ ? chalk.yellow('WARN')
2645
+ : chalk.red('BLOCK');
2646
+ console.log(`${badge} ${result.summary}`);
2647
+
2648
+ const riskyPairs = result.pairs.filter((pair) => pair.status !== 'pass');
2649
+ if (riskyPairs.length > 0) {
2650
+ console.log(chalk.bold(' Risky pairs:'));
2651
+ for (const pair of riskyPairs) {
2652
+ console.log(` ${chalk.cyan(pair.worktree_a)} ${chalk.dim('vs')} ${chalk.cyan(pair.worktree_b)} ${chalk.dim(pair.status)} ${chalk.dim(`score=${pair.score}`)}`);
2653
+ for (const reason of pair.reasons.slice(0, 3)) {
2654
+ console.log(` ${chalk.yellow(reason)}`);
2655
+ }
2656
+ }
2657
+ }
2658
+
2659
+ if ((result.boundary_validations?.length || 0) > 0) {
2660
+ console.log(chalk.bold(' Boundary validations:'));
2661
+ for (const validation of result.boundary_validations.slice(0, 5)) {
2662
+ console.log(` ${chalk.cyan(validation.task_id)} ${chalk.dim(validation.severity)} ${chalk.dim(validation.missing_task_types.join(', '))}`);
2663
+ if (validation.rationale?.[0]) {
2664
+ console.log(` ${chalk.yellow(validation.rationale[0])}`);
2665
+ }
2666
+ }
2667
+ }
2668
+
2669
+ if ((result.dependency_invalidations?.length || 0) > 0) {
2670
+ console.log(chalk.bold(' Stale dependency invalidations:'));
2671
+ for (const invalidation of result.dependency_invalidations.slice(0, 5)) {
2672
+ console.log(` ${chalk.cyan(invalidation.affected_task_id)} ${chalk.dim(invalidation.severity)} ${chalk.dim(invalidation.stale_area)}`);
2673
+ }
2674
+ }
2675
+
2676
+ if ((result.semantic_conflicts?.length || 0) > 0) {
2677
+ console.log(chalk.bold(' Semantic conflicts:'));
2678
+ for (const conflict of result.semantic_conflicts.slice(0, 5)) {
2679
+ console.log(` ${chalk.cyan(conflict.object_name)} ${chalk.dim(conflict.type)} ${chalk.dim(`${conflict.worktreeA} vs ${conflict.worktreeB}`)}`);
2680
+ }
2681
+ }
2682
+
2683
+ const riskyWorktrees = result.worktrees.filter((worktree) => worktree.findings.length > 0);
2684
+ if (riskyWorktrees.length > 0) {
2685
+ console.log(chalk.bold(' Worktree signals:'));
2686
+ for (const worktree of riskyWorktrees) {
2687
+ console.log(` ${chalk.cyan(worktree.worktree)} ${chalk.dim(`score=${worktree.score}`)}`);
2688
+ for (const finding of worktree.findings.slice(0, 2)) {
2689
+ console.log(` ${chalk.yellow(finding)}`);
2690
+ }
2691
+ }
2692
+ }
2693
+ }
2694
+
2695
+ if (result.status === 'blocked') process.exitCode = 1;
2696
+ });
2697
+
2698
+ const semanticCmd = program
2699
+ .command('semantic')
2700
+ .description('Inspect or materialize the derived semantic code-object view');
2701
+
2702
+ semanticCmd
2703
+ .command('materialize')
2704
+ .description('Write a deterministic semantic index artifact to .switchman/semantic-index.json')
2705
+ .action(() => {
2706
+ const repoRoot = getRepo();
2707
+ const db = getDb(repoRoot);
2708
+ const worktrees = listWorktrees(db);
2709
+ const result = materializeSemanticIndex(repoRoot, { worktrees });
2710
+ db.close();
2711
+ console.log(`${chalk.green('✓')} Wrote semantic index to ${chalk.cyan(result.output_path)}`);
2712
+ });
2713
+
2714
+ const objectCmd = program
2715
+ .command('object')
2716
+ .description('Experimental object-source mode backed by canonical exported code objects');
2717
+
2718
+ objectCmd
2719
+ .command('import')
2720
+ .description('Import exported code objects from tracked source files into the canonical object store')
2721
+ .option('--json', 'Output raw JSON')
2722
+ .action((opts) => {
2723
+ const repoRoot = getRepo();
2724
+ const db = getDb(repoRoot);
2725
+ const objects = importCodeObjectsToStore(db, repoRoot);
2726
+ db.close();
2727
+ if (opts.json) {
2728
+ console.log(JSON.stringify({ object_count: objects.length, objects }, null, 2));
2729
+ return;
2730
+ }
2731
+ console.log(`${chalk.green('✓')} Imported ${objects.length} code object(s) into the canonical store`);
2732
+ });
2733
+
2734
+ objectCmd
2735
+ .command('list')
2736
+ .description('List canonical code objects currently stored in Switchman')
2737
+ .option('--json', 'Output raw JSON')
2738
+ .action((opts) => {
2739
+ const repoRoot = getRepo();
2740
+ const db = getDb(repoRoot);
2741
+ const objects = listCodeObjects(db);
2742
+ db.close();
2743
+ if (opts.json) {
2744
+ console.log(JSON.stringify({ object_count: objects.length, objects }, null, 2));
2745
+ return;
2746
+ }
2747
+ if (objects.length === 0) {
2748
+ console.log(chalk.dim('No canonical code objects stored yet. Run `switchman object import` first.'));
2749
+ return;
2750
+ }
2751
+ for (const object of objects) {
2752
+ console.log(`${chalk.cyan(object.object_id)} ${chalk.dim(`${object.file_path} ${object.kind}`)}`);
2753
+ }
2754
+ });
2755
+
2756
+ objectCmd
2757
+ .command('update <objectId>')
2758
+ .description('Update the canonical source text for a stored code object')
2759
+ .requiredOption('--text <source>', 'Replacement exported source text')
2760
+ .option('--json', 'Output raw JSON')
2761
+ .action((objectId, opts) => {
2762
+ const repoRoot = getRepo();
2763
+ const db = getDb(repoRoot);
2764
+ const object = updateCodeObjectSource(db, objectId, opts.text);
2765
+ db.close();
2766
+ if (!object) {
2767
+ console.error(chalk.red(`Unknown code object: ${objectId}`));
2768
+ process.exitCode = 1;
2769
+ return;
2770
+ }
2771
+ if (opts.json) {
2772
+ console.log(JSON.stringify({ object }, null, 2));
2773
+ return;
2774
+ }
2775
+ console.log(`${chalk.green('✓')} Updated ${chalk.cyan(object.object_id)} in the canonical object store`);
2776
+ });
2777
+
2778
+ objectCmd
2779
+ .command('materialize')
2780
+ .description('Materialize source files from the canonical object store')
2781
+ .option('--output-root <path>', 'Alternate root directory to write materialized files into')
2782
+ .option('--json', 'Output raw JSON')
2783
+ .action((opts) => {
2784
+ const repoRoot = getRepo();
2785
+ const db = getDb(repoRoot);
2786
+ const result = materializeCodeObjects(db, repoRoot, { outputRoot: opts.outputRoot || repoRoot });
2787
+ db.close();
2788
+ if (opts.json) {
2789
+ console.log(JSON.stringify(result, null, 2));
2790
+ return;
2791
+ }
2792
+ console.log(`${chalk.green('✓')} Materialized ${result.file_count} file(s) from the canonical object store`);
2793
+ });
2794
+
2795
+ // ── monitor ──────────────────────────────────────────────────────────────────
2796
+
2797
+ const monitorCmd = program.command('monitor').description('Observe workspaces for runtime file changes');
2798
+
2799
+ monitorCmd
2800
+ .command('once')
2801
+ .description('Capture one monitoring pass and log observed file changes')
2802
+ .option('--json', 'Output raw JSON')
2803
+ .option('--quarantine', 'Move or restore denied runtime changes immediately after detection')
2804
+ .action((opts) => {
2805
+ const repoRoot = getRepo();
2806
+ const db = getDb(repoRoot);
2807
+ const worktrees = listGitWorktrees(repoRoot);
2808
+ const result = monitorWorktreesOnce(db, repoRoot, worktrees, { quarantine: opts.quarantine });
2809
+ db.close();
2810
+
2811
+ if (opts.json) {
2812
+ console.log(JSON.stringify(result, null, 2));
2813
+ return;
2814
+ }
2815
+
2816
+ if (result.events.length === 0) {
2817
+ console.log(chalk.dim('No file changes observed since the last monitor snapshot.'));
2818
+ return;
2819
+ }
2820
+
2821
+ console.log(`${chalk.green('✓')} Observed ${result.summary.total} file change(s)`);
2822
+ for (const event of result.events) {
2823
+ const badge = event.status === 'allowed' ? chalk.green('ALLOWED') : chalk.red('DENIED ');
2824
+ const action = event.enforcement_action ? ` ${chalk.dim(event.enforcement_action)}` : '';
2825
+ console.log(` ${badge} ${chalk.cyan(event.worktree)} ${chalk.yellow(event.file_path)} ${chalk.dim(event.change_type)}${event.reason_code ? ` ${chalk.dim(event.reason_code)}` : ''}${action}`);
2826
+ }
2827
+ });
2828
+
2829
+ monitorCmd
2830
+ .command('watch')
2831
+ .description('Poll workspaces continuously and log observed file changes')
2832
+ .option('--interval-ms <ms>', 'Polling interval in milliseconds', '2000')
2833
+ .option('--quarantine', 'Move or restore denied runtime changes immediately after detection')
2834
+ .option('--daemonized', 'Internal flag used by monitor start', false)
2835
+ .action(async (opts) => {
2836
+ const repoRoot = getRepo();
2837
+ const intervalMs = Number.parseInt(opts.intervalMs, 10);
2838
+
2839
+ if (!Number.isFinite(intervalMs) || intervalMs < 100) {
2840
+ console.error(chalk.red('--interval-ms must be at least 100'));
2841
+ process.exit(1);
2842
+ }
2843
+
2844
+ console.log(chalk.cyan(`Watching workspaces every ${intervalMs}ms. Press Ctrl+C to stop.`));
2845
+
2846
+ let stopped = false;
2847
+ const stop = () => {
2848
+ stopped = true;
2849
+ process.stdout.write('\n');
2850
+ if (opts.daemonized) {
2851
+ clearMonitorState(repoRoot);
2852
+ }
2853
+ };
2854
+ process.on('SIGINT', stop);
2855
+ process.on('SIGTERM', stop);
2856
+
2857
+ while (!stopped) {
2858
+ const db = getDb(repoRoot);
2859
+ const worktrees = listGitWorktrees(repoRoot);
2860
+ const result = monitorWorktreesOnce(db, repoRoot, worktrees, { quarantine: opts.quarantine });
2861
+ db.close();
2862
+
2863
+ for (const event of result.events) {
2864
+ const badge = event.status === 'allowed' ? chalk.green('ALLOWED') : chalk.red('DENIED ');
2865
+ const action = event.enforcement_action ? ` ${chalk.dim(event.enforcement_action)}` : '';
2866
+ console.log(` ${badge} ${chalk.cyan(event.worktree)} ${chalk.yellow(event.file_path)} ${chalk.dim(event.change_type)}${event.reason_code ? ` ${chalk.dim(event.reason_code)}` : ''}${action}`);
2867
+ }
2868
+
2869
+ if (stopped) break;
2870
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, intervalMs));
2871
+ }
2872
+
2873
+ console.log(chalk.dim('Stopped worktree monitor.'));
2874
+ });
2875
+
2876
+ monitorCmd
2877
+ .command('start')
2878
+ .description('Start the worktree monitor as a background process')
2879
+ .option('--interval-ms <ms>', 'Polling interval in milliseconds', '2000')
2880
+ .option('--quarantine', 'Move or restore denied runtime changes immediately after detection')
2881
+ .action((opts) => {
2882
+ const repoRoot = getRepo();
2883
+ const intervalMs = Number.parseInt(opts.intervalMs, 10);
2884
+ const existingState = readMonitorState(repoRoot);
2885
+
2886
+ if (existingState && isProcessRunning(existingState.pid)) {
2887
+ console.log(chalk.yellow(`Monitor already running with pid ${existingState.pid}`));
2888
+ return;
2889
+ }
2890
+
2891
+ const logPath = join(repoRoot, '.switchman', 'monitor.log');
2892
+ const child = spawn(process.execPath, [
2893
+ process.argv[1],
2894
+ 'monitor',
2895
+ 'watch',
2896
+ '--interval-ms',
2897
+ String(intervalMs),
2898
+ ...(opts.quarantine ? ['--quarantine'] : []),
2899
+ '--daemonized',
2900
+ ], {
2901
+ cwd: repoRoot,
2902
+ detached: true,
2903
+ stdio: 'ignore',
2904
+ });
2905
+ child.unref();
2906
+
2907
+ const statePath = writeMonitorState(repoRoot, {
2908
+ pid: child.pid,
2909
+ interval_ms: intervalMs,
2910
+ quarantine: Boolean(opts.quarantine),
2911
+ log_path: logPath,
2912
+ started_at: new Date().toISOString(),
2913
+ });
2914
+
2915
+ console.log(`${chalk.green('✓')} Started monitor pid ${chalk.cyan(String(child.pid))}`);
2916
+ console.log(`${chalk.dim('State:')} ${statePath}`);
2917
+ });
2918
+
2919
+ monitorCmd
2920
+ .command('stop')
2921
+ .description('Stop the background worktree monitor')
2922
+ .action(() => {
2923
+ const repoRoot = getRepo();
2924
+ const state = readMonitorState(repoRoot);
2925
+
2926
+ if (!state) {
2927
+ console.log(chalk.dim('Monitor is not running.'));
2928
+ return;
2929
+ }
2930
+
2931
+ if (!isProcessRunning(state.pid)) {
2932
+ clearMonitorState(repoRoot);
2933
+ console.log(chalk.dim('Monitor state was stale and has been cleared.'));
2934
+ return;
2935
+ }
2936
+
2937
+ process.kill(state.pid, 'SIGTERM');
2938
+ clearMonitorState(repoRoot);
2939
+ console.log(`${chalk.green('✓')} Stopped monitor pid ${chalk.cyan(String(state.pid))}`);
2940
+ });
2941
+
2942
+ monitorCmd
2943
+ .command('status')
2944
+ .description('Show background monitor process status')
2945
+ .action(() => {
2946
+ const repoRoot = getRepo();
2947
+ const state = readMonitorState(repoRoot);
2948
+
2949
+ if (!state) {
2950
+ console.log(chalk.dim('Monitor is not running.'));
2951
+ return;
2952
+ }
2953
+
2954
+ const running = isProcessRunning(state.pid);
2955
+ if (!running) {
2956
+ clearMonitorState(repoRoot);
2957
+ console.log(chalk.yellow('Monitor state existed but the process is no longer running.'));
2958
+ return;
2959
+ }
2960
+
2961
+ console.log(`${chalk.green('✓')} Monitor running`);
2962
+ console.log(` ${chalk.dim('pid')} ${state.pid}`);
2963
+ console.log(` ${chalk.dim('interval_ms')} ${state.interval_ms}`);
2964
+ console.log(` ${chalk.dim('quarantine')} ${state.quarantine ? 'true' : 'false'}`);
2965
+ console.log(` ${chalk.dim('started_at')} ${state.started_at}`);
2966
+ });
2967
+
2968
+ // ── policy ───────────────────────────────────────────────────────────────────
2969
+
2970
+ const policyCmd = program.command('policy').description('Manage enforcement policy exceptions');
2971
+
2972
+ policyCmd
2973
+ .command('init')
2974
+ .description('Write a starter enforcement policy file for generated-path exceptions')
2975
+ .action(() => {
2976
+ const repoRoot = getRepo();
2977
+ const policyPath = writeEnforcementPolicy(repoRoot, {
2978
+ allowed_generated_paths: [
2979
+ 'dist/**',
2980
+ 'build/**',
2981
+ 'coverage/**',
2982
+ ],
2983
+ });
2984
+ console.log(`${chalk.green('✓')} Wrote enforcement policy to ${chalk.cyan(policyPath)}`);
799
2985
  });
800
2986
 
801
2987
  program.parse();