switchman-dev 0.1.2 → 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,23 +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
28
  DEFAULT_STALE_LEASE_MINUTES,
31
- createTask, startTaskLease, completeTask, failTask, listTasks, getTask, getNextPendingTask,
32
- listLeases, heartbeatLease, getStaleLeases, reapStaleLeases,
29
+ createTask, startTaskLease, completeTask, failTask, getBoundaryValidationState, getTaskSpec, listTasks, getTask, getNextPendingTask,
30
+ listDependencyInvalidations, listLeases, listScopeReservations, heartbeatLease, getStaleLeases, reapStaleLeases,
33
31
  registerWorktree, listWorktrees,
34
32
  claimFiles, releaseFileClaims, getActiveFileClaims, checkFileConflicts,
33
+ verifyAuditTrail,
35
34
  } from '../core/db.js';
36
35
  import { scanAllWorktrees } from '../core/detector.js';
37
-
38
- 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
+ }
39
47
 
40
48
  // ─── Helpers ─────────────────────────────────────────────────────────────────
41
49
 
@@ -68,6 +76,10 @@ function statusBadge(status) {
68
76
  expired: chalk.red,
69
77
  idle: chalk.gray,
70
78
  busy: chalk.blue,
79
+ managed: chalk.green,
80
+ observed: chalk.yellow,
81
+ non_compliant: chalk.red,
82
+ stale: chalk.red,
71
83
  };
72
84
  return (colors[status] || chalk.white)(status.toUpperCase().padEnd(11));
73
85
  }
@@ -105,6 +117,412 @@ function printTable(rows, columns) {
105
117
  }
106
118
  }
107
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
+
108
526
  // ─── Program ──────────────────────────────────────────────────────────────────
109
527
 
110
528
  program
@@ -129,10 +547,13 @@ program
129
547
  registerWorktree(db, { name: wt.name, path: wt.path, branch: wt.branch || 'unknown' });
130
548
  }
131
549
 
550
+ const mcpConfigWrites = installMcpConfig([...new Set([repoRoot, ...gitWorktrees.map((wt) => wt.path)])]);
551
+
132
552
  db.close();
133
553
  spinner.succeed(`Initialized in ${chalk.cyan(repoRoot)}`);
134
554
  console.log(chalk.dim(` Found and registered ${gitWorktrees.length} git worktree(s)`));
135
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`));
136
557
  console.log('');
137
558
  console.log(`Next steps:`);
138
559
  console.log(` ${chalk.cyan('switchman task add "Fix the login bug"')} — add a task`);
@@ -204,6 +625,8 @@ program
204
625
  registerWorktree(db, { name: wt.name, path: wt.path, branch: wt.branch || 'unknown' });
205
626
  }
206
627
 
628
+ const mcpConfigWrites = installMcpConfig([...new Set([repoRoot, ...created.map((wt) => wt.path)])]);
629
+
207
630
  db.close();
208
631
 
209
632
  const label = agentCount === 1 ? 'workspace' : 'workspaces';
@@ -216,11 +639,18 @@ program
216
639
  console.log(` ${chalk.dim('branch:')} ${wt.branch}`);
217
640
  }
218
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
+
219
649
  console.log('');
220
650
  console.log(chalk.bold('Next steps:'));
221
651
  console.log(` 1. Add your tasks:`);
222
652
  console.log(` ${chalk.cyan('switchman task add "Your first task" --priority 8')}`);
223
- console.log(` 2. Open Claude Code in each folder above — agents will coordinate automatically`);
653
+ console.log(` 2. Open Claude Code in each folder above — the local .mcp.json will attach Switchman automatically`);
224
654
  console.log(` 3. Check status at any time:`);
225
655
  console.log(` ${chalk.cyan('switchman status')}`);
226
656
  console.log('');
@@ -252,8 +682,14 @@ taskCmd
252
682
  priority: parseInt(opts.priority),
253
683
  });
254
684
  db.close();
685
+ const scopeWarning = analyzeTaskScope(title, opts.description || '');
255
686
  console.log(`${chalk.green('✓')} Task created: ${chalk.cyan(taskId)}`);
256
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
+ }
257
693
  });
258
694
 
259
695
  taskCmd
@@ -303,11 +739,13 @@ taskCmd
303
739
  .description('Mark a task as complete and release all file claims')
304
740
  .action((taskId) => {
305
741
  const repoRoot = getRepo();
306
- const db = getDb(repoRoot);
307
- completeTask(db, taskId);
308
- releaseFileClaims(db, taskId);
309
- db.close();
310
- console.log(`${chalk.green('✓')} Task ${chalk.cyan(taskId)} marked done — file claims released`);
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
+ }
311
749
  });
312
750
 
313
751
  taskCmd
@@ -330,23 +768,17 @@ taskCmd
330
768
  .option('--agent <name>', 'Agent identifier for logging (e.g. claude-code)')
331
769
  .action((opts) => {
332
770
  const repoRoot = getRepo();
333
- const db = getDb(repoRoot);
334
- const task = getNextPendingTask(db);
771
+ const worktreeName = getCurrentWorktreeName(opts.worktree);
772
+ const { task, lease, exhausted } = acquireNextTaskLeaseWithRetries(repoRoot, worktreeName, opts.agent || null);
335
773
 
336
774
  if (!task) {
337
- db.close();
338
775
  if (opts.json) console.log(JSON.stringify({ task: null }));
339
- 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.'));
340
778
  return;
341
779
  }
342
780
 
343
- // Determine worktree name: explicit flag, or derive from cwd
344
- const worktreeName = getCurrentWorktreeName(opts.worktree);
345
- const lease = startTaskLease(db, task.id, worktreeName, opts.agent || null);
346
- db.close();
347
-
348
781
  if (!lease) {
349
- // Race condition: another agent grabbed it between get and assign
350
782
  if (opts.json) console.log(JSON.stringify({ task: null, message: 'Task claimed by another agent — try again' }));
351
783
  else console.log(chalk.yellow('Task was just claimed by another agent. Run again to get the next one.'));
352
784
  return;
@@ -360,6 +792,300 @@ taskCmd
360
792
  }
361
793
  });
362
794
 
795
+ // ── pipeline ──────────────────────────────────────────────────────────────────
796
+
797
+ const pipelineCmd = program.command('pipeline').description('Create and summarize issue-to-PR execution pipelines');
798
+
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) => {
807
+ const repoRoot = getRepo();
808
+ const db = getDb(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
+ });
815
+ db.close();
816
+
817
+ if (opts.json) {
818
+ console.log(JSON.stringify(result, null, 2));
819
+ return;
820
+ }
821
+
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}`);
828
+ }
829
+ });
830
+
831
+ pipelineCmd
832
+ .command('status <pipelineId>')
833
+ .description('Show task status for a pipeline')
834
+ .option('--json', 'Output raw JSON')
835
+ .action((pipelineId, opts) => {
836
+ const repoRoot = getRepo();
837
+ const db = getDb(repoRoot);
838
+
839
+ try {
840
+ const result = getPipelineStatus(db, pipelineId);
841
+ db.close();
842
+
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;
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) => {
875
+ const repoRoot = getRepo();
876
+ const db = getDb(repoRoot);
877
+
878
+ try {
879
+ const result = await buildPipelinePrSummary(db, repoRoot, pipelineId);
880
+ db.close();
881
+
882
+ if (opts.json) {
883
+ console.log(JSON.stringify(result, null, 2));
884
+ return;
885
+ }
886
+
887
+ console.log(result.markdown);
888
+ } catch (err) {
889
+ db.close();
890
+ console.error(chalk.red(err.message));
891
+ process.exitCode = 1;
892
+ }
893
+ });
894
+
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) => {
900
+ const repoRoot = getRepo();
901
+ const db = getDb(repoRoot);
902
+
903
+ try {
904
+ const result = await exportPipelinePrBundle(db, repoRoot, pipelineId, outputDir || null);
905
+ db.close();
906
+
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')
931
+ .option('--json', 'Output raw JSON')
932
+ .action(async (pipelineId, outputDir, opts) => {
933
+ const repoRoot = getRepo();
934
+ const db = getDb(repoRoot);
935
+
936
+ try {
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
+ });
944
+ db.close();
945
+
946
+ if (opts.json) {
947
+ console.log(JSON.stringify(result, null, 2));
948
+ return;
949
+ }
950
+
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)}`);
956
+ }
957
+ } catch (err) {
958
+ db.close();
959
+ console.error(chalk.red(err.message));
960
+ process.exitCode = 1;
961
+ }
962
+ });
963
+
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);
973
+
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;
986
+ }
987
+
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;
991
+ }
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
+ }
1002
+ } catch (err) {
1003
+ db.close();
1004
+ console.error(chalk.red(err.message));
1005
+ process.exitCode = 1;
1006
+ }
1007
+ });
1008
+
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) => {
1014
+ const repoRoot = getRepo();
1015
+ const db = getDb(repoRoot);
1016
+
1017
+ try {
1018
+ const result = await createPipelineFollowupTasks(db, repoRoot, pipelineId);
1019
+ db.close();
1020
+
1021
+ if (opts.json) {
1022
+ console.log(JSON.stringify(result, null, 2));
1023
+ return;
1024
+ }
1025
+
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
+ }
1030
+
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}`);
1034
+ }
1035
+ } catch (err) {
1036
+ db.close();
1037
+ console.error(chalk.red(err.message));
1038
+ process.exitCode = 1;
1039
+ }
1040
+ });
1041
+
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}`);
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;
1086
+ }
1087
+ });
1088
+
363
1089
  // ── lease ────────────────────────────────────────────────────────────────────
364
1090
 
365
1091
  const leaseCmd = program.command('lease').description('Manage active work leases');
@@ -404,20 +1130,16 @@ leaseCmd
404
1130
  .option('--agent <name>', 'Agent identifier for logging')
405
1131
  .action((opts) => {
406
1132
  const repoRoot = getRepo();
407
- const db = getDb(repoRoot);
408
- const task = getNextPendingTask(db);
1133
+ const worktreeName = getCurrentWorktreeName(opts.worktree);
1134
+ const { task, lease, exhausted } = acquireNextTaskLeaseWithRetries(repoRoot, worktreeName, opts.agent || null);
409
1135
 
410
1136
  if (!task) {
411
- db.close();
412
1137
  if (opts.json) console.log(JSON.stringify({ task: null, lease: null }));
413
- else console.log(chalk.dim('No pending tasks.'));
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.'));
414
1140
  return;
415
1141
  }
416
1142
 
417
- const worktreeName = getCurrentWorktreeName(opts.worktree);
418
- const lease = startTaskLease(db, task.id, worktreeName, opts.agent || null);
419
- db.close();
420
-
421
1143
  if (!lease) {
422
1144
  if (opts.json) console.log(JSON.stringify({ task: null, lease: null, message: 'Task claimed by another agent — try again' }));
423
1145
  else console.log(chalk.yellow('Task was just claimed by another agent. Run again to get the next one.'));
@@ -556,7 +1278,8 @@ wtCmd
556
1278
  const dbInfo = worktrees.find(d => d.path === wt.path);
557
1279
  const agent = dbInfo?.agent ? chalk.cyan(dbInfo.agent) : chalk.dim('no agent');
558
1280
  const status = dbInfo?.status ? statusBadge(dbInfo.status) : chalk.dim('unregistered');
559
- console.log(` ${chalk.bold(wt.name.padEnd(20))} ${status} branch: ${chalk.cyan(wt.branch || 'unknown')} agent: ${agent}`);
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}`);
560
1283
  console.log(` ${chalk.dim(wt.path)}`);
561
1284
  }
562
1285
  console.log('');
@@ -573,6 +1296,7 @@ wtCmd
573
1296
  registerWorktree(db, { name: wt.name, path: wt.path, branch: wt.branch || 'unknown' });
574
1297
  }
575
1298
  db.close();
1299
+ installMcpConfig([...new Set([repoRoot, ...gitWorktrees.map((wt) => wt.path)])]);
576
1300
  console.log(`${chalk.green('✓')} Synced ${gitWorktrees.length} worktree(s) from git`);
577
1301
  });
578
1302
 
@@ -600,6 +1324,7 @@ program
600
1324
  console.log(` ${chalk.yellow(c.file)} → already claimed by worktree ${chalk.cyan(c.claimedBy.worktree)} (task: ${c.claimedBy.task_title})`);
601
1325
  }
602
1326
  console.log(chalk.dim('\nUse --force to claim anyway, or resolve conflicts first.'));
1327
+ process.exitCode = 1;
603
1328
  return;
604
1329
  }
605
1330
 
@@ -625,6 +1350,153 @@ program
625
1350
  console.log(`${chalk.green('✓')} Released all claims for task ${chalk.cyan(taskId)}`);
626
1351
  });
627
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
+
628
1500
  // ── scan ───────────────────────────────────────────────────────────────────────
629
1501
 
630
1502
  program
@@ -657,7 +1529,8 @@ program
657
1529
  console.log(chalk.bold('Worktrees:'));
658
1530
  for (const wt of report.worktrees) {
659
1531
  const files = report.fileMap?.[wt.name] || [];
660
- console.log(` ${chalk.cyan(wt.name.padEnd(20))} branch: ${(wt.branch || 'unknown').padEnd(30)} ${chalk.dim(files.length + ' changed file(s)')}`);
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)')}`);
661
1534
  }
662
1535
  console.log('');
663
1536
  }
@@ -672,6 +1545,31 @@ program
672
1545
  console.log('');
673
1546
  }
674
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
+
675
1573
  // Branch-level conflicts
676
1574
  if (report.conflicts.length > 0) {
677
1575
  console.log(chalk.red(`✗ Branch conflicts detected:`));
@@ -687,8 +1585,21 @@ program
687
1585
  console.log('');
688
1586
  }
689
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
+
690
1601
  // All clear
691
- if (report.conflicts.length === 0 && report.fileConflicts.length === 0) {
1602
+ if (report.conflicts.length === 0 && report.fileConflicts.length === 0 && (report.ownershipConflicts?.length || 0) === 0 && (report.semanticConflicts?.length || 0) === 0 && report.unclaimedChanges.length === 0) {
692
1603
  console.log(chalk.green(`✓ No conflicts detected across ${report.worktrees.length} worktree(s)`));
693
1604
  }
694
1605
 
@@ -732,7 +1643,13 @@ program
732
1643
  console.log('');
733
1644
  console.log(chalk.bold('Active Leases:'));
734
1645
  for (const lease of activeLeases) {
735
- console.log(` ${chalk.cyan(lease.worktree)} ${lease.task_title} ${chalk.dim(lease.id)}`);
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}`);
736
1653
  }
737
1654
  } else if (inProgress.length > 0) {
738
1655
  console.log('');
@@ -759,6 +1676,29 @@ program
759
1676
  }
760
1677
  }
761
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
+
762
1702
  // File Claims
763
1703
  const claims = getActiveFileClaims(db);
764
1704
  if (claims.length > 0) {
@@ -781,12 +1721,78 @@ program
781
1721
  const report = await scanAllWorktrees(db, repoRoot);
782
1722
  spinner.stop();
783
1723
 
784
- const totalConflicts = report.conflicts.length + report.fileConflicts.length;
1724
+ const totalConflicts = report.conflicts.length + report.fileConflicts.length + (report.ownershipConflicts?.length || 0) + (report.semanticConflicts?.length || 0) + report.unclaimedChanges.length;
785
1725
  if (totalConflicts === 0) {
786
1726
  console.log(chalk.green(`✓ No conflicts across ${report.worktrees.length} worktree(s)`));
787
1727
  } else {
788
1728
  console.log(chalk.red(`⚠ ${totalConflicts} conflict(s) detected — run 'switchman scan' for details`));
789
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
+ }
790
1796
  } catch {
791
1797
  spinner.stop();
792
1798
  console.log(chalk.dim('Could not run conflict scan'));
@@ -798,4 +1804,660 @@ program
798
1804
  console.log('');
799
1805
  });
800
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)}`);
2461
+ });
2462
+
801
2463
  program.parse();