switchman-dev 0.1.1 → 0.1.3

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
@@ -19,22 +19,31 @@
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
- createTask, assignTask, completeTask, failTask, listTasks, getTask, getNextPendingTask,
28
+ DEFAULT_STALE_LEASE_MINUTES,
29
+ createTask, startTaskLease, completeTask, failTask, getBoundaryValidationState, getTaskSpec, listTasks, getTask, getNextPendingTask,
30
+ listDependencyInvalidations, listLeases, listScopeReservations, heartbeatLease, getStaleLeases, reapStaleLeases,
31
31
  registerWorktree, listWorktrees,
32
32
  claimFiles, releaseFileClaims, getActiveFileClaims, checkFileConflicts,
33
- logConflict,
33
+ verifyAuditTrail,
34
34
  } from '../core/db.js';
35
35
  import { scanAllWorktrees } from '../core/detector.js';
36
-
37
- const __dirname = dirname(fileURLToPath(import.meta.url));
36
+ import { upsertProjectMcpConfig } from '../core/mcp.js';
37
+ import { gatewayAppendFile, gatewayMakeDirectory, gatewayMovePath, gatewayRemovePath, gatewayWriteFile, installGateHooks, monitorWorktreesOnce, runCommitGate, runWrappedCommand, writeEnforcementPolicy } from '../core/enforcement.js';
38
+ import { runAiMergeGate } from '../core/merge-gate.js';
39
+ import { clearMonitorState, getMonitorStatePath, isProcessRunning, readMonitorState, writeMonitorState } from '../core/monitor.js';
40
+ import { buildPipelinePrSummary, createPipelineFollowupTasks, executePipeline, exportPipelinePrBundle, getPipelineStatus, publishPipelinePr, runPipeline, startPipeline } from '../core/pipeline.js';
41
+ import { installGitHubActionsWorkflow, resolveGitHubOutputTargets, writeGitHubCiStatus } from '../core/ci.js';
42
+ import { importCodeObjectsToStore, listCodeObjects, materializeCodeObjects, materializeSemanticIndex, updateCodeObjectSource } from '../core/semantic.js';
43
+
44
+ function installMcpConfig(targetDirs) {
45
+ return targetDirs.map((targetDir) => upsertProjectMcpConfig(targetDir));
46
+ }
38
47
 
39
48
  // ─── Helpers ─────────────────────────────────────────────────────────────────
40
49
 
@@ -60,14 +69,38 @@ function statusBadge(status) {
60
69
  const colors = {
61
70
  pending: chalk.yellow,
62
71
  in_progress: chalk.blue,
72
+ active: chalk.blue,
73
+ completed: chalk.green,
63
74
  done: chalk.green,
64
75
  failed: chalk.red,
76
+ expired: chalk.red,
65
77
  idle: chalk.gray,
66
78
  busy: chalk.blue,
79
+ managed: chalk.green,
80
+ observed: chalk.yellow,
81
+ non_compliant: chalk.red,
82
+ stale: chalk.red,
67
83
  };
68
84
  return (colors[status] || chalk.white)(status.toUpperCase().padEnd(11));
69
85
  }
70
86
 
87
+ function getCurrentWorktreeName(explicitWorktree) {
88
+ return explicitWorktree || process.cwd().split('/').pop();
89
+ }
90
+
91
+ function taskJsonWithLease(task, worktree, lease) {
92
+ return {
93
+ task: {
94
+ ...task,
95
+ worktree,
96
+ status: 'in_progress',
97
+ lease_id: lease?.id ?? null,
98
+ lease_status: lease?.status ?? null,
99
+ heartbeat_at: lease?.heartbeat_at ?? null,
100
+ },
101
+ };
102
+ }
103
+
71
104
  function printTable(rows, columns) {
72
105
  if (!rows.length) return;
73
106
  const widths = columns.map(col =>
@@ -84,6 +117,412 @@ function printTable(rows, columns) {
84
117
  }
85
118
  }
86
119
 
120
+ function sleepSync(ms) {
121
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
122
+ }
123
+
124
+ function summarizeLeaseScope(db, lease) {
125
+ const reservations = listScopeReservations(db, { leaseId: lease.id });
126
+ const pathScopes = reservations
127
+ .filter((reservation) => reservation.ownership_level === 'path_scope' && reservation.scope_pattern)
128
+ .map((reservation) => reservation.scope_pattern);
129
+ if (pathScopes.length === 1) return `scope:${pathScopes[0]}`;
130
+ if (pathScopes.length > 1) return `scope:${pathScopes.length} paths`;
131
+
132
+ const subsystemScopes = reservations
133
+ .filter((reservation) => reservation.ownership_level === 'subsystem' && reservation.subsystem_tag)
134
+ .map((reservation) => reservation.subsystem_tag);
135
+ if (subsystemScopes.length === 1) return `subsystem:${subsystemScopes[0]}`;
136
+ if (subsystemScopes.length > 1) return `subsystem:${subsystemScopes.length}`;
137
+ return null;
138
+ }
139
+
140
+ function isBusyError(err) {
141
+ const message = String(err?.message || '').toLowerCase();
142
+ return message.includes('database is locked') || message.includes('sqlite_busy');
143
+ }
144
+
145
+ function humanizeReasonCode(reasonCode) {
146
+ const labels = {
147
+ no_active_lease: 'no active lease',
148
+ lease_expired: 'lease expired',
149
+ worktree_mismatch: 'wrong worktree',
150
+ path_not_claimed: 'path not claimed',
151
+ path_claimed_by_other_lease: 'claimed by another lease',
152
+ path_scoped_by_other_lease: 'scoped by another lease',
153
+ path_within_task_scope: 'within task scope',
154
+ policy_exception_required: 'policy exception required',
155
+ policy_exception_allowed: 'policy exception allowed',
156
+ changes_outside_claims: 'changed files outside claims',
157
+ changes_outside_task_scope: 'changed files outside task scope',
158
+ missing_expected_tests: 'missing expected tests',
159
+ missing_expected_docs: 'missing expected docs',
160
+ missing_expected_source_changes: 'missing expected source changes',
161
+ objective_not_evidenced: 'task objective not evidenced',
162
+ no_changes_detected: 'no changes detected',
163
+ task_execution_timeout: 'task execution timed out',
164
+ task_failed: 'task failed',
165
+ agent_command_failed: 'agent command failed',
166
+ rejected: 'rejected',
167
+ };
168
+ return labels[reasonCode] || String(reasonCode || 'unknown').replace(/_/g, ' ');
169
+ }
170
+
171
+ function nextStepForReason(reasonCode) {
172
+ const actions = {
173
+ no_active_lease: 'reacquire the task or lease before writing',
174
+ lease_expired: 'refresh or reacquire the lease, then retry',
175
+ worktree_mismatch: 'run the task from the assigned worktree',
176
+ path_not_claimed: 'claim the file before editing it',
177
+ path_claimed_by_other_lease: 'wait for the other task or pick a different file',
178
+ changes_outside_claims: 'claim all edited files or narrow the task scope',
179
+ changes_outside_task_scope: 'keep edits inside allowed paths or update the plan',
180
+ missing_expected_tests: 'add test coverage before rerunning',
181
+ missing_expected_docs: 'add the expected docs change before rerunning',
182
+ missing_expected_source_changes: 'make a source change inside the task scope',
183
+ objective_not_evidenced: 'align the output more closely to the task objective',
184
+ no_changes_detected: 'produce a tracked change or close the task differently',
185
+ task_execution_timeout: 'raise the timeout or reduce task size',
186
+ agent_command_failed: 'inspect stderr/stdout and rerun the agent',
187
+ };
188
+ return actions[reasonCode] || null;
189
+ }
190
+
191
+ function latestTaskFailure(task) {
192
+ const failureLine = String(task.description || '')
193
+ .split('\n')
194
+ .map((line) => line.trim())
195
+ .filter(Boolean)
196
+ .reverse()
197
+ .find((line) => line.startsWith('FAILED: '));
198
+ if (!failureLine) return null;
199
+ const failureText = failureLine.slice('FAILED: '.length);
200
+ const reasonMatch = failureText.match(/^([a-z0-9_]+):\s*(.+)$/i);
201
+ return {
202
+ reason_code: reasonMatch ? reasonMatch[1] : null,
203
+ summary: reasonMatch ? reasonMatch[2] : failureText,
204
+ };
205
+ }
206
+
207
+ function analyzeTaskScope(title, description = '') {
208
+ const text = `${title}\n${description}`.toLowerCase();
209
+ const broadPatterns = [
210
+ /\brefactor\b/,
211
+ /\bwhole repo\b/,
212
+ /\bentire repo\b/,
213
+ /\bacross the repo\b/,
214
+ /\bacross the codebase\b/,
215
+ /\bmultiple modules\b/,
216
+ /\ball routes\b/,
217
+ /\bevery route\b/,
218
+ /\ball files\b/,
219
+ /\bevery file\b/,
220
+ /\brename\b.*\bacross\b/,
221
+ /\bsweep(ing)?\b/,
222
+ /\bglobal\b/,
223
+ /\bwide\b/,
224
+ /\blarge\b/,
225
+ ];
226
+ const matches = broadPatterns.filter((pattern) => pattern.test(text));
227
+ if (matches.length === 0) return null;
228
+
229
+ return {
230
+ level: 'warn',
231
+ summary: 'This task looks broad and may fan out across many files or shared areas.',
232
+ next_step: 'Split it into smaller tasks or use `switchman pipeline start` so Switchman can plan and govern the work explicitly.',
233
+ command: `switchman pipeline start "${title.replace(/"/g, '\\"')}"`,
234
+ };
235
+ }
236
+
237
+ function commandForFailedTask(task, failure) {
238
+ if (!task?.id) return null;
239
+ switch (failure?.reason_code) {
240
+ case 'changes_outside_task_scope':
241
+ case 'objective_not_evidenced':
242
+ case 'missing_expected_tests':
243
+ case 'missing_expected_docs':
244
+ case 'missing_expected_source_changes':
245
+ case 'no_changes_detected':
246
+ return `switchman pipeline status ${task.id.split('-').slice(0, -1).join('-')}`;
247
+ default:
248
+ return null;
249
+ }
250
+ }
251
+
252
+ function buildDoctorReport({ db, repoRoot, tasks, activeLeases, staleLeases, scanReport, aiGate }) {
253
+ const failedTasks = tasks
254
+ .filter((task) => task.status === 'failed')
255
+ .map((task) => {
256
+ const failure = latestTaskFailure(task);
257
+ return {
258
+ id: task.id,
259
+ title: task.title,
260
+ worktree: task.worktree || null,
261
+ reason_code: failure?.reason_code || null,
262
+ summary: failure?.summary || 'task failed without a recorded summary',
263
+ next_step: nextStepForReason(failure?.reason_code) || 'inspect the task output and rerun with a narrower scope',
264
+ command: commandForFailedTask(task, failure),
265
+ };
266
+ });
267
+
268
+ const blockedWorktrees = scanReport.unclaimedChanges.map((entry) => ({
269
+ worktree: entry.worktree,
270
+ files: entry.files,
271
+ reason_code: entry.reasons?.[0]?.reason_code || null,
272
+ next_step: nextStepForReason(entry.reasons?.[0]?.reason_code) || 'inspect the changed files and bring them back under Switchman claims',
273
+ }));
274
+
275
+ const fileConflicts = scanReport.fileConflicts.map((conflict) => ({
276
+ file: conflict.file,
277
+ worktrees: conflict.worktrees,
278
+ next_step: 'let one task finish first or re-scope the conflicting work',
279
+ }));
280
+
281
+ const ownershipConflicts = (scanReport.ownershipConflicts || []).map((conflict) => ({
282
+ type: conflict.type,
283
+ worktree_a: conflict.worktreeA,
284
+ worktree_b: conflict.worktreeB,
285
+ subsystem_tag: conflict.subsystemTag || null,
286
+ scope_a: conflict.scopeA || null,
287
+ scope_b: conflict.scopeB || null,
288
+ next_step: 'split the task scopes or serialize work across the shared ownership boundary',
289
+ }));
290
+ const semanticConflicts = (scanReport.semanticConflicts || []).map((conflict) => ({
291
+ ...conflict,
292
+ next_step: 'review the overlapping exported object or split the work across different boundaries',
293
+ }));
294
+
295
+ const branchConflicts = scanReport.conflicts.map((conflict) => ({
296
+ worktree_a: conflict.worktreeA,
297
+ worktree_b: conflict.worktreeB,
298
+ files: conflict.conflictingFiles,
299
+ next_step: 'review the overlapping branches before merge',
300
+ }));
301
+
302
+ const attention = [
303
+ ...staleLeases.map((lease) => ({
304
+ kind: 'stale_lease',
305
+ title: `${lease.worktree} lost its active heartbeat`,
306
+ detail: lease.task_title,
307
+ next_step: 'run `switchman lease reap` to return the task to pending',
308
+ command: 'switchman lease reap',
309
+ severity: 'block',
310
+ })),
311
+ ...failedTasks.map((task) => ({
312
+ kind: 'failed_task',
313
+ title: task.title,
314
+ detail: task.summary,
315
+ next_step: task.next_step,
316
+ command: task.command,
317
+ severity: 'warn',
318
+ })),
319
+ ...blockedWorktrees.map((entry) => ({
320
+ kind: 'unmanaged_changes',
321
+ title: `${entry.worktree} has unmanaged changed files`,
322
+ detail: `${entry.files.slice(0, 3).join(', ')}${entry.files.length > 3 ? ` +${entry.files.length - 3} more` : ''}`,
323
+ next_step: entry.next_step,
324
+ command: 'switchman scan',
325
+ severity: 'block',
326
+ })),
327
+ ...fileConflicts.map((conflict) => ({
328
+ kind: 'file_conflict',
329
+ title: `${conflict.file} is being edited in multiple worktrees`,
330
+ detail: conflict.worktrees.join(', '),
331
+ next_step: conflict.next_step,
332
+ command: 'switchman scan',
333
+ severity: 'block',
334
+ })),
335
+ ...ownershipConflicts.map((conflict) => ({
336
+ kind: 'ownership_conflict',
337
+ title: conflict.type === 'subsystem_overlap'
338
+ ? `${conflict.worktree_a} and ${conflict.worktree_b} share subsystem ownership`
339
+ : `${conflict.worktree_a} and ${conflict.worktree_b} share scoped ownership`,
340
+ detail: conflict.type === 'subsystem_overlap'
341
+ ? `subsystem:${conflict.subsystem_tag}`
342
+ : `${conflict.scope_a} ↔ ${conflict.scope_b}`,
343
+ next_step: conflict.next_step,
344
+ command: 'switchman scan',
345
+ severity: 'block',
346
+ })),
347
+ ...semanticConflicts.map((conflict) => ({
348
+ kind: 'semantic_conflict',
349
+ title: conflict.type === 'semantic_object_overlap'
350
+ ? `${conflict.worktreeA} and ${conflict.worktreeB} changed the same exported object`
351
+ : `${conflict.worktreeA} and ${conflict.worktreeB} changed semantically similar objects`,
352
+ detail: `${conflict.object_name} (${conflict.fileA} ↔ ${conflict.fileB})`,
353
+ next_step: conflict.next_step,
354
+ command: 'switchman gate ai',
355
+ severity: conflict.severity === 'blocked' ? 'block' : 'warn',
356
+ })),
357
+ ...branchConflicts.map((conflict) => ({
358
+ kind: 'branch_conflict',
359
+ title: `${conflict.worktree_a} and ${conflict.worktree_b} have merge risk`,
360
+ detail: `${conflict.files.slice(0, 3).join(', ')}${conflict.files.length > 3 ? ` +${conflict.files.length - 3} more` : ''}`,
361
+ next_step: conflict.next_step,
362
+ command: 'switchman gate ai',
363
+ severity: 'block',
364
+ })),
365
+ ];
366
+
367
+ if (aiGate.status === 'warn' || aiGate.status === 'blocked') {
368
+ attention.push({
369
+ kind: 'ai_merge_gate',
370
+ title: aiGate.status === 'blocked' ? 'AI merge gate blocked the repo' : 'AI merge gate wants manual review',
371
+ detail: aiGate.summary,
372
+ next_step: 'run `switchman gate ai` and review the risky worktree pairs',
373
+ command: 'switchman gate ai',
374
+ severity: aiGate.status === 'blocked' ? 'block' : 'warn',
375
+ });
376
+ }
377
+
378
+ for (const validation of aiGate.boundary_validations || []) {
379
+ attention.push({
380
+ kind: 'boundary_validation',
381
+ title: validation.summary,
382
+ detail: validation.rationale?.[0] || `missing ${validation.missing_task_types.join(', ')}`,
383
+ next_step: 'complete the missing validation work before merge',
384
+ command: validation.pipeline_id ? `switchman pipeline status ${validation.pipeline_id}` : 'switchman gate ai',
385
+ severity: validation.severity === 'blocked' ? 'block' : 'warn',
386
+ });
387
+ }
388
+
389
+ for (const invalidation of aiGate.dependency_invalidations || []) {
390
+ attention.push({
391
+ kind: 'dependency_invalidation',
392
+ title: invalidation.summary,
393
+ detail: `${invalidation.source_worktree || 'unknown'} -> ${invalidation.affected_worktree || 'unknown'} (${invalidation.stale_area})`,
394
+ next_step: 'rerun or re-review the stale task before merge',
395
+ command: invalidation.affected_pipeline_id ? `switchman pipeline status ${invalidation.affected_pipeline_id}` : 'switchman gate ai',
396
+ severity: invalidation.severity === 'blocked' ? 'block' : 'warn',
397
+ });
398
+ }
399
+
400
+ const health = attention.some((item) => item.severity === 'block')
401
+ ? 'block'
402
+ : attention.some((item) => item.severity === 'warn')
403
+ ? 'warn'
404
+ : 'healthy';
405
+
406
+ return {
407
+ repo_root: repoRoot,
408
+ health,
409
+ summary: health === 'healthy'
410
+ ? 'Repo looks healthy. Agents are coordinated and merge checks are clear.'
411
+ : health === 'warn'
412
+ ? 'Repo is running, but there are issues that need review before merge.'
413
+ : 'Repo needs attention before more work or merge.',
414
+ counts: {
415
+ pending: tasks.filter((task) => task.status === 'pending').length,
416
+ in_progress: tasks.filter((task) => task.status === 'in_progress').length,
417
+ done: tasks.filter((task) => task.status === 'done').length,
418
+ failed: failedTasks.length,
419
+ active_leases: activeLeases.length,
420
+ stale_leases: staleLeases.length,
421
+ },
422
+ active_work: activeLeases.map((lease) => ({
423
+ worktree: lease.worktree,
424
+ task_id: lease.task_id,
425
+ task_title: lease.task_title,
426
+ heartbeat_at: lease.heartbeat_at,
427
+ scope_summary: summarizeLeaseScope(db, lease),
428
+ boundary_validation: getBoundaryValidationState(db, lease.id),
429
+ dependency_invalidations: listDependencyInvalidations(db, { affectedTaskId: lease.task_id }),
430
+ })),
431
+ attention,
432
+ merge_readiness: {
433
+ ci_gate_ok: scanReport.conflicts.length === 0
434
+ && scanReport.fileConflicts.length === 0
435
+ && (scanReport.ownershipConflicts?.length || 0) === 0
436
+ && (scanReport.semanticConflicts?.length || 0) === 0
437
+ && scanReport.unclaimedChanges.length === 0
438
+ && scanReport.complianceSummary.non_compliant === 0
439
+ && scanReport.complianceSummary.stale === 0
440
+ && aiGate.status !== 'blocked'
441
+ && (aiGate.dependency_invalidations || []).filter((item) => item.severity === 'blocked').length === 0,
442
+ ai_gate_status: aiGate.status,
443
+ boundary_validations: aiGate.boundary_validations || [],
444
+ dependency_invalidations: aiGate.dependency_invalidations || [],
445
+ compliance: scanReport.complianceSummary,
446
+ semantic_conflicts: scanReport.semanticConflicts || [],
447
+ },
448
+ next_steps: attention.length > 0
449
+ ? [...new Set(attention.map((item) => item.next_step))].slice(0, 5)
450
+ : ['run `switchman gate ci` before merge', 'run `switchman scan` after major parallel work'],
451
+ suggested_commands: attention.length > 0
452
+ ? [...new Set(attention.map((item) => item.command).filter(Boolean))].slice(0, 5)
453
+ : ['switchman gate ci', 'switchman scan'],
454
+ };
455
+ }
456
+
457
+ function acquireNextTaskLease(db, worktreeName, agent, attempts = 20) {
458
+ for (let attempt = 1; attempt <= attempts; attempt++) {
459
+ try {
460
+ const task = getNextPendingTask(db);
461
+ if (!task) {
462
+ return { task: null, lease: null, exhausted: true };
463
+ }
464
+
465
+ const lease = startTaskLease(db, task.id, worktreeName, agent || null);
466
+ if (lease) {
467
+ return { task, lease, exhausted: false };
468
+ }
469
+ } catch (err) {
470
+ if (!isBusyError(err) || attempt === attempts) {
471
+ throw err;
472
+ }
473
+ }
474
+
475
+ if (attempt < attempts) {
476
+ sleepSync(75 * attempt);
477
+ }
478
+ }
479
+
480
+ return { task: null, lease: null, exhausted: false };
481
+ }
482
+
483
+ function acquireNextTaskLeaseWithRetries(repoRoot, worktreeName, agent, attempts = 20) {
484
+ let lastError = null;
485
+ for (let attempt = 1; attempt <= attempts; attempt++) {
486
+ let db = null;
487
+ try {
488
+ db = openDb(repoRoot);
489
+ const result = acquireNextTaskLease(db, worktreeName, agent, attempts);
490
+ db.close();
491
+ return result;
492
+ } catch (err) {
493
+ lastError = err;
494
+ try { db?.close(); } catch { /* no-op */ }
495
+ if (!isBusyError(err) || attempt === attempts) {
496
+ throw err;
497
+ }
498
+ sleepSync(100 * attempt);
499
+ }
500
+ }
501
+ throw lastError;
502
+ }
503
+
504
+ function completeTaskWithRetries(repoRoot, taskId, attempts = 20) {
505
+ let lastError = null;
506
+ for (let attempt = 1; attempt <= attempts; attempt++) {
507
+ let db = null;
508
+ try {
509
+ db = openDb(repoRoot);
510
+ completeTask(db, taskId);
511
+ releaseFileClaims(db, taskId);
512
+ db.close();
513
+ return;
514
+ } catch (err) {
515
+ lastError = err;
516
+ try { db?.close(); } catch { /* no-op */ }
517
+ if (!isBusyError(err) || attempt === attempts) {
518
+ throw err;
519
+ }
520
+ sleepSync(100 * attempt);
521
+ }
522
+ }
523
+ throw lastError;
524
+ }
525
+
87
526
  // ─── Program ──────────────────────────────────────────────────────────────────
88
527
 
89
528
  program
@@ -108,10 +547,13 @@ program
108
547
  registerWorktree(db, { name: wt.name, path: wt.path, branch: wt.branch || 'unknown' });
109
548
  }
110
549
 
550
+ const mcpConfigWrites = installMcpConfig([...new Set([repoRoot, ...gitWorktrees.map((wt) => wt.path)])]);
551
+
111
552
  db.close();
112
553
  spinner.succeed(`Initialized in ${chalk.cyan(repoRoot)}`);
113
554
  console.log(chalk.dim(` Found and registered ${gitWorktrees.length} git worktree(s)`));
114
555
  console.log(chalk.dim(` Database: .switchman/switchman.db`));
556
+ console.log(chalk.dim(` MCP config: ${mcpConfigWrites.filter((result) => result.changed).length} file(s) written`));
115
557
  console.log('');
116
558
  console.log(`Next steps:`);
117
559
  console.log(` ${chalk.cyan('switchman task add "Fix the login bug"')} — add a task`);
@@ -183,6 +625,8 @@ program
183
625
  registerWorktree(db, { name: wt.name, path: wt.path, branch: wt.branch || 'unknown' });
184
626
  }
185
627
 
628
+ const mcpConfigWrites = installMcpConfig([...new Set([repoRoot, ...created.map((wt) => wt.path)])]);
629
+
186
630
  db.close();
187
631
 
188
632
  const label = agentCount === 1 ? 'workspace' : 'workspaces';
@@ -195,11 +639,18 @@ program
195
639
  console.log(` ${chalk.dim('branch:')} ${wt.branch}`);
196
640
  }
197
641
 
642
+ console.log('');
643
+ console.log(chalk.bold('MCP config:'));
644
+ for (const result of mcpConfigWrites) {
645
+ const status = result.created ? 'created' : result.changed ? 'updated' : 'unchanged';
646
+ console.log(` ${chalk.green('✓')} ${chalk.cyan(result.path)} ${chalk.dim(`(${status})`)}`);
647
+ }
648
+
198
649
  console.log('');
199
650
  console.log(chalk.bold('Next steps:'));
200
651
  console.log(` 1. Add your tasks:`);
201
652
  console.log(` ${chalk.cyan('switchman task add "Your first task" --priority 8')}`);
202
- console.log(` 2. Open Claude Code in each folder above — agents will coordinate automatically`);
653
+ console.log(` 2. Open Claude Code in each folder above — the local .mcp.json will attach Switchman automatically`);
203
654
  console.log(` 3. Check status at any time:`);
204
655
  console.log(` ${chalk.cyan('switchman status')}`);
205
656
  console.log('');
@@ -231,8 +682,14 @@ taskCmd
231
682
  priority: parseInt(opts.priority),
232
683
  });
233
684
  db.close();
685
+ const scopeWarning = analyzeTaskScope(title, opts.description || '');
234
686
  console.log(`${chalk.green('✓')} Task created: ${chalk.cyan(taskId)}`);
235
687
  console.log(` ${chalk.dim(title)}`);
688
+ if (scopeWarning) {
689
+ console.log(chalk.yellow(` warning: ${scopeWarning.summary}`));
690
+ console.log(chalk.yellow(` next: ${scopeWarning.next_step}`));
691
+ console.log(chalk.cyan(` try: ${scopeWarning.command}`));
692
+ }
236
693
  });
237
694
 
238
695
  taskCmd
@@ -263,15 +720,15 @@ taskCmd
263
720
 
264
721
  taskCmd
265
722
  .command('assign <taskId> <worktree>')
266
- .description('Assign a task to a worktree')
723
+ .description('Assign a task to a worktree (compatibility shim for lease acquire)')
267
724
  .option('--agent <name>', 'Agent name (e.g. claude-code)')
268
725
  .action((taskId, worktree, opts) => {
269
726
  const repoRoot = getRepo();
270
727
  const db = getDb(repoRoot);
271
- const ok = assignTask(db, taskId, worktree, opts.agent);
728
+ const lease = startTaskLease(db, taskId, worktree, opts.agent);
272
729
  db.close();
273
- if (ok) {
274
- console.log(`${chalk.green('✓')} Assigned ${chalk.cyan(taskId)} → ${chalk.cyan(worktree)}`);
730
+ if (lease) {
731
+ console.log(`${chalk.green('✓')} Assigned ${chalk.cyan(taskId)} → ${chalk.cyan(worktree)} (${chalk.dim(lease.id)})`);
275
732
  } else {
276
733
  console.log(chalk.red(`Could not assign task. It may not exist or is not in 'pending' status.`));
277
734
  }
@@ -282,11 +739,13 @@ taskCmd
282
739
  .description('Mark a task as complete and release all file claims')
283
740
  .action((taskId) => {
284
741
  const repoRoot = getRepo();
285
- const db = getDb(repoRoot);
286
- completeTask(db, taskId);
287
- releaseFileClaims(db, taskId);
288
- db.close();
289
- console.log(`${chalk.green('✓')} Task ${chalk.cyan(taskId)} marked done — file claims released`);
742
+ try {
743
+ completeTaskWithRetries(repoRoot, taskId);
744
+ console.log(`${chalk.green('✓')} Task ${chalk.cyan(taskId)} marked done — file claims released`);
745
+ } catch (err) {
746
+ console.error(chalk.red(err.message));
747
+ process.exitCode = 1;
748
+ }
290
749
  });
291
750
 
292
751
  taskCmd
@@ -303,300 +762,1702 @@ taskCmd
303
762
 
304
763
  taskCmd
305
764
  .command('next')
306
- .description('Get and assign the next pending task (for agent automation)')
765
+ .description('Get and assign the next pending task (compatibility shim for lease next)')
307
766
  .option('--json', 'Output as JSON')
308
767
  .option('--worktree <name>', 'Worktree to assign the task to (defaults to current worktree name)')
309
768
  .option('--agent <name>', 'Agent identifier for logging (e.g. claude-code)')
310
769
  .action((opts) => {
311
770
  const repoRoot = getRepo();
312
- const db = getDb(repoRoot);
313
- const task = getNextPendingTask(db);
771
+ const worktreeName = getCurrentWorktreeName(opts.worktree);
772
+ const { task, lease, exhausted } = acquireNextTaskLeaseWithRetries(repoRoot, worktreeName, opts.agent || null);
314
773
 
315
774
  if (!task) {
316
- db.close();
317
775
  if (opts.json) console.log(JSON.stringify({ task: null }));
318
- else console.log(chalk.dim('No pending tasks.'));
776
+ else if (exhausted) console.log(chalk.dim('No pending tasks.'));
777
+ else console.log(chalk.yellow('Tasks were claimed by other agents during assignment. Run again to get the next one.'));
319
778
  return;
320
779
  }
321
780
 
322
- // Determine worktree name: explicit flag, or derive from cwd
323
- const worktreeName = opts.worktree || process.cwd().split('/').pop();
324
- const assigned = assignTask(db, task.id, worktreeName, opts.agent || null);
325
- db.close();
326
-
327
- if (!assigned) {
328
- // Race condition: another agent grabbed it between get and assign
781
+ if (!lease) {
329
782
  if (opts.json) console.log(JSON.stringify({ task: null, message: 'Task claimed by another agent — try again' }));
330
783
  else console.log(chalk.yellow('Task was just claimed by another agent. Run again to get the next one.'));
331
784
  return;
332
785
  }
333
786
 
334
787
  if (opts.json) {
335
- console.log(JSON.stringify({ task: { ...task, worktree: worktreeName, status: 'in_progress' } }, null, 2));
788
+ console.log(JSON.stringify(taskJsonWithLease(task, worktreeName, lease), null, 2));
336
789
  } else {
337
790
  console.log(`${chalk.green('✓')} Assigned: ${chalk.bold(task.title)}`);
338
- console.log(` ${chalk.dim('id:')} ${task.id} ${chalk.dim('worktree:')} ${chalk.cyan(worktreeName)} ${chalk.dim('priority:')} ${task.priority}`);
791
+ console.log(` ${chalk.dim('id:')} ${task.id} ${chalk.dim('worktree:')} ${chalk.cyan(worktreeName)} ${chalk.dim('lease:')} ${chalk.dim(lease.id)} ${chalk.dim('priority:')} ${task.priority}`);
339
792
  }
340
793
  });
341
794
 
342
- // ── worktree ───────────────────────────────────────────────────────────────────
343
-
344
- const wtCmd = program.command('worktree').description('Manage worktrees');
795
+ // ── pipeline ──────────────────────────────────────────────────────────────────
345
796
 
346
- wtCmd
347
- .command('add <name> <path> <branch>')
348
- .description('Register a worktree with switchman')
349
- .option('--agent <name>', 'Agent assigned to this worktree')
350
- .action((name, path, branch, opts) => {
351
- const repoRoot = getRepo();
352
- const db = getDb(repoRoot);
353
- registerWorktree(db, { name, path, branch, agent: opts.agent });
354
- db.close();
355
- console.log(`${chalk.green('✓')} Registered worktree: ${chalk.cyan(name)}`);
356
- });
797
+ const pipelineCmd = program.command('pipeline').description('Create and summarize issue-to-PR execution pipelines');
357
798
 
358
- wtCmd
359
- .command('list')
360
- .description('List all registered worktrees')
361
- .action(() => {
799
+ pipelineCmd
800
+ .command('start <title>')
801
+ .description('Create a pipeline from one issue title and split it into execution subtasks')
802
+ .option('-d, --description <desc>', 'Issue description or markdown checklist')
803
+ .option('-p, --priority <n>', 'Priority 1-10 (default 5)', '5')
804
+ .option('--id <id>', 'Custom pipeline ID')
805
+ .option('--json', 'Output raw JSON')
806
+ .action((title, opts) => {
362
807
  const repoRoot = getRepo();
363
808
  const db = getDb(repoRoot);
364
- const worktrees = listWorktrees(db);
365
- const gitWorktrees = listGitWorktrees(repoRoot);
809
+ const result = startPipeline(db, {
810
+ title,
811
+ description: opts.description || null,
812
+ priority: Number.parseInt(opts.priority, 10),
813
+ pipelineId: opts.id || null,
814
+ });
366
815
  db.close();
367
816
 
368
- if (!worktrees.length && !gitWorktrees.length) {
369
- console.log(chalk.dim('No worktrees found.'));
817
+ if (opts.json) {
818
+ console.log(JSON.stringify(result, null, 2));
370
819
  return;
371
820
  }
372
821
 
373
- // Show git worktrees (source of truth) annotated with db info
374
- console.log('');
375
- console.log(chalk.bold('Git Worktrees:'));
376
- for (const wt of gitWorktrees) {
377
- const dbInfo = worktrees.find(d => d.path === wt.path);
378
- const agent = dbInfo?.agent ? chalk.cyan(dbInfo.agent) : chalk.dim('no agent');
379
- const status = dbInfo?.status ? statusBadge(dbInfo.status) : chalk.dim('unregistered');
380
- console.log(` ${chalk.bold(wt.name.padEnd(20))} ${status} branch: ${chalk.cyan(wt.branch || 'unknown')} agent: ${agent}`);
381
- console.log(` ${chalk.dim(wt.path)}`);
822
+ console.log(`${chalk.green('✓')} Pipeline created ${chalk.cyan(result.pipeline_id)}`);
823
+ console.log(` ${chalk.bold(result.title)}`);
824
+ for (const task of result.tasks) {
825
+ const suggested = task.suggested_worktree ? ` ${chalk.dim(`→ ${task.suggested_worktree}`)}` : '';
826
+ const type = task.task_spec?.task_type ? ` ${chalk.dim(`[${task.task_spec.task_type}]`)}` : '';
827
+ console.log(` ${chalk.cyan(task.id)} ${task.title}${type}${suggested}`);
382
828
  }
383
- console.log('');
384
829
  });
385
830
 
386
- wtCmd
387
- .command('sync')
388
- .description('Sync git worktrees into the switchman database')
389
- .action(() => {
831
+ pipelineCmd
832
+ .command('status <pipelineId>')
833
+ .description('Show task status for a pipeline')
834
+ .option('--json', 'Output raw JSON')
835
+ .action((pipelineId, opts) => {
390
836
  const repoRoot = getRepo();
391
837
  const db = getDb(repoRoot);
392
- const gitWorktrees = listGitWorktrees(repoRoot);
393
- for (const wt of gitWorktrees) {
394
- registerWorktree(db, { name: wt.name, path: wt.path, branch: wt.branch || 'unknown' });
395
- }
396
- db.close();
397
- console.log(`${chalk.green('✓')} Synced ${gitWorktrees.length} worktree(s) from git`);
398
- });
399
838
 
400
- // ── claim ──────────────────────────────────────────────────────────────────────
839
+ try {
840
+ const result = getPipelineStatus(db, pipelineId);
841
+ db.close();
401
842
 
402
- program
403
- .command('claim <taskId> <worktree> [files...]')
404
- .description('Claim files for a task (warns if conflicts exist)')
405
- .option('--agent <name>', 'Agent name')
406
- .option('--force', 'Claim even if conflicts exist')
407
- .action((taskId, worktree, files, opts) => {
408
- if (!files.length) {
409
- console.log(chalk.yellow('No files specified. Use: switchman claim <taskId> <worktree> file1 file2 ...'));
410
- return;
843
+ if (opts.json) {
844
+ console.log(JSON.stringify(result, null, 2));
845
+ return;
846
+ }
847
+
848
+ console.log(`${chalk.bold(result.title)} ${chalk.dim(result.pipeline_id)}`);
849
+ 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}`);
850
+ for (const task of result.tasks) {
851
+ const worktree = task.worktree || task.suggested_worktree || 'unassigned';
852
+ const blocked = task.blocked_by?.length ? ` ${chalk.dim(`blocked by ${task.blocked_by.join(', ')}`)}` : '';
853
+ const type = task.task_spec?.task_type ? ` ${chalk.dim(`[${task.task_spec.task_type}]`)}` : '';
854
+ console.log(` ${statusBadge(task.status)} ${task.id} ${task.title}${type} ${chalk.dim(worktree)}${blocked}`);
855
+ if (task.failure?.summary) {
856
+ const reasonLabel = humanizeReasonCode(task.failure.reason_code);
857
+ console.log(` ${chalk.red('why:')} ${task.failure.summary} ${chalk.dim(`(${reasonLabel})`)}`);
858
+ }
859
+ if (task.next_action) {
860
+ console.log(` ${chalk.yellow('next:')} ${task.next_action}`);
861
+ }
862
+ }
863
+ } catch (err) {
864
+ db.close();
865
+ console.error(chalk.red(err.message));
866
+ process.exitCode = 1;
411
867
  }
868
+ });
869
+
870
+ pipelineCmd
871
+ .command('pr <pipelineId>')
872
+ .description('Generate a PR-ready summary for a pipeline using the repo and AI gates')
873
+ .option('--json', 'Output raw JSON')
874
+ .action(async (pipelineId, opts) => {
412
875
  const repoRoot = getRepo();
413
876
  const db = getDb(repoRoot);
414
877
 
415
- // Check for existing claims
416
- const conflicts = checkFileConflicts(db, files, worktree);
878
+ try {
879
+ const result = await buildPipelinePrSummary(db, repoRoot, pipelineId);
880
+ db.close();
417
881
 
418
- if (conflicts.length > 0 && !opts.force) {
419
- console.log(chalk.red(`\n⚠ Claim conflicts detected:`));
420
- for (const c of conflicts) {
421
- console.log(` ${chalk.yellow(c.file)} → already claimed by worktree ${chalk.cyan(c.claimedBy.worktree)} (task: ${c.claimedBy.task_title})`);
882
+ if (opts.json) {
883
+ console.log(JSON.stringify(result, null, 2));
884
+ return;
422
885
  }
423
- console.log(chalk.dim('\nUse --force to claim anyway, or resolve conflicts first.'));
886
+
887
+ console.log(result.markdown);
888
+ } catch (err) {
424
889
  db.close();
425
- return;
890
+ console.error(chalk.red(err.message));
891
+ process.exitCode = 1;
426
892
  }
427
-
428
- claimFiles(db, taskId, worktree, files, opts.agent);
429
- db.close();
430
- console.log(`${chalk.green('✓')} Claimed ${files.length} file(s) for task ${chalk.cyan(taskId)}`);
431
- files.forEach(f => console.log(` ${chalk.dim(f)}`));
432
893
  });
433
894
 
434
- program
435
- .command('release <taskId>')
436
- .description('Release all file claims for a task')
437
- .action((taskId) => {
895
+ pipelineCmd
896
+ .command('bundle <pipelineId> [outputDir]')
897
+ .description('Export a reviewer-ready PR bundle for a pipeline to disk')
898
+ .option('--json', 'Output raw JSON')
899
+ .action(async (pipelineId, outputDir, opts) => {
438
900
  const repoRoot = getRepo();
439
901
  const db = getDb(repoRoot);
440
- releaseFileClaims(db, taskId);
441
- db.close();
442
- console.log(`${chalk.green('✓')} Released all claims for task ${chalk.cyan(taskId)}`);
443
- });
444
902
 
445
- // ── scan ───────────────────────────────────────────────────────────────────────
903
+ try {
904
+ const result = await exportPipelinePrBundle(db, repoRoot, pipelineId, outputDir || null);
905
+ db.close();
446
906
 
447
- program
448
- .command('scan')
449
- .description('Scan all worktrees for conflicts')
907
+ if (opts.json) {
908
+ console.log(JSON.stringify(result, null, 2));
909
+ return;
910
+ }
911
+
912
+ console.log(`${chalk.green('✓')} Exported PR bundle for ${chalk.cyan(result.pipeline_id)}`);
913
+ console.log(` ${chalk.dim(result.output_dir)}`);
914
+ console.log(` ${chalk.dim('json:')} ${result.files.summary_json}`);
915
+ console.log(` ${chalk.dim('summary:')} ${result.files.summary_markdown}`);
916
+ console.log(` ${chalk.dim('body:')} ${result.files.pr_body_markdown}`);
917
+ } catch (err) {
918
+ db.close();
919
+ console.error(chalk.red(err.message));
920
+ process.exitCode = 1;
921
+ }
922
+ });
923
+
924
+ pipelineCmd
925
+ .command('publish <pipelineId> [outputDir]')
926
+ .description('Create a hosted GitHub pull request for a pipeline using gh')
927
+ .option('--base <branch>', 'Base branch for the pull request', 'main')
928
+ .option('--head <branch>', 'Head branch for the pull request (defaults to inferred pipeline branch)')
929
+ .option('--draft', 'Create the pull request as a draft')
930
+ .option('--gh-command <command>', 'Executable to use for GitHub CLI', 'gh')
450
931
  .option('--json', 'Output raw JSON')
451
- .option('--quiet', 'Only show conflicts')
452
- .action(async (opts) => {
932
+ .action(async (pipelineId, outputDir, opts) => {
453
933
  const repoRoot = getRepo();
454
934
  const db = getDb(repoRoot);
455
- const spinner = ora('Scanning worktrees for conflicts...').start();
456
935
 
457
936
  try {
458
- const report = await scanAllWorktrees(db, repoRoot);
937
+ const result = await publishPipelinePr(db, repoRoot, pipelineId, {
938
+ baseBranch: opts.base,
939
+ headBranch: opts.head || null,
940
+ draft: Boolean(opts.draft),
941
+ ghCommand: opts.ghCommand,
942
+ outputDir: outputDir || null,
943
+ });
459
944
  db.close();
460
- spinner.stop();
461
945
 
462
946
  if (opts.json) {
463
- console.log(JSON.stringify(report, null, 2));
947
+ console.log(JSON.stringify(result, null, 2));
464
948
  return;
465
949
  }
466
950
 
467
- console.log('');
468
- console.log(chalk.bold(`Conflict Scan Report`));
469
- console.log(chalk.dim(`${report.scannedAt}`));
470
- console.log('');
471
-
472
- // Worktrees summary
473
- if (!opts.quiet) {
474
- console.log(chalk.bold('Worktrees:'));
475
- for (const wt of report.worktrees) {
476
- const files = report.fileMap?.[wt.name] || [];
477
- console.log(` ${chalk.cyan(wt.name.padEnd(20))} branch: ${(wt.branch || 'unknown').padEnd(30)} ${chalk.dim(files.length + ' changed file(s)')}`);
478
- }
479
- console.log('');
951
+ console.log(`${chalk.green('')} Published PR for ${chalk.cyan(result.pipeline_id)}`);
952
+ console.log(` ${chalk.dim('base:')} ${result.base_branch}`);
953
+ console.log(` ${chalk.dim('head:')} ${result.head_branch}`);
954
+ if (result.output) {
955
+ console.log(` ${chalk.dim(result.output)}`);
480
956
  }
957
+ } catch (err) {
958
+ db.close();
959
+ console.error(chalk.red(err.message));
960
+ process.exitCode = 1;
961
+ }
962
+ });
481
963
 
482
- // File-level overlaps (uncommitted)
483
- if (report.fileConflicts.length > 0) {
484
- console.log(chalk.yellow(`⚠ Files being edited in multiple worktrees (uncommitted):`));
485
- for (const fc of report.fileConflicts) {
486
- console.log(` ${chalk.yellow(fc.file)}`);
487
- console.log(` ${chalk.dim('edited in:')} ${fc.worktrees.join(', ')}`);
488
- }
489
- console.log('');
490
- }
964
+ pipelineCmd
965
+ .command('run <pipelineId> [agentCommand...]')
966
+ .description('Dispatch pending pipeline tasks onto available worktrees and optionally launch an agent command in each one')
967
+ .option('--agent <name>', 'Agent name to record on acquired leases', 'pipeline-runner')
968
+ .option('--detached', 'Launch agent commands as detached background processes')
969
+ .option('--json', 'Output raw JSON')
970
+ .action((pipelineId, agentCommand, opts) => {
971
+ const repoRoot = getRepo();
972
+ const db = getDb(repoRoot);
491
973
 
492
- // Branch-level conflicts
493
- if (report.conflicts.length > 0) {
494
- console.log(chalk.red(`✗ Branch conflicts detected:`));
495
- for (const c of report.conflicts) {
496
- const icon = c.type === 'merge_conflict' ? chalk.red('MERGE CONFLICT') : chalk.yellow('FILE OVERLAP');
497
- console.log(` ${icon}`);
498
- console.log(` ${chalk.cyan(c.worktreeA)} (${c.branchA}) ↔ ${chalk.cyan(c.worktreeB)} (${c.branchB})`);
499
- if (c.conflictingFiles.length) {
500
- console.log(` Conflicting files:`);
501
- c.conflictingFiles.forEach(f => console.log(` ${chalk.yellow(f)}`));
502
- }
503
- }
504
- console.log('');
974
+ try {
975
+ const result = runPipeline(db, repoRoot, {
976
+ pipelineId,
977
+ agentCommand,
978
+ agentName: opts.agent,
979
+ detached: Boolean(opts.detached),
980
+ });
981
+ db.close();
982
+
983
+ if (opts.json) {
984
+ console.log(JSON.stringify(result, null, 2));
985
+ return;
505
986
  }
506
987
 
507
- // All clear
508
- if (report.conflicts.length === 0 && report.fileConflicts.length === 0) {
509
- console.log(chalk.green(`✓ No conflicts detected across ${report.worktrees.length} worktree(s)`));
988
+ if (result.assigned.length === 0) {
989
+ console.log(chalk.dim('No pending pipeline tasks were assigned. All worktrees may already be busy.'));
990
+ return;
510
991
  }
511
992
 
993
+ console.log(`${chalk.green('✓')} Dispatched ${result.assigned.length} pipeline task(s)`);
994
+ for (const assignment of result.assigned) {
995
+ const launch = result.launched.find((item) => item.task_id === assignment.task_id);
996
+ const launchInfo = launch ? ` ${chalk.dim(`pid=${launch.pid}`)}` : '';
997
+ console.log(` ${chalk.cyan(assignment.task_id)} → ${chalk.cyan(assignment.worktree)} ${chalk.dim(assignment.lease_id)}${launchInfo}`);
998
+ }
999
+ if (result.remaining_pending > 0) {
1000
+ console.log(chalk.dim(`${result.remaining_pending} pipeline task(s) remain pending due to unavailable worktrees.`));
1001
+ }
512
1002
  } catch (err) {
513
- spinner.fail(err.message);
514
1003
  db.close();
515
- process.exit(1);
1004
+ console.error(chalk.red(err.message));
1005
+ process.exitCode = 1;
516
1006
  }
517
1007
  });
518
1008
 
519
- // ── status ─────────────────────────────────────────────────────────────────────
520
-
521
- program
522
- .command('status')
523
- .description('Show full system status: tasks, worktrees, claims, and conflicts')
524
- .action(async () => {
1009
+ pipelineCmd
1010
+ .command('review <pipelineId>')
1011
+ .description('Inspect repo and AI gate failures for a pipeline and create follow-up fix tasks')
1012
+ .option('--json', 'Output raw JSON')
1013
+ .action(async (pipelineId, opts) => {
525
1014
  const repoRoot = getRepo();
526
1015
  const db = getDb(repoRoot);
527
1016
 
528
- console.log('');
529
- console.log(chalk.bold.cyan('━━━ switchman status ━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
530
- console.log(chalk.dim(`Repo: ${repoRoot}`));
531
- console.log('');
1017
+ try {
1018
+ const result = await createPipelineFollowupTasks(db, repoRoot, pipelineId);
1019
+ db.close();
532
1020
 
533
- // Tasks
534
- const tasks = listTasks(db);
535
- const pending = tasks.filter(t => t.status === 'pending');
536
- const inProgress = tasks.filter(t => t.status === 'in_progress');
537
- const done = tasks.filter(t => t.status === 'done');
538
- const failed = tasks.filter(t => t.status === 'failed');
1021
+ if (opts.json) {
1022
+ console.log(JSON.stringify(result, null, 2));
1023
+ return;
1024
+ }
539
1025
 
540
- console.log(chalk.bold('Tasks:'));
541
- console.log(` ${chalk.yellow('Pending')} ${pending.length}`);
542
- console.log(` ${chalk.blue('In Progress')} ${inProgress.length}`);
543
- console.log(` ${chalk.green('Done')} ${done.length}`);
544
- console.log(` ${chalk.red('Failed')} ${failed.length}`);
1026
+ if (result.created_count === 0) {
1027
+ console.log(chalk.dim('No follow-up tasks were created. The pipeline gates did not surface new actionable items.'));
1028
+ return;
1029
+ }
545
1030
 
546
- if (inProgress.length > 0) {
547
- console.log('');
548
- console.log(chalk.bold('Active Tasks:'));
549
- for (const t of inProgress) {
550
- console.log(` ${chalk.cyan(t.worktree || 'unassigned')} → ${t.title}`);
1031
+ console.log(`${chalk.green('✓')} Created ${result.created_count} follow-up task(s)`);
1032
+ for (const task of result.created) {
1033
+ console.log(` ${chalk.cyan(task.id)} ${task.title}`);
551
1034
  }
1035
+ } catch (err) {
1036
+ db.close();
1037
+ console.error(chalk.red(err.message));
1038
+ process.exitCode = 1;
552
1039
  }
1040
+ });
553
1041
 
554
- if (pending.length > 0) {
555
- console.log('');
556
- console.log(chalk.bold('Next Up:'));
557
- const next = pending.slice(0, 3);
558
- for (const t of next) {
559
- console.log(` [p${t.priority}] ${t.title} ${chalk.dim(t.id)}`);
1042
+ pipelineCmd
1043
+ .command('exec <pipelineId> [agentCommand...]')
1044
+ .description('Run a bounded autonomous loop: dispatch, execute, review, and stop when ready or blocked')
1045
+ .option('--agent <name>', 'Agent name to record on acquired leases', 'pipeline-runner')
1046
+ .option('--max-iterations <n>', 'Maximum execution/review iterations', '3')
1047
+ .option('--max-retries <n>', 'Retry a failed pipeline task up to this many times', '1')
1048
+ .option('--retry-backoff-ms <ms>', 'Base backoff in milliseconds between retry attempts', '0')
1049
+ .option('--timeout-ms <ms>', 'Default command timeout in milliseconds when a task spec does not provide one', '0')
1050
+ .option('--json', 'Output raw JSON')
1051
+ .action(async (pipelineId, agentCommand, opts) => {
1052
+ const repoRoot = getRepo();
1053
+ const db = getDb(repoRoot);
1054
+
1055
+ try {
1056
+ const result = await executePipeline(db, repoRoot, {
1057
+ pipelineId,
1058
+ agentCommand,
1059
+ agentName: opts.agent,
1060
+ maxIterations: Number.parseInt(opts.maxIterations, 10),
1061
+ maxRetries: Number.parseInt(opts.maxRetries, 10),
1062
+ retryBackoffMs: Number.parseInt(opts.retryBackoffMs, 10),
1063
+ timeoutMs: Number.parseInt(opts.timeoutMs, 10),
1064
+ });
1065
+ db.close();
1066
+
1067
+ if (opts.json) {
1068
+ console.log(JSON.stringify(result, null, 2));
1069
+ return;
1070
+ }
1071
+
1072
+ const badge = result.status === 'ready'
1073
+ ? chalk.green('READY')
1074
+ : result.status === 'blocked'
1075
+ ? chalk.red('BLOCKED')
1076
+ : chalk.yellow('MAX');
1077
+ console.log(`${badge} Pipeline ${chalk.cyan(result.pipeline_id)} ${chalk.dim(result.status)}`);
1078
+ for (const iteration of result.iterations) {
1079
+ 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}`);
560
1080
  }
1081
+ console.log(chalk.dim(result.pr.markdown.split('\n')[0]));
1082
+ } catch (err) {
1083
+ db.close();
1084
+ console.error(chalk.red(err.message));
1085
+ process.exitCode = 1;
561
1086
  }
1087
+ });
562
1088
 
563
- // File Claims
564
- const claims = getActiveFileClaims(db);
565
- if (claims.length > 0) {
1089
+ // ── lease ────────────────────────────────────────────────────────────────────
1090
+
1091
+ const leaseCmd = program.command('lease').description('Manage active work leases');
1092
+
1093
+ leaseCmd
1094
+ .command('acquire <taskId> <worktree>')
1095
+ .description('Acquire a lease for a pending task')
1096
+ .option('--agent <name>', 'Agent identifier for logging')
1097
+ .option('--json', 'Output as JSON')
1098
+ .action((taskId, worktree, opts) => {
1099
+ const repoRoot = getRepo();
1100
+ const db = getDb(repoRoot);
1101
+ const task = getTask(db, taskId);
1102
+ const lease = startTaskLease(db, taskId, worktree, opts.agent || null);
1103
+ db.close();
1104
+
1105
+ if (!lease || !task) {
1106
+ if (opts.json) console.log(JSON.stringify({ lease: null, task: null }));
1107
+ else console.log(chalk.red(`Could not acquire lease. The task may not exist or is not pending.`));
1108
+ process.exitCode = 1;
1109
+ return;
1110
+ }
1111
+
1112
+ if (opts.json) {
1113
+ console.log(JSON.stringify({
1114
+ lease,
1115
+ task: taskJsonWithLease(task, worktree, lease).task,
1116
+ }, null, 2));
1117
+ return;
1118
+ }
1119
+
1120
+ console.log(`${chalk.green('✓')} Lease acquired ${chalk.dim(lease.id)}`);
1121
+ console.log(` ${chalk.dim('task:')} ${chalk.bold(task.title)}`);
1122
+ console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(worktree)}`);
1123
+ });
1124
+
1125
+ leaseCmd
1126
+ .command('next')
1127
+ .description('Claim the next pending task and acquire its lease')
1128
+ .option('--json', 'Output as JSON')
1129
+ .option('--worktree <name>', 'Worktree to assign the task to (defaults to current worktree name)')
1130
+ .option('--agent <name>', 'Agent identifier for logging')
1131
+ .action((opts) => {
1132
+ const repoRoot = getRepo();
1133
+ const worktreeName = getCurrentWorktreeName(opts.worktree);
1134
+ const { task, lease, exhausted } = acquireNextTaskLeaseWithRetries(repoRoot, worktreeName, opts.agent || null);
1135
+
1136
+ if (!task) {
1137
+ if (opts.json) console.log(JSON.stringify({ task: null, lease: null }));
1138
+ else if (exhausted) console.log(chalk.dim('No pending tasks.'));
1139
+ else console.log(chalk.yellow('Tasks were claimed by other agents during assignment. Run again to get the next one.'));
1140
+ return;
1141
+ }
1142
+
1143
+ if (!lease) {
1144
+ if (opts.json) console.log(JSON.stringify({ task: null, lease: null, message: 'Task claimed by another agent — try again' }));
1145
+ else console.log(chalk.yellow('Task was just claimed by another agent. Run again to get the next one.'));
1146
+ return;
1147
+ }
1148
+
1149
+ if (opts.json) {
1150
+ console.log(JSON.stringify({
1151
+ lease,
1152
+ ...taskJsonWithLease(task, worktreeName, lease),
1153
+ }, null, 2));
1154
+ return;
1155
+ }
1156
+
1157
+ console.log(`${chalk.green('✓')} Lease acquired: ${chalk.bold(task.title)}`);
1158
+ console.log(` ${chalk.dim('task:')} ${task.id} ${chalk.dim('lease:')} ${lease.id}`);
1159
+ console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(worktreeName)} ${chalk.dim('priority:')} ${task.priority}`);
1160
+ });
1161
+
1162
+ leaseCmd
1163
+ .command('list')
1164
+ .description('List leases, newest first')
1165
+ .option('-s, --status <status>', 'Filter by status (active|completed|failed|expired)')
1166
+ .action((opts) => {
1167
+ const repoRoot = getRepo();
1168
+ const db = getDb(repoRoot);
1169
+ const leases = listLeases(db, opts.status);
1170
+ db.close();
1171
+
1172
+ if (!leases.length) {
1173
+ console.log(chalk.dim('No leases found.'));
1174
+ return;
1175
+ }
1176
+
1177
+ console.log('');
1178
+ for (const lease of leases) {
1179
+ console.log(`${statusBadge(lease.status)} ${chalk.bold(lease.task_title)}`);
1180
+ console.log(` ${chalk.dim('lease:')} ${lease.id} ${chalk.dim('task:')} ${lease.task_id}`);
1181
+ console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(lease.worktree)} ${chalk.dim('agent:')} ${lease.agent || 'unknown'}`);
1182
+ console.log(` ${chalk.dim('started:')} ${lease.started_at} ${chalk.dim('heartbeat:')} ${lease.heartbeat_at}`);
1183
+ if (lease.failure_reason) console.log(` ${chalk.red(lease.failure_reason)}`);
566
1184
  console.log('');
567
- console.log(chalk.bold(`Active File Claims (${claims.length}):`));
568
- const byWorktree = {};
569
- for (const c of claims) {
570
- if (!byWorktree[c.worktree]) byWorktree[c.worktree] = [];
571
- byWorktree[c.worktree].push(c.file_path);
572
- }
573
- for (const [wt, files] of Object.entries(byWorktree)) {
574
- console.log(` ${chalk.cyan(wt)}: ${files.slice(0, 5).join(', ')}${files.length > 5 ? ` +${files.length - 5} more` : ''}`);
575
- }
1185
+ }
1186
+ });
1187
+
1188
+ leaseCmd
1189
+ .command('heartbeat <leaseId>')
1190
+ .description('Refresh the heartbeat timestamp for an active lease')
1191
+ .option('--agent <name>', 'Agent identifier for logging')
1192
+ .option('--json', 'Output as JSON')
1193
+ .action((leaseId, opts) => {
1194
+ const repoRoot = getRepo();
1195
+ const db = getDb(repoRoot);
1196
+ const lease = heartbeatLease(db, leaseId, opts.agent || null);
1197
+ db.close();
1198
+
1199
+ if (!lease) {
1200
+ if (opts.json) console.log(JSON.stringify({ lease: null }));
1201
+ else console.log(chalk.red(`No active lease found for ${leaseId}`));
1202
+ process.exitCode = 1;
1203
+ return;
576
1204
  }
577
1205
 
578
- // Quick conflict scan
1206
+ if (opts.json) {
1207
+ console.log(JSON.stringify({ lease }, null, 2));
1208
+ return;
1209
+ }
1210
+
1211
+ console.log(`${chalk.green('✓')} Heartbeat refreshed for ${chalk.dim(lease.id)}`);
1212
+ console.log(` ${chalk.dim('task:')} ${lease.task_title} ${chalk.dim('worktree:')} ${chalk.cyan(lease.worktree)}`);
1213
+ });
1214
+
1215
+ leaseCmd
1216
+ .command('reap')
1217
+ .description('Expire stale leases, release their claims, and return their tasks to pending')
1218
+ .option('--stale-after-minutes <minutes>', 'Age threshold for staleness', String(DEFAULT_STALE_LEASE_MINUTES))
1219
+ .option('--json', 'Output as JSON')
1220
+ .action((opts) => {
1221
+ const repoRoot = getRepo();
1222
+ const db = getDb(repoRoot);
1223
+ const staleAfterMinutes = Number.parseInt(opts.staleAfterMinutes, 10);
1224
+ const expired = reapStaleLeases(db, staleAfterMinutes);
1225
+ db.close();
1226
+
1227
+ if (opts.json) {
1228
+ console.log(JSON.stringify({ stale_after_minutes: staleAfterMinutes, expired }, null, 2));
1229
+ return;
1230
+ }
1231
+
1232
+ if (!expired.length) {
1233
+ console.log(chalk.dim(`No stale leases older than ${staleAfterMinutes} minute(s).`));
1234
+ return;
1235
+ }
1236
+
1237
+ console.log(`${chalk.green('✓')} Reaped ${expired.length} stale lease(s)`);
1238
+ for (const lease of expired) {
1239
+ console.log(` ${chalk.dim(lease.id)} ${chalk.cyan(lease.worktree)} → ${lease.task_title}`);
1240
+ }
1241
+ });
1242
+
1243
+ // ── worktree ───────────────────────────────────────────────────────────────────
1244
+
1245
+ const wtCmd = program.command('worktree').description('Manage worktrees');
1246
+
1247
+ wtCmd
1248
+ .command('add <name> <path> <branch>')
1249
+ .description('Register a worktree with switchman')
1250
+ .option('--agent <name>', 'Agent assigned to this worktree')
1251
+ .action((name, path, branch, opts) => {
1252
+ const repoRoot = getRepo();
1253
+ const db = getDb(repoRoot);
1254
+ registerWorktree(db, { name, path, branch, agent: opts.agent });
1255
+ db.close();
1256
+ console.log(`${chalk.green('✓')} Registered worktree: ${chalk.cyan(name)}`);
1257
+ });
1258
+
1259
+ wtCmd
1260
+ .command('list')
1261
+ .description('List all registered worktrees')
1262
+ .action(() => {
1263
+ const repoRoot = getRepo();
1264
+ const db = getDb(repoRoot);
1265
+ const worktrees = listWorktrees(db);
1266
+ const gitWorktrees = listGitWorktrees(repoRoot);
1267
+ db.close();
1268
+
1269
+ if (!worktrees.length && !gitWorktrees.length) {
1270
+ console.log(chalk.dim('No worktrees found.'));
1271
+ return;
1272
+ }
1273
+
1274
+ // Show git worktrees (source of truth) annotated with db info
579
1275
  console.log('');
580
- const spinner = ora('Running conflict scan...').start();
1276
+ console.log(chalk.bold('Git Worktrees:'));
1277
+ for (const wt of gitWorktrees) {
1278
+ const dbInfo = worktrees.find(d => d.path === wt.path);
1279
+ const agent = dbInfo?.agent ? chalk.cyan(dbInfo.agent) : chalk.dim('no agent');
1280
+ const status = dbInfo?.status ? statusBadge(dbInfo.status) : chalk.dim('unregistered');
1281
+ const compliance = dbInfo?.compliance_state ? statusBadge(dbInfo.compliance_state) : chalk.dim('unknown');
1282
+ console.log(` ${chalk.bold(wt.name.padEnd(20))} ${status} ${compliance} branch: ${chalk.cyan(wt.branch || 'unknown')} agent: ${agent}`);
1283
+ console.log(` ${chalk.dim(wt.path)}`);
1284
+ }
1285
+ console.log('');
1286
+ });
1287
+
1288
+ wtCmd
1289
+ .command('sync')
1290
+ .description('Sync git worktrees into the switchman database')
1291
+ .action(() => {
1292
+ const repoRoot = getRepo();
1293
+ const db = getDb(repoRoot);
1294
+ const gitWorktrees = listGitWorktrees(repoRoot);
1295
+ for (const wt of gitWorktrees) {
1296
+ registerWorktree(db, { name: wt.name, path: wt.path, branch: wt.branch || 'unknown' });
1297
+ }
1298
+ db.close();
1299
+ installMcpConfig([...new Set([repoRoot, ...gitWorktrees.map((wt) => wt.path)])]);
1300
+ console.log(`${chalk.green('✓')} Synced ${gitWorktrees.length} worktree(s) from git`);
1301
+ });
1302
+
1303
+ // ── claim ──────────────────────────────────────────────────────────────────────
1304
+
1305
+ program
1306
+ .command('claim <taskId> <worktree> [files...]')
1307
+ .description('Claim files for a task (warns if conflicts exist)')
1308
+ .option('--agent <name>', 'Agent name')
1309
+ .option('--force', 'Claim even if conflicts exist')
1310
+ .action((taskId, worktree, files, opts) => {
1311
+ if (!files.length) {
1312
+ console.log(chalk.yellow('No files specified. Use: switchman claim <taskId> <worktree> file1 file2 ...'));
1313
+ return;
1314
+ }
1315
+ const repoRoot = getRepo();
1316
+ const db = getDb(repoRoot);
1317
+
581
1318
  try {
582
- const report = await scanAllWorktrees(db, repoRoot);
583
- spinner.stop();
1319
+ const conflicts = checkFileConflicts(db, files, worktree);
584
1320
 
585
- const totalConflicts = report.conflicts.length + report.fileConflicts.length;
586
- if (totalConflicts === 0) {
587
- console.log(chalk.green(`✓ No conflicts across ${report.worktrees.length} worktree(s)`));
588
- } else {
589
- console.log(chalk.red(`⚠ ${totalConflicts} conflict(s) detected — run 'switchman scan' for details`));
1321
+ if (conflicts.length > 0 && !opts.force) {
1322
+ console.log(chalk.red(`\n⚠ Claim conflicts detected:`));
1323
+ for (const c of conflicts) {
1324
+ console.log(` ${chalk.yellow(c.file)} already claimed by worktree ${chalk.cyan(c.claimedBy.worktree)} (task: ${c.claimedBy.task_title})`);
1325
+ }
1326
+ console.log(chalk.dim('\nUse --force to claim anyway, or resolve conflicts first.'));
1327
+ process.exitCode = 1;
1328
+ return;
590
1329
  }
591
- } catch {
592
- spinner.stop();
593
- console.log(chalk.dim('Could not run conflict scan'));
1330
+
1331
+ const lease = claimFiles(db, taskId, worktree, files, opts.agent);
1332
+ console.log(`${chalk.green('✓')} Claimed ${files.length} file(s) for task ${chalk.cyan(taskId)} (${chalk.dim(lease.id)})`);
1333
+ files.forEach(f => console.log(` ${chalk.dim(f)}`));
1334
+ } catch (err) {
1335
+ console.error(chalk.red(err.message));
1336
+ process.exitCode = 1;
1337
+ } finally {
1338
+ db.close();
594
1339
  }
1340
+ });
595
1341
 
1342
+ program
1343
+ .command('release <taskId>')
1344
+ .description('Release all file claims for a task')
1345
+ .action((taskId) => {
1346
+ const repoRoot = getRepo();
1347
+ const db = getDb(repoRoot);
1348
+ releaseFileClaims(db, taskId);
596
1349
  db.close();
597
- console.log('');
598
- console.log(chalk.dim('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
599
- console.log('');
1350
+ console.log(`${chalk.green('')} Released all claims for task ${chalk.cyan(taskId)}`);
1351
+ });
1352
+
1353
+ program
1354
+ .command('write <leaseId> <path>')
1355
+ .description('Write a file through the Switchman enforcement gateway')
1356
+ .requiredOption('--text <content>', 'Replacement file content')
1357
+ .option('--worktree <name>', 'Expected worktree for lease validation')
1358
+ .action((leaseId, path, opts) => {
1359
+ const repoRoot = getRepo();
1360
+ const db = getDb(repoRoot);
1361
+ const result = gatewayWriteFile(db, repoRoot, {
1362
+ leaseId,
1363
+ path,
1364
+ content: opts.text,
1365
+ worktree: opts.worktree || null,
1366
+ });
1367
+ db.close();
1368
+
1369
+ if (!result.ok) {
1370
+ console.log(chalk.red(`✗ Write denied for ${chalk.cyan(result.file_path || path)} ${chalk.dim(result.reason_code)}`));
1371
+ process.exitCode = 1;
1372
+ return;
1373
+ }
1374
+
1375
+ console.log(`${chalk.green('✓')} Wrote ${chalk.cyan(result.file_path)} via lease ${chalk.dim(result.lease_id)}`);
1376
+ });
1377
+
1378
+ program
1379
+ .command('rm <leaseId> <path>')
1380
+ .description('Remove a file or directory through the Switchman enforcement gateway')
1381
+ .option('--worktree <name>', 'Expected worktree for lease validation')
1382
+ .action((leaseId, path, opts) => {
1383
+ const repoRoot = getRepo();
1384
+ const db = getDb(repoRoot);
1385
+ const result = gatewayRemovePath(db, repoRoot, {
1386
+ leaseId,
1387
+ path,
1388
+ worktree: opts.worktree || null,
1389
+ });
1390
+ db.close();
1391
+
1392
+ if (!result.ok) {
1393
+ console.log(chalk.red(`✗ Remove denied for ${chalk.cyan(result.file_path || path)} ${chalk.dim(result.reason_code)}`));
1394
+ process.exitCode = 1;
1395
+ return;
1396
+ }
1397
+
1398
+ console.log(`${chalk.green('✓')} Removed ${chalk.cyan(result.file_path)} via lease ${chalk.dim(result.lease_id)}`);
1399
+ });
1400
+
1401
+ program
1402
+ .command('append <leaseId> <path>')
1403
+ .description('Append to a file through the Switchman enforcement gateway')
1404
+ .requiredOption('--text <content>', 'Content to append')
1405
+ .option('--worktree <name>', 'Expected worktree for lease validation')
1406
+ .action((leaseId, path, opts) => {
1407
+ const repoRoot = getRepo();
1408
+ const db = getDb(repoRoot);
1409
+ const result = gatewayAppendFile(db, repoRoot, {
1410
+ leaseId,
1411
+ path,
1412
+ content: opts.text,
1413
+ worktree: opts.worktree || null,
1414
+ });
1415
+ db.close();
1416
+
1417
+ if (!result.ok) {
1418
+ console.log(chalk.red(`✗ Append denied for ${chalk.cyan(result.file_path || path)} ${chalk.dim(result.reason_code)}`));
1419
+ process.exitCode = 1;
1420
+ return;
1421
+ }
1422
+
1423
+ console.log(`${chalk.green('✓')} Appended to ${chalk.cyan(result.file_path)} via lease ${chalk.dim(result.lease_id)}`);
1424
+ });
1425
+
1426
+ program
1427
+ .command('mv <leaseId> <sourcePath> <destinationPath>')
1428
+ .description('Move a file through the Switchman enforcement gateway')
1429
+ .option('--worktree <name>', 'Expected worktree for lease validation')
1430
+ .action((leaseId, sourcePath, destinationPath, opts) => {
1431
+ const repoRoot = getRepo();
1432
+ const db = getDb(repoRoot);
1433
+ const result = gatewayMovePath(db, repoRoot, {
1434
+ leaseId,
1435
+ sourcePath,
1436
+ destinationPath,
1437
+ worktree: opts.worktree || null,
1438
+ });
1439
+ db.close();
1440
+
1441
+ if (!result.ok) {
1442
+ console.log(chalk.red(`✗ Move denied for ${chalk.cyan(result.file_path || destinationPath)} ${chalk.dim(result.reason_code)}`));
1443
+ process.exitCode = 1;
1444
+ return;
1445
+ }
1446
+
1447
+ console.log(`${chalk.green('✓')} Moved ${chalk.cyan(result.source_path)} → ${chalk.cyan(result.file_path)} via lease ${chalk.dim(result.lease_id)}`);
1448
+ });
1449
+
1450
+ program
1451
+ .command('mkdir <leaseId> <path>')
1452
+ .description('Create a directory through the Switchman enforcement gateway')
1453
+ .option('--worktree <name>', 'Expected worktree for lease validation')
1454
+ .action((leaseId, path, opts) => {
1455
+ const repoRoot = getRepo();
1456
+ const db = getDb(repoRoot);
1457
+ const result = gatewayMakeDirectory(db, repoRoot, {
1458
+ leaseId,
1459
+ path,
1460
+ worktree: opts.worktree || null,
1461
+ });
1462
+ db.close();
1463
+
1464
+ if (!result.ok) {
1465
+ console.log(chalk.red(`✗ Mkdir denied for ${chalk.cyan(result.file_path || path)} ${chalk.dim(result.reason_code)}`));
1466
+ process.exitCode = 1;
1467
+ return;
1468
+ }
1469
+
1470
+ console.log(`${chalk.green('✓')} Created ${chalk.cyan(result.file_path)} via lease ${chalk.dim(result.lease_id)}`);
1471
+ });
1472
+
1473
+ program
1474
+ .command('wrap <leaseId> <command...>')
1475
+ .description('Launch a CLI tool under an active Switchman lease with enforcement context env vars')
1476
+ .option('--worktree <name>', 'Expected worktree for lease validation')
1477
+ .option('--cwd <path>', 'Override working directory for the wrapped command')
1478
+ .action((leaseId, commandParts, opts) => {
1479
+ const repoRoot = getRepo();
1480
+ const db = getDb(repoRoot);
1481
+ const [command, ...args] = commandParts;
1482
+ const result = runWrappedCommand(db, repoRoot, {
1483
+ leaseId,
1484
+ command,
1485
+ args,
1486
+ worktree: opts.worktree || null,
1487
+ cwd: opts.cwd || null,
1488
+ });
1489
+ db.close();
1490
+
1491
+ if (!result.ok) {
1492
+ console.log(chalk.red(`✗ Wrapped command denied ${chalk.dim(result.reason_code || 'wrapped_command_failed')}`));
1493
+ process.exitCode = 1;
1494
+ return;
1495
+ }
1496
+
1497
+ console.log(`${chalk.green('✓')} Wrapped command completed under lease ${chalk.dim(result.lease_id)}`);
1498
+ });
1499
+
1500
+ // ── scan ───────────────────────────────────────────────────────────────────────
1501
+
1502
+ program
1503
+ .command('scan')
1504
+ .description('Scan all worktrees for conflicts')
1505
+ .option('--json', 'Output raw JSON')
1506
+ .option('--quiet', 'Only show conflicts')
1507
+ .action(async (opts) => {
1508
+ const repoRoot = getRepo();
1509
+ const db = getDb(repoRoot);
1510
+ const spinner = ora('Scanning worktrees for conflicts...').start();
1511
+
1512
+ try {
1513
+ const report = await scanAllWorktrees(db, repoRoot);
1514
+ db.close();
1515
+ spinner.stop();
1516
+
1517
+ if (opts.json) {
1518
+ console.log(JSON.stringify(report, null, 2));
1519
+ return;
1520
+ }
1521
+
1522
+ console.log('');
1523
+ console.log(chalk.bold(`Conflict Scan Report`));
1524
+ console.log(chalk.dim(`${report.scannedAt}`));
1525
+ console.log('');
1526
+
1527
+ // Worktrees summary
1528
+ if (!opts.quiet) {
1529
+ console.log(chalk.bold('Worktrees:'));
1530
+ for (const wt of report.worktrees) {
1531
+ const files = report.fileMap?.[wt.name] || [];
1532
+ const compliance = report.worktreeCompliance?.find((entry) => entry.worktree === wt.name)?.compliance_state || wt.compliance_state || 'observed';
1533
+ console.log(` ${chalk.cyan(wt.name.padEnd(20))} ${statusBadge(compliance)} branch: ${(wt.branch || 'unknown').padEnd(30)} ${chalk.dim(files.length + ' changed file(s)')}`);
1534
+ }
1535
+ console.log('');
1536
+ }
1537
+
1538
+ // File-level overlaps (uncommitted)
1539
+ if (report.fileConflicts.length > 0) {
1540
+ console.log(chalk.yellow(`⚠ Files being edited in multiple worktrees (uncommitted):`));
1541
+ for (const fc of report.fileConflicts) {
1542
+ console.log(` ${chalk.yellow(fc.file)}`);
1543
+ console.log(` ${chalk.dim('edited in:')} ${fc.worktrees.join(', ')}`);
1544
+ }
1545
+ console.log('');
1546
+ }
1547
+
1548
+ if ((report.ownershipConflicts?.length || 0) > 0) {
1549
+ console.log(chalk.yellow(`⚠ Ownership boundary overlaps detected:`));
1550
+ for (const conflict of report.ownershipConflicts) {
1551
+ if (conflict.type === 'subsystem_overlap') {
1552
+ console.log(` ${chalk.yellow(`subsystem:${conflict.subsystemTag}`)}`);
1553
+ console.log(` ${chalk.dim('reserved by:')} ${conflict.worktreeA}, ${conflict.worktreeB}`);
1554
+ } else {
1555
+ console.log(` ${chalk.yellow(conflict.scopeA)}`);
1556
+ console.log(` ${chalk.dim('overlaps with:')} ${conflict.scopeB}`);
1557
+ console.log(` ${chalk.dim('reserved by:')} ${conflict.worktreeA}, ${conflict.worktreeB}`);
1558
+ }
1559
+ }
1560
+ console.log('');
1561
+ }
1562
+
1563
+ if ((report.semanticConflicts?.length || 0) > 0) {
1564
+ console.log(chalk.yellow(`⚠ Semantic overlaps detected:`));
1565
+ for (const conflict of report.semanticConflicts) {
1566
+ console.log(` ${chalk.yellow(conflict.object_name)}`);
1567
+ console.log(` ${chalk.dim('changed by:')} ${conflict.worktreeA}, ${conflict.worktreeB}`);
1568
+ console.log(` ${chalk.dim('files:')} ${conflict.fileA} ↔ ${conflict.fileB}`);
1569
+ }
1570
+ console.log('');
1571
+ }
1572
+
1573
+ // Branch-level conflicts
1574
+ if (report.conflicts.length > 0) {
1575
+ console.log(chalk.red(`✗ Branch conflicts detected:`));
1576
+ for (const c of report.conflicts) {
1577
+ const icon = c.type === 'merge_conflict' ? chalk.red('MERGE CONFLICT') : chalk.yellow('FILE OVERLAP');
1578
+ console.log(` ${icon}`);
1579
+ console.log(` ${chalk.cyan(c.worktreeA)} (${c.branchA}) ↔ ${chalk.cyan(c.worktreeB)} (${c.branchB})`);
1580
+ if (c.conflictingFiles.length) {
1581
+ console.log(` Conflicting files:`);
1582
+ c.conflictingFiles.forEach(f => console.log(` ${chalk.yellow(f)}`));
1583
+ }
1584
+ }
1585
+ console.log('');
1586
+ }
1587
+
1588
+ if (report.unclaimedChanges.length > 0) {
1589
+ console.log(chalk.red(`✗ Unclaimed or unmanaged changed files detected:`));
1590
+ for (const entry of report.unclaimedChanges) {
1591
+ console.log(` ${chalk.cyan(entry.worktree)} ${chalk.dim(entry.lease_id || 'no active lease')}`);
1592
+ entry.files.forEach((file) => {
1593
+ const reason = entry.reasons.find((item) => item.file === file)?.reason_code || 'path_not_claimed';
1594
+ const nextStep = nextStepForReason(reason);
1595
+ console.log(` ${chalk.yellow(file)} ${chalk.dim(humanizeReasonCode(reason))}${nextStep ? ` ${chalk.dim(`— ${nextStep}`)}` : ''}`);
1596
+ });
1597
+ }
1598
+ console.log('');
1599
+ }
1600
+
1601
+ // All clear
1602
+ if (report.conflicts.length === 0 && report.fileConflicts.length === 0 && (report.ownershipConflicts?.length || 0) === 0 && (report.semanticConflicts?.length || 0) === 0 && report.unclaimedChanges.length === 0) {
1603
+ console.log(chalk.green(`✓ No conflicts detected across ${report.worktrees.length} worktree(s)`));
1604
+ }
1605
+
1606
+ } catch (err) {
1607
+ spinner.fail(err.message);
1608
+ db.close();
1609
+ process.exit(1);
1610
+ }
1611
+ });
1612
+
1613
+ // ── status ─────────────────────────────────────────────────────────────────────
1614
+
1615
+ program
1616
+ .command('status')
1617
+ .description('Show full system status: tasks, worktrees, claims, and conflicts')
1618
+ .action(async () => {
1619
+ const repoRoot = getRepo();
1620
+ const db = getDb(repoRoot);
1621
+
1622
+ console.log('');
1623
+ console.log(chalk.bold.cyan('━━━ switchman status ━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
1624
+ console.log(chalk.dim(`Repo: ${repoRoot}`));
1625
+ console.log('');
1626
+
1627
+ // Tasks
1628
+ const tasks = listTasks(db);
1629
+ const pending = tasks.filter(t => t.status === 'pending');
1630
+ const inProgress = tasks.filter(t => t.status === 'in_progress');
1631
+ const done = tasks.filter(t => t.status === 'done');
1632
+ const failed = tasks.filter(t => t.status === 'failed');
1633
+ const activeLeases = listLeases(db, 'active');
1634
+ const staleLeases = getStaleLeases(db);
1635
+
1636
+ console.log(chalk.bold('Tasks:'));
1637
+ console.log(` ${chalk.yellow('Pending')} ${pending.length}`);
1638
+ console.log(` ${chalk.blue('In Progress')} ${inProgress.length}`);
1639
+ console.log(` ${chalk.green('Done')} ${done.length}`);
1640
+ console.log(` ${chalk.red('Failed')} ${failed.length}`);
1641
+
1642
+ if (activeLeases.length > 0) {
1643
+ console.log('');
1644
+ console.log(chalk.bold('Active Leases:'));
1645
+ for (const lease of activeLeases) {
1646
+ const agent = lease.agent ? ` ${chalk.dim(`agent:${lease.agent}`)}` : '';
1647
+ const scope = summarizeLeaseScope(db, lease);
1648
+ const boundaryValidation = getBoundaryValidationState(db, lease.id);
1649
+ const dependencyInvalidations = listDependencyInvalidations(db, { affectedTaskId: lease.task_id });
1650
+ const boundary = boundaryValidation ? ` ${chalk.dim(`validation:${boundaryValidation.status}`)}` : '';
1651
+ const staleMarker = dependencyInvalidations.length > 0 ? ` ${chalk.dim(`stale:${dependencyInvalidations.length}`)}` : '';
1652
+ console.log(` ${chalk.cyan(lease.worktree)} → ${lease.task_title} ${chalk.dim(lease.id)} ${chalk.dim(`task:${lease.task_id}`)}${agent}${scope ? ` ${chalk.dim(scope)}` : ''}${boundary}${staleMarker}`);
1653
+ }
1654
+ } else if (inProgress.length > 0) {
1655
+ console.log('');
1656
+ console.log(chalk.bold('In-Progress Tasks Without Lease:'));
1657
+ for (const t of inProgress) {
1658
+ console.log(` ${chalk.cyan(t.worktree || 'unassigned')} → ${t.title}`);
1659
+ }
1660
+ }
1661
+
1662
+ if (staleLeases.length > 0) {
1663
+ console.log('');
1664
+ console.log(chalk.bold('Stale Leases:'));
1665
+ for (const lease of staleLeases) {
1666
+ console.log(` ${chalk.red(lease.worktree)} → ${lease.task_title} ${chalk.dim(lease.id)} ${chalk.dim(lease.heartbeat_at)}`);
1667
+ }
1668
+ }
1669
+
1670
+ if (pending.length > 0) {
1671
+ console.log('');
1672
+ console.log(chalk.bold('Next Up:'));
1673
+ const next = pending.slice(0, 3);
1674
+ for (const t of next) {
1675
+ console.log(` [p${t.priority}] ${t.title} ${chalk.dim(t.id)}`);
1676
+ }
1677
+ }
1678
+
1679
+ if (failed.length > 0) {
1680
+ console.log('');
1681
+ console.log(chalk.bold('Failed Tasks:'));
1682
+ for (const task of failed.slice(0, 5)) {
1683
+ const failureLine = String(task.description || '')
1684
+ .split('\n')
1685
+ .map((line) => line.trim())
1686
+ .filter(Boolean)
1687
+ .reverse()
1688
+ .find((line) => line.startsWith('FAILED: '));
1689
+ const failureText = failureLine ? failureLine.slice('FAILED: '.length) : 'unknown failure';
1690
+ const reasonMatch = failureText.match(/^([a-z0-9_]+):\s*(.+)$/i);
1691
+ const reasonCode = reasonMatch ? reasonMatch[1] : null;
1692
+ const summary = reasonMatch ? reasonMatch[2] : failureText;
1693
+ const nextStep = nextStepForReason(reasonCode);
1694
+ console.log(` ${chalk.red(task.title)} ${chalk.dim(task.id)}`);
1695
+ console.log(` ${chalk.red('why:')} ${summary} ${chalk.dim(`(${humanizeReasonCode(reasonCode)})`)}`);
1696
+ if (nextStep) {
1697
+ console.log(` ${chalk.yellow('next:')} ${nextStep}`);
1698
+ }
1699
+ }
1700
+ }
1701
+
1702
+ // File Claims
1703
+ const claims = getActiveFileClaims(db);
1704
+ if (claims.length > 0) {
1705
+ console.log('');
1706
+ console.log(chalk.bold(`Active File Claims (${claims.length}):`));
1707
+ const byWorktree = {};
1708
+ for (const c of claims) {
1709
+ if (!byWorktree[c.worktree]) byWorktree[c.worktree] = [];
1710
+ byWorktree[c.worktree].push(c.file_path);
1711
+ }
1712
+ for (const [wt, files] of Object.entries(byWorktree)) {
1713
+ console.log(` ${chalk.cyan(wt)}: ${files.slice(0, 5).join(', ')}${files.length > 5 ? ` +${files.length - 5} more` : ''}`);
1714
+ }
1715
+ }
1716
+
1717
+ // Quick conflict scan
1718
+ console.log('');
1719
+ const spinner = ora('Running conflict scan...').start();
1720
+ try {
1721
+ const report = await scanAllWorktrees(db, repoRoot);
1722
+ spinner.stop();
1723
+
1724
+ const totalConflicts = report.conflicts.length + report.fileConflicts.length + (report.ownershipConflicts?.length || 0) + (report.semanticConflicts?.length || 0) + report.unclaimedChanges.length;
1725
+ if (totalConflicts === 0) {
1726
+ console.log(chalk.green(`✓ No conflicts across ${report.worktrees.length} worktree(s)`));
1727
+ } else {
1728
+ console.log(chalk.red(`⚠ ${totalConflicts} conflict(s) detected — run 'switchman scan' for details`));
1729
+ }
1730
+
1731
+ console.log('');
1732
+ console.log(chalk.bold('Compliance:'));
1733
+ console.log(` ${chalk.green('Managed')} ${report.complianceSummary.managed}`);
1734
+ console.log(` ${chalk.yellow('Observed')} ${report.complianceSummary.observed}`);
1735
+ console.log(` ${chalk.red('Non-Compliant')} ${report.complianceSummary.non_compliant}`);
1736
+ console.log(` ${chalk.red('Stale')} ${report.complianceSummary.stale}`);
1737
+
1738
+ if (report.unclaimedChanges.length > 0) {
1739
+ console.log('');
1740
+ console.log(chalk.bold('Unclaimed Changed Paths:'));
1741
+ for (const entry of report.unclaimedChanges) {
1742
+ const reasonCode = entry.reasons?.[0]?.reason_code || null;
1743
+ const nextStep = nextStepForReason(reasonCode);
1744
+ console.log(` ${chalk.cyan(entry.worktree)}: ${entry.files.slice(0, 5).join(', ')}${entry.files.length > 5 ? ` +${entry.files.length - 5} more` : ''}`);
1745
+ console.log(` ${chalk.dim(humanizeReasonCode(reasonCode))}${nextStep ? ` — ${nextStep}` : ''}`);
1746
+ }
1747
+ }
1748
+
1749
+ if ((report.ownershipConflicts?.length || 0) > 0) {
1750
+ console.log('');
1751
+ console.log(chalk.bold('Ownership Boundary Overlaps:'));
1752
+ for (const conflict of report.ownershipConflicts.slice(0, 5)) {
1753
+ if (conflict.type === 'subsystem_overlap') {
1754
+ console.log(` ${chalk.cyan(conflict.worktreeA)}: ${chalk.dim(`subsystem:${conflict.subsystemTag}`)} ${chalk.dim('vs')} ${chalk.cyan(conflict.worktreeB)}`);
1755
+ } else {
1756
+ console.log(` ${chalk.cyan(conflict.worktreeA)}: ${chalk.dim(conflict.scopeA)} ${chalk.dim('vs')} ${chalk.cyan(conflict.worktreeB)} ${chalk.dim(conflict.scopeB)}`);
1757
+ }
1758
+ }
1759
+ }
1760
+
1761
+ if ((report.semanticConflicts?.length || 0) > 0) {
1762
+ console.log('');
1763
+ console.log(chalk.bold('Semantic Overlaps:'));
1764
+ for (const conflict of report.semanticConflicts.slice(0, 5)) {
1765
+ console.log(` ${chalk.cyan(conflict.worktreeA)}: ${chalk.dim(conflict.object_name)} ${chalk.dim('vs')} ${chalk.cyan(conflict.worktreeB)} ${chalk.dim(`${conflict.fileA} ↔ ${conflict.fileB}`)}`);
1766
+ }
1767
+ }
1768
+
1769
+ const staleInvalidations = listDependencyInvalidations(db, { status: 'stale' });
1770
+ if (staleInvalidations.length > 0) {
1771
+ console.log('');
1772
+ console.log(chalk.bold('Stale For Revalidation:'));
1773
+ for (const invalidation of staleInvalidations.slice(0, 5)) {
1774
+ const staleArea = invalidation.reason_type === 'subsystem_overlap'
1775
+ ? `subsystem:${invalidation.subsystem_tag}`
1776
+ : `${invalidation.source_scope_pattern} ↔ ${invalidation.affected_scope_pattern}`;
1777
+ console.log(` ${chalk.cyan(invalidation.affected_worktree || 'unknown')}: ${chalk.dim(staleArea)} ${chalk.dim('because')} ${chalk.cyan(invalidation.source_worktree || 'unknown')} changed it`);
1778
+ }
1779
+ }
1780
+
1781
+ if (report.commitGateFailures.length > 0) {
1782
+ console.log('');
1783
+ console.log(chalk.bold('Recent Commit Gate Failures:'));
1784
+ for (const failure of report.commitGateFailures.slice(0, 5)) {
1785
+ console.log(` ${chalk.red(failure.worktree || 'unknown')} ${chalk.dim(humanizeReasonCode(failure.reason_code || 'rejected'))} ${chalk.dim(failure.created_at)}`);
1786
+ }
1787
+ }
1788
+
1789
+ if (report.deniedWrites.length > 0) {
1790
+ console.log('');
1791
+ console.log(chalk.bold('Recent Denied Events:'));
1792
+ for (const event of report.deniedWrites.slice(0, 5)) {
1793
+ console.log(` ${chalk.red(event.event_type)} ${chalk.cyan(event.worktree || 'repo')} ${chalk.dim(humanizeReasonCode(event.reason_code || event.status))}`);
1794
+ }
1795
+ }
1796
+ } catch {
1797
+ spinner.stop();
1798
+ console.log(chalk.dim('Could not run conflict scan'));
1799
+ }
1800
+
1801
+ db.close();
1802
+ console.log('');
1803
+ console.log(chalk.dim('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
1804
+ console.log('');
1805
+ });
1806
+
1807
+ program
1808
+ .command('doctor')
1809
+ .description('Show one operator-focused health view: what is running, what is blocked, and what to do next')
1810
+ .option('--json', 'Output raw JSON')
1811
+ .action(async (opts) => {
1812
+ const repoRoot = getRepo();
1813
+ const db = getDb(repoRoot);
1814
+ const tasks = listTasks(db);
1815
+ const activeLeases = listLeases(db, 'active');
1816
+ const staleLeases = getStaleLeases(db);
1817
+ const scanReport = await scanAllWorktrees(db, repoRoot);
1818
+ const aiGate = await runAiMergeGate(db, repoRoot);
1819
+ const report = buildDoctorReport({
1820
+ db,
1821
+ repoRoot,
1822
+ tasks,
1823
+ activeLeases,
1824
+ staleLeases,
1825
+ scanReport,
1826
+ aiGate,
1827
+ });
1828
+ db.close();
1829
+
1830
+ if (opts.json) {
1831
+ console.log(JSON.stringify(report, null, 2));
1832
+ return;
1833
+ }
1834
+
1835
+ const badge = report.health === 'healthy'
1836
+ ? chalk.green('HEALTHY')
1837
+ : report.health === 'warn'
1838
+ ? chalk.yellow('ATTENTION')
1839
+ : chalk.red('BLOCKED');
1840
+ console.log(`${badge} ${report.summary}`);
1841
+ console.log(chalk.dim(repoRoot));
1842
+ console.log('');
1843
+
1844
+ console.log(chalk.bold('At a glance:'));
1845
+ console.log(` ${chalk.dim('tasks')} ${report.counts.pending} pending, ${report.counts.in_progress} in progress, ${report.counts.done} done, ${report.counts.failed} failed`);
1846
+ console.log(` ${chalk.dim('leases')} ${report.counts.active_leases} active, ${report.counts.stale_leases} stale`);
1847
+ 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}`);
1848
+
1849
+ if (report.active_work.length > 0) {
1850
+ console.log('');
1851
+ console.log(chalk.bold('Running now:'));
1852
+ for (const item of report.active_work.slice(0, 5)) {
1853
+ const leaseId = activeLeases.find((lease) => lease.task_id === item.task_id && lease.worktree === item.worktree)?.id || null;
1854
+ const boundary = item.boundary_validation
1855
+ ? ` ${chalk.dim(`validation:${item.boundary_validation.status}`)}`
1856
+ : '';
1857
+ const stale = (item.dependency_invalidations?.length || 0) > 0
1858
+ ? ` ${chalk.dim(`stale:${item.dependency_invalidations.length}`)}`
1859
+ : '';
1860
+ 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}`);
1861
+ }
1862
+ }
1863
+
1864
+ console.log('');
1865
+ console.log(chalk.bold('Attention now:'));
1866
+ if (report.attention.length === 0) {
1867
+ console.log(` ${chalk.green('Nothing urgent.')}`);
1868
+ } else {
1869
+ for (const item of report.attention.slice(0, 6)) {
1870
+ const itemBadge = item.severity === 'block' ? chalk.red('block') : chalk.yellow('warn ');
1871
+ console.log(` ${itemBadge} ${item.title}`);
1872
+ if (item.detail) console.log(` ${chalk.dim(item.detail)}`);
1873
+ console.log(` ${chalk.yellow('next:')} ${item.next_step}`);
1874
+ if (item.command) console.log(` ${chalk.cyan('run:')} ${item.command}`);
1875
+ }
1876
+ }
1877
+
1878
+ console.log('');
1879
+ console.log(chalk.bold('Recommended next steps:'));
1880
+ for (const step of report.next_steps) {
1881
+ console.log(` - ${step}`);
1882
+ }
1883
+ if (report.suggested_commands.length > 0) {
1884
+ console.log('');
1885
+ console.log(chalk.bold('Suggested commands:'));
1886
+ for (const command of report.suggested_commands) {
1887
+ console.log(` ${chalk.cyan(command)}`);
1888
+ }
1889
+ }
1890
+ });
1891
+
1892
+ // ── gate ─────────────────────────────────────────────────────────────────────
1893
+
1894
+ const gateCmd = program.command('gate').description('Enforcement and commit-gate helpers');
1895
+
1896
+ const auditCmd = program.command('audit').description('Inspect and verify the tamper-evident audit trail');
1897
+
1898
+ auditCmd
1899
+ .command('verify')
1900
+ .description('Verify the audit log hash chain and project signatures')
1901
+ .option('--json', 'Output verification details as JSON')
1902
+ .action((options) => {
1903
+ const repo = getRepo();
1904
+ const db = getDb(repo);
1905
+ const result = verifyAuditTrail(db);
1906
+
1907
+ if (options.json) {
1908
+ console.log(JSON.stringify(result, null, 2));
1909
+ process.exit(result.ok ? 0 : 1);
1910
+ }
1911
+
1912
+ if (result.ok) {
1913
+ console.log(chalk.green(`Audit trail verified: ${result.count} signed events in order.`));
1914
+ return;
1915
+ }
1916
+
1917
+ console.log(chalk.red(`Audit trail verification failed: ${result.failures.length} problem(s) across ${result.count} events.`));
1918
+ for (const failure of result.failures.slice(0, 10)) {
1919
+ const prefix = failure.sequence ? `#${failure.sequence}` : `event ${failure.id}`;
1920
+ console.log(` ${chalk.red(prefix)} ${failure.reason_code}: ${failure.message}`);
1921
+ }
1922
+ if (result.failures.length > 10) {
1923
+ console.log(chalk.dim(` ...and ${result.failures.length - 10} more`));
1924
+ }
1925
+ process.exit(1);
1926
+ });
1927
+
1928
+ gateCmd
1929
+ .command('commit')
1930
+ .description('Validate current worktree changes against the active lease and claims')
1931
+ .option('--json', 'Output raw JSON')
1932
+ .action((opts) => {
1933
+ const repoRoot = getRepo();
1934
+ const db = getDb(repoRoot);
1935
+ const result = runCommitGate(db, repoRoot);
1936
+ db.close();
1937
+
1938
+ if (opts.json) {
1939
+ console.log(JSON.stringify(result, null, 2));
1940
+ } else if (result.ok) {
1941
+ console.log(`${chalk.green('✓')} ${result.summary}`);
1942
+ } else {
1943
+ console.log(chalk.red(`✗ ${result.summary}`));
1944
+ for (const violation of result.violations) {
1945
+ const label = violation.file || '(worktree)';
1946
+ console.log(` ${chalk.yellow(label)} ${chalk.dim(violation.reason_code)}`);
1947
+ }
1948
+ }
1949
+
1950
+ if (!result.ok) process.exitCode = 1;
1951
+ });
1952
+
1953
+ gateCmd
1954
+ .command('merge')
1955
+ .description('Validate current worktree changes before recording a merge commit')
1956
+ .option('--json', 'Output raw JSON')
1957
+ .action((opts) => {
1958
+ const repoRoot = getRepo();
1959
+ const db = getDb(repoRoot);
1960
+ const result = runCommitGate(db, repoRoot);
1961
+ db.close();
1962
+
1963
+ if (opts.json) {
1964
+ console.log(JSON.stringify(result, null, 2));
1965
+ } else if (result.ok) {
1966
+ console.log(`${chalk.green('✓')} Merge gate passed for ${chalk.cyan(result.worktree || 'current worktree')}.`);
1967
+ } else {
1968
+ console.log(chalk.red(`✗ Merge gate rejected changes in ${chalk.cyan(result.worktree || 'current worktree')}.`));
1969
+ for (const violation of result.violations) {
1970
+ const label = violation.file || '(worktree)';
1971
+ console.log(` ${chalk.yellow(label)} ${chalk.dim(violation.reason_code)}`);
1972
+ }
1973
+ }
1974
+
1975
+ if (!result.ok) process.exitCode = 1;
1976
+ });
1977
+
1978
+ gateCmd
1979
+ .command('install')
1980
+ .description('Install git hooks that run the Switchman commit and merge gates')
1981
+ .action(() => {
1982
+ const repoRoot = getRepo();
1983
+ const hookPaths = installGateHooks(repoRoot);
1984
+ console.log(`${chalk.green('✓')} Installed pre-commit hook at ${chalk.cyan(hookPaths.pre_commit)}`);
1985
+ console.log(`${chalk.green('✓')} Installed pre-merge-commit hook at ${chalk.cyan(hookPaths.pre_merge_commit)}`);
1986
+ });
1987
+
1988
+ gateCmd
1989
+ .command('ci')
1990
+ .description('Run a repo-level enforcement gate suitable for CI, merges, or PR validation')
1991
+ .option('--github', 'Write GitHub Actions step summary/output when GITHUB_* env vars are present')
1992
+ .option('--github-step-summary <path>', 'Path to write GitHub Actions step summary markdown')
1993
+ .option('--github-output <path>', 'Path to write GitHub Actions outputs')
1994
+ .option('--json', 'Output raw JSON')
1995
+ .action(async (opts) => {
1996
+ const repoRoot = getRepo();
1997
+ const db = getDb(repoRoot);
1998
+ const report = await scanAllWorktrees(db, repoRoot);
1999
+ const aiGate = await runAiMergeGate(db, repoRoot);
2000
+ db.close();
2001
+
2002
+ const ok = report.conflicts.length === 0
2003
+ && report.fileConflicts.length === 0
2004
+ && (report.ownershipConflicts?.length || 0) === 0
2005
+ && (report.semanticConflicts?.length || 0) === 0
2006
+ && report.unclaimedChanges.length === 0
2007
+ && report.complianceSummary.non_compliant === 0
2008
+ && report.complianceSummary.stale === 0
2009
+ && aiGate.status !== 'blocked'
2010
+ && (aiGate.dependency_invalidations?.filter((item) => item.severity === 'blocked').length || 0) === 0;
2011
+
2012
+ const result = {
2013
+ ok,
2014
+ summary: ok
2015
+ ? `Repo gate passed for ${report.worktrees.length} worktree(s).`
2016
+ : 'Repo gate rejected unmanaged changes, stale leases, ownership conflicts, stale dependency invalidations, or boundary validation failures.',
2017
+ compliance: report.complianceSummary,
2018
+ unclaimed_changes: report.unclaimedChanges,
2019
+ file_conflicts: report.fileConflicts,
2020
+ ownership_conflicts: report.ownershipConflicts || [],
2021
+ semantic_conflicts: report.semanticConflicts || [],
2022
+ branch_conflicts: report.conflicts,
2023
+ ai_gate_status: aiGate.status,
2024
+ boundary_validations: aiGate.boundary_validations || [],
2025
+ dependency_invalidations: aiGate.dependency_invalidations || [],
2026
+ };
2027
+
2028
+ const githubTargets = resolveGitHubOutputTargets(opts);
2029
+ if (githubTargets.stepSummaryPath || githubTargets.outputPath) {
2030
+ writeGitHubCiStatus({
2031
+ result,
2032
+ stepSummaryPath: githubTargets.stepSummaryPath,
2033
+ outputPath: githubTargets.outputPath,
2034
+ });
2035
+ }
2036
+
2037
+ if (opts.json) {
2038
+ console.log(JSON.stringify(result, null, 2));
2039
+ } else if (ok) {
2040
+ console.log(`${chalk.green('✓')} ${result.summary}`);
2041
+ } else {
2042
+ console.log(chalk.red(`✗ ${result.summary}`));
2043
+ if (result.unclaimed_changes.length > 0) {
2044
+ console.log(chalk.bold(' Unclaimed changes:'));
2045
+ for (const entry of result.unclaimed_changes) {
2046
+ console.log(` ${chalk.cyan(entry.worktree)}: ${entry.files.join(', ')}`);
2047
+ }
2048
+ }
2049
+ if (result.file_conflicts.length > 0) {
2050
+ console.log(chalk.bold(' File conflicts:'));
2051
+ for (const conflict of result.file_conflicts) {
2052
+ console.log(` ${chalk.yellow(conflict.file)} ${chalk.dim(conflict.worktrees.join(', '))}`);
2053
+ }
2054
+ }
2055
+ if (result.ownership_conflicts.length > 0) {
2056
+ console.log(chalk.bold(' Ownership conflicts:'));
2057
+ for (const conflict of result.ownership_conflicts) {
2058
+ if (conflict.type === 'subsystem_overlap') {
2059
+ console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)} ${chalk.dim(`subsystem:${conflict.subsystemTag}`)}`);
2060
+ } else {
2061
+ console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)} ${chalk.dim(`${conflict.scopeA} ↔ ${conflict.scopeB}`)}`);
2062
+ }
2063
+ }
2064
+ }
2065
+ if (result.semantic_conflicts.length > 0) {
2066
+ console.log(chalk.bold(' Semantic conflicts:'));
2067
+ for (const conflict of result.semantic_conflicts) {
2068
+ console.log(` ${chalk.yellow(conflict.object_name)} ${chalk.dim(`${conflict.worktreeA} vs ${conflict.worktreeB}`)}`);
2069
+ }
2070
+ }
2071
+ if (result.branch_conflicts.length > 0) {
2072
+ console.log(chalk.bold(' Branch conflicts:'));
2073
+ for (const conflict of result.branch_conflicts) {
2074
+ console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)}`);
2075
+ }
2076
+ }
2077
+ if (result.boundary_validations.length > 0) {
2078
+ console.log(chalk.bold(' Boundary validations:'));
2079
+ for (const validation of result.boundary_validations) {
2080
+ console.log(` ${chalk.yellow(validation.task_id)} ${chalk.dim(validation.missing_task_types.join(', '))}`);
2081
+ }
2082
+ }
2083
+ if (result.dependency_invalidations.length > 0) {
2084
+ console.log(chalk.bold(' Stale dependency invalidations:'));
2085
+ for (const invalidation of result.dependency_invalidations) {
2086
+ console.log(` ${chalk.yellow(invalidation.affected_task_id)} ${chalk.dim(invalidation.stale_area)}`);
2087
+ }
2088
+ }
2089
+ }
2090
+
2091
+ if (!ok) process.exitCode = 1;
2092
+ });
2093
+
2094
+ gateCmd
2095
+ .command('install-ci')
2096
+ .description('Install a GitHub Actions workflow that runs the Switchman CI gate on PRs and pushes')
2097
+ .option('--workflow-name <name>', 'Workflow file name', 'switchman-gate.yml')
2098
+ .action((opts) => {
2099
+ const repoRoot = getRepo();
2100
+ const workflowPath = installGitHubActionsWorkflow(repoRoot, opts.workflowName);
2101
+ console.log(`${chalk.green('✓')} Installed GitHub Actions workflow at ${chalk.cyan(workflowPath)}`);
2102
+ });
2103
+
2104
+ gateCmd
2105
+ .command('ai')
2106
+ .description('Run the AI-style merge gate to assess semantic integration risk across worktrees')
2107
+ .option('--json', 'Output raw JSON')
2108
+ .action(async (opts) => {
2109
+ const repoRoot = getRepo();
2110
+ const db = getDb(repoRoot);
2111
+ const result = await runAiMergeGate(db, repoRoot);
2112
+ db.close();
2113
+
2114
+ if (opts.json) {
2115
+ console.log(JSON.stringify(result, null, 2));
2116
+ } else {
2117
+ const badge = result.status === 'pass'
2118
+ ? chalk.green('PASS')
2119
+ : result.status === 'warn'
2120
+ ? chalk.yellow('WARN')
2121
+ : chalk.red('BLOCK');
2122
+ console.log(`${badge} ${result.summary}`);
2123
+
2124
+ const riskyPairs = result.pairs.filter((pair) => pair.status !== 'pass');
2125
+ if (riskyPairs.length > 0) {
2126
+ console.log(chalk.bold(' Risky pairs:'));
2127
+ for (const pair of riskyPairs) {
2128
+ console.log(` ${chalk.cyan(pair.worktree_a)} ${chalk.dim('vs')} ${chalk.cyan(pair.worktree_b)} ${chalk.dim(pair.status)} ${chalk.dim(`score=${pair.score}`)}`);
2129
+ for (const reason of pair.reasons.slice(0, 3)) {
2130
+ console.log(` ${chalk.yellow(reason)}`);
2131
+ }
2132
+ }
2133
+ }
2134
+
2135
+ if ((result.boundary_validations?.length || 0) > 0) {
2136
+ console.log(chalk.bold(' Boundary validations:'));
2137
+ for (const validation of result.boundary_validations.slice(0, 5)) {
2138
+ console.log(` ${chalk.cyan(validation.task_id)} ${chalk.dim(validation.severity)} ${chalk.dim(validation.missing_task_types.join(', '))}`);
2139
+ if (validation.rationale?.[0]) {
2140
+ console.log(` ${chalk.yellow(validation.rationale[0])}`);
2141
+ }
2142
+ }
2143
+ }
2144
+
2145
+ if ((result.dependency_invalidations?.length || 0) > 0) {
2146
+ console.log(chalk.bold(' Stale dependency invalidations:'));
2147
+ for (const invalidation of result.dependency_invalidations.slice(0, 5)) {
2148
+ console.log(` ${chalk.cyan(invalidation.affected_task_id)} ${chalk.dim(invalidation.severity)} ${chalk.dim(invalidation.stale_area)}`);
2149
+ }
2150
+ }
2151
+
2152
+ if ((result.semantic_conflicts?.length || 0) > 0) {
2153
+ console.log(chalk.bold(' Semantic conflicts:'));
2154
+ for (const conflict of result.semantic_conflicts.slice(0, 5)) {
2155
+ console.log(` ${chalk.cyan(conflict.object_name)} ${chalk.dim(conflict.type)} ${chalk.dim(`${conflict.worktreeA} vs ${conflict.worktreeB}`)}`);
2156
+ }
2157
+ }
2158
+
2159
+ const riskyWorktrees = result.worktrees.filter((worktree) => worktree.findings.length > 0);
2160
+ if (riskyWorktrees.length > 0) {
2161
+ console.log(chalk.bold(' Worktree signals:'));
2162
+ for (const worktree of riskyWorktrees) {
2163
+ console.log(` ${chalk.cyan(worktree.worktree)} ${chalk.dim(`score=${worktree.score}`)}`);
2164
+ for (const finding of worktree.findings.slice(0, 2)) {
2165
+ console.log(` ${chalk.yellow(finding)}`);
2166
+ }
2167
+ }
2168
+ }
2169
+ }
2170
+
2171
+ if (result.status === 'blocked') process.exitCode = 1;
2172
+ });
2173
+
2174
+ const semanticCmd = program
2175
+ .command('semantic')
2176
+ .description('Inspect or materialize the derived semantic code-object view');
2177
+
2178
+ semanticCmd
2179
+ .command('materialize')
2180
+ .description('Write a deterministic semantic index artifact to .switchman/semantic-index.json')
2181
+ .action(() => {
2182
+ const repoRoot = getRepo();
2183
+ const db = getDb(repoRoot);
2184
+ const worktrees = listWorktrees(db);
2185
+ const result = materializeSemanticIndex(repoRoot, { worktrees });
2186
+ db.close();
2187
+ console.log(`${chalk.green('✓')} Wrote semantic index to ${chalk.cyan(result.output_path)}`);
2188
+ });
2189
+
2190
+ const objectCmd = program
2191
+ .command('object')
2192
+ .description('Experimental object-source mode backed by canonical exported code objects');
2193
+
2194
+ objectCmd
2195
+ .command('import')
2196
+ .description('Import exported code objects from tracked source files into the canonical object store')
2197
+ .option('--json', 'Output raw JSON')
2198
+ .action((opts) => {
2199
+ const repoRoot = getRepo();
2200
+ const db = getDb(repoRoot);
2201
+ const objects = importCodeObjectsToStore(db, repoRoot);
2202
+ db.close();
2203
+ if (opts.json) {
2204
+ console.log(JSON.stringify({ object_count: objects.length, objects }, null, 2));
2205
+ return;
2206
+ }
2207
+ console.log(`${chalk.green('✓')} Imported ${objects.length} code object(s) into the canonical store`);
2208
+ });
2209
+
2210
+ objectCmd
2211
+ .command('list')
2212
+ .description('List canonical code objects currently stored in Switchman')
2213
+ .option('--json', 'Output raw JSON')
2214
+ .action((opts) => {
2215
+ const repoRoot = getRepo();
2216
+ const db = getDb(repoRoot);
2217
+ const objects = listCodeObjects(db);
2218
+ db.close();
2219
+ if (opts.json) {
2220
+ console.log(JSON.stringify({ object_count: objects.length, objects }, null, 2));
2221
+ return;
2222
+ }
2223
+ if (objects.length === 0) {
2224
+ console.log(chalk.dim('No canonical code objects stored yet. Run `switchman object import` first.'));
2225
+ return;
2226
+ }
2227
+ for (const object of objects) {
2228
+ console.log(`${chalk.cyan(object.object_id)} ${chalk.dim(`${object.file_path} ${object.kind}`)}`);
2229
+ }
2230
+ });
2231
+
2232
+ objectCmd
2233
+ .command('update <objectId>')
2234
+ .description('Update the canonical source text for a stored code object')
2235
+ .requiredOption('--text <source>', 'Replacement exported source text')
2236
+ .option('--json', 'Output raw JSON')
2237
+ .action((objectId, opts) => {
2238
+ const repoRoot = getRepo();
2239
+ const db = getDb(repoRoot);
2240
+ const object = updateCodeObjectSource(db, objectId, opts.text);
2241
+ db.close();
2242
+ if (!object) {
2243
+ console.error(chalk.red(`Unknown code object: ${objectId}`));
2244
+ process.exitCode = 1;
2245
+ return;
2246
+ }
2247
+ if (opts.json) {
2248
+ console.log(JSON.stringify({ object }, null, 2));
2249
+ return;
2250
+ }
2251
+ console.log(`${chalk.green('✓')} Updated ${chalk.cyan(object.object_id)} in the canonical object store`);
2252
+ });
2253
+
2254
+ objectCmd
2255
+ .command('materialize')
2256
+ .description('Materialize source files from the canonical object store')
2257
+ .option('--output-root <path>', 'Alternate root directory to write materialized files into')
2258
+ .option('--json', 'Output raw JSON')
2259
+ .action((opts) => {
2260
+ const repoRoot = getRepo();
2261
+ const db = getDb(repoRoot);
2262
+ const result = materializeCodeObjects(db, repoRoot, { outputRoot: opts.outputRoot || repoRoot });
2263
+ db.close();
2264
+ if (opts.json) {
2265
+ console.log(JSON.stringify(result, null, 2));
2266
+ return;
2267
+ }
2268
+ console.log(`${chalk.green('✓')} Materialized ${result.file_count} file(s) from the canonical object store`);
2269
+ });
2270
+
2271
+ // ── monitor ──────────────────────────────────────────────────────────────────
2272
+
2273
+ const monitorCmd = program.command('monitor').description('Observe worktrees for runtime file mutations');
2274
+
2275
+ monitorCmd
2276
+ .command('once')
2277
+ .description('Capture one monitoring pass and log observed file changes')
2278
+ .option('--json', 'Output raw JSON')
2279
+ .option('--quarantine', 'Move or restore denied runtime changes immediately after detection')
2280
+ .action((opts) => {
2281
+ const repoRoot = getRepo();
2282
+ const db = getDb(repoRoot);
2283
+ const worktrees = listGitWorktrees(repoRoot);
2284
+ const result = monitorWorktreesOnce(db, repoRoot, worktrees, { quarantine: opts.quarantine });
2285
+ db.close();
2286
+
2287
+ if (opts.json) {
2288
+ console.log(JSON.stringify(result, null, 2));
2289
+ return;
2290
+ }
2291
+
2292
+ if (result.events.length === 0) {
2293
+ console.log(chalk.dim('No file changes observed since the last monitor snapshot.'));
2294
+ return;
2295
+ }
2296
+
2297
+ console.log(`${chalk.green('✓')} Observed ${result.summary.total} file change(s)`);
2298
+ for (const event of result.events) {
2299
+ const badge = event.status === 'allowed' ? chalk.green('ALLOWED') : chalk.red('DENIED ');
2300
+ const action = event.enforcement_action ? ` ${chalk.dim(event.enforcement_action)}` : '';
2301
+ 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}`);
2302
+ }
2303
+ });
2304
+
2305
+ monitorCmd
2306
+ .command('watch')
2307
+ .description('Poll worktrees continuously and log observed file changes')
2308
+ .option('--interval-ms <ms>', 'Polling interval in milliseconds', '2000')
2309
+ .option('--quarantine', 'Move or restore denied runtime changes immediately after detection')
2310
+ .option('--daemonized', 'Internal flag used by monitor start', false)
2311
+ .action(async (opts) => {
2312
+ const repoRoot = getRepo();
2313
+ const intervalMs = Number.parseInt(opts.intervalMs, 10);
2314
+
2315
+ if (!Number.isFinite(intervalMs) || intervalMs < 100) {
2316
+ console.error(chalk.red('--interval-ms must be at least 100'));
2317
+ process.exit(1);
2318
+ }
2319
+
2320
+ console.log(chalk.cyan(`Watching worktrees every ${intervalMs}ms. Press Ctrl+C to stop.`));
2321
+
2322
+ let stopped = false;
2323
+ const stop = () => {
2324
+ stopped = true;
2325
+ process.stdout.write('\n');
2326
+ if (opts.daemonized) {
2327
+ clearMonitorState(repoRoot);
2328
+ }
2329
+ };
2330
+ process.on('SIGINT', stop);
2331
+ process.on('SIGTERM', stop);
2332
+
2333
+ while (!stopped) {
2334
+ const db = getDb(repoRoot);
2335
+ const worktrees = listGitWorktrees(repoRoot);
2336
+ const result = monitorWorktreesOnce(db, repoRoot, worktrees, { quarantine: opts.quarantine });
2337
+ db.close();
2338
+
2339
+ for (const event of result.events) {
2340
+ const badge = event.status === 'allowed' ? chalk.green('ALLOWED') : chalk.red('DENIED ');
2341
+ const action = event.enforcement_action ? ` ${chalk.dim(event.enforcement_action)}` : '';
2342
+ 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}`);
2343
+ }
2344
+
2345
+ if (stopped) break;
2346
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, intervalMs));
2347
+ }
2348
+
2349
+ console.log(chalk.dim('Stopped worktree monitor.'));
2350
+ });
2351
+
2352
+ monitorCmd
2353
+ .command('start')
2354
+ .description('Start the worktree monitor as a background process')
2355
+ .option('--interval-ms <ms>', 'Polling interval in milliseconds', '2000')
2356
+ .option('--quarantine', 'Move or restore denied runtime changes immediately after detection')
2357
+ .action((opts) => {
2358
+ const repoRoot = getRepo();
2359
+ const intervalMs = Number.parseInt(opts.intervalMs, 10);
2360
+ const existingState = readMonitorState(repoRoot);
2361
+
2362
+ if (existingState && isProcessRunning(existingState.pid)) {
2363
+ console.log(chalk.yellow(`Monitor already running with pid ${existingState.pid}`));
2364
+ return;
2365
+ }
2366
+
2367
+ const logPath = join(repoRoot, '.switchman', 'monitor.log');
2368
+ const child = spawn(process.execPath, [
2369
+ process.argv[1],
2370
+ 'monitor',
2371
+ 'watch',
2372
+ '--interval-ms',
2373
+ String(intervalMs),
2374
+ ...(opts.quarantine ? ['--quarantine'] : []),
2375
+ '--daemonized',
2376
+ ], {
2377
+ cwd: repoRoot,
2378
+ detached: true,
2379
+ stdio: 'ignore',
2380
+ });
2381
+ child.unref();
2382
+
2383
+ const statePath = writeMonitorState(repoRoot, {
2384
+ pid: child.pid,
2385
+ interval_ms: intervalMs,
2386
+ quarantine: Boolean(opts.quarantine),
2387
+ log_path: logPath,
2388
+ started_at: new Date().toISOString(),
2389
+ });
2390
+
2391
+ console.log(`${chalk.green('✓')} Started monitor pid ${chalk.cyan(String(child.pid))}`);
2392
+ console.log(`${chalk.dim('State:')} ${statePath}`);
2393
+ });
2394
+
2395
+ monitorCmd
2396
+ .command('stop')
2397
+ .description('Stop the background worktree monitor')
2398
+ .action(() => {
2399
+ const repoRoot = getRepo();
2400
+ const state = readMonitorState(repoRoot);
2401
+
2402
+ if (!state) {
2403
+ console.log(chalk.dim('Monitor is not running.'));
2404
+ return;
2405
+ }
2406
+
2407
+ if (!isProcessRunning(state.pid)) {
2408
+ clearMonitorState(repoRoot);
2409
+ console.log(chalk.dim('Monitor state was stale and has been cleared.'));
2410
+ return;
2411
+ }
2412
+
2413
+ process.kill(state.pid, 'SIGTERM');
2414
+ clearMonitorState(repoRoot);
2415
+ console.log(`${chalk.green('✓')} Stopped monitor pid ${chalk.cyan(String(state.pid))}`);
2416
+ });
2417
+
2418
+ monitorCmd
2419
+ .command('status')
2420
+ .description('Show background monitor process status')
2421
+ .action(() => {
2422
+ const repoRoot = getRepo();
2423
+ const state = readMonitorState(repoRoot);
2424
+
2425
+ if (!state) {
2426
+ console.log(chalk.dim('Monitor is not running.'));
2427
+ return;
2428
+ }
2429
+
2430
+ const running = isProcessRunning(state.pid);
2431
+ if (!running) {
2432
+ clearMonitorState(repoRoot);
2433
+ console.log(chalk.yellow('Monitor state existed but the process is no longer running.'));
2434
+ return;
2435
+ }
2436
+
2437
+ console.log(`${chalk.green('✓')} Monitor running`);
2438
+ console.log(` ${chalk.dim('pid')} ${state.pid}`);
2439
+ console.log(` ${chalk.dim('interval_ms')} ${state.interval_ms}`);
2440
+ console.log(` ${chalk.dim('quarantine')} ${state.quarantine ? 'true' : 'false'}`);
2441
+ console.log(` ${chalk.dim('started_at')} ${state.started_at}`);
2442
+ });
2443
+
2444
+ // ── policy ───────────────────────────────────────────────────────────────────
2445
+
2446
+ const policyCmd = program.command('policy').description('Manage enforcement policy exceptions');
2447
+
2448
+ policyCmd
2449
+ .command('init')
2450
+ .description('Write a starter enforcement policy file for generated-path exceptions')
2451
+ .action(() => {
2452
+ const repoRoot = getRepo();
2453
+ const policyPath = writeEnforcementPolicy(repoRoot, {
2454
+ allowed_generated_paths: [
2455
+ 'dist/**',
2456
+ 'build/**',
2457
+ 'coverage/**',
2458
+ ],
2459
+ });
2460
+ console.log(`${chalk.green('✓')} Wrote enforcement policy to ${chalk.cyan(policyPath)}`);
600
2461
  });
601
2462
 
602
- program.parse();
2463
+ program.parse();