switchman-dev 0.1.8 → 0.1.9

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,10 +19,10 @@
19
19
  import { Help, program } from 'commander';
20
20
  import chalk from 'chalk';
21
21
  import ora from 'ora';
22
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
22
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
23
23
  import { tmpdir } from 'os';
24
24
  import { dirname, join, posix } from 'path';
25
- import { execSync, spawn } from 'child_process';
25
+ import { execSync, spawn, spawnSync } from 'child_process';
26
26
 
27
27
  import { cleanupCrashedLandingTempWorktrees, createGitWorktree, findRepoRoot, getWorktreeBranch, getWorktreeChangedFiles, gitAssessBranchFreshness, gitBranchExists, listGitWorktrees } from '../core/git.js';
28
28
  import { matchesPathPatterns } from '../core/ignore.js';
@@ -39,6 +39,7 @@ import {
39
39
  claimFiles, releaseFileClaims, getActiveFileClaims, checkFileConflicts, retryTask,
40
40
  upsertTaskSpec,
41
41
  listAuditEvents, pruneDatabaseMaintenance, verifyAuditTrail,
42
+ getLeaseExecutionContext, getActiveLeaseForTask,
42
43
  } from '../core/db.js';
43
44
  import { scanAllWorktrees } from '../core/detector.js';
44
45
  import { ensureProjectLocalMcpGitExcludes, getWindsurfMcpConfigPath, upsertAllProjectMcpConfigs, upsertWindsurfMcpConfig } from '../core/mcp.js';
@@ -64,6 +65,16 @@ import {
64
65
  import { checkLicence, clearCredentials, FREE_AGENT_LIMIT, getRetentionDaysForCurrentPlan, loginWithGitHub, PRO_PAGE_URL, readCredentials } from '../core/licence.js';
65
66
  import { homedir } from 'os';
66
67
  import { cleanupOldSyncEvents, pullActiveTeamMembers, pullTeamState, pushSyncEvent } from '../core/sync.js';
68
+ import { registerClaudeCommands } from './commands/claude.js';
69
+ import { registerMcpCommands } from './commands/mcp.js';
70
+ import { registerAuditCommands } from './commands/audit.js';
71
+ import { registerGateCommands } from './commands/gate.js';
72
+ import { registerLeaseCommands } from './commands/lease.js';
73
+ import { registerMonitorCommands } from './commands/monitor.js';
74
+ import { registerQueueCommands } from './commands/queue.js';
75
+ import { registerTaskCommands } from './commands/task.js';
76
+ import { registerTelemetryCommands } from './commands/telemetry.js';
77
+ import { registerWorktreeCommands } from './commands/worktree.js';
67
78
 
68
79
  const originalProcessEmit = process.emit.bind(process);
69
80
  process.emit = function patchedProcessEmit(event, ...args) {
@@ -200,7 +211,7 @@ function summarizeRecentCommitContext(branchGoal, subjects) {
200
211
  return `${effectiveCount} recent ${topicLabel}commit${effectiveCount === 1 ? '' : 's'}`;
201
212
  }
202
213
 
203
- function collectPlanContext(repoRoot, explicitGoal = null) {
214
+ function collectPlanContext(repoRoot, explicitGoal = null, issueContext = null) {
204
215
  const planningFiles = ['CLAUDE.md', 'ROADMAP.md', 'tasks.md', 'TASKS.md', 'TODO.md', 'README.md']
205
216
  .map((fileName) => readPlanningFile(repoRoot, fileName))
206
217
  .filter(Boolean);
@@ -217,14 +228,19 @@ function collectPlanContext(repoRoot, explicitGoal = null) {
217
228
  || planningByName.get('README.md')
218
229
  || null;
219
230
  const planningSignal = preferredPlanningFile ? extractMarkdownSignal(preferredPlanningFile.text) : null;
220
- const title = capitalizeSentence(explicitGoal || branchGoal || planningSignal || 'Plan the next coordinated change');
231
+ const title = capitalizeSentence(issueContext?.title || explicitGoal || branchGoal || planningSignal || 'Plan the next coordinated change');
221
232
  const descriptionParts = [];
233
+ if (issueContext?.description) descriptionParts.push(issueContext.description);
222
234
  if (preferredPlanningFile?.text) descriptionParts.push(preferredPlanningFile.text);
223
235
  if (recentCommitSubjects.length > 0) descriptionParts.push(`Recent git history summary: ${recentCommitSubjects.slice(0, 3).join('; ')}.`);
224
236
  const description = descriptionParts.join('\n\n').trim() || null;
225
237
 
226
238
  const found = [];
227
239
  const used = [];
240
+ if (issueContext?.number) {
241
+ found.push(`issue #${issueContext.number} "${issueContext.title}"`);
242
+ used.push(`GitHub issue #${issueContext.number}`);
243
+ }
228
244
  if (explicitGoal) {
229
245
  used.push('explicit goal');
230
246
  }
@@ -250,6 +266,123 @@ function collectPlanContext(repoRoot, explicitGoal = null) {
250
266
  };
251
267
  }
252
268
 
269
+ function fetchGitHubIssueContext(repoRoot, issueNumber, ghCommand = 'gh') {
270
+ const normalizedIssueNumber = String(issueNumber || '').trim();
271
+ if (!normalizedIssueNumber) {
272
+ throw new Error('A GitHub issue number is required.');
273
+ }
274
+
275
+ const result = spawnSync(ghCommand, [
276
+ 'issue',
277
+ 'view',
278
+ normalizedIssueNumber,
279
+ '--json',
280
+ 'title,body,comments',
281
+ ], {
282
+ cwd: repoRoot,
283
+ encoding: 'utf8',
284
+ });
285
+
286
+ if (result.error) {
287
+ throw new Error(`Could not run ${ghCommand} to read issue #${normalizedIssueNumber}. ${result.error.message}`);
288
+ }
289
+
290
+ if (result.status !== 0) {
291
+ const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
292
+ throw new Error(output || `gh issue view failed for issue #${normalizedIssueNumber}. Make sure gh is installed and authenticated.`);
293
+ }
294
+
295
+ let issue;
296
+ try {
297
+ issue = JSON.parse(result.stdout || '{}');
298
+ } catch {
299
+ throw new Error(`Could not parse GitHub issue #${normalizedIssueNumber}.`);
300
+ }
301
+
302
+ const comments = Array.isArray(issue.comments)
303
+ ? issue.comments.map((entry) => entry?.body || '').filter(Boolean)
304
+ : [];
305
+ const descriptionParts = [
306
+ `GitHub issue #${normalizedIssueNumber}: ${issue.title || 'Untitled issue'}`,
307
+ issue.body || '',
308
+ comments.length > 0 ? `Comments:\n${comments.join('\n---\n')}` : '',
309
+ ].filter(Boolean);
310
+
311
+ return {
312
+ number: normalizedIssueNumber,
313
+ title: issue.title || `Issue #${normalizedIssueNumber}`,
314
+ description: descriptionParts.join('\n\n'),
315
+ comment_count: comments.length,
316
+ };
317
+ }
318
+
319
+ function buildPlanningCommentBody(context, plannedTasks) {
320
+ const lines = [
321
+ '## Switchman plan summary',
322
+ '',
323
+ `Planned from: **${context.title}**`,
324
+ ];
325
+
326
+ if (context.used.length > 0) {
327
+ lines.push(`Context used: ${context.used.join(', ')}`);
328
+ }
329
+
330
+ lines.push('');
331
+ lines.push('Proposed parallel tasks:');
332
+ lines.push('');
333
+
334
+ plannedTasks.forEach((task, index) => {
335
+ const worktreeLabel = task.suggested_worktree || 'unassigned';
336
+ lines.push(`${index + 1}. ${task.title} (${worktreeLabel})`);
337
+ });
338
+
339
+ lines.push('');
340
+ lines.push('_Generated by Switchman._');
341
+ return `${lines.join('\n')}\n`;
342
+ }
343
+
344
+ function postPlanningSummaryComment(repoRoot, {
345
+ ghCommand = 'gh',
346
+ issueNumber = null,
347
+ prNumber = null,
348
+ body,
349
+ }) {
350
+ const targetNumber = prNumber || issueNumber;
351
+ if (!targetNumber) {
352
+ throw new Error('A GitHub issue or pull request number is required to post a planning summary.');
353
+ }
354
+
355
+ const tempDir = mkdtempSync(join(tmpdir(), 'switchman-plan-comment-'));
356
+ const bodyPath = join(tempDir, 'plan-summary.md');
357
+ writeFileSync(bodyPath, body, 'utf8');
358
+
359
+ try {
360
+ const args = prNumber
361
+ ? ['pr', 'comment', String(prNumber), '--body-file', bodyPath]
362
+ : ['issue', 'comment', String(issueNumber), '--body-file', bodyPath];
363
+ const result = spawnSync(ghCommand, args, {
364
+ cwd: repoRoot,
365
+ encoding: 'utf8',
366
+ });
367
+
368
+ if (result.error) {
369
+ throw new Error(`Could not run ${ghCommand} to post the planning summary. ${result.error.message}`);
370
+ }
371
+
372
+ if (result.status !== 0) {
373
+ const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
374
+ throw new Error(output || `gh ${prNumber ? 'pr' : 'issue'} comment failed.`);
375
+ }
376
+
377
+ return {
378
+ target_type: prNumber ? 'pr' : 'issue',
379
+ target_number: String(targetNumber),
380
+ };
381
+ } finally {
382
+ rmSync(tempDir, { recursive: true, force: true });
383
+ }
384
+ }
385
+
253
386
  function resolvePlanningWorktrees(repoRoot, db = null) {
254
387
  if (db) {
255
388
  const registered = listWorktrees(db)
@@ -1465,6 +1598,184 @@ function printRepairSummary(report, {
1465
1598
  }
1466
1599
  }
1467
1600
 
1601
+ function summarizeRecoveredTaskState({
1602
+ task,
1603
+ lease = null,
1604
+ worktree = null,
1605
+ changedFiles = [],
1606
+ claims = [],
1607
+ boundaryValidation = null,
1608
+ auditEvents = [],
1609
+ recoveryKind,
1610
+ staleAfterMinutes = null,
1611
+ }) {
1612
+ const observedWrites = auditEvents.filter((event) => event.event_type === 'write_observed');
1613
+ const latestAuditEvent = auditEvents[0] || null;
1614
+ const nextAction = worktree?.path
1615
+ ? `cd "${worktree.path}" && git status`
1616
+ : `switchman task retry ${task.id}`;
1617
+
1618
+ return {
1619
+ kind: recoveryKind,
1620
+ task_id: task.id,
1621
+ task_title: task.title,
1622
+ worktree: worktree?.name || lease?.worktree || task.worktree || null,
1623
+ worktree_path: worktree?.path || null,
1624
+ lease_id: lease?.id || null,
1625
+ agent: lease?.agent || task.agent || null,
1626
+ stale_after_minutes: staleAfterMinutes,
1627
+ changed_files: changedFiles,
1628
+ claimed_files: claims,
1629
+ observed_write_count: observedWrites.length,
1630
+ latest_audit_event: latestAuditEvent ? {
1631
+ event_type: latestAuditEvent.event_type,
1632
+ status: latestAuditEvent.status,
1633
+ created_at: latestAuditEvent.created_at,
1634
+ reason_code: latestAuditEvent.reason_code || null,
1635
+ } : null,
1636
+ boundary_validation: boundaryValidation ? {
1637
+ status: boundaryValidation.status,
1638
+ missing_task_types: boundaryValidation.missing_task_types || [],
1639
+ } : null,
1640
+ progress_summary: changedFiles.length > 0
1641
+ ? `Observed uncommitted changes in ${changedFiles.length} file(s).`
1642
+ : claims.length > 0
1643
+ ? `Lease held ${claims.length} active claim(s) before recovery.`
1644
+ : observedWrites.length > 0
1645
+ ? `Observed ${observedWrites.length} governed write event(s) before recovery.`
1646
+ : 'No uncommitted changes were detected at recovery time.',
1647
+ next_action: nextAction,
1648
+ };
1649
+ }
1650
+
1651
+ function buildRecoverReport(db, repoRoot, { staleAfterMinutes = null, reason = 'operator recover' } = {}) {
1652
+ const leasePolicy = loadLeasePolicy(repoRoot);
1653
+ const staleMinutes = staleAfterMinutes
1654
+ ? Number.parseInt(staleAfterMinutes, 10)
1655
+ : leasePolicy.stale_after_minutes;
1656
+ const worktreeMap = new Map(listWorktrees(db).map((worktree) => [worktree.name, worktree]));
1657
+ const staleLeases = getStaleLeases(db, staleMinutes);
1658
+ const staleTaskIds = new Set(staleLeases.map((lease) => lease.task_id));
1659
+ const staleLeaseSummaries = staleLeases.map((lease) => {
1660
+ const execution = getLeaseExecutionContext(db, lease.id);
1661
+ const worktree = worktreeMap.get(lease.worktree) || execution?.worktree || null;
1662
+ const changedFiles = worktree?.path ? getWorktreeChangedFiles(worktree.path, repoRoot) : [];
1663
+ const claims = execution?.claims?.map((claim) => claim.file_path) || [];
1664
+ const boundaryValidation = getBoundaryValidationState(db, lease.id);
1665
+ const auditEvents = listAuditEvents(db, { taskId: lease.task_id, limit: 10 });
1666
+ return summarizeRecoveredTaskState({
1667
+ task: execution?.task || getTask(db, lease.task_id),
1668
+ lease,
1669
+ worktree,
1670
+ changedFiles,
1671
+ claims,
1672
+ boundaryValidation,
1673
+ auditEvents,
1674
+ recoveryKind: 'stale_lease',
1675
+ staleAfterMinutes: staleMinutes,
1676
+ });
1677
+ });
1678
+
1679
+ const strandedTasks = listTasks(db, 'in_progress')
1680
+ .filter((task) => !staleTaskIds.has(task.id))
1681
+ .filter((task) => !getActiveLeaseForTask(db, task.id));
1682
+ const strandedTaskSummaries = strandedTasks.map((task) => {
1683
+ const worktree = task.worktree ? worktreeMap.get(task.worktree) || null : null;
1684
+ const changedFiles = worktree?.path ? getWorktreeChangedFiles(worktree.path, repoRoot) : [];
1685
+ const auditEvents = listAuditEvents(db, { taskId: task.id, limit: 10 });
1686
+ return summarizeRecoveredTaskState({
1687
+ task,
1688
+ worktree,
1689
+ changedFiles,
1690
+ claims: [],
1691
+ boundaryValidation: null,
1692
+ auditEvents,
1693
+ recoveryKind: 'stranded_task',
1694
+ });
1695
+ });
1696
+
1697
+ const expiredLeases = staleLeases.length > 0
1698
+ ? reapStaleLeases(db, staleMinutes, { requeueTask: leasePolicy.requeue_task_on_reap })
1699
+ : [];
1700
+ const retriedTasks = strandedTasks
1701
+ .map((task) => retryTask(db, task.id, reason))
1702
+ .filter(Boolean);
1703
+ const repair = repairRepoState(db, repoRoot);
1704
+
1705
+ return {
1706
+ stale_after_minutes: staleMinutes,
1707
+ requeue_task_on_reap: leasePolicy.requeue_task_on_reap,
1708
+ stale_leases: staleLeaseSummaries.map((item) => ({
1709
+ ...item,
1710
+ recovered_to: leasePolicy.requeue_task_on_reap ? 'pending' : 'failed',
1711
+ })),
1712
+ stranded_tasks: strandedTaskSummaries.map((item) => ({
1713
+ ...item,
1714
+ recovered_to: 'pending',
1715
+ })),
1716
+ repair,
1717
+ recovered: {
1718
+ stale_leases: expiredLeases.length,
1719
+ stranded_tasks: retriedTasks.length,
1720
+ repo_actions: repair.actions.length,
1721
+ },
1722
+ next_steps: [
1723
+ ...(staleLeaseSummaries.length > 0 || strandedTaskSummaries.length > 0
1724
+ ? ['switchman status', 'switchman task list --status pending']
1725
+ : []),
1726
+ ...(repair.next_action ? [repair.next_action] : []),
1727
+ ].filter((value, index, all) => all.indexOf(value) === index),
1728
+ };
1729
+ }
1730
+
1731
+ function printRecoverSummary(report) {
1732
+ const totalRecovered = report.recovered.stale_leases + report.recovered.stranded_tasks;
1733
+ console.log(totalRecovered > 0 || report.repair.repaired
1734
+ ? `${chalk.green('✓')} Recovered abandoned work and repaired safe interrupted state`
1735
+ : `${chalk.green('✓')} No abandoned work needed recovery`);
1736
+
1737
+ console.log(` ${chalk.dim('stale lease threshold:')} ${report.stale_after_minutes} minute(s)`);
1738
+ console.log(` ${chalk.dim('requeue on reap:')} ${report.requeue_task_on_reap ? 'on' : 'off'}`);
1739
+ console.log(` ${chalk.green('recovered stale leases:')} ${report.recovered.stale_leases}`);
1740
+ console.log(` ${chalk.green('recovered stranded tasks:')} ${report.recovered.stranded_tasks}`);
1741
+ console.log(` ${chalk.green('repo repair actions:')} ${report.recovered.repo_actions}`);
1742
+
1743
+ const recoveredItems = [...report.stale_leases, ...report.stranded_tasks];
1744
+ if (recoveredItems.length > 0) {
1745
+ console.log('');
1746
+ console.log(chalk.bold('Recovered work:'));
1747
+ for (const item of recoveredItems) {
1748
+ console.log(` ${chalk.cyan(item.worktree || 'unknown')} ${chalk.dim(item.task_id)} ${chalk.bold(item.task_title)}`);
1749
+ console.log(` ${chalk.dim('type:')} ${item.kind === 'stale_lease' ? 'stale lease' : 'stranded in-progress task'}${item.lease_id ? ` ${chalk.dim('lease:')} ${item.lease_id}` : ''}`);
1750
+ console.log(` ${chalk.dim('summary:')} ${item.progress_summary}`);
1751
+ if (item.changed_files.length > 0) {
1752
+ console.log(` ${chalk.dim('changed:')} ${item.changed_files.slice(0, 5).join(', ')}${item.changed_files.length > 5 ? ` ${chalk.dim(`+${item.changed_files.length - 5} more`)}` : ''}`);
1753
+ }
1754
+ if (item.claimed_files.length > 0) {
1755
+ console.log(` ${chalk.dim('claimed:')} ${item.claimed_files.slice(0, 5).join(', ')}${item.claimed_files.length > 5 ? ` ${chalk.dim(`+${item.claimed_files.length - 5} more`)}` : ''}`);
1756
+ }
1757
+ if (item.boundary_validation) {
1758
+ console.log(` ${chalk.dim('validation:')} ${item.boundary_validation.status}${item.boundary_validation.missing_task_types.length > 0 ? ` ${chalk.dim(`missing ${item.boundary_validation.missing_task_types.join(', ')}`)}` : ''}`);
1759
+ }
1760
+ console.log(` ${chalk.yellow('inspect:')} ${item.next_action}`);
1761
+ }
1762
+ }
1763
+
1764
+ console.log('');
1765
+ printRepairSummary(report.repair, {
1766
+ repairedHeading: `${chalk.green('✓')} Repaired safe interrupted repo state during recovery`,
1767
+ noRepairHeading: `${chalk.green('✓')} No extra repo repair action was needed during recovery`,
1768
+ limit: 6,
1769
+ });
1770
+ if (report.next_steps.length > 0) {
1771
+ console.log('');
1772
+ console.log(chalk.bold('Next steps:'));
1773
+ for (const step of report.next_steps) {
1774
+ console.log(` ${chalk.cyan(step)}`);
1775
+ }
1776
+ }
1777
+ }
1778
+
1468
1779
  function repairRepoState(db, repoRoot) {
1469
1780
  const actions = [];
1470
1781
  const warnings = [];
@@ -1758,7 +2069,7 @@ function collectSetupVerification(repoRoot, { homeDir = null } = {}) {
1758
2069
  detail: claudeGuideExists ? 'CLAUDE.md is present' : 'CLAUDE.md is optional but recommended for Claude Code',
1759
2070
  });
1760
2071
  if (!claudeGuideExists) {
1761
- nextSteps.push('If you use Claude Code, add `CLAUDE.md` from the repo root setup guide.');
2072
+ nextSteps.push('If you use Claude Code, run `switchman claude refresh` to generate a repo-aware `CLAUDE.md`.');
1762
2073
  }
1763
2074
 
1764
2075
  const windsurfConfigExists = existsSync(getWindsurfMcpConfigPath(homeDir || undefined));
@@ -1794,6 +2105,238 @@ function collectSetupVerification(repoRoot, { homeDir = null } = {}) {
1794
2105
  };
1795
2106
  }
1796
2107
 
2108
+ function readJsonFileIfExists(filePath) {
2109
+ if (!existsSync(filePath)) return null;
2110
+ try {
2111
+ return JSON.parse(readFileSync(filePath, 'utf8'));
2112
+ } catch {
2113
+ return null;
2114
+ }
2115
+ }
2116
+
2117
+ function detectClaudeGuideContext(repoRoot) {
2118
+ const packageJson = readJsonFileIfExists(join(repoRoot, 'package.json'));
2119
+ const hasTsconfig = existsSync(join(repoRoot, 'tsconfig.json'));
2120
+ const hasPyproject = existsSync(join(repoRoot, 'pyproject.toml'));
2121
+ const hasRequirements = existsSync(join(repoRoot, 'requirements.txt'));
2122
+ const hasGoMod = existsSync(join(repoRoot, 'go.mod'));
2123
+ const hasCargo = existsSync(join(repoRoot, 'Cargo.toml'));
2124
+ const hasGemfile = existsSync(join(repoRoot, 'Gemfile'));
2125
+
2126
+ const stack = [];
2127
+ if (packageJson) stack.push(hasTsconfig ? 'Node.js + TypeScript' : 'Node.js');
2128
+ if (!packageJson && hasTsconfig) stack.push('TypeScript');
2129
+ if (hasPyproject || hasRequirements) stack.push('Python');
2130
+ if (hasGoMod) stack.push('Go');
2131
+ if (hasCargo) stack.push('Rust');
2132
+ if (hasGemfile) stack.push('Ruby');
2133
+ if (stack.length === 0) stack.push('general-purpose codebase');
2134
+
2135
+ const packageManager = existsSync(join(repoRoot, 'pnpm-lock.yaml'))
2136
+ ? 'pnpm'
2137
+ : existsSync(join(repoRoot, 'yarn.lock'))
2138
+ ? 'yarn'
2139
+ : 'npm';
2140
+
2141
+ const scripts = packageJson?.scripts || {};
2142
+ const packageRunner = packageManager === 'pnpm'
2143
+ ? 'pnpm'
2144
+ : packageManager === 'yarn'
2145
+ ? 'yarn'
2146
+ : 'npm run';
2147
+ const testCommand = typeof scripts.test === 'string' && scripts.test.trim() && !scripts.test.includes('no test specified')
2148
+ ? `${packageRunner}${packageManager === 'yarn' ? ' test' : ' test'}`
2149
+ : hasPyproject || hasRequirements
2150
+ ? 'pytest'
2151
+ : hasGoMod
2152
+ ? 'go test ./...'
2153
+ : hasCargo
2154
+ ? 'cargo test'
2155
+ : null;
2156
+ const buildCommand = typeof scripts.build === 'string' && scripts.build.trim()
2157
+ ? `${packageRunner}${packageManager === 'yarn' ? ' build' : ' build'}`
2158
+ : hasGoMod
2159
+ ? 'go build ./...'
2160
+ : hasCargo
2161
+ ? 'cargo build'
2162
+ : null;
2163
+ const lintCommand = typeof scripts.lint === 'string' && scripts.lint.trim()
2164
+ ? `${packageRunner}${packageManager === 'yarn' ? ' lint' : ' lint'}`
2165
+ : null;
2166
+
2167
+ const importantPaths = [
2168
+ existsSync(join(repoRoot, 'src')) ? 'src/' : null,
2169
+ existsSync(join(repoRoot, 'app')) ? 'app/' : null,
2170
+ existsSync(join(repoRoot, 'lib')) ? 'lib/' : null,
2171
+ existsSync(join(repoRoot, 'tests')) ? 'tests/' : null,
2172
+ existsSync(join(repoRoot, 'test')) ? 'test/' : null,
2173
+ existsSync(join(repoRoot, 'docs')) ? 'docs/' : null,
2174
+ existsSync(join(repoRoot, 'packages')) ? 'packages/' : null,
2175
+ ].filter(Boolean);
2176
+
2177
+ const conventions = [];
2178
+ if (existsSync(join(repoRoot, '.mcp.json'))) conventions.push('project-local MCP config is already wired');
2179
+ if (existsSync(join(repoRoot, '.cursor', 'mcp.json'))) conventions.push('Cursor MCP config is present');
2180
+ if (existsSync(join(repoRoot, '.git', 'info', 'exclude'))) conventions.push('repo-local git excludes can hide MCP noise from merges');
2181
+
2182
+ return {
2183
+ stack,
2184
+ packageManager,
2185
+ testCommand,
2186
+ buildCommand,
2187
+ lintCommand,
2188
+ importantPaths: importantPaths.slice(0, 5),
2189
+ conventions,
2190
+ };
2191
+ }
2192
+
2193
+ function renderClaudeGuide(repoRoot) {
2194
+ const context = detectClaudeGuideContext(repoRoot);
2195
+ const stackSummary = formatHumanList(context.stack);
2196
+ const importantPaths = context.importantPaths.length > 0
2197
+ ? context.importantPaths.join(', ')
2198
+ : 'the repo root';
2199
+ const preferredCommands = [
2200
+ context.testCommand ? `- Tests: \`${context.testCommand}\`` : null,
2201
+ context.buildCommand ? `- Build: \`${context.buildCommand}\`` : null,
2202
+ context.lintCommand ? `- Lint: \`${context.lintCommand}\`` : null,
2203
+ ].filter(Boolean);
2204
+ const conventions = context.conventions.length > 0
2205
+ ? context.conventions.map((item) => `- ${item}`)
2206
+ : ['- follow the existing file layout and naming conventions already in the repo'];
2207
+
2208
+ return `# Switchman Agent Instructions
2209
+
2210
+ This repository uses **Switchman** to coordinate parallel AI coding agents.
2211
+ You MUST follow these instructions every session to avoid conflicting with other agents.
2212
+
2213
+ You must use the Switchman MCP tools for coordination. Do not read from or write to \`.switchman/switchman.db\` directly, and do not bypass Switchman by issuing raw SQLite queries.
2214
+
2215
+ ---
2216
+
2217
+ ## Repo profile
2218
+
2219
+ - Stack: ${stackSummary}
2220
+ - Important paths: ${importantPaths}
2221
+ ${preferredCommands.length > 0 ? preferredCommands.join('\n') : '- Commands: follow the repo-specific test/build scripts before marking work complete'}
2222
+
2223
+ ## Existing conventions
2224
+
2225
+ ${conventions.join('\n')}
2226
+
2227
+ When you make changes, preserve the current code style, folder structure, and script usage that already exist in this repo.
2228
+
2229
+ ---
2230
+
2231
+ ## Your worktree
2232
+
2233
+ Find your worktree name by running:
2234
+ \`\`\`bash
2235
+ git worktree list
2236
+ \`\`\`
2237
+ The path that matches your current directory is your worktree. Use the last path segment as your worktree name (e.g. \`/projects/myapp-feature-auth\` → \`feature-auth\`). The main repo root is always named \`main\`.
2238
+
2239
+ ---
2240
+
2241
+ ## Required workflow — follow this every session
2242
+
2243
+ ### 1. Start of session — get your task
2244
+
2245
+ Call the \`switchman_task_next\` MCP tool with your worktree name:
2246
+ \`\`\`
2247
+ switchman_task_next({ worktree: "<your-worktree-name>", agent: "claude-code" })
2248
+ \`\`\`
2249
+
2250
+ - If \`task\` is \`null\` — the queue is empty. Ask the user what to work on, or stop.
2251
+ - If you receive a task — note the \`task.id\`. You'll need it in the next steps.
2252
+ - If the \`switchman_*\` tools are unavailable, stop and tell the user the MCP server is not connected. Do not fall back to direct SQLite access.
2253
+
2254
+ ### 2. Before editing any files — claim them
2255
+
2256
+ Call \`switchman_task_claim\` with every file you plan to edit **before you edit them**:
2257
+ \`\`\`
2258
+ switchman_task_claim({
2259
+ task_id: "<task-id>",
2260
+ worktree: "<your-worktree-name>",
2261
+ files: ["src/auth/login.js", "tests/auth.test.js"]
2262
+ })
2263
+ \`\`\`
2264
+
2265
+ - If \`safe_to_proceed\` is \`false\` — there are conflicts. Do NOT edit those files.
2266
+ Read the \`conflicts\` array to see which worktrees own them, then either:
2267
+ - Choose different files that accomplish the same goal
2268
+ - Ask the user how to proceed
2269
+
2270
+ - If \`safe_to_proceed\` is \`true\` — you are clear to edit.
2271
+
2272
+ ### 3. Do the work
2273
+
2274
+ Implement the task. Make commits as normal. Other agents will avoid your claimed files.
2275
+
2276
+ If you discover mid-task that you need to edit additional files, call \`switchman_task_claim\` again for those files before editing them.
2277
+
2278
+ When MCP write tools are available, prefer the Switchman enforcement gateway over native file writes:
2279
+ \`\`\`text
2280
+ switchman_write_file(...)
2281
+ switchman_append_file(...)
2282
+ switchman_make_directory(...)
2283
+ switchman_move_path(...)
2284
+ switchman_remove_path(...)
2285
+ \`\`\`
2286
+
2287
+ These tools validate your active lease and claimed paths before changing the filesystem. Use native file writes only when the Switchman write tools are unavailable and you have already claimed the path.
2288
+
2289
+ ### 4. Before marking work complete
2290
+
2291
+ Run the repo's most relevant verification commands for the files you changed.
2292
+ ${context.testCommand ? `At minimum, prefer \`${context.testCommand}\` when the task changes behavior or tests.\n` : ''}${context.lintCommand ? `Use \`${context.lintCommand}\` when the repo relies on linting before merge.\n` : ''}${context.buildCommand ? `Use \`${context.buildCommand}\` when the repo has a meaningful build step.\n` : ''}If the correct command is unclear, inspect the existing scripts and match the repo's normal workflow.
2293
+
2294
+ ### 5. End of session — mark complete or failed
2295
+
2296
+ **On success:**
2297
+ \`\`\`
2298
+ switchman_task_done({ task_id: "<task-id>" })
2299
+ \`\`\`
2300
+
2301
+ **On failure (can't complete the task):**
2302
+ \`\`\`
2303
+ switchman_task_fail({ task_id: "<task-id>", reason: "Brief explanation of what blocked you" })
2304
+ \`\`\`
2305
+
2306
+ Always call one of these before ending your session. Released file claims allow other agents to proceed.
2307
+
2308
+ ---
2309
+
2310
+ ## Checking system state
2311
+
2312
+ To see what other agents are doing:
2313
+ \`\`\`
2314
+ switchman_status()
2315
+ \`\`\`
2316
+
2317
+ To recover abandoned work or stale sessions:
2318
+ \`\`\`
2319
+ switchman_recover()
2320
+ \`\`\`
2321
+
2322
+ To scan for conflicts before merge:
2323
+ \`\`\`
2324
+ switchman_scan()
2325
+ \`\`\`
2326
+
2327
+ ---
2328
+
2329
+ ## Rules
2330
+
2331
+ 1. **Always claim files before editing them** — not after.
2332
+ 2. **Always call \`switchman_task_done\` or \`switchman_task_fail\` at end of session** — never leave tasks as \`in_progress\` when you stop.
2333
+ 3. **If \`safe_to_proceed\` is false, do not edit the conflicting files** — coordinate first.
2334
+ 4. **Do not claim files you don't need** — over-claiming blocks other agents unnecessarily.
2335
+ 5. **One task per session** — complete or fail your current task before taking another.
2336
+ 6. **Never query or mutate the Switchman SQLite database directly** — use MCP tools only.
2337
+ `;
2338
+ }
2339
+
1797
2340
  function renderSetupVerification(report, { compact = false } = {}) {
1798
2341
  console.log(chalk.bold(compact ? 'First-run check:' : 'Setup verification:'));
1799
2342
  for (const check of report.checks) {
@@ -2911,6 +3454,7 @@ program
2911
3454
  program.showHelpAfterError('(run with --help for usage examples)');
2912
3455
  const ROOT_HELP_COMMANDS = new Set([
2913
3456
  'advanced',
3457
+ 'claude',
2914
3458
  'demo',
2915
3459
  'setup',
2916
3460
  'verify-setup',
@@ -2919,6 +3463,7 @@ const ROOT_HELP_COMMANDS = new Set([
2919
3463
  'plan',
2920
3464
  'task',
2921
3465
  'status',
3466
+ 'recover',
2922
3467
  'merge',
2923
3468
  'repair',
2924
3469
  'help',
@@ -2936,13 +3481,16 @@ Start here:
2936
3481
  switchman setup --agents 3
2937
3482
  switchman task add "Your task" --priority 8
2938
3483
  switchman status --watch
3484
+ switchman recover
2939
3485
  switchman gate ci && switchman queue run
2940
3486
 
2941
3487
  For you (the operator):
2942
3488
  switchman demo
2943
3489
  switchman setup
3490
+ switchman claude refresh
2944
3491
  switchman task add
2945
3492
  switchman status
3493
+ switchman recover
2946
3494
  switchman merge
2947
3495
  switchman repair
2948
3496
  switchman upgrade
@@ -3257,156 +3805,53 @@ Use this after setup or whenever editor/config wiring feels off.
3257
3805
  }, { homeDir: opts.home || null });
3258
3806
  if (!report.ok) process.exitCode = 1;
3259
3807
  });
3808
+ registerClaudeCommands(program, {
3809
+ chalk,
3810
+ existsSync,
3811
+ getRepo,
3812
+ join,
3813
+ renderClaudeGuide,
3814
+ writeFileSync,
3815
+ });
3260
3816
 
3261
3817
 
3262
3818
  // ── mcp ───────────────────────────────────────────────────────────────────────
3263
3819
 
3264
- const mcpCmd = program.command('mcp').description('Manage editor connections for Switchman');
3265
- const telemetryCmd = program.command('telemetry').description('Control anonymous opt-in telemetry for Switchman');
3266
-
3267
- telemetryCmd
3268
- .command('status')
3269
- .description('Show whether telemetry is enabled and where events would be sent')
3270
- .option('--home <path>', 'Override the home directory for telemetry config')
3271
- .option('--json', 'Output raw JSON')
3272
- .action((opts) => {
3273
- const config = loadTelemetryConfig(opts.home || undefined);
3274
- const runtime = getTelemetryRuntimeConfig();
3275
- const payload = {
3276
- enabled: config.telemetry_enabled === true,
3277
- configured: Boolean(runtime.apiKey) && !runtime.disabled,
3278
- install_id: config.telemetry_install_id,
3279
- destination: runtime.apiKey && !runtime.disabled ? runtime.host : null,
3280
- config_path: getTelemetryConfigPath(opts.home || undefined),
3281
- };
3282
-
3283
- if (opts.json) {
3284
- console.log(JSON.stringify(payload, null, 2));
3285
- return;
3286
- }
3287
-
3288
- console.log(`Telemetry: ${payload.enabled ? chalk.green('enabled') : chalk.yellow('disabled')}`);
3289
- console.log(`Configured destination: ${payload.configured ? chalk.cyan(payload.destination) : chalk.dim('not configured')}`);
3290
- console.log(`Config file: ${chalk.dim(payload.config_path)}`);
3291
- if (payload.install_id) {
3292
- console.log(`Install ID: ${chalk.dim(payload.install_id)}`);
3293
- }
3294
- });
3295
-
3296
- telemetryCmd
3297
- .command('enable')
3298
- .description('Enable anonymous telemetry for setup and operator workflows')
3299
- .option('--home <path>', 'Override the home directory for telemetry config')
3300
- .action((opts) => {
3301
- const runtime = getTelemetryRuntimeConfig();
3302
- if (!runtime.apiKey || runtime.disabled) {
3303
- printErrorWithNext('Telemetry destination is not configured. Set SWITCHMAN_TELEMETRY_API_KEY first.', 'switchman telemetry status');
3304
- process.exitCode = 1;
3305
- return;
3306
- }
3307
- const result = enableTelemetry(opts.home || undefined);
3308
- console.log(`${chalk.green('✓')} Telemetry enabled`);
3309
- console.log(` ${chalk.dim(result.path)}`);
3310
- });
3311
-
3312
- telemetryCmd
3313
- .command('disable')
3314
- .description('Disable anonymous telemetry')
3315
- .option('--home <path>', 'Override the home directory for telemetry config')
3316
- .action((opts) => {
3317
- const result = disableTelemetry(opts.home || undefined);
3318
- console.log(`${chalk.green('✓')} Telemetry disabled`);
3319
- console.log(` ${chalk.dim(result.path)}`);
3320
- });
3321
-
3322
- telemetryCmd
3323
- .command('test')
3324
- .description('Send one test telemetry event and report whether delivery succeeded')
3325
- .option('--home <path>', 'Override the home directory for telemetry config')
3326
- .option('--json', 'Output raw JSON')
3327
- .action(async (opts) => {
3328
- const result = await sendTelemetryEvent('telemetry_test', {
3329
- app_version: program.version(),
3330
- os: process.platform,
3331
- node_version: process.version,
3332
- source: 'switchman-cli-test',
3333
- }, { homeDir: opts.home || undefined });
3334
-
3335
- if (opts.json) {
3336
- console.log(JSON.stringify(result, null, 2));
3337
- if (!result.ok) process.exitCode = 1;
3338
- return;
3339
- }
3340
-
3341
- if (result.ok) {
3342
- console.log(`${chalk.green('✓')} Telemetry test event delivered`);
3343
- console.log(` ${chalk.dim('destination:')} ${chalk.cyan(result.destination)}`);
3344
- if (result.status) {
3345
- console.log(` ${chalk.dim('status:')} ${result.status}`);
3346
- }
3347
- return;
3348
- }
3349
-
3350
- printErrorWithNext(`Telemetry test failed (${result.reason || 'unknown_error'}).`, 'switchman telemetry status');
3351
- console.log(` ${chalk.dim('destination:')} ${result.destination || 'unknown'}`);
3352
- if (result.status) {
3353
- console.log(` ${chalk.dim('status:')} ${result.status}`);
3354
- }
3355
- if (result.error) {
3356
- console.log(` ${chalk.dim('error:')} ${result.error}`);
3357
- }
3358
- process.exitCode = 1;
3359
- });
3360
-
3361
- mcpCmd
3362
- .command('install')
3363
- .description('Install editor-specific MCP config for Switchman')
3364
- .option('--windsurf', 'Write Windsurf MCP config to ~/.codeium/mcp_config.json')
3365
- .option('--home <path>', 'Override the home directory for config writes (useful for testing)')
3366
- .option('--json', 'Output raw JSON')
3367
- .addHelpText('after', `
3368
- Examples:
3369
- switchman mcp install --windsurf
3370
- switchman mcp install --windsurf --json
3371
- `)
3372
- .action((opts) => {
3373
- if (!opts.windsurf) {
3374
- console.error(chalk.red('Choose an editor install target, for example `switchman mcp install --windsurf`.'));
3375
- process.exitCode = 1;
3376
- return;
3377
- }
3378
-
3379
- const result = upsertWindsurfMcpConfig(opts.home);
3380
-
3381
- if (opts.json) {
3382
- console.log(JSON.stringify({
3383
- editor: 'windsurf',
3384
- path: result.path,
3385
- created: result.created,
3386
- changed: result.changed,
3387
- }, null, 2));
3388
- return;
3389
- }
3390
-
3391
- console.log(`${chalk.green('✓')} Windsurf MCP config ${result.changed ? 'written' : 'already up to date'}`);
3392
- console.log(` ${chalk.dim('path:')} ${chalk.cyan(result.path)}`);
3393
- console.log(` ${chalk.dim('open:')} Windsurf -> Settings -> Cascade -> MCP Servers`);
3394
- console.log(` ${chalk.dim('note:')} Windsurf reads the shared config from ${getWindsurfMcpConfigPath(opts.home)}`);
3395
- });
3820
+ registerMcpCommands(program, {
3821
+ chalk,
3822
+ getWindsurfMcpConfigPath,
3823
+ upsertWindsurfMcpConfig,
3824
+ });
3825
+ registerTelemetryCommands(program, {
3826
+ chalk,
3827
+ disableTelemetry,
3828
+ enableTelemetry,
3829
+ getTelemetryConfigPath,
3830
+ getTelemetryRuntimeConfig,
3831
+ loadTelemetryConfig,
3832
+ printErrorWithNext,
3833
+ sendTelemetryEvent,
3834
+ });
3396
3835
 
3397
3836
 
3398
3837
  // ── plan ──────────────────────────────────────────────────────────────────────
3399
3838
 
3400
3839
  program
3401
3840
  .command('plan [goal]')
3402
- .description('Pro: suggest a parallel task plan from an explicit goal')
3841
+ .description('Pro: suggest a parallel task plan from an explicit goal or GitHub issue')
3842
+ .option('--issue <number>', 'Read planning context from a GitHub issue via gh')
3843
+ .option('--pr <number>', 'Post the resulting plan summary to a GitHub pull request')
3844
+ .option('--comment', 'Post a GitHub comment with the created plan summary after --apply')
3845
+ .option('--gh-command <command>', 'Executable to use for GitHub CLI', 'gh')
3403
3846
  .option('--apply', 'Create the suggested tasks in Switchman')
3404
3847
  .option('--max-tasks <n>', 'Maximum number of suggested tasks', '6')
3405
3848
  .option('--json', 'Output raw JSON')
3406
3849
  .addHelpText('after', `
3407
3850
  Examples:
3408
3851
  switchman plan "Add authentication"
3852
+ switchman plan --issue 47
3409
3853
  switchman plan "Add authentication" --apply
3854
+ switchman plan --issue 47 --apply --comment
3410
3855
  `)
3411
3856
  .action(async (goal, opts) => {
3412
3857
  const repoRoot = getRepo();
@@ -3424,17 +3869,48 @@ Examples:
3424
3869
  return;
3425
3870
  }
3426
3871
 
3427
- if (!goal || !goal.trim()) {
3872
+ let issueContext = null;
3873
+ if (opts.issue) {
3874
+ try {
3875
+ issueContext = fetchGitHubIssueContext(repoRoot, opts.issue, opts.ghCommand);
3876
+ } catch (err) {
3877
+ printErrorWithNext(err.message, 'switchman plan "Add authentication"');
3878
+ process.exitCode = 1;
3879
+ return;
3880
+ }
3881
+ }
3882
+
3883
+ if ((!goal || !goal.trim()) && !issueContext) {
3428
3884
  console.log('');
3429
3885
  console.log(chalk.yellow(' ⚠ AI planning currently requires an explicit goal.'));
3430
3886
  console.log(` ${chalk.dim('Try:')} ${chalk.cyan('switchman plan "Add authentication"')}`);
3887
+ console.log(` ${chalk.dim('Or: ')} ${chalk.cyan('switchman plan --issue 47')}`);
3431
3888
  console.log(` ${chalk.dim('Then:')} ${chalk.cyan('switchman plan "Add authentication" --apply')}`);
3432
3889
  console.log('');
3433
3890
  process.exitCode = 1;
3434
3891
  return;
3435
3892
  }
3436
3893
 
3437
- const context = collectPlanContext(repoRoot, goal || null);
3894
+ if (opts.comment && !opts.apply) {
3895
+ console.log('');
3896
+ console.log(chalk.yellow(' ⚠ GitHub plan comments are only posted after task creation.'));
3897
+ console.log(` ${chalk.dim('Run:')} ${chalk.cyan('switchman plan --apply --comment')}`);
3898
+ console.log('');
3899
+ process.exitCode = 1;
3900
+ return;
3901
+ }
3902
+
3903
+ if (opts.comment && !opts.pr && !issueContext) {
3904
+ console.log('');
3905
+ console.log(chalk.yellow(' ⚠ Choose where to post the plan summary.'));
3906
+ console.log(` ${chalk.dim('Use:')} ${chalk.cyan('switchman plan --issue 47 --apply --comment')}`);
3907
+ console.log(` ${chalk.dim('Or: ')} ${chalk.cyan('switchman plan "Add authentication" --pr 123 --apply --comment')}`);
3908
+ console.log('');
3909
+ process.exitCode = 1;
3910
+ return;
3911
+ }
3912
+
3913
+ const context = collectPlanContext(repoRoot, goal || null, issueContext);
3438
3914
  const planningWorktrees = resolvePlanningWorktrees(repoRoot, db);
3439
3915
  const pipelineId = `plan-${slugifyValue(context.title)}-${Date.now().toString(36)}`;
3440
3916
  const plannedTasks = planPipelineTasks({
@@ -3514,6 +3990,19 @@ Examples:
3514
3990
 
3515
3991
  console.log('');
3516
3992
  console.log(`${chalk.green('✓')} Planned ${plannedTasks.length} task(s) from repo context.`);
3993
+ if (opts.comment) {
3994
+ const commentBody = buildPlanningCommentBody(context, plannedTasks);
3995
+ const commentResult = postPlanningSummaryComment(repoRoot, {
3996
+ ghCommand: opts.ghCommand,
3997
+ issueNumber: issueContext?.number || null,
3998
+ prNumber: opts.pr || null,
3999
+ body: commentBody,
4000
+ });
4001
+ const targetLabel = commentResult.target_type === 'pr'
4002
+ ? `PR #${commentResult.target_number}`
4003
+ : `issue #${commentResult.target_number}`;
4004
+ console.log(` ${chalk.green('✓')} Posted plan summary to ${chalk.cyan(targetLabel)}.`);
4005
+ }
3517
4006
  console.log(` ${chalk.yellow('next:')} ${chalk.cyan('switchman status --watch')}`);
3518
4007
  } finally {
3519
4008
  db?.close();
@@ -3523,677 +4012,56 @@ Examples:
3523
4012
 
3524
4013
  // ── task ──────────────────────────────────────────────────────────────────────
3525
4014
 
3526
- const taskCmd = program.command('task').description('Manage the task list');
3527
- taskCmd.addHelpText('after', `
3528
- Examples:
3529
- switchman task add "Fix login bug" --priority 8
3530
- switchman task list --status pending
3531
- switchman task done task-123
3532
- `);
3533
-
3534
- taskCmd
3535
- .command('add <title>')
3536
- .description('Add a new task to the queue')
3537
- .option('-d, --description <desc>', 'Task description')
3538
- .option('-p, --priority <n>', 'Priority 1-10 (default 5)', '5')
3539
- .option('--id <id>', 'Custom task ID')
3540
- .action((title, opts) => {
3541
- const repoRoot = getRepo();
3542
- const db = getDb(repoRoot);
3543
- const taskId = createTask(db, {
3544
- id: opts.id,
3545
- title,
3546
- description: opts.description,
3547
- priority: parseInt(opts.priority),
3548
- });
3549
- db.close();
3550
- const scopeWarning = analyzeTaskScope(title, opts.description || '');
3551
- console.log(`${chalk.green('✓')} Task created: ${chalk.cyan(taskId)}`);
3552
- pushSyncEvent('task_added', { task_id: taskId, title, priority: parseInt(opts.priority) }).catch(() => {});
3553
- console.log(` ${chalk.dim(title)}`);
3554
- if (scopeWarning) {
3555
- console.log(chalk.yellow(` warning: ${scopeWarning.summary}`));
3556
- console.log(chalk.yellow(` next: ${scopeWarning.next_step}`));
3557
- console.log(chalk.cyan(` try: ${scopeWarning.command}`));
3558
- }
3559
- });
3560
-
3561
- taskCmd
3562
- .command('list')
3563
- .description('List all tasks')
3564
- .option('-s, --status <status>', 'Filter by status (pending|in_progress|done|failed)')
3565
- .action((opts) => {
3566
- const repoRoot = getRepo();
3567
- const db = getDb(repoRoot);
3568
- const tasks = listTasks(db, opts.status);
3569
- db.close();
3570
-
3571
- if (!tasks.length) {
3572
- console.log(chalk.dim('No tasks found.'));
3573
- return;
3574
- }
3575
-
3576
- console.log('');
3577
- for (const t of tasks) {
3578
- const badge = statusBadge(t.status);
3579
- const worktree = t.worktree ? chalk.cyan(t.worktree) : chalk.dim('unassigned');
3580
- console.log(`${badge} ${chalk.bold(t.title)}`);
3581
- console.log(` ${chalk.dim('id:')} ${t.id} ${chalk.dim('worktree:')} ${worktree} ${chalk.dim('priority:')} ${t.priority}`);
3582
- if (t.description) console.log(` ${chalk.dim(t.description)}`);
3583
- console.log('');
3584
- }
3585
- });
3586
-
3587
- taskCmd
3588
- .command('assign <taskId> <worktree>')
3589
- .description('Assign a task to a workspace (compatibility shim for lease acquire)')
3590
- .option('--agent <name>', 'Agent name (e.g. claude-code)')
3591
- .action((taskId, worktree, opts) => {
3592
- const repoRoot = getRepo();
3593
- const db = getDb(repoRoot);
3594
- const lease = startTaskLease(db, taskId, worktree, opts.agent);
3595
- db.close();
3596
- if (lease) {
3597
- console.log(`${chalk.green('✓')} Assigned ${chalk.cyan(taskId)} → ${chalk.cyan(worktree)} (${chalk.dim(lease.id)})`);
3598
- } else {
3599
- console.log(chalk.red(`Could not assign task. It may not exist or is not in 'pending' status.`));
3600
- }
3601
- });
3602
-
3603
- taskCmd
3604
- .command('retry <taskId>')
3605
- .description('Return a failed or stale completed task to pending so it can be revalidated')
3606
- .option('--reason <text>', 'Reason to record for the retry')
3607
- .option('--json', 'Output raw JSON')
3608
- .action((taskId, opts) => {
3609
- const repoRoot = getRepo();
3610
- const db = getDb(repoRoot);
3611
- const task = retryTask(db, taskId, opts.reason || 'manual retry');
3612
- db.close();
3613
-
3614
- if (!task) {
3615
- printErrorWithNext(`Task ${taskId} is not retryable.`, 'switchman task list --status failed');
3616
- process.exitCode = 1;
3617
- return;
3618
- }
3619
-
3620
- if (opts.json) {
3621
- console.log(JSON.stringify(task, null, 2));
3622
- return;
3623
- }
3624
-
3625
- console.log(`${chalk.green('✓')} Reset ${chalk.cyan(task.id)} to pending`);
3626
- pushSyncEvent('task_retried', { task_id: task.id, title: task.title, reason: opts.reason || 'manual retry' }).catch(() => {});
3627
- console.log(` ${chalk.dim('title:')} ${task.title}`);
3628
- console.log(`${chalk.yellow('next:')} switchman task assign ${task.id} <workspace>`);
3629
- });
3630
-
3631
- taskCmd
3632
- .command('retry-stale')
3633
- .description('Return all currently stale tasks to pending so they can be revalidated together')
3634
- .option('--pipeline <id>', 'Only retry stale tasks for one pipeline')
3635
- .option('--reason <text>', 'Reason to record for the retry', 'bulk stale retry')
3636
- .option('--json', 'Output raw JSON')
3637
- .action((opts) => {
3638
- const repoRoot = getRepo();
3639
- const db = getDb(repoRoot);
3640
- const result = retryStaleTasks(db, {
3641
- pipelineId: opts.pipeline || null,
3642
- reason: opts.reason,
3643
- });
3644
- db.close();
3645
-
3646
- if (opts.json) {
3647
- console.log(JSON.stringify(result, null, 2));
3648
- return;
3649
- }
3650
-
3651
- if (result.retried.length === 0) {
3652
- const scope = result.pipeline_id ? ` for ${result.pipeline_id}` : '';
3653
- console.log(chalk.dim(`No stale tasks to retry${scope}.`));
3654
- return;
3655
- }
3656
-
3657
- console.log(`${chalk.green('✓')} Reset ${result.retried.length} stale task(s) to pending`);
3658
- pushSyncEvent('task_retried', {
3659
- pipeline_id: result.pipeline_id || null,
3660
- task_count: result.retried.length,
3661
- task_ids: result.retried.map((task) => task.id),
3662
- reason: opts.reason,
3663
- }).catch(() => {});
3664
- if (result.pipeline_id) {
3665
- console.log(` ${chalk.dim('pipeline:')} ${result.pipeline_id}`);
3666
- }
3667
- console.log(` ${chalk.dim('tasks:')} ${result.retried.map((task) => task.id).join(', ')}`);
3668
- console.log(`${chalk.yellow('next:')} switchman status`);
3669
- });
3670
-
3671
- taskCmd
3672
- .command('done <taskId>')
3673
- .description('Mark a task as complete and release all file claims')
3674
- .action((taskId) => {
3675
- const repoRoot = getRepo();
3676
- try {
3677
- const result = completeTaskWithRetries(repoRoot, taskId);
3678
- if (result?.status === 'already_done') {
3679
- console.log(`${chalk.yellow('!')} Task ${chalk.cyan(taskId)} was already marked done — no new changes were recorded`);
3680
- return;
3681
- }
3682
- if (result?.status === 'failed') {
3683
- console.log(`${chalk.yellow('!')} Task ${chalk.cyan(taskId)} is currently failed — retry it before marking it done again`);
3684
- return;
3685
- }
3686
- if (result?.status === 'not_in_progress') {
3687
- console.log(`${chalk.yellow('!')} Task ${chalk.cyan(taskId)} is not currently in progress — start a lease before marking it done`);
3688
- return;
3689
- }
3690
- if (result?.status === 'no_active_lease') {
3691
- console.log(`${chalk.yellow('!')} Task ${chalk.cyan(taskId)} has no active lease — reacquire the task before marking it done`);
3692
- return;
3693
- }
3694
- console.log(`${chalk.green('✓')} Task ${chalk.cyan(taskId)} marked done — file claims released`);
3695
- pushSyncEvent('task_done', { task_id: taskId }).catch(() => {});
3696
- } catch (err) {
3697
- console.error(chalk.red(err.message));
3698
- process.exitCode = 1;
3699
- }
3700
- });
3701
-
3702
- taskCmd
3703
- .command('fail <taskId> [reason]')
3704
- .description('Mark a task as failed')
3705
- .action((taskId, reason) => {
3706
- const repoRoot = getRepo();
3707
- const db = getDb(repoRoot);
3708
- failTask(db, taskId, reason);
3709
- releaseFileClaims(db, taskId);
3710
- db.close();
3711
- console.log(`${chalk.red('✗')} Task ${chalk.cyan(taskId)} marked failed`);
3712
- pushSyncEvent('task_failed', { task_id: taskId, reason: reason || null }).catch(() => {});
3713
- });
3714
-
3715
- taskCmd
3716
- .command('next')
3717
- .description('Get the next pending task quickly (use `lease next` for the full workflow)')
3718
- .option('--json', 'Output as JSON')
3719
- .option('--worktree <name>', 'Workspace to assign the task to (defaults to the current folder name)')
3720
- .option('--agent <name>', 'Agent identifier for logging (e.g. claude-code)')
3721
- .addHelpText('after', `
3722
- Examples:
3723
- switchman task next
3724
- switchman task next --json
3725
- `)
3726
- .action((opts) => {
3727
- const repoRoot = getRepo();
3728
- const worktreeName = getCurrentWorktreeName(opts.worktree);
3729
- const { task, lease, exhausted } = acquireNextTaskLeaseWithRetries(repoRoot, worktreeName, opts.agent || null);
3730
-
3731
- if (!task) {
3732
- if (opts.json) console.log(JSON.stringify({ task: null }));
3733
- else if (exhausted) console.log(chalk.dim('No pending tasks.'));
3734
- else console.log(chalk.yellow('Tasks were claimed by other agents during assignment. Run again to get the next one.'));
3735
- return;
3736
- }
3737
-
3738
- if (!lease) {
3739
- if (opts.json) console.log(JSON.stringify({ task: null, message: 'Task claimed by another agent — try again' }));
3740
- else console.log(chalk.yellow('Task was just claimed by another agent. Run again to get the next one.'));
3741
- return;
3742
- }
3743
-
3744
- if (opts.json) {
3745
- console.log(JSON.stringify(taskJsonWithLease(task, worktreeName, lease), null, 2));
3746
- } else {
3747
- console.log(`${chalk.green('✓')} Assigned: ${chalk.bold(task.title)}`);
3748
- 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}`);
3749
- }
3750
- });
4015
+ registerTaskCommands(program, {
4016
+ acquireNextTaskLeaseWithRetries,
4017
+ analyzeTaskScope,
4018
+ chalk,
4019
+ completeTaskWithRetries,
4020
+ createTask,
4021
+ failTask,
4022
+ getCurrentWorktreeName,
4023
+ getDb,
4024
+ getRepo,
4025
+ listTasks,
4026
+ printErrorWithNext,
4027
+ pushSyncEvent,
4028
+ releaseFileClaims,
4029
+ retryStaleTasks,
4030
+ retryTask,
4031
+ startTaskLease,
4032
+ statusBadge,
4033
+ taskJsonWithLease,
4034
+ });
3751
4035
 
3752
4036
  // ── queue ─────────────────────────────────────────────────────────────────────
3753
4037
 
3754
- const queueCmd = program.command('queue').alias('land').description('Land finished work safely back onto main, one item at a time');
3755
- queueCmd.addHelpText('after', `
3756
- Examples:
3757
- switchman queue add --worktree agent1
3758
- switchman queue status
3759
- switchman queue run --watch
3760
- `);
3761
-
3762
- queueCmd
3763
- .command('add [branch]')
3764
- .description('Add a branch, workspace, or pipeline to the landing queue')
3765
- .option('--worktree <name>', 'Queue a registered workspace by name')
3766
- .option('--pipeline <pipelineId>', 'Queue a pipeline by id')
3767
- .option('--target <branch>', 'Target branch to merge into', 'main')
3768
- .option('--max-retries <n>', 'Maximum automatic retries', '1')
3769
- .option('--submitted-by <name>', 'Operator or automation name')
3770
- .option('--json', 'Output raw JSON')
3771
- .addHelpText('after', `
3772
- Examples:
3773
- switchman queue add feature/auth-hardening
3774
- switchman queue add --worktree agent2
3775
- switchman queue add --pipeline pipe-123
3776
-
3777
- Pipeline landing rule:
3778
- switchman queue add --pipeline <id>
3779
- lands the pipeline's inferred landing branch.
3780
- If completed work spans multiple branches, Switchman creates one synthetic landing branch first.
3781
- `)
3782
- .action(async (branch, opts) => {
3783
- const repoRoot = getRepo();
3784
- const db = getDb(repoRoot);
3785
-
3786
- try {
3787
- let payload;
3788
- if (opts.worktree) {
3789
- const worktree = listWorktrees(db).find((entry) => entry.name === opts.worktree);
3790
- if (!worktree) {
3791
- throw new Error(`Workspace ${opts.worktree} is not registered.`);
3792
- }
3793
- payload = {
3794
- sourceType: 'worktree',
3795
- sourceRef: worktree.branch,
3796
- sourceWorktree: worktree.name,
3797
- targetBranch: opts.target,
3798
- maxRetries: opts.maxRetries,
3799
- submittedBy: opts.submittedBy || null,
3800
- };
3801
- } else if (opts.pipeline) {
3802
- const policyGate = await evaluatePipelinePolicyGate(db, repoRoot, opts.pipeline);
3803
- if (!policyGate.ok) {
3804
- throw new Error(`${policyGate.summary} Next: ${policyGate.next_action}`);
3805
- }
3806
- const landingTarget = preparePipelineLandingTarget(db, repoRoot, opts.pipeline, {
3807
- baseBranch: opts.target || 'main',
3808
- requireCompleted: true,
3809
- allowCurrentBranchFallback: false,
3810
- });
3811
- payload = {
3812
- sourceType: 'pipeline',
3813
- sourceRef: landingTarget.branch,
3814
- sourcePipelineId: opts.pipeline,
3815
- sourceWorktree: landingTarget.worktree || null,
3816
- targetBranch: opts.target,
3817
- maxRetries: opts.maxRetries,
3818
- submittedBy: opts.submittedBy || null,
3819
- eventDetails: policyGate.override_applied
3820
- ? {
3821
- policy_override_summary: policyGate.override_summary,
3822
- overridden_task_types: policyGate.policy_state?.overridden_task_types || [],
3823
- }
3824
- : null,
3825
- };
3826
- } else if (branch) {
3827
- payload = {
3828
- sourceType: 'branch',
3829
- sourceRef: branch,
3830
- targetBranch: opts.target,
3831
- maxRetries: opts.maxRetries,
3832
- submittedBy: opts.submittedBy || null,
3833
- };
3834
- } else {
3835
- throw new Error('Choose one source to land: a branch name, `--worktree`, or `--pipeline`.');
3836
- }
3837
-
3838
- const result = enqueueMergeItem(db, payload);
3839
- db.close();
3840
- pushSyncEvent('queue_added', {
3841
- item_id: result.id,
3842
- source_type: result.source_type,
3843
- source_ref: result.source_ref,
3844
- source_worktree: result.source_worktree || null,
3845
- target_branch: result.target_branch,
3846
- }, { worktree: result.source_worktree || null }).catch(() => {});
3847
-
3848
- if (opts.json) {
3849
- console.log(JSON.stringify(result, null, 2));
3850
- return;
3851
- }
3852
-
3853
- console.log(`${chalk.green('✓')} Queued ${chalk.cyan(result.id)} for ${chalk.bold(result.target_branch)}`);
3854
- console.log(` ${chalk.dim('source:')} ${result.source_type} ${result.source_ref}`);
3855
- if (result.source_worktree) console.log(` ${chalk.dim('worktree:')} ${result.source_worktree}`);
3856
- if (payload.eventDetails?.policy_override_summary) {
3857
- console.log(` ${chalk.dim('policy override:')} ${payload.eventDetails.policy_override_summary}`);
3858
- }
3859
- } catch (err) {
3860
- db.close();
3861
- printErrorWithNext(err.message, 'switchman queue add --help');
3862
- process.exitCode = 1;
3863
- }
3864
- });
3865
-
3866
- queueCmd
3867
- .command('list')
3868
- .description('List merge queue items')
3869
- .option('--status <status>', 'Filter by queue status')
3870
- .option('--json', 'Output raw JSON')
3871
- .action((opts) => {
3872
- const repoRoot = getRepo();
3873
- const db = getDb(repoRoot);
3874
- const items = listMergeQueue(db, { status: opts.status || null });
3875
- db.close();
3876
-
3877
- if (opts.json) {
3878
- console.log(JSON.stringify(items, null, 2));
3879
- return;
3880
- }
3881
-
3882
- if (items.length === 0) {
3883
- console.log(chalk.dim('Merge queue is empty.'));
3884
- return;
3885
- }
3886
-
3887
- for (const item of items) {
3888
- const retryInfo = chalk.dim(`retries:${item.retry_count}/${item.max_retries}`);
3889
- const attemptInfo = item.last_attempt_at ? ` ${chalk.dim(`last-attempt:${item.last_attempt_at}`)}` : '';
3890
- const backoffInfo = item.backoff_until ? ` ${chalk.dim(`backoff-until:${item.backoff_until}`)}` : '';
3891
- const escalationInfo = item.escalated_at ? ` ${chalk.dim(`escalated:${item.escalated_at}`)}` : '';
3892
- console.log(` ${statusBadge(item.status)} ${item.id} ${item.source_type}:${item.source_ref} ${chalk.dim(`→ ${item.target_branch}`)} ${retryInfo}${attemptInfo}${backoffInfo}${escalationInfo}`);
3893
- if (item.last_error_summary) {
3894
- console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
3895
- }
3896
- if (item.next_action) {
3897
- console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
3898
- }
3899
- }
3900
- });
3901
-
3902
- queueCmd
3903
- .command('status')
3904
- .description('Show an operator-friendly merge queue summary')
3905
- .option('--json', 'Output raw JSON')
3906
- .addHelpText('after', `
3907
- Plain English:
3908
- Use this when finished branches are waiting to land and you want one safe queue view.
3909
-
3910
- Examples:
3911
- switchman queue status
3912
- switchman queue status --json
3913
-
3914
- What it helps you answer:
3915
- - what lands next
3916
- - what is blocked
3917
- - what command should I run now
3918
- `)
3919
- .action((opts) => {
3920
- const repoRoot = getRepo();
3921
- const db = getDb(repoRoot);
3922
- const items = listMergeQueue(db);
3923
- const summary = buildQueueStatusSummary(items, { db, repoRoot });
3924
- const recentEvents = items.slice(0, 5).flatMap((item) =>
3925
- listMergeQueueEvents(db, item.id, { limit: 3 }).map((event) => ({ ...event, queue_item_id: item.id })),
3926
- ).sort((a, b) => b.id - a.id).slice(0, 8);
3927
- db.close();
3928
-
3929
- if (opts.json) {
3930
- console.log(JSON.stringify({ items, summary, recent_events: recentEvents }, null, 2));
3931
- return;
3932
- }
3933
-
3934
- if (items.length === 0) {
3935
- console.log('');
3936
- console.log(chalk.bold('switchman queue status'));
3937
- console.log('');
3938
- console.log('Queue is empty.');
3939
- console.log(`Add finished work with: ${chalk.cyan('switchman queue add --worktree agent1')}`);
3940
- return;
3941
- }
3942
-
3943
- const queueHealth = summary.counts.blocked > 0
3944
- ? 'block'
3945
- : summary.counts.retrying > 0 || summary.counts.held > 0 || summary.counts.wave_blocked > 0 || summary.counts.escalated > 0
3946
- ? 'warn'
3947
- : 'healthy';
3948
- const queueHealthColor = colorForHealth(queueHealth);
3949
- const retryingItems = items.filter((item) => item.status === 'retrying');
3950
- const focus = summary.blocked[0] || retryingItems[0] || summary.next || null;
3951
- const focusLine = focus
3952
- ? `${focus.id} ${focus.source_type}:${focus.source_ref}${focus.last_error_summary ? ` ${chalk.dim(`• ${focus.last_error_summary}`)}` : ''}`
3953
- : 'Nothing waiting. Landing queue is clear.';
3954
-
3955
- console.log('');
3956
- console.log(queueHealthColor('='.repeat(72)));
3957
- console.log(`${queueHealthColor(healthLabel(queueHealth))} ${chalk.bold('switchman queue status')} ${chalk.dim('• landing mission control')}`);
3958
- console.log(queueHealthColor('='.repeat(72)));
3959
- console.log(renderSignalStrip([
3960
- renderChip('queued', summary.counts.queued, summary.counts.queued > 0 ? chalk.yellow : chalk.green),
3961
- renderChip('retrying', summary.counts.retrying, summary.counts.retrying > 0 ? chalk.yellow : chalk.green),
3962
- renderChip('held', summary.counts.held, summary.counts.held > 0 ? chalk.yellow : chalk.green),
3963
- renderChip('wave blocked', summary.counts.wave_blocked, summary.counts.wave_blocked > 0 ? chalk.yellow : chalk.green),
3964
- renderChip('escalated', summary.counts.escalated, summary.counts.escalated > 0 ? chalk.red : chalk.green),
3965
- renderChip('blocked', summary.counts.blocked, summary.counts.blocked > 0 ? chalk.red : chalk.green),
3966
- renderChip('merging', summary.counts.merging, summary.counts.merging > 0 ? chalk.blue : chalk.green),
3967
- renderChip('merged', summary.counts.merged, summary.counts.merged > 0 ? chalk.green : chalk.white),
3968
- ]));
3969
- console.log(renderMetricRow([
3970
- { label: 'items', value: items.length, color: chalk.white },
3971
- { label: 'validating', value: summary.counts.validating, color: chalk.blue },
3972
- { label: 'rebasing', value: summary.counts.rebasing, color: chalk.blue },
3973
- { label: 'target', value: summary.next?.target_branch || 'main', color: chalk.cyan },
3974
- ]));
3975
- console.log(`${chalk.bold('Focus now:')} ${focusLine}`);
3976
-
3977
- const queueFocusLines = summary.next
3978
- ? [
3979
- `${renderChip(summary.next.recommendation?.action === 'retry' ? 'RETRY' : summary.next.recommendation?.action === 'escalate' ? 'ESCALATE' : 'NEXT', summary.next.id, summary.next.recommendation?.action === 'retry' ? chalk.yellow : summary.next.recommendation?.action === 'escalate' ? chalk.red : chalk.green)} ${summary.next.source_type}:${summary.next.source_ref} ${chalk.dim(`retries:${summary.next.retry_count}/${summary.next.max_retries}`)}${summary.next.queue_assessment?.goal_priority ? ` ${chalk.dim(`priority:${summary.next.queue_assessment.goal_priority}`)}` : ''}${summary.next.queue_assessment?.integration_risk && summary.next.queue_assessment.integration_risk !== 'normal' ? ` ${chalk.dim(`risk:${summary.next.queue_assessment.integration_risk}`)}` : ''}${summary.next.queue_assessment?.freshness ? ` ${chalk.dim(`freshness:${summary.next.queue_assessment.freshness}`)}` : ''}${summary.next.queue_assessment?.stale_invalidation_count ? ` ${chalk.dim(`stale:${summary.next.queue_assessment.stale_invalidation_count}`)}` : ''}`,
3980
- ...(summary.next.queue_assessment?.reason ? [` ${chalk.dim('why next:')} ${summary.next.queue_assessment.reason}`] : []),
3981
- ...(summary.next.recommendation?.summary ? [` ${chalk.dim('decision:')} ${summary.next.recommendation.summary}`] : []),
3982
- ` ${chalk.yellow('run:')} ${summary.next.recommendation?.command || 'switchman queue run'}`,
3983
- ]
3984
- : [chalk.dim('No queued landing work right now.')];
3985
-
3986
- const queueHeldBackLines = summary.held_back.length > 0
3987
- ? summary.held_back.flatMap((item) => {
3988
- const lines = [`${renderChip(item.recommendation?.action === 'escalate' ? 'ESCALATE' : 'HOLD', item.id, item.recommendation?.action === 'escalate' ? chalk.red : chalk.yellow)} ${item.source_type}:${item.source_ref}${item.queue_assessment?.goal_priority ? ` ${chalk.dim(`priority:${item.queue_assessment.goal_priority}`)}` : ''} ${chalk.dim(`freshness:${item.queue_assessment?.freshness || 'unknown'}`)}${item.queue_assessment?.integration_risk && item.queue_assessment.integration_risk !== 'normal' ? ` ${chalk.dim(`risk:${item.queue_assessment.integration_risk}`)}` : ''}${item.queue_assessment?.stale_invalidation_count ? ` ${chalk.dim(`stale:${item.queue_assessment.stale_invalidation_count}`)}` : ''}`];
3989
- if (item.queue_assessment?.reason) lines.push(` ${chalk.dim('why later:')} ${item.queue_assessment.reason}`);
3990
- if (item.recommendation?.summary) lines.push(` ${chalk.dim('decision:')} ${item.recommendation.summary}`);
3991
- if (item.queue_assessment?.next_action) lines.push(` ${chalk.yellow('next:')} ${item.queue_assessment.next_action}`);
3992
- return lines;
3993
- })
3994
- : [chalk.green('Nothing significant is being held back.')];
3995
-
3996
- const queueBlockedLines = summary.blocked.length > 0
3997
- ? summary.blocked.slice(0, 4).flatMap((item) => {
3998
- const lines = [`${renderChip('BLOCKED', item.id, chalk.red)} ${item.source_type}:${item.source_ref} ${chalk.dim(`retries:${item.retry_count}/${item.max_retries}`)}`];
3999
- if (item.last_error_summary) lines.push(` ${chalk.red('why:')} ${item.last_error_summary}`);
4000
- if (item.next_action) lines.push(` ${chalk.yellow('next:')} ${item.next_action}`);
4001
- return lines;
4002
- })
4003
- : [chalk.green('Nothing blocked.')];
4004
-
4005
- const queueWatchLines = items.filter((item) => ['retrying', 'held', 'wave_blocked', 'escalated', 'merging', 'rebasing', 'validating'].includes(item.status)).length > 0
4006
- ? items
4007
- .filter((item) => ['retrying', 'held', 'wave_blocked', 'escalated', 'merging', 'rebasing', 'validating'].includes(item.status))
4008
- .slice(0, 4)
4009
- .flatMap((item) => {
4010
- const lines = [`${renderChip(item.status.toUpperCase(), item.id, item.status === 'retrying' || item.status === 'held' || item.status === 'wave_blocked' ? chalk.yellow : item.status === 'escalated' ? chalk.red : chalk.blue)} ${item.source_type}:${item.source_ref}`];
4011
- if (item.last_error_summary) lines.push(` ${chalk.dim(item.last_error_summary)}`);
4012
- if (item.next_action) lines.push(` ${chalk.yellow('next:')} ${item.next_action}`);
4013
- return lines;
4014
- })
4015
- : [chalk.green('No in-flight queue items right now.')];
4016
-
4017
- const queueCommandLines = [
4018
- `${chalk.cyan('$')} switchman queue run`,
4019
- `${chalk.cyan('$')} switchman queue status --json`,
4020
- ...(summary.blocked[0] ? [`${chalk.cyan('$')} switchman queue retry ${summary.blocked[0].id}`] : []),
4021
- ];
4022
-
4023
- const queuePlanLines = [
4024
- ...(summary.plan?.land_now?.slice(0, 2).map((item) => `${renderChip('LAND NOW', item.item_id, chalk.green)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
4025
- ...(summary.plan?.prepare_next?.slice(0, 2).map((item) => `${renderChip('PREP NEXT', item.item_id, chalk.cyan)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
4026
- ...(summary.plan?.unblock_first?.slice(0, 2).map((item) => `${renderChip('UNBLOCK', item.item_id, chalk.yellow)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
4027
- ...(summary.plan?.escalate?.slice(0, 2).map((item) => `${renderChip('ESCALATE', item.item_id, chalk.red)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
4028
- ...(summary.plan?.defer?.slice(0, 2).map((item) => `${renderChip('DEFER', item.item_id, chalk.white)} ${item.source_type}:${item.source_ref} ${chalk.dim(item.summary)}`) || []),
4029
- ];
4030
- const queueSequenceLines = summary.recommended_sequence?.length > 0
4031
- ? summary.recommended_sequence.map((item) => `${chalk.bold(`${item.stage}.`)} ${item.source_type}:${item.source_ref} ${chalk.dim(`[${item.lane}]`)} ${item.summary}`)
4032
- : [chalk.green('No recommended sequence beyond the current landing focus.')];
4033
-
4034
- console.log('');
4035
- for (const block of [
4036
- renderPanel('Landing focus', queueFocusLines, chalk.green),
4037
- renderPanel('Recommended sequence', queueSequenceLines, summary.recommended_sequence?.length > 0 ? chalk.cyan : chalk.green),
4038
- renderPanel('Queue plan', queuePlanLines.length > 0 ? queuePlanLines : [chalk.green('Nothing else needs planning right now.')], queuePlanLines.length > 0 ? chalk.cyan : chalk.green),
4039
- renderPanel('Held back', queueHeldBackLines, summary.held_back.length > 0 ? chalk.yellow : chalk.green),
4040
- renderPanel('Blocked', queueBlockedLines, summary.counts.blocked > 0 ? chalk.red : chalk.green),
4041
- renderPanel('In flight', queueWatchLines, queueWatchLines[0] === 'No in-flight queue items right now.' ? chalk.green : chalk.blue),
4042
- renderPanel('Next commands', queueCommandLines, chalk.cyan),
4043
- ]) {
4044
- for (const line of block) console.log(line);
4045
- console.log('');
4046
- }
4047
-
4048
- if (recentEvents.length > 0) {
4049
- console.log(chalk.bold('Recent Queue Events:'));
4050
- for (const event of recentEvents) {
4051
- console.log(` ${chalk.cyan(event.queue_item_id)} ${chalk.dim(event.event_type)} ${chalk.dim(event.status || '')} ${chalk.dim(event.created_at)}`.trim());
4052
- }
4053
- }
4054
- });
4055
-
4056
- queueCmd
4057
- .command('run')
4058
- .description('Process landing-queue items one at a time')
4059
- .option('--max-items <n>', 'Maximum queue items to process', '1')
4060
- .option('--follow-plan', 'Only run queue items that are currently in the land_now lane')
4061
- .option('--merge-budget <n>', 'Maximum successful merges to allow in this run')
4062
- .option('--target <branch>', 'Default target branch', 'main')
4063
- .option('--watch', 'Keep polling for new queue items')
4064
- .option('--watch-interval-ms <n>', 'Polling interval for --watch mode', '1000')
4065
- .option('--max-cycles <n>', 'Maximum watch cycles before exiting (mainly for tests)')
4066
- .option('--json', 'Output raw JSON')
4067
- .addHelpText('after', `
4068
- Examples:
4069
- switchman queue run
4070
- switchman queue run --follow-plan --merge-budget 2
4071
- switchman queue run --watch
4072
- switchman queue run --watch --watch-interval-ms 1000
4073
- `)
4074
- .action(async (opts) => {
4075
- const repoRoot = getRepo();
4076
-
4077
- try {
4078
- const watch = Boolean(opts.watch);
4079
- const followPlan = Boolean(opts.followPlan);
4080
- const watchIntervalMs = Math.max(0, Number.parseInt(opts.watchIntervalMs, 10) || 1000);
4081
- const maxCycles = opts.maxCycles ? Math.max(1, Number.parseInt(opts.maxCycles, 10) || 1) : null;
4082
- const mergeBudget = opts.mergeBudget !== undefined
4083
- ? Math.max(0, Number.parseInt(opts.mergeBudget, 10) || 0)
4084
- : null;
4085
- const aggregate = {
4086
- processed: [],
4087
- cycles: 0,
4088
- watch,
4089
- execution_policy: {
4090
- follow_plan: followPlan,
4091
- merge_budget: mergeBudget,
4092
- merged_count: 0,
4093
- },
4094
- };
4095
-
4096
- while (true) {
4097
- const db = getDb(repoRoot);
4098
- const result = await runMergeQueue(db, repoRoot, {
4099
- maxItems: Number.parseInt(opts.maxItems, 10) || 1,
4100
- targetBranch: opts.target || 'main',
4101
- followPlan,
4102
- mergeBudget,
4103
- });
4104
- db.close();
4105
-
4106
- aggregate.processed.push(...result.processed);
4107
- aggregate.summary = result.summary;
4108
- aggregate.deferred = result.deferred || aggregate.deferred || null;
4109
- aggregate.execution_policy = result.execution_policy || aggregate.execution_policy;
4110
- aggregate.cycles += 1;
4111
-
4112
- if (!watch) break;
4113
- if (maxCycles && aggregate.cycles >= maxCycles) break;
4114
- if (mergeBudget !== null && aggregate.execution_policy.merged_count >= mergeBudget) break;
4115
- if (result.processed.length === 0) {
4116
- sleepSync(watchIntervalMs);
4117
- }
4118
- }
4119
-
4120
- if (opts.json) {
4121
- console.log(JSON.stringify(aggregate, null, 2));
4122
- return;
4123
- }
4124
-
4125
- if (aggregate.processed.length === 0) {
4126
- const deferredFocus = aggregate.deferred || aggregate.summary?.next || null;
4127
- if (deferredFocus?.recommendation?.action) {
4128
- console.log(chalk.yellow('No landing candidate is ready to run right now.'));
4129
- console.log(` ${chalk.dim('focus:')} ${deferredFocus.id} ${deferredFocus.source_type}:${deferredFocus.source_ref}`);
4130
- if (followPlan) {
4131
- console.log(` ${chalk.dim('policy:')} following the queue plan, so only land_now items will run automatically`);
4132
- }
4133
- if (deferredFocus.recommendation?.summary) {
4134
- console.log(` ${chalk.dim('decision:')} ${deferredFocus.recommendation.summary}`);
4135
- }
4136
- if (deferredFocus.recommendation?.command) {
4137
- console.log(` ${chalk.yellow('next:')} ${deferredFocus.recommendation.command}`);
4138
- }
4139
- } else {
4140
- console.log(chalk.dim('No queued merge items.'));
4141
- }
4142
- await maybeCaptureTelemetry('queue_used', {
4143
- watch,
4144
- cycles: aggregate.cycles,
4145
- processed_count: 0,
4146
- merged_count: 0,
4147
- blocked_count: 0,
4148
- });
4149
- return;
4150
- }
4151
-
4152
- for (const entry of aggregate.processed) {
4153
- const item = entry.item;
4154
- if (entry.status === 'merged') {
4155
- pushSyncEvent('queue_merged', {
4156
- item_id: item.id,
4157
- source_type: item.source_type,
4158
- source_ref: item.source_ref,
4159
- source_worktree: item.source_worktree || null,
4160
- target_branch: item.target_branch,
4161
- merged_commit: item.merged_commit || null,
4162
- }, { worktree: item.source_worktree || null }).catch(() => {});
4163
- console.log(`${chalk.green('✓')} Merged ${chalk.cyan(item.id)} into ${chalk.bold(item.target_branch)}`);
4164
- console.log(` ${chalk.dim('commit:')} ${item.merged_commit}`);
4165
- } else {
4166
- pushSyncEvent('queue_blocked', {
4167
- item_id: item.id,
4168
- source_type: item.source_type,
4169
- source_ref: item.source_ref,
4170
- source_worktree: item.source_worktree || null,
4171
- target_branch: item.target_branch,
4172
- error_code: item.last_error_code || null,
4173
- error_summary: item.last_error_summary || null,
4174
- }, { worktree: item.source_worktree || null }).catch(() => {});
4175
- console.log(`${chalk.red('✗')} Blocked ${chalk.cyan(item.id)}`);
4176
- console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
4177
- if (item.next_action) console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
4178
- }
4179
- }
4180
-
4181
- if (aggregate.execution_policy.follow_plan) {
4182
- console.log(`${chalk.dim('plan-aware run:')} merged ${aggregate.execution_policy.merged_count}${aggregate.execution_policy.merge_budget !== null ? ` of ${aggregate.execution_policy.merge_budget}` : ''} budgeted item(s)`);
4183
- }
4184
-
4185
- await maybeCaptureTelemetry('queue_used', {
4186
- watch,
4187
- cycles: aggregate.cycles,
4188
- processed_count: aggregate.processed.length,
4189
- merged_count: aggregate.processed.filter((entry) => entry.status === 'merged').length,
4190
- blocked_count: aggregate.processed.filter((entry) => entry.status !== 'merged').length,
4191
- });
4192
- } catch (err) {
4193
- console.error(chalk.red(err.message));
4194
- process.exitCode = 1;
4195
- }
4196
- });
4038
+ registerQueueCommands(program, {
4039
+ buildQueueStatusSummary,
4040
+ chalk,
4041
+ colorForHealth,
4042
+ escalateMergeQueueItem,
4043
+ enqueueMergeItem,
4044
+ evaluatePipelinePolicyGate,
4045
+ getDb,
4046
+ getRepo,
4047
+ healthLabel,
4048
+ listMergeQueue,
4049
+ listMergeQueueEvents,
4050
+ listWorktrees,
4051
+ maybeCaptureTelemetry,
4052
+ preparePipelineLandingTarget,
4053
+ printErrorWithNext,
4054
+ pushSyncEvent,
4055
+ renderChip,
4056
+ renderMetricRow,
4057
+ renderPanel,
4058
+ renderSignalStrip,
4059
+ removeMergeQueueItem,
4060
+ retryMergeQueueItem,
4061
+ runMergeQueue,
4062
+ sleepSync,
4063
+ statusBadge,
4064
+ });
4197
4065
 
4198
4066
  program
4199
4067
  .command('merge')
@@ -4331,82 +4199,6 @@ Examples:
4331
4199
  }
4332
4200
  });
4333
4201
 
4334
- queueCmd
4335
- .command('retry <itemId>')
4336
- .description('Retry a blocked merge queue item')
4337
- .option('--json', 'Output raw JSON')
4338
- .action((itemId, opts) => {
4339
- const repoRoot = getRepo();
4340
- const db = getDb(repoRoot);
4341
- const item = retryMergeQueueItem(db, itemId);
4342
- db.close();
4343
-
4344
- if (!item) {
4345
- printErrorWithNext(`Queue item ${itemId} is not retryable.`, 'switchman queue status');
4346
- process.exitCode = 1;
4347
- return;
4348
- }
4349
-
4350
- if (opts.json) {
4351
- console.log(JSON.stringify(item, null, 2));
4352
- return;
4353
- }
4354
-
4355
- console.log(`${chalk.green('✓')} Queue item ${chalk.cyan(item.id)} reset to retrying`);
4356
- });
4357
-
4358
- queueCmd
4359
- .command('escalate <itemId>')
4360
- .description('Mark a queue item as needing explicit operator review before landing')
4361
- .option('--reason <text>', 'Why this item is being escalated')
4362
- .option('--json', 'Output raw JSON')
4363
- .action((itemId, opts) => {
4364
- const repoRoot = getRepo();
4365
- const db = getDb(repoRoot);
4366
- const item = escalateMergeQueueItem(db, itemId, {
4367
- summary: opts.reason || null,
4368
- nextAction: `Run \`switchman explain queue ${itemId}\` to review the landing risk, then \`switchman queue retry ${itemId}\` when it is ready again.`,
4369
- });
4370
- db.close();
4371
-
4372
- if (!item) {
4373
- printErrorWithNext(`Queue item ${itemId} cannot be escalated.`, 'switchman queue status');
4374
- process.exitCode = 1;
4375
- return;
4376
- }
4377
-
4378
- if (opts.json) {
4379
- console.log(JSON.stringify(item, null, 2));
4380
- return;
4381
- }
4382
-
4383
- console.log(`${chalk.yellow('!')} Queue item ${chalk.cyan(item.id)} marked escalated for operator review`);
4384
- if (item.last_error_summary) {
4385
- console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
4386
- }
4387
- if (item.next_action) {
4388
- console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
4389
- }
4390
- });
4391
-
4392
- queueCmd
4393
- .command('remove <itemId>')
4394
- .description('Remove a merge queue item')
4395
- .action((itemId) => {
4396
- const repoRoot = getRepo();
4397
- const db = getDb(repoRoot);
4398
- const item = removeMergeQueueItem(db, itemId);
4399
- db.close();
4400
-
4401
- if (!item) {
4402
- printErrorWithNext(`Queue item ${itemId} does not exist.`, 'switchman queue status');
4403
- process.exitCode = 1;
4404
- return;
4405
- }
4406
-
4407
- console.log(`${chalk.green('✓')} Removed ${chalk.cyan(item.id)} from the merge queue`);
4408
- });
4409
-
4410
4202
  // ── explain ───────────────────────────────────────────────────────────────────
4411
4203
 
4412
4204
  const explainCmd = program.command('explain').description('Explain why Switchman blocked something and what to do next');
@@ -5451,317 +5243,37 @@ Examples:
5451
5243
 
5452
5244
  // ── lease ────────────────────────────────────────────────────────────────────
5453
5245
 
5454
- const leaseCmd = program.command('lease').alias('session').description('Manage active work sessions and keep long-running tasks alive');
5455
- leaseCmd.addHelpText('after', `
5456
- Plain English:
5457
- lease = a task currently checked out by an agent
5458
-
5459
- Examples:
5460
- switchman lease next --json
5461
- switchman lease heartbeat lease-123
5462
- switchman lease reap
5463
- `);
5464
-
5465
- leaseCmd
5466
- .command('acquire <taskId> <worktree>')
5467
- .description('Start a tracked work session for a specific pending task')
5468
- .option('--agent <name>', 'Agent identifier for logging')
5469
- .option('--json', 'Output as JSON')
5470
- .addHelpText('after', `
5471
- Examples:
5472
- switchman lease acquire task-123 agent2
5473
- switchman lease acquire task-123 agent2 --agent cursor
5474
- `)
5475
- .action((taskId, worktree, opts) => {
5476
- const repoRoot = getRepo();
5477
- const db = getDb(repoRoot);
5478
- const task = getTask(db, taskId);
5479
- const lease = startTaskLease(db, taskId, worktree, opts.agent || null);
5480
- db.close();
5481
-
5482
- if (!lease || !task) {
5483
- if (opts.json) console.log(JSON.stringify({ lease: null, task: null }));
5484
- else printErrorWithNext('Could not start a work session. The task may not exist or may already be in progress.', 'switchman task list --status pending');
5485
- process.exitCode = 1;
5486
- return;
5487
- }
5488
-
5489
- if (opts.json) {
5490
- console.log(JSON.stringify({
5491
- lease,
5492
- task: taskJsonWithLease(task, worktree, lease).task,
5493
- }, null, 2));
5494
- return;
5495
- }
5496
-
5497
- console.log(`${chalk.green('✓')} Lease acquired ${chalk.dim(lease.id)}`);
5498
- console.log(` ${chalk.dim('task:')} ${chalk.bold(task.title)}`);
5499
- console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(worktree)}`);
5500
- });
5501
-
5502
- leaseCmd
5503
- .command('next')
5504
- .description('Start the next pending task and open a tracked work session for it')
5505
- .option('--json', 'Output as JSON')
5506
- .option('--worktree <name>', 'Workspace to assign the task to (defaults to the current folder name)')
5507
- .option('--agent <name>', 'Agent identifier for logging')
5508
- .addHelpText('after', `
5509
- Examples:
5510
- switchman lease next
5511
- switchman lease next --json
5512
- switchman lease next --worktree agent2 --agent cursor
5513
- `)
5514
- .action((opts) => {
5515
- const repoRoot = getRepo();
5516
- const worktreeName = getCurrentWorktreeName(opts.worktree);
5517
- const { task, lease, exhausted } = acquireNextTaskLeaseWithRetries(repoRoot, worktreeName, opts.agent || null);
5518
-
5519
- if (!task) {
5520
- if (opts.json) console.log(JSON.stringify({ task: null, lease: null }));
5521
- else if (exhausted) console.log(chalk.dim('No pending tasks. Add one with `switchman task add "Your task"`.'));
5522
- else console.log(chalk.yellow('Tasks were claimed by other agents during assignment. Run again to get the next one.'));
5523
- return;
5524
- }
5525
-
5526
- if (!lease) {
5527
- if (opts.json) console.log(JSON.stringify({ task: null, lease: null, message: 'Task claimed by another agent — try again' }));
5528
- else console.log(chalk.yellow('Task was just claimed by another agent. Run again to get the next one.'));
5529
- return;
5530
- }
5531
-
5532
- if (opts.json) {
5533
- console.log(JSON.stringify({
5534
- lease,
5535
- ...taskJsonWithLease(task, worktreeName, lease),
5536
- }, null, 2));
5537
- return;
5538
- }
5539
-
5540
- console.log(`${chalk.green('✓')} Lease acquired: ${chalk.bold(task.title)}`);
5541
- pushSyncEvent('lease_acquired', { task_id: task.id, title: task.title }, { worktree: worktreeName }).catch(() => {});
5542
- console.log(` ${chalk.dim('task:')} ${task.id} ${chalk.dim('lease:')} ${lease.id}`);
5543
- console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(worktreeName)} ${chalk.dim('priority:')} ${task.priority}`);
5544
- });
5545
-
5546
- leaseCmd
5547
- .command('list')
5548
- .description('List leases, newest first')
5549
- .option('-s, --status <status>', 'Filter by status (active|completed|failed|expired)')
5550
- .action((opts) => {
5551
- const repoRoot = getRepo();
5552
- const db = getDb(repoRoot);
5553
- const leases = listLeases(db, opts.status);
5554
- db.close();
5555
-
5556
- if (!leases.length) {
5557
- console.log(chalk.dim('No leases found.'));
5558
- return;
5559
- }
5560
-
5561
- console.log('');
5562
- for (const lease of leases) {
5563
- console.log(`${statusBadge(lease.status)} ${chalk.bold(lease.task_title)}`);
5564
- console.log(` ${chalk.dim('lease:')} ${lease.id} ${chalk.dim('task:')} ${lease.task_id}`);
5565
- console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(lease.worktree)} ${chalk.dim('agent:')} ${lease.agent || 'unknown'}`);
5566
- console.log(` ${chalk.dim('started:')} ${lease.started_at} ${chalk.dim('heartbeat:')} ${lease.heartbeat_at}`);
5567
- if (lease.failure_reason) console.log(` ${chalk.red(lease.failure_reason)}`);
5568
- console.log('');
5569
- }
5570
- });
5571
-
5572
- leaseCmd
5573
- .command('heartbeat <leaseId>')
5574
- .description('Refresh the heartbeat timestamp for an active lease')
5575
- .option('--agent <name>', 'Agent identifier for logging')
5576
- .option('--json', 'Output as JSON')
5577
- .action((leaseId, opts) => {
5578
- const repoRoot = getRepo();
5579
- const db = getDb(repoRoot);
5580
- const lease = heartbeatLease(db, leaseId, opts.agent || null);
5581
- db.close();
5582
-
5583
- if (!lease) {
5584
- if (opts.json) console.log(JSON.stringify({ lease: null }));
5585
- else printErrorWithNext(`No active work session found for ${leaseId}.`, 'switchman lease list --status active');
5586
- process.exitCode = 1;
5587
- return;
5588
- }
5589
-
5590
- if (opts.json) {
5591
- console.log(JSON.stringify({ lease }, null, 2));
5592
- return;
5593
- }
5594
-
5595
- console.log(`${chalk.green('✓')} Heartbeat refreshed for ${chalk.dim(lease.id)}`);
5596
- console.log(` ${chalk.dim('task:')} ${lease.task_title} ${chalk.dim('worktree:')} ${chalk.cyan(lease.worktree)}`);
5597
- });
5598
-
5599
- leaseCmd
5600
- .command('reap')
5601
- .description('Clean up abandoned work sessions and release their file locks')
5602
- .option('--stale-after-minutes <minutes>', 'Age threshold for staleness')
5603
- .option('--json', 'Output as JSON')
5604
- .addHelpText('after', `
5605
- Examples:
5606
- switchman lease reap
5607
- switchman lease reap --stale-after-minutes 20
5608
- `)
5609
- .action((opts) => {
5610
- const repoRoot = getRepo();
5611
- const db = getDb(repoRoot);
5612
- const leasePolicy = loadLeasePolicy(repoRoot);
5613
- const staleAfterMinutes = opts.staleAfterMinutes
5614
- ? Number.parseInt(opts.staleAfterMinutes, 10)
5615
- : leasePolicy.stale_after_minutes;
5616
- const expired = reapStaleLeases(db, staleAfterMinutes, {
5617
- requeueTask: leasePolicy.requeue_task_on_reap,
5618
- });
5619
- db.close();
5620
-
5621
- if (opts.json) {
5622
- console.log(JSON.stringify({ stale_after_minutes: staleAfterMinutes, expired }, null, 2));
5623
- return;
5624
- }
5625
-
5626
- if (!expired.length) {
5627
- console.log(chalk.dim(`No stale leases older than ${staleAfterMinutes} minute(s).`));
5628
- return;
5629
- }
5630
-
5631
- console.log(`${chalk.green('✓')} Reaped ${expired.length} stale lease(s)`);
5632
- for (const lease of expired) {
5633
- console.log(` ${chalk.dim(lease.id)} ${chalk.cyan(lease.worktree)} → ${lease.task_title}`);
5634
- }
5635
- });
5636
-
5637
- const leasePolicyCmd = leaseCmd.command('policy').description('Inspect or update the stale-lease policy for this repo');
5638
-
5639
- leasePolicyCmd
5640
- .command('set')
5641
- .description('Persist a stale-lease policy for this repo')
5642
- .option('--heartbeat-interval-seconds <seconds>', 'Recommended heartbeat interval')
5643
- .option('--stale-after-minutes <minutes>', 'Age threshold for staleness')
5644
- .option('--reap-on-status-check <boolean>', 'Automatically reap stale leases during `switchman status`')
5645
- .option('--requeue-task-on-reap <boolean>', 'Return stale tasks to pending instead of failing them')
5646
- .option('--json', 'Output as JSON')
5647
- .action((opts) => {
5648
- const repoRoot = getRepo();
5649
- const current = loadLeasePolicy(repoRoot);
5650
- const next = {
5651
- ...current,
5652
- ...(opts.heartbeatIntervalSeconds ? { heartbeat_interval_seconds: Number.parseInt(opts.heartbeatIntervalSeconds, 10) } : {}),
5653
- ...(opts.staleAfterMinutes ? { stale_after_minutes: Number.parseInt(opts.staleAfterMinutes, 10) } : {}),
5654
- ...(opts.reapOnStatusCheck ? { reap_on_status_check: opts.reapOnStatusCheck === 'true' } : {}),
5655
- ...(opts.requeueTaskOnReap ? { requeue_task_on_reap: opts.requeueTaskOnReap === 'true' } : {}),
5656
- };
5657
- const path = writeLeasePolicy(repoRoot, next);
5658
- const saved = loadLeasePolicy(repoRoot);
5659
-
5660
- if (opts.json) {
5661
- console.log(JSON.stringify({ path, policy: saved }, null, 2));
5662
- return;
5663
- }
5664
-
5665
- console.log(`${chalk.green('✓')} Lease policy updated`);
5666
- console.log(` ${chalk.dim(path)}`);
5667
- console.log(` ${chalk.dim('heartbeat_interval_seconds:')} ${saved.heartbeat_interval_seconds}`);
5668
- console.log(` ${chalk.dim('stale_after_minutes:')} ${saved.stale_after_minutes}`);
5669
- console.log(` ${chalk.dim('reap_on_status_check:')} ${saved.reap_on_status_check}`);
5670
- console.log(` ${chalk.dim('requeue_task_on_reap:')} ${saved.requeue_task_on_reap}`);
5671
- });
5672
-
5673
- leasePolicyCmd
5674
- .description('Show the active stale-lease policy for this repo')
5675
- .option('--json', 'Output as JSON')
5676
- .action((opts) => {
5677
- const repoRoot = getRepo();
5678
- const policy = loadLeasePolicy(repoRoot);
5679
- if (opts.json) {
5680
- console.log(JSON.stringify({ policy }, null, 2));
5681
- return;
5682
- }
5683
-
5684
- console.log(chalk.bold('Lease policy'));
5685
- console.log(` ${chalk.dim('heartbeat_interval_seconds:')} ${policy.heartbeat_interval_seconds}`);
5686
- console.log(` ${chalk.dim('stale_after_minutes:')} ${policy.stale_after_minutes}`);
5687
- console.log(` ${chalk.dim('reap_on_status_check:')} ${policy.reap_on_status_check}`);
5688
- console.log(` ${chalk.dim('requeue_task_on_reap:')} ${policy.requeue_task_on_reap}`);
5689
- });
5246
+ registerLeaseCommands(program, {
5247
+ acquireNextTaskLeaseWithRetries,
5248
+ chalk,
5249
+ getCurrentWorktreeName,
5250
+ getDb,
5251
+ getRepo,
5252
+ getTask,
5253
+ heartbeatLease,
5254
+ listLeases,
5255
+ loadLeasePolicy,
5256
+ pushSyncEvent,
5257
+ reapStaleLeases,
5258
+ startTaskLease,
5259
+ statusBadge,
5260
+ taskJsonWithLease,
5261
+ writeLeasePolicy,
5262
+ });
5690
5263
 
5691
5264
  // ── worktree ───────────────────────────────────────────────────────────────────
5692
5265
 
5693
- const wtCmd = program.command('worktree').alias('workspace').description('Manage registered workspaces (Git worktrees)');
5694
- wtCmd.addHelpText('after', `
5695
- Plain English:
5696
- worktree = the Git feature behind each agent workspace
5697
-
5698
- Examples:
5699
- switchman worktree list
5700
- switchman workspace list
5701
- switchman worktree sync
5702
- `);
5703
-
5704
- wtCmd
5705
- .command('add <name> <path> <branch>')
5706
- .description('Register a workspace with Switchman')
5707
- .option('--agent <name>', 'Agent assigned to this worktree')
5708
- .action((name, path, branch, opts) => {
5709
- const repoRoot = getRepo();
5710
- const db = getDb(repoRoot);
5711
- registerWorktree(db, { name, path, branch, agent: opts.agent });
5712
- db.close();
5713
- console.log(`${chalk.green('✓')} Registered worktree: ${chalk.cyan(name)}`);
5714
- });
5715
-
5716
- wtCmd
5717
- .command('list')
5718
- .description('List all registered workspaces')
5719
- .action(() => {
5720
- const repoRoot = getRepo();
5721
- const db = getDb(repoRoot);
5722
- const worktrees = listWorktrees(db);
5723
- const gitWorktrees = listGitWorktrees(repoRoot);
5724
-
5725
- if (!worktrees.length && !gitWorktrees.length) {
5726
- db.close();
5727
- console.log(chalk.dim('No workspaces found. Run `switchman setup --agents 3` or `switchman worktree sync`.'));
5728
- return;
5729
- }
5730
-
5731
- // Show git worktrees (source of truth) annotated with db info
5732
- const complianceReport = evaluateRepoCompliance(db, repoRoot, gitWorktrees);
5733
- console.log('');
5734
- console.log(chalk.bold('Git Worktrees:'));
5735
- for (const wt of gitWorktrees) {
5736
- const dbInfo = worktrees.find(d => d.path === wt.path);
5737
- const complianceInfo = complianceReport.worktreeCompliance.find((entry) => entry.worktree === wt.name) || null;
5738
- const agent = dbInfo?.agent ? chalk.cyan(dbInfo.agent) : chalk.dim('no agent');
5739
- const status = dbInfo?.status ? statusBadge(dbInfo.status) : chalk.dim('unregistered');
5740
- const compliance = complianceInfo?.compliance_state ? statusBadge(complianceInfo.compliance_state) : dbInfo?.compliance_state ? statusBadge(dbInfo.compliance_state) : chalk.dim('unknown');
5741
- console.log(` ${chalk.bold(wt.name.padEnd(20))} ${status} ${compliance} branch: ${chalk.cyan(wt.branch || 'unknown')} agent: ${agent}`);
5742
- console.log(` ${chalk.dim(wt.path)}`);
5743
- if ((complianceInfo?.unclaimed_changed_files || []).length > 0) {
5744
- console.log(` ${chalk.red('files:')} ${complianceInfo.unclaimed_changed_files.slice(0, 5).join(', ')}${complianceInfo.unclaimed_changed_files.length > 5 ? ` ${chalk.dim(`+${complianceInfo.unclaimed_changed_files.length - 5} more`)}` : ''}`);
5745
- }
5746
- }
5747
- console.log('');
5748
- db.close();
5749
- });
5750
-
5751
- wtCmd
5752
- .command('sync')
5753
- .description('Sync Git workspaces into the Switchman database')
5754
- .action(() => {
5755
- const repoRoot = getRepo();
5756
- const db = getDb(repoRoot);
5757
- const gitWorktrees = listGitWorktrees(repoRoot);
5758
- for (const wt of gitWorktrees) {
5759
- registerWorktree(db, { name: wt.name, path: wt.path, branch: wt.branch || 'unknown' });
5760
- }
5761
- db.close();
5762
- installMcpConfig([...new Set([repoRoot, ...gitWorktrees.map((wt) => wt.path)])]);
5763
- console.log(`${chalk.green('✓')} Synced ${gitWorktrees.length} worktree(s) from git`);
5764
- });
5266
+ registerWorktreeCommands(program, {
5267
+ chalk,
5268
+ evaluateRepoCompliance,
5269
+ getDb,
5270
+ getRepo,
5271
+ installMcpConfig,
5272
+ listGitWorktrees,
5273
+ listWorktrees,
5274
+ registerWorktree,
5275
+ statusBadge,
5276
+ });
5765
5277
 
5766
5278
  // ── claim ──────────────────────────────────────────────────────────────────────
5767
5279
 
@@ -6183,11 +5695,47 @@ Use this first when the repo feels stuck.
6183
5695
  sleepSync(watchIntervalMs);
6184
5696
  }
6185
5697
 
6186
- if (watch) {
6187
- await maybeCaptureTelemetry('status_watch_used', {
6188
- cycles,
6189
- interval_ms: watchIntervalMs,
5698
+ if (watch) {
5699
+ await maybeCaptureTelemetry('status_watch_used', {
5700
+ cycles,
5701
+ interval_ms: watchIntervalMs,
5702
+ });
5703
+ }
5704
+ });
5705
+
5706
+ program
5707
+ .command('recover')
5708
+ .description('Recover abandoned agent work, repair safe interrupted state, and point at the right checkpoint')
5709
+ .option('--stale-after-minutes <minutes>', 'Age threshold for stale lease recovery')
5710
+ .option('--json', 'Output raw JSON')
5711
+ .addHelpText('after', `
5712
+ Examples:
5713
+ switchman recover
5714
+ switchman recover --stale-after-minutes 20
5715
+ switchman recover --json
5716
+
5717
+ Use this when an agent crashed, a worktree was abandoned mid-task, or the repo feels stuck after interrupted work.
5718
+ `)
5719
+ .action((opts) => {
5720
+ const repoRoot = getRepo();
5721
+ const db = getDb(repoRoot);
5722
+
5723
+ try {
5724
+ const report = buildRecoverReport(db, repoRoot, {
5725
+ staleAfterMinutes: opts.staleAfterMinutes || null,
6190
5726
  });
5727
+ db.close();
5728
+
5729
+ if (opts.json) {
5730
+ console.log(JSON.stringify(report, null, 2));
5731
+ return;
5732
+ }
5733
+
5734
+ printRecoverSummary(report);
5735
+ } catch (err) {
5736
+ db.close();
5737
+ printErrorWithNext(err.message, 'switchman status');
5738
+ process.exitCode = 1;
6191
5739
  }
6192
5740
  });
6193
5741
 
@@ -6362,334 +5910,29 @@ Examples:
6362
5910
 
6363
5911
  // ── gate ─────────────────────────────────────────────────────────────────────
6364
5912
 
6365
- const gateCmd = program.command('gate').description('Safety checks for edits, merges, and CI');
6366
- gateCmd.addHelpText('after', `
6367
- Examples:
6368
- switchman gate ci
6369
- switchman gate ai
6370
- switchman gate install-ci
6371
- `);
6372
-
6373
- const auditCmd = program.command('audit').description('Inspect and verify the tamper-evident audit trail');
6374
- auditCmd._switchmanAdvanced = true;
6375
-
6376
- auditCmd
6377
- .command('change <pipelineId>')
6378
- .description('Show a signed, operator-friendly history for one pipeline')
6379
- .option('--json', 'Output raw JSON')
6380
- .action((pipelineId, options) => {
6381
- const repoRoot = getRepo();
6382
- const db = getDb(repoRoot);
6383
-
6384
- try {
6385
- const report = buildPipelineHistoryReport(db, repoRoot, pipelineId);
6386
- db.close();
6387
-
6388
- if (options.json) {
6389
- console.log(JSON.stringify(report, null, 2));
6390
- return;
6391
- }
6392
-
6393
- console.log(chalk.bold(`Audit history for pipeline ${report.pipeline_id}`));
6394
- console.log(` ${chalk.dim('title:')} ${report.title}`);
6395
- console.log(` ${chalk.dim('events:')} ${report.events.length}`);
6396
- console.log(` ${chalk.yellow('next:')} ${report.next_action}`);
6397
- for (const event of report.events.slice(-20)) {
6398
- const status = event.status ? ` ${statusBadge(event.status).trim()}` : '';
6399
- console.log(` ${chalk.dim(event.created_at)} ${chalk.cyan(event.label)}${status}`);
6400
- console.log(` ${event.summary}`);
6401
- }
6402
- } catch (err) {
6403
- db.close();
6404
- printErrorWithNext(err.message, `switchman pipeline status ${pipelineId}`);
6405
- process.exitCode = 1;
6406
- }
6407
- });
6408
-
6409
- auditCmd
6410
- .command('verify')
6411
- .description('Verify the audit log hash chain and project signatures')
6412
- .option('--json', 'Output verification details as JSON')
6413
- .action((options) => {
6414
- const repo = getRepo();
6415
- const db = getDb(repo);
6416
- const result = verifyAuditTrail(db);
6417
-
6418
- if (options.json) {
6419
- console.log(JSON.stringify(result, null, 2));
6420
- process.exit(result.ok ? 0 : 1);
6421
- }
6422
-
6423
- if (result.ok) {
6424
- console.log(chalk.green(`Audit trail verified: ${result.count} signed events in order.`));
6425
- return;
6426
- }
6427
-
6428
- console.log(chalk.red(`Audit trail verification failed: ${result.failures.length} problem(s) across ${result.count} events.`));
6429
- for (const failure of result.failures.slice(0, 10)) {
6430
- const prefix = failure.sequence ? `#${failure.sequence}` : `event ${failure.id}`;
6431
- console.log(` ${chalk.red(prefix)} ${failure.reason_code}: ${failure.message}`);
6432
- }
6433
- if (result.failures.length > 10) {
6434
- console.log(chalk.dim(` ...and ${result.failures.length - 10} more`));
6435
- }
6436
- process.exit(1);
6437
- });
6438
-
6439
- gateCmd
6440
- .command('commit')
6441
- .description('Validate current worktree changes against the active lease and claims')
6442
- .option('--json', 'Output raw JSON')
6443
- .action((opts) => {
6444
- const repoRoot = getRepo();
6445
- const db = getDb(repoRoot);
6446
- const result = runCommitGate(db, repoRoot);
6447
- db.close();
6448
-
6449
- if (opts.json) {
6450
- console.log(JSON.stringify(result, null, 2));
6451
- } else if (result.ok) {
6452
- console.log(`${chalk.green('✓')} ${result.summary}`);
6453
- } else {
6454
- console.log(chalk.red(`✗ ${result.summary}`));
6455
- for (const violation of result.violations) {
6456
- const label = violation.file || '(worktree)';
6457
- console.log(` ${chalk.yellow(label)} ${chalk.dim(violation.reason_code)}`);
6458
- }
6459
- }
6460
-
6461
- if (!result.ok) process.exitCode = 1;
6462
- });
6463
-
6464
- gateCmd
6465
- .command('merge')
6466
- .description('Validate current worktree changes before recording a merge commit')
6467
- .option('--json', 'Output raw JSON')
6468
- .action((opts) => {
6469
- const repoRoot = getRepo();
6470
- const db = getDb(repoRoot);
6471
- const result = runCommitGate(db, repoRoot);
6472
- db.close();
6473
-
6474
- if (opts.json) {
6475
- console.log(JSON.stringify(result, null, 2));
6476
- } else if (result.ok) {
6477
- console.log(`${chalk.green('✓')} Merge gate passed for ${chalk.cyan(result.worktree || 'current worktree')}.`);
6478
- } else {
6479
- console.log(chalk.red(`✗ Merge gate rejected changes in ${chalk.cyan(result.worktree || 'current worktree')}.`));
6480
- for (const violation of result.violations) {
6481
- const label = violation.file || '(worktree)';
6482
- console.log(` ${chalk.yellow(label)} ${chalk.dim(violation.reason_code)}`);
6483
- }
6484
- }
6485
-
6486
- if (!result.ok) process.exitCode = 1;
6487
- });
6488
-
6489
- gateCmd
6490
- .command('install')
6491
- .description('Install git hooks that run the Switchman commit and merge gates')
6492
- .action(() => {
6493
- const repoRoot = getRepo();
6494
- const hookPaths = installGateHooks(repoRoot);
6495
- console.log(`${chalk.green('✓')} Installed pre-commit hook at ${chalk.cyan(hookPaths.pre_commit)}`);
6496
- console.log(`${chalk.green('✓')} Installed pre-merge-commit hook at ${chalk.cyan(hookPaths.pre_merge_commit)}`);
6497
- });
6498
-
6499
- gateCmd
6500
- .command('ci')
6501
- .description('Run a repo-level enforcement gate suitable for CI, merges, or PR validation')
6502
- .option('--github', 'Write GitHub Actions step summary/output when GITHUB_* env vars are present')
6503
- .option('--github-step-summary <path>', 'Path to write GitHub Actions step summary markdown')
6504
- .option('--github-output <path>', 'Path to write GitHub Actions outputs')
6505
- .option('--json', 'Output raw JSON')
6506
- .action(async (opts) => {
6507
- const repoRoot = getRepo();
6508
- const db = getDb(repoRoot);
6509
- const report = await scanAllWorktrees(db, repoRoot);
6510
- const aiGate = await runAiMergeGate(db, repoRoot);
6511
- db.close();
6512
-
6513
- const ok = report.conflicts.length === 0
6514
- && report.fileConflicts.length === 0
6515
- && (report.ownershipConflicts?.length || 0) === 0
6516
- && (report.semanticConflicts?.length || 0) === 0
6517
- && report.unclaimedChanges.length === 0
6518
- && report.complianceSummary.non_compliant === 0
6519
- && report.complianceSummary.stale === 0
6520
- && aiGate.status !== 'blocked'
6521
- && (aiGate.dependency_invalidations?.filter((item) => item.severity === 'blocked').length || 0) === 0;
6522
-
6523
- const result = {
6524
- ok,
6525
- summary: ok
6526
- ? `Repo gate passed for ${report.worktrees.length} worktree(s).`
6527
- : 'Repo gate rejected unmanaged changes, stale leases, ownership conflicts, stale dependency invalidations, or boundary validation failures.',
6528
- compliance: report.complianceSummary,
6529
- unclaimed_changes: report.unclaimedChanges,
6530
- file_conflicts: report.fileConflicts,
6531
- ownership_conflicts: report.ownershipConflicts || [],
6532
- semantic_conflicts: report.semanticConflicts || [],
6533
- branch_conflicts: report.conflicts,
6534
- ai_gate_status: aiGate.status,
6535
- boundary_validations: aiGate.boundary_validations || [],
6536
- dependency_invalidations: aiGate.dependency_invalidations || [],
6537
- };
6538
-
6539
- const githubTargets = resolveGitHubOutputTargets(opts);
6540
- if (githubTargets.stepSummaryPath || githubTargets.outputPath) {
6541
- writeGitHubCiStatus({
6542
- result,
6543
- stepSummaryPath: githubTargets.stepSummaryPath,
6544
- outputPath: githubTargets.outputPath,
6545
- });
6546
- }
6547
-
6548
- if (opts.json) {
6549
- console.log(JSON.stringify(result, null, 2));
6550
- } else if (ok) {
6551
- console.log(`${chalk.green('✓')} ${result.summary}`);
6552
- } else {
6553
- console.log(chalk.red(`✗ ${result.summary}`));
6554
- if (result.unclaimed_changes.length > 0) {
6555
- console.log(chalk.bold(' Unclaimed changes:'));
6556
- for (const entry of result.unclaimed_changes) {
6557
- console.log(` ${chalk.cyan(entry.worktree)}: ${entry.files.join(', ')}`);
6558
- }
6559
- }
6560
- if (result.file_conflicts.length > 0) {
6561
- console.log(chalk.bold(' File conflicts:'));
6562
- for (const conflict of result.file_conflicts) {
6563
- console.log(` ${chalk.yellow(conflict.file)} ${chalk.dim(conflict.worktrees.join(', '))}`);
6564
- }
6565
- }
6566
- if (result.ownership_conflicts.length > 0) {
6567
- console.log(chalk.bold(' Ownership conflicts:'));
6568
- for (const conflict of result.ownership_conflicts) {
6569
- if (conflict.type === 'subsystem_overlap') {
6570
- console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)} ${chalk.dim(`subsystem:${conflict.subsystemTag}`)}`);
6571
- } else {
6572
- console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)} ${chalk.dim(`${conflict.scopeA} ↔ ${conflict.scopeB}`)}`);
6573
- }
6574
- }
6575
- }
6576
- if (result.semantic_conflicts.length > 0) {
6577
- console.log(chalk.bold(' Semantic conflicts:'));
6578
- for (const conflict of result.semantic_conflicts) {
6579
- console.log(` ${chalk.yellow(conflict.object_name)} ${chalk.dim(`${conflict.worktreeA} vs ${conflict.worktreeB}`)}`);
6580
- }
6581
- }
6582
- if (result.branch_conflicts.length > 0) {
6583
- console.log(chalk.bold(' Branch conflicts:'));
6584
- for (const conflict of result.branch_conflicts) {
6585
- console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)}`);
6586
- }
6587
- }
6588
- if (result.boundary_validations.length > 0) {
6589
- console.log(chalk.bold(' Boundary validations:'));
6590
- for (const validation of result.boundary_validations) {
6591
- console.log(` ${chalk.yellow(validation.task_id)} ${chalk.dim(validation.missing_task_types.join(', '))}`);
6592
- }
6593
- }
6594
- if (result.dependency_invalidations.length > 0) {
6595
- console.log(chalk.bold(' Stale dependency invalidations:'));
6596
- for (const invalidation of result.dependency_invalidations) {
6597
- console.log(` ${chalk.yellow(invalidation.affected_task_id)} ${chalk.dim(invalidation.stale_area)}`);
6598
- }
6599
- }
6600
- }
6601
-
6602
- await maybeCaptureTelemetry(ok ? 'gate_ci_passed' : 'gate_ci_failed', {
6603
- worktree_count: report.worktrees.length,
6604
- unclaimed_change_count: result.unclaimed_changes.length,
6605
- file_conflict_count: result.file_conflicts.length,
6606
- ownership_conflict_count: result.ownership_conflicts.length,
6607
- semantic_conflict_count: result.semantic_conflicts.length,
6608
- branch_conflict_count: result.branch_conflicts.length,
6609
- });
6610
-
6611
- if (!ok) process.exitCode = 1;
6612
- });
6613
-
6614
- gateCmd
6615
- .command('install-ci')
6616
- .description('Install a GitHub Actions workflow that runs the Switchman CI gate on PRs and pushes')
6617
- .option('--workflow-name <name>', 'Workflow file name', 'switchman-gate.yml')
6618
- .action((opts) => {
6619
- const repoRoot = getRepo();
6620
- const workflowPath = installGitHubActionsWorkflow(repoRoot, opts.workflowName);
6621
- console.log(`${chalk.green('✓')} Installed GitHub Actions workflow at ${chalk.cyan(workflowPath)}`);
6622
- });
6623
-
6624
- gateCmd
6625
- .command('ai')
6626
- .description('Run the AI-style merge check to assess risky overlap across workspaces')
6627
- .option('--json', 'Output raw JSON')
6628
- .action(async (opts) => {
6629
- const repoRoot = getRepo();
6630
- const db = getDb(repoRoot);
6631
- const result = await runAiMergeGate(db, repoRoot);
6632
- db.close();
6633
-
6634
- if (opts.json) {
6635
- console.log(JSON.stringify(result, null, 2));
6636
- } else {
6637
- const badge = result.status === 'pass'
6638
- ? chalk.green('PASS')
6639
- : result.status === 'warn'
6640
- ? chalk.yellow('WARN')
6641
- : chalk.red('BLOCK');
6642
- console.log(`${badge} ${result.summary}`);
6643
-
6644
- const riskyPairs = result.pairs.filter((pair) => pair.status !== 'pass');
6645
- if (riskyPairs.length > 0) {
6646
- console.log(chalk.bold(' Risky pairs:'));
6647
- for (const pair of riskyPairs) {
6648
- console.log(` ${chalk.cyan(pair.worktree_a)} ${chalk.dim('vs')} ${chalk.cyan(pair.worktree_b)} ${chalk.dim(pair.status)} ${chalk.dim(`score=${pair.score}`)}`);
6649
- for (const reason of pair.reasons.slice(0, 3)) {
6650
- console.log(` ${chalk.yellow(reason)}`);
6651
- }
6652
- }
6653
- }
6654
-
6655
- if ((result.boundary_validations?.length || 0) > 0) {
6656
- console.log(chalk.bold(' Boundary validations:'));
6657
- for (const validation of result.boundary_validations.slice(0, 5)) {
6658
- console.log(` ${chalk.cyan(validation.task_id)} ${chalk.dim(validation.severity)} ${chalk.dim(validation.missing_task_types.join(', '))}`);
6659
- if (validation.rationale?.[0]) {
6660
- console.log(` ${chalk.yellow(validation.rationale[0])}`);
6661
- }
6662
- }
6663
- }
6664
-
6665
- if ((result.dependency_invalidations?.length || 0) > 0) {
6666
- console.log(chalk.bold(' Stale dependency invalidations:'));
6667
- for (const invalidation of result.dependency_invalidations.slice(0, 5)) {
6668
- console.log(` ${chalk.cyan(invalidation.affected_task_id)} ${chalk.dim(invalidation.severity)} ${chalk.dim(invalidation.stale_area)}`);
6669
- }
6670
- }
6671
-
6672
- if ((result.semantic_conflicts?.length || 0) > 0) {
6673
- console.log(chalk.bold(' Semantic conflicts:'));
6674
- for (const conflict of result.semantic_conflicts.slice(0, 5)) {
6675
- console.log(` ${chalk.cyan(conflict.object_name)} ${chalk.dim(conflict.type)} ${chalk.dim(`${conflict.worktreeA} vs ${conflict.worktreeB}`)}`);
6676
- }
6677
- }
6678
-
6679
- const riskyWorktrees = result.worktrees.filter((worktree) => worktree.findings.length > 0);
6680
- if (riskyWorktrees.length > 0) {
6681
- console.log(chalk.bold(' Worktree signals:'));
6682
- for (const worktree of riskyWorktrees) {
6683
- console.log(` ${chalk.cyan(worktree.worktree)} ${chalk.dim(`score=${worktree.score}`)}`);
6684
- for (const finding of worktree.findings.slice(0, 2)) {
6685
- console.log(` ${chalk.yellow(finding)}`);
6686
- }
6687
- }
6688
- }
6689
- }
5913
+ registerAuditCommands(program, {
5914
+ buildPipelineHistoryReport,
5915
+ chalk,
5916
+ getDb,
5917
+ getRepo,
5918
+ printErrorWithNext,
5919
+ statusBadge,
5920
+ verifyAuditTrail,
5921
+ });
6690
5922
 
6691
- if (result.status === 'blocked') process.exitCode = 1;
6692
- });
5923
+ registerGateCommands(program, {
5924
+ chalk,
5925
+ getDb,
5926
+ getRepo,
5927
+ installGateHooks,
5928
+ installGitHubActionsWorkflow,
5929
+ maybeCaptureTelemetry,
5930
+ resolveGitHubOutputTargets,
5931
+ runAiMergeGate,
5932
+ runCommitGate,
5933
+ scanAllWorktrees,
5934
+ writeGitHubCiStatus,
5935
+ });
6693
5936
 
6694
5937
  const semanticCmd = program
6695
5938
  .command('semantic')
@@ -6792,180 +6035,20 @@ objectCmd
6792
6035
 
6793
6036
  // ── monitor ──────────────────────────────────────────────────────────────────
6794
6037
 
6795
- const monitorCmd = program.command('monitor').description('Observe workspaces for runtime file changes');
6796
- monitorCmd._switchmanAdvanced = true;
6797
-
6798
- monitorCmd
6799
- .command('once')
6800
- .description('Capture one monitoring pass and log observed file changes')
6801
- .option('--json', 'Output raw JSON')
6802
- .option('--quarantine', 'Move or restore denied runtime changes immediately after detection')
6803
- .action((opts) => {
6804
- const repoRoot = getRepo();
6805
- const db = getDb(repoRoot);
6806
- const worktrees = resolveMonitoredWorktrees(db, repoRoot);
6807
- const result = monitorWorktreesOnce(db, repoRoot, worktrees, { quarantine: opts.quarantine });
6808
- db.close();
6809
-
6810
- if (opts.json) {
6811
- console.log(JSON.stringify(result, null, 2));
6812
- return;
6813
- }
6814
-
6815
- if (result.events.length === 0) {
6816
- console.log(chalk.dim('No file changes observed since the last monitor snapshot.'));
6817
- return;
6818
- }
6819
-
6820
- console.log(`${chalk.green('✓')} Observed ${result.summary.total} file change(s)`);
6821
- for (const event of result.events) {
6822
- renderMonitorEvent(event);
6823
- }
6824
- });
6825
-
6826
- monitorCmd
6827
- .command('watch')
6828
- .description('Poll workspaces continuously and log observed file changes')
6829
- .option('--interval-ms <ms>', 'Polling interval in milliseconds', '2000')
6830
- .option('--quarantine', 'Move or restore denied runtime changes immediately after detection')
6831
- .option('--daemonized', 'Internal flag used by monitor start', false)
6832
- .action(async (opts) => {
6833
- const repoRoot = getRepo();
6834
- const intervalMs = Number.parseInt(opts.intervalMs, 10);
6835
-
6836
- if (!Number.isFinite(intervalMs) || intervalMs < 100) {
6837
- console.error(chalk.red('--interval-ms must be at least 100'));
6838
- process.exit(1);
6839
- }
6840
-
6841
- console.log(chalk.cyan(`Watching workspaces every ${intervalMs}ms. Press Ctrl+C to stop.`));
6842
-
6843
- let stopped = false;
6844
- const stop = () => {
6845
- stopped = true;
6846
- process.stdout.write('\n');
6847
- if (opts.daemonized) {
6848
- clearMonitorState(repoRoot);
6849
- }
6850
- };
6851
- process.on('SIGINT', stop);
6852
- process.on('SIGTERM', stop);
6853
-
6854
- while (!stopped) {
6855
- const db = getDb(repoRoot);
6856
- const worktrees = resolveMonitoredWorktrees(db, repoRoot);
6857
- const result = monitorWorktreesOnce(db, repoRoot, worktrees, { quarantine: opts.quarantine });
6858
- db.close();
6859
-
6860
- for (const event of result.events) {
6861
- renderMonitorEvent(event);
6862
- }
6863
-
6864
- if (stopped) break;
6865
- await new Promise((resolvePromise) => setTimeout(resolvePromise, intervalMs));
6866
- }
6867
-
6868
- console.log(chalk.dim('Stopped worktree monitor.'));
6869
- });
6870
-
6871
- monitorCmd
6872
- .command('start')
6873
- .description('Start the worktree monitor as a background process')
6874
- .option('--interval-ms <ms>', 'Polling interval in milliseconds', '2000')
6875
- .option('--quarantine', 'Move or restore denied runtime changes immediately after detection')
6876
- .action((opts) => {
6877
- const repoRoot = getRepo();
6878
- const intervalMs = Number.parseInt(opts.intervalMs, 10);
6879
- const state = startBackgroundMonitor(repoRoot, {
6880
- intervalMs,
6881
- quarantine: Boolean(opts.quarantine),
6882
- });
6883
-
6884
- if (state.already_running) {
6885
- console.log(chalk.yellow(`Monitor already running with pid ${state.state.pid}`));
6886
- return;
6887
- }
6888
-
6889
- console.log(`${chalk.green('✓')} Started monitor pid ${chalk.cyan(String(state.state.pid))}`);
6890
- console.log(`${chalk.dim('State:')} ${state.state_path}`);
6891
- });
6892
-
6893
- monitorCmd
6894
- .command('stop')
6895
- .description('Stop the background worktree monitor')
6896
- .action(() => {
6897
- const repoRoot = getRepo();
6898
- const state = readMonitorState(repoRoot);
6899
-
6900
- if (!state) {
6901
- console.log(chalk.dim('Monitor is not running.'));
6902
- return;
6903
- }
6904
-
6905
- if (!isProcessRunning(state.pid)) {
6906
- clearMonitorState(repoRoot);
6907
- console.log(chalk.dim('Monitor state was stale and has been cleared.'));
6908
- return;
6909
- }
6910
-
6911
- process.kill(state.pid, 'SIGTERM');
6912
- clearMonitorState(repoRoot);
6913
- console.log(`${chalk.green('✓')} Stopped monitor pid ${chalk.cyan(String(state.pid))}`);
6914
- });
6915
-
6916
- monitorCmd
6917
- .command('status')
6918
- .description('Show background monitor process status')
6919
- .action(() => {
6920
- const repoRoot = getRepo();
6921
- const state = readMonitorState(repoRoot);
6922
-
6923
- if (!state) {
6924
- console.log(chalk.dim('Monitor is not running.'));
6925
- return;
6926
- }
6927
-
6928
- const running = isProcessRunning(state.pid);
6929
- if (!running) {
6930
- clearMonitorState(repoRoot);
6931
- console.log(chalk.yellow('Monitor state existed but the process is no longer running.'));
6932
- return;
6933
- }
6934
-
6935
- console.log(`${chalk.green('✓')} Monitor running`);
6936
- console.log(` ${chalk.dim('pid')} ${state.pid}`);
6937
- console.log(` ${chalk.dim('interval_ms')} ${state.interval_ms}`);
6938
- console.log(` ${chalk.dim('quarantine')} ${state.quarantine ? 'true' : 'false'}`);
6939
- console.log(` ${chalk.dim('started_at')} ${state.started_at}`);
6940
- });
6941
-
6942
- program
6943
- .command('watch')
6944
- .description('Watch worktrees for direct writes and rogue edits in real time')
6945
- .option('--interval-ms <ms>', 'Polling interval in milliseconds', '2000')
6946
- .option('--quarantine', 'Move or restore denied runtime changes immediately after detection')
6947
- .action(async (opts) => {
6948
- const repoRoot = getRepo();
6949
- const child = spawn(process.execPath, [
6950
- process.argv[1],
6951
- 'monitor',
6952
- 'watch',
6953
- '--interval-ms',
6954
- String(opts.intervalMs || '2000'),
6955
- ...(opts.quarantine ? ['--quarantine'] : []),
6956
- ], {
6957
- cwd: repoRoot,
6958
- stdio: 'inherit',
6959
- });
6960
-
6961
- await new Promise((resolve, reject) => {
6962
- child.on('exit', (code) => {
6963
- process.exitCode = code ?? 0;
6964
- resolve();
6965
- });
6966
- child.on('error', reject);
6967
- });
6968
- });
6038
+ registerMonitorCommands(program, {
6039
+ chalk,
6040
+ clearMonitorState,
6041
+ getDb,
6042
+ getRepo,
6043
+ isProcessRunning,
6044
+ monitorWorktreesOnce,
6045
+ processExecPath: process.execPath,
6046
+ readMonitorState,
6047
+ renderMonitorEvent,
6048
+ resolveMonitoredWorktrees,
6049
+ spawn,
6050
+ startBackgroundMonitor,
6051
+ });
6969
6052
 
6970
6053
  // ── policy ───────────────────────────────────────────────────────────────────
6971
6054