switchman-dev 0.1.7 → 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
@@ -16,15 +16,15 @@
16
16
  * switchman status - Show the repo dashboard
17
17
  */
18
18
 
19
- import { program } from 'commander';
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
- import { cleanupCrashedLandingTempWorktrees, findRepoRoot, listGitWorktrees, createGitWorktree } from '../core/git.js';
27
+ import { cleanupCrashedLandingTempWorktrees, createGitWorktree, findRepoRoot, getWorktreeBranch, getWorktreeChangedFiles, gitAssessBranchFreshness, gitBranchExists, listGitWorktrees } from '../core/git.js';
28
28
  import { matchesPathPatterns } from '../core/ignore.js';
29
29
  import {
30
30
  initDb, openDb,
@@ -37,18 +37,21 @@ import {
37
37
  createPolicyOverride, listPolicyOverrides, revokePolicyOverride,
38
38
  finishOperationJournalEntry, listOperationJournal, listTempResources, updateTempResource,
39
39
  claimFiles, releaseFileClaims, getActiveFileClaims, checkFileConflicts, retryTask,
40
- listAuditEvents, verifyAuditTrail,
40
+ upsertTaskSpec,
41
+ listAuditEvents, pruneDatabaseMaintenance, verifyAuditTrail,
42
+ getLeaseExecutionContext, getActiveLeaseForTask,
41
43
  } from '../core/db.js';
42
44
  import { scanAllWorktrees } from '../core/detector.js';
43
- import { getWindsurfMcpConfigPath, upsertAllProjectMcpConfigs, upsertWindsurfMcpConfig } from '../core/mcp.js';
44
- import { gatewayAppendFile, gatewayMakeDirectory, gatewayMovePath, gatewayRemovePath, gatewayWriteFile, installGateHooks, monitorWorktreesOnce, runCommitGate, runWrappedCommand, writeEnforcementPolicy } from '../core/enforcement.js';
45
+ import { ensureProjectLocalMcpGitExcludes, getWindsurfMcpConfigPath, upsertAllProjectMcpConfigs, upsertWindsurfMcpConfig } from '../core/mcp.js';
46
+ import { evaluateRepoCompliance, gatewayAppendFile, gatewayMakeDirectory, gatewayMovePath, gatewayRemovePath, gatewayWriteFile, installGateHooks, monitorWorktreesOnce, runCommitGate, runWrappedCommand, writeEnforcementPolicy } from '../core/enforcement.js';
45
47
  import { runAiMergeGate } from '../core/merge-gate.js';
46
48
  import { clearMonitorState, getMonitorStatePath, isProcessRunning, readMonitorState, writeMonitorState } from '../core/monitor.js';
47
49
  import { buildPipelinePrSummary, cleanupPipelineLandingRecovery, commentPipelinePr, createPipelineFollowupTasks, evaluatePipelinePolicyGate, executePipeline, exportPipelinePrBundle, getPipelineLandingBranchStatus, getPipelineLandingExplainReport, getPipelineStatus, inferPipelineIdFromBranch, materializePipelineLandingBranch, preparePipelineLandingRecovery, preparePipelineLandingTarget, publishPipelinePr, repairPipelineState, resumePipelineLandingRecovery, runPipeline, startPipeline, summarizePipelinePolicyState, syncPipelinePr } from '../core/pipeline.js';
48
50
  import { installGitHubActionsWorkflow, resolveGitHubOutputTargets, writeGitHubCiStatus, writeGitHubPipelineLandingStatus } from '../core/ci.js';
49
51
  import { importCodeObjectsToStore, listCodeObjects, materializeCodeObjects, materializeSemanticIndex, updateCodeObjectSource } from '../core/semantic.js';
50
- import { buildQueueStatusSummary, resolveQueueSource, runMergeQueue } from '../core/queue.js';
52
+ import { buildQueueStatusSummary, evaluateQueueRepoGate, resolveQueueSource, runMergeQueue } from '../core/queue.js';
51
53
  import { DEFAULT_CHANGE_POLICY, DEFAULT_LEASE_POLICY, getChangePolicyPath, loadChangePolicy, loadLeasePolicy, writeChangePolicy, writeLeasePolicy } from '../core/policy.js';
54
+ import { planPipelineTasks } from '../core/planner.js';
52
55
  import {
53
56
  captureTelemetryEvent,
54
57
  disableTelemetry,
@@ -59,6 +62,19 @@ import {
59
62
  maybePromptForTelemetry,
60
63
  sendTelemetryEvent,
61
64
  } from '../core/telemetry.js';
65
+ import { checkLicence, clearCredentials, FREE_AGENT_LIMIT, getRetentionDaysForCurrentPlan, loginWithGitHub, PRO_PAGE_URL, readCredentials } from '../core/licence.js';
66
+ import { homedir } from 'os';
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';
62
78
 
63
79
  const originalProcessEmit = process.emit.bind(process);
64
80
  process.emit = function patchedProcessEmit(event, ...args) {
@@ -95,6 +111,299 @@ function getDb(repoRoot) {
95
111
  }
96
112
  }
97
113
 
114
+ function getOptionalDb(repoRoot) {
115
+ try {
116
+ return openDb(repoRoot);
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ function slugifyValue(value) {
123
+ return String(value || '')
124
+ .toLowerCase()
125
+ .replace(/[^a-z0-9]+/g, '-')
126
+ .replace(/^-+|-+$/g, '')
127
+ .slice(0, 40) || 'plan';
128
+ }
129
+
130
+ function capitalizeSentence(value) {
131
+ const text = String(value || '').trim();
132
+ if (!text) return text;
133
+ return text.charAt(0).toUpperCase() + text.slice(1);
134
+ }
135
+
136
+ function formatHumanList(values = []) {
137
+ if (values.length === 0) return '';
138
+ if (values.length === 1) return values[0];
139
+ if (values.length === 2) return `${values[0]} and ${values[1]}`;
140
+ return `${values.slice(0, -1).join(', ')}, and ${values[values.length - 1]}`;
141
+ }
142
+
143
+ function readPlanningFile(repoRoot, fileName, maxChars = 1200) {
144
+ const filePath = join(repoRoot, fileName);
145
+ if (!existsSync(filePath)) return null;
146
+ try {
147
+ const text = readFileSync(filePath, 'utf8').trim();
148
+ if (!text) return null;
149
+ return {
150
+ file: fileName,
151
+ text: text.slice(0, maxChars),
152
+ };
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+
158
+ function extractMarkdownSignal(text) {
159
+ const lines = String(text || '')
160
+ .split('\n')
161
+ .map((line) => line.trim())
162
+ .filter(Boolean);
163
+ for (const line of lines) {
164
+ const normalized = line.replace(/^#+\s*/, '').replace(/^[-*]\s+/, '').trim();
165
+ if (!normalized) continue;
166
+ if (/^switchman\b/i.test(normalized)) continue;
167
+ return normalized;
168
+ }
169
+ return null;
170
+ }
171
+
172
+ function deriveGoalFromBranch(branchName) {
173
+ const raw = String(branchName || '').replace(/^refs\/heads\//, '').trim();
174
+ if (!raw || ['main', 'master', 'trunk', 'develop', 'development'].includes(raw)) return null;
175
+ const tail = raw.split('/').pop() || raw;
176
+ const tokens = tail
177
+ .replace(/^\d+[-_]?/, '')
178
+ .split(/[-_]/)
179
+ .filter(Boolean)
180
+ .filter((token) => !['feature', 'feat', 'fix', 'bugfix', 'chore', 'task', 'issue', 'story', 'work'].includes(token.toLowerCase()));
181
+ if (tokens.length === 0) return null;
182
+ return capitalizeSentence(tokens.join(' '));
183
+ }
184
+
185
+ function getRecentCommitSubjects(repoRoot, limit = 6) {
186
+ try {
187
+ return execSync(`git log --pretty=%s -n ${limit}`, {
188
+ cwd: repoRoot,
189
+ encoding: 'utf8',
190
+ stdio: ['pipe', 'pipe', 'pipe'],
191
+ }).trim().split('\n').map((line) => line.trim()).filter(Boolean);
192
+ } catch {
193
+ return [];
194
+ }
195
+ }
196
+
197
+ function summarizeRecentCommitContext(branchGoal, subjects) {
198
+ if (!subjects.length) return null;
199
+ const topicWords = String(branchGoal || '')
200
+ .toLowerCase()
201
+ .split(/\s+/)
202
+ .filter((word) => word.length >= 4);
203
+ const relatedCount = topicWords.length > 0
204
+ ? subjects.filter((subject) => {
205
+ const lower = subject.toLowerCase();
206
+ return topicWords.some((word) => lower.includes(word));
207
+ }).length
208
+ : 0;
209
+ const effectiveCount = relatedCount > 0 ? relatedCount : Math.min(subjects.length, 3);
210
+ const topicLabel = relatedCount > 0 && topicWords.length > 0 ? `${topicWords[0]}-related ` : '';
211
+ return `${effectiveCount} recent ${topicLabel}commit${effectiveCount === 1 ? '' : 's'}`;
212
+ }
213
+
214
+ function collectPlanContext(repoRoot, explicitGoal = null, issueContext = null) {
215
+ const planningFiles = ['CLAUDE.md', 'ROADMAP.md', 'tasks.md', 'TASKS.md', 'TODO.md', 'README.md']
216
+ .map((fileName) => readPlanningFile(repoRoot, fileName))
217
+ .filter(Boolean);
218
+ const planningByName = new Map(planningFiles.map((entry) => [entry.file, entry]));
219
+ const branch = getWorktreeBranch(process.cwd()) || null;
220
+ const branchGoal = deriveGoalFromBranch(branch);
221
+ const recentCommitSubjects = getRecentCommitSubjects(repoRoot, 6);
222
+ const recentCommitSummary = summarizeRecentCommitContext(branchGoal, recentCommitSubjects);
223
+ const preferredPlanningFile = planningByName.get('CLAUDE.md')
224
+ || planningByName.get('tasks.md')
225
+ || planningByName.get('TASKS.md')
226
+ || planningByName.get('ROADMAP.md')
227
+ || planningByName.get('TODO.md')
228
+ || planningByName.get('README.md')
229
+ || null;
230
+ const planningSignal = preferredPlanningFile ? extractMarkdownSignal(preferredPlanningFile.text) : null;
231
+ const title = capitalizeSentence(issueContext?.title || explicitGoal || branchGoal || planningSignal || 'Plan the next coordinated change');
232
+ const descriptionParts = [];
233
+ if (issueContext?.description) descriptionParts.push(issueContext.description);
234
+ if (preferredPlanningFile?.text) descriptionParts.push(preferredPlanningFile.text);
235
+ if (recentCommitSubjects.length > 0) descriptionParts.push(`Recent git history summary: ${recentCommitSubjects.slice(0, 3).join('; ')}.`);
236
+ const description = descriptionParts.join('\n\n').trim() || null;
237
+
238
+ const found = [];
239
+ const used = [];
240
+ if (issueContext?.number) {
241
+ found.push(`issue #${issueContext.number} "${issueContext.title}"`);
242
+ used.push(`GitHub issue #${issueContext.number}`);
243
+ }
244
+ if (explicitGoal) {
245
+ used.push('explicit goal');
246
+ }
247
+ if (branch) {
248
+ found.push(`branch ${branch}`);
249
+ if (branchGoal) used.push('branch name');
250
+ }
251
+ if (preferredPlanningFile?.file) {
252
+ found.push(preferredPlanningFile.file);
253
+ used.push(preferredPlanningFile.file);
254
+ }
255
+ if (recentCommitSummary) {
256
+ found.push(recentCommitSummary);
257
+ used.push('recent git history');
258
+ }
259
+
260
+ return {
261
+ branch,
262
+ title,
263
+ description,
264
+ found,
265
+ used: [...new Set(used)],
266
+ };
267
+ }
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
+
386
+ function resolvePlanningWorktrees(repoRoot, db = null) {
387
+ if (db) {
388
+ const registered = listWorktrees(db)
389
+ .filter((worktree) => worktree.name !== 'main' && worktree.status !== 'missing')
390
+ .map((worktree) => ({ name: worktree.name, path: worktree.path, branch: worktree.branch }));
391
+ if (registered.length > 0) return registered;
392
+ }
393
+ return listGitWorktrees(repoRoot)
394
+ .filter((worktree) => !worktree.isMain)
395
+ .map((worktree) => ({ name: worktree.name, path: worktree.path, branch: worktree.branch || null }));
396
+ }
397
+
398
+ function planTaskPriority(taskSpec = null) {
399
+ const taskType = taskSpec?.task_type || 'implementation';
400
+ if (taskType === 'implementation') return 8;
401
+ if (taskType === 'tests') return 7;
402
+ if (taskType === 'docs') return 6;
403
+ if (taskType === 'governance') return 6;
404
+ return 5;
405
+ }
406
+
98
407
  function resolvePrNumberFromEnv(env = process.env) {
99
408
  if (env.SWITCHMAN_PR_NUMBER) return String(env.SWITCHMAN_PR_NUMBER);
100
409
  if (env.GITHUB_PR_NUMBER) return String(env.GITHUB_PR_NUMBER);
@@ -1289,6 +1598,184 @@ function printRepairSummary(report, {
1289
1598
  }
1290
1599
  }
1291
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
+
1292
1779
  function repairRepoState(db, repoRoot) {
1293
1780
  const actions = [];
1294
1781
  const warnings = [];
@@ -1582,7 +2069,7 @@ function collectSetupVerification(repoRoot, { homeDir = null } = {}) {
1582
2069
  detail: claudeGuideExists ? 'CLAUDE.md is present' : 'CLAUDE.md is optional but recommended for Claude Code',
1583
2070
  });
1584
2071
  if (!claudeGuideExists) {
1585
- 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`.');
1586
2073
  }
1587
2074
 
1588
2075
  const windsurfConfigExists = existsSync(getWindsurfMcpConfigPath(homeDir || undefined));
@@ -1618,6 +2105,238 @@ function collectSetupVerification(repoRoot, { homeDir = null } = {}) {
1618
2105
  };
1619
2106
  }
1620
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
+
1621
2340
  function renderSetupVerification(report, { compact = false } = {}) {
1622
2341
  console.log(chalk.bold(compact ? 'First-run check:' : 'Setup verification:'));
1623
2342
  for (const check of report.checks) {
@@ -1783,12 +2502,24 @@ function buildDoctorReport({ db, repoRoot, tasks, activeLeases, staleLeases, sca
1783
2502
  };
1784
2503
  });
1785
2504
 
1786
- const blockedWorktrees = scanReport.unclaimedChanges.map((entry) => ({
1787
- worktree: entry.worktree,
1788
- files: entry.files,
1789
- reason_code: entry.reasons?.[0]?.reason_code || null,
1790
- next_step: nextStepForReason(entry.reasons?.[0]?.reason_code) || 'inspect the changed files and bring them back under Switchman claims',
1791
- }));
2505
+ const worktreeByName = new Map((scanReport.worktrees || []).map((worktree) => [worktree.name, worktree]));
2506
+ const blockedWorktrees = scanReport.unclaimedChanges.map((entry) => {
2507
+ const worktreeInfo = worktreeByName.get(entry.worktree) || null;
2508
+ const reasonCode = entry.reasons?.[0]?.reason_code || null;
2509
+ const isDirtyWorktree = reasonCode === 'no_active_lease';
2510
+ return {
2511
+ worktree: entry.worktree,
2512
+ path: worktreeInfo?.path || null,
2513
+ files: entry.files,
2514
+ reason_code: reasonCode,
2515
+ next_step: isDirtyWorktree
2516
+ ? 'commit or discard the changed files in that worktree, then rescan before continuing'
2517
+ : (nextStepForReason(reasonCode) || 'inspect the changed files and bring them back under Switchman claims'),
2518
+ command: worktreeInfo?.path
2519
+ ? `cd ${JSON.stringify(worktreeInfo.path)} && git status`
2520
+ : 'switchman scan',
2521
+ };
2522
+ });
1792
2523
 
1793
2524
  const fileConflicts = scanReport.fileConflicts.map((conflict) => ({
1794
2525
  file: conflict.file,
@@ -1838,9 +2569,9 @@ function buildDoctorReport({ db, repoRoot, tasks, activeLeases, staleLeases, sca
1838
2569
  ...blockedWorktrees.map((entry) => ({
1839
2570
  kind: 'unmanaged_changes',
1840
2571
  title: `${entry.worktree} has unmanaged changed files`,
1841
- detail: `${entry.files.slice(0, 3).join(', ')}${entry.files.length > 3 ? ` +${entry.files.length - 3} more` : ''}`,
2572
+ detail: `${entry.files.slice(0, 5).join(', ')}${entry.files.length > 5 ? ` +${entry.files.length - 5} more` : ''}${entry.path ? ` • ${entry.path}` : ''}`,
1842
2573
  next_step: entry.next_step,
1843
- command: 'switchman scan',
2574
+ command: entry.command,
1844
2575
  severity: 'block',
1845
2576
  })),
1846
2577
  ...fileConflicts.map((conflict) => ({
@@ -1997,6 +2728,7 @@ function buildUnifiedStatusReport({
1997
2728
  queueItems,
1998
2729
  queueSummary,
1999
2730
  recentQueueEvents,
2731
+ retentionDays = 7,
2000
2732
  }) {
2001
2733
  const queueAttention = [
2002
2734
  ...queueItems
@@ -2101,6 +2833,7 @@ function buildUnifiedStatusReport({
2101
2833
  ...queueAttention.map((item) => item.next_step),
2102
2834
  ])].slice(0, 6),
2103
2835
  suggested_commands: [...new Set(attention.length > 0 ? suggestedCommands : defaultSuggestedCommands)].slice(0, 6),
2836
+ retention_days: retentionDays,
2104
2837
  };
2105
2838
  }
2106
2839
 
@@ -2108,6 +2841,9 @@ async function collectStatusSnapshot(repoRoot) {
2108
2841
  const db = getDb(repoRoot);
2109
2842
  try {
2110
2843
  const leasePolicy = loadLeasePolicy(repoRoot);
2844
+ const retentionDays = await getRetentionDaysForCurrentPlan();
2845
+ pruneDatabaseMaintenance(db, { retentionDays });
2846
+ cleanupOldSyncEvents({ retentionDays }).catch(() => {});
2111
2847
 
2112
2848
  if (leasePolicy.reap_on_status_check) {
2113
2849
  reapStaleLeases(db, leasePolicy.stale_after_minutes, {
@@ -2147,13 +2883,40 @@ async function collectStatusSnapshot(repoRoot) {
2147
2883
  queueItems,
2148
2884
  queueSummary,
2149
2885
  recentQueueEvents,
2886
+ retentionDays,
2150
2887
  });
2151
2888
  } finally {
2152
2889
  db.close();
2153
2890
  }
2154
2891
  }
2155
2892
 
2156
- function renderUnifiedStatusReport(report) {
2893
+ function summarizeTeamCoordinationState(events = [], myUserId = null) {
2894
+ const visibleEvents = events.filter((event) => event.user_id !== myUserId);
2895
+ if (visibleEvents.length === 0) {
2896
+ return {
2897
+ members: 0,
2898
+ queue_events: 0,
2899
+ lease_events: 0,
2900
+ claim_events: 0,
2901
+ latest_queue_event: null,
2902
+ };
2903
+ }
2904
+
2905
+ const activeMembers = new Set(visibleEvents.map((event) => event.user_id || `${event.payload?.email || 'unknown'}:${event.worktree || 'unknown'}`));
2906
+ const queueEvents = visibleEvents.filter((event) => ['queue_added', 'queue_merged', 'queue_blocked'].includes(event.event_type));
2907
+ const leaseEvents = visibleEvents.filter((event) => ['lease_acquired', 'task_done', 'task_failed', 'task_retried'].includes(event.event_type));
2908
+ const claimEvents = visibleEvents.filter((event) => ['claim_added', 'claim_released'].includes(event.event_type));
2909
+
2910
+ return {
2911
+ members: activeMembers.size,
2912
+ queue_events: queueEvents.length,
2913
+ lease_events: leaseEvents.length,
2914
+ claim_events: claimEvents.length,
2915
+ latest_queue_event: queueEvents[0] || null,
2916
+ };
2917
+ }
2918
+
2919
+ function renderUnifiedStatusReport(report, { teamActivity = [], teamSummary = null } = {}) {
2157
2920
  const healthColor = colorForHealth(report.health);
2158
2921
  const badge = healthColor(healthLabel(report.health));
2159
2922
  const mergeColor = report.merge_readiness.ci_gate_ok ? chalk.green : chalk.red;
@@ -2212,10 +2975,43 @@ function renderUnifiedStatusReport(report) {
2212
2975
  console.log(`${chalk.bold('Run next:')} ${chalk.cyan(primaryCommand)}`);
2213
2976
  console.log(`${chalk.dim('why:')} ${nextStepLine}`);
2214
2977
  console.log(chalk.dim(`policy: ${formatRelativePolicy(report.lease_policy)} • requeue on reap ${report.lease_policy.requeue_task_on_reap ? 'on' : 'off'}`));
2978
+ console.log(chalk.dim(`history retention: ${report.retention_days || 7} days`));
2215
2979
  if (report.merge_readiness.policy_state?.active) {
2216
2980
  console.log(chalk.dim(`change policy: ${report.merge_readiness.policy_state.domains.join(', ')} • ${report.merge_readiness.policy_state.enforcement} • missing ${report.merge_readiness.policy_state.missing_task_types.join(', ') || 'none'}`));
2217
2981
  }
2218
2982
 
2983
+ // ── Team activity (Pro cloud sync) ──────────────────────────────────────────
2984
+ if (teamSummary && teamSummary.members > 0) {
2985
+ console.log('');
2986
+ console.log(chalk.bold('Shared cloud state:'));
2987
+ console.log(` ${chalk.dim('members:')} ${teamSummary.members} ${chalk.dim('leases:')} ${teamSummary.lease_events} ${chalk.dim('claims:')} ${teamSummary.claim_events} ${chalk.dim('queue:')} ${teamSummary.queue_events}`);
2988
+ if (teamSummary.latest_queue_event) {
2989
+ console.log(` ${chalk.dim('latest queue event:')} ${chalk.cyan(teamSummary.latest_queue_event.event_type)} ${chalk.dim(teamSummary.latest_queue_event.payload?.source_ref || teamSummary.latest_queue_event.payload?.item_id || '')}`.trim());
2990
+ }
2991
+ }
2992
+ if (teamActivity.length > 0) {
2993
+ console.log('');
2994
+ console.log(chalk.bold('Team activity:'));
2995
+ for (const member of teamActivity) {
2996
+ const email = member.payload?.email ?? chalk.dim(member.user_id?.slice(0, 8) ?? 'unknown');
2997
+ const worktree = chalk.cyan(member.worktree ?? 'unknown');
2998
+ const eventLabel = {
2999
+ task_added: 'added a task',
3000
+ task_done: 'completed a task',
3001
+ task_failed: 'failed a task',
3002
+ task_retried: 'retried a task',
3003
+ lease_acquired: `working on: ${chalk.dim(member.payload?.title ?? '')}`,
3004
+ claim_added: `claimed ${chalk.dim(member.payload?.file_count ?? 0)} file(s)`,
3005
+ claim_released: 'released file claims',
3006
+ queue_added: `queued ${chalk.dim(member.payload?.source_ref ?? member.payload?.item_id ?? 'work')}`,
3007
+ queue_merged: `landed ${chalk.dim(member.payload?.source_ref ?? member.payload?.item_id ?? 'work')}`,
3008
+ queue_blocked: `blocked ${chalk.dim(member.payload?.source_ref ?? member.payload?.item_id ?? 'work')}`,
3009
+ status_ping: 'active',
3010
+ }[member.event_type] ?? member.event_type;
3011
+ console.log(` ${chalk.dim('○')} ${email} · ${worktree} · ${eventLabel}`);
3012
+ }
3013
+ }
3014
+
2219
3015
  const runningLines = report.active_work.length > 0
2220
3016
  ? report.active_work.slice(0, 5).map((item) => {
2221
3017
  const boundary = item.boundary_validation
@@ -2230,6 +3026,27 @@ function renderUnifiedStatusReport(report) {
2230
3026
 
2231
3027
  const blockedItems = report.attention.filter((item) => item.severity === 'block');
2232
3028
  const warningItems = report.attention.filter((item) => item.severity !== 'block');
3029
+ const isQuietEmptyState = report.active_work.length === 0
3030
+ && blockedItems.length === 0
3031
+ && warningItems.length === 0
3032
+ && report.queue.items.length === 0
3033
+ && report.next_up.length === 0
3034
+ && report.failed_tasks.length === 0;
3035
+
3036
+ if (isQuietEmptyState) {
3037
+ console.log('');
3038
+ console.log(healthColor('='.repeat(72)));
3039
+ console.log(`${badge} ${chalk.bold('switchman status')} ${chalk.dim('• mission control for parallel agents')}`);
3040
+ console.log(`${chalk.dim(report.repo_root)}`);
3041
+ console.log(`${chalk.dim(report.summary)}`);
3042
+ console.log(healthColor('='.repeat(72)));
3043
+ console.log('');
3044
+ console.log(chalk.green('Nothing is running yet.'));
3045
+ console.log(`Add work with: ${chalk.cyan('switchman task add "Your first task" --priority 8')}`);
3046
+ console.log(`Or prove the flow in 30 seconds with: ${chalk.cyan('switchman demo')}`);
3047
+ console.log('');
3048
+ return;
3049
+ }
2233
3050
 
2234
3051
  const blockedLines = blockedItems.length > 0
2235
3052
  ? blockedItems.slice(0, 4).flatMap((item) => {
@@ -2405,10 +3222,12 @@ function completeTaskWithRetries(repoRoot, taskId, attempts = 20) {
2405
3222
  let db = null;
2406
3223
  try {
2407
3224
  db = openDb(repoRoot);
2408
- completeTask(db, taskId);
2409
- releaseFileClaims(db, taskId);
3225
+ const result = completeTask(db, taskId);
3226
+ if (result?.status === 'completed') {
3227
+ releaseFileClaims(db, taskId);
3228
+ }
2410
3229
  db.close();
2411
- return;
3230
+ return result;
2412
3231
  } catch (err) {
2413
3232
  lastError = err;
2414
3233
  try { db?.close(); } catch { /* no-op */ }
@@ -2421,38 +3240,311 @@ function completeTaskWithRetries(repoRoot, taskId, attempts = 20) {
2421
3240
  throw lastError;
2422
3241
  }
2423
3242
 
2424
- // ─── Program ──────────────────────────────────────────────────────────────────
3243
+ function startBackgroundMonitor(repoRoot, { intervalMs = 2000, quarantine = false } = {}) {
3244
+ const existingState = readMonitorState(repoRoot);
3245
+ if (existingState && isProcessRunning(existingState.pid)) {
3246
+ return {
3247
+ already_running: true,
3248
+ state: existingState,
3249
+ state_path: getMonitorStatePath(repoRoot),
3250
+ };
3251
+ }
2425
3252
 
2426
- program
2427
- .name('switchman')
2428
- .description('Conflict-aware task coordinator for parallel AI coding agents')
2429
- .version('0.1.0');
3253
+ const logPath = join(repoRoot, '.switchman', 'monitor.log');
3254
+ const child = spawn(process.execPath, [
3255
+ process.argv[1],
3256
+ 'monitor',
3257
+ 'watch',
3258
+ '--interval-ms',
3259
+ String(intervalMs),
3260
+ ...(quarantine ? ['--quarantine'] : []),
3261
+ '--daemonized',
3262
+ ], {
3263
+ cwd: repoRoot,
3264
+ detached: true,
3265
+ stdio: 'ignore',
3266
+ });
3267
+ child.unref();
3268
+
3269
+ const statePath = writeMonitorState(repoRoot, {
3270
+ pid: child.pid,
3271
+ interval_ms: intervalMs,
3272
+ quarantine: Boolean(quarantine),
3273
+ log_path: logPath,
3274
+ started_at: new Date().toISOString(),
3275
+ });
2430
3276
 
2431
- program.showHelpAfterError('(run with --help for usage examples)');
2432
- program.addHelpText('after', `
2433
- Start here:
2434
- switchman demo
2435
- switchman setup --agents 5
2436
- switchman status --watch
2437
- switchman gate ci
3277
+ return {
3278
+ already_running: false,
3279
+ state: readMonitorState(repoRoot),
3280
+ state_path: statePath,
3281
+ };
3282
+ }
2438
3283
 
2439
- Most useful commands:
2440
- switchman task add "Implement auth helper" --priority 9
2441
- switchman lease next --json
2442
- switchman queue run --watch
3284
+ function renderMonitorEvent(event) {
3285
+ const ownerText = event.owner_worktree
3286
+ ? `${event.owner_worktree}${event.owner_task_id ? ` (${event.owner_task_id})` : ''}`
3287
+ : null;
3288
+ const claimCommand = event.task_id
3289
+ ? `switchman claim ${event.task_id} ${event.worktree} ${event.file_path}`
3290
+ : null;
2443
3291
 
2444
- Docs:
2445
- README.md
2446
- docs/setup-cursor.md
2447
- `);
3292
+ if (event.status === 'denied') {
3293
+ console.log(`${chalk.yellow('⚠')} ${chalk.cyan(event.worktree)} modified ${chalk.yellow(event.file_path)} without governed ownership`);
3294
+ if (ownerText) {
3295
+ console.log(` ${chalk.dim('Owned by:')} ${chalk.cyan(ownerText)}${event.owner_task_title ? ` ${chalk.dim(`— ${event.owner_task_title}`)}` : ''}`);
3296
+ }
3297
+ if (claimCommand) {
3298
+ console.log(` ${chalk.dim('Run:')} ${chalk.cyan(claimCommand)}`);
3299
+ }
3300
+ console.log(` ${chalk.dim('Or:')} ${chalk.cyan('switchman status')} ${chalk.dim('to inspect current claims and blockers')}`);
3301
+ if (event.enforcement_action) {
3302
+ console.log(` ${chalk.dim('Action:')} ${event.enforcement_action}`);
3303
+ }
3304
+ return;
3305
+ }
2448
3306
 
2449
- program
2450
- .command('demo')
2451
- .description('Create a throwaway repo that proves overlapping claims are blocked and safe landing works')
2452
- .option('--path <dir>', 'Directory to create the demo repo in')
2453
- .option('--cleanup', 'Delete the demo repo after the run finishes')
2454
- .option('--json', 'Output raw JSON')
2455
- .addHelpText('after', `
3307
+ const ownerSuffix = ownerText ? ` ${chalk.dim(`(${ownerText})`)}` : '';
3308
+ console.log(`${chalk.green('')} ${chalk.cyan(event.worktree)} ${chalk.yellow(event.file_path)} ${chalk.dim(event.change_type)}${ownerSuffix}`);
3309
+ }
3310
+
3311
+ function resolveMonitoredWorktrees(db, repoRoot) {
3312
+ const registeredByPath = new Map(
3313
+ listWorktrees(db)
3314
+ .filter((worktree) => worktree.path)
3315
+ .map((worktree) => [worktree.path, worktree])
3316
+ );
3317
+
3318
+ return listGitWorktrees(repoRoot).map((worktree) => {
3319
+ const registered = registeredByPath.get(worktree.path);
3320
+ if (!registered) return worktree;
3321
+ return {
3322
+ ...worktree,
3323
+ name: registered.name,
3324
+ path: registered.path || worktree.path,
3325
+ branch: registered.branch || worktree.branch,
3326
+ };
3327
+ });
3328
+ }
3329
+
3330
+ function discoverMergeCandidates(db, repoRoot, { targetBranch = 'main' } = {}) {
3331
+ const worktrees = listWorktrees(db).filter((worktree) => worktree.name !== 'main');
3332
+ const activeLeases = new Set(listLeases(db, 'active').map((lease) => lease.worktree));
3333
+ const tasks = listTasks(db);
3334
+ const queueItems = listMergeQueue(db).filter((item) => item.status !== 'merged');
3335
+ const alreadyQueued = new Set(queueItems.map((item) => item.source_worktree).filter(Boolean));
3336
+
3337
+ const eligible = [];
3338
+ const blocked = [];
3339
+ const skipped = [];
3340
+
3341
+ for (const worktree of worktrees) {
3342
+ const doneTasks = tasks.filter((task) => task.worktree === worktree.name && task.status === 'done');
3343
+ if (doneTasks.length === 0) {
3344
+ skipped.push({
3345
+ worktree: worktree.name,
3346
+ branch: worktree.branch,
3347
+ reason: 'no_completed_tasks',
3348
+ summary: 'no completed tasks are assigned to this worktree yet',
3349
+ command: `switchman task list --status done`,
3350
+ });
3351
+ continue;
3352
+ }
3353
+
3354
+ if (!worktree.branch || worktree.branch === targetBranch) {
3355
+ skipped.push({
3356
+ worktree: worktree.name,
3357
+ branch: worktree.branch || null,
3358
+ reason: 'no_merge_branch',
3359
+ summary: `worktree is on ${targetBranch} already`,
3360
+ command: `switchman worktree list`,
3361
+ });
3362
+ continue;
3363
+ }
3364
+
3365
+ if (!gitBranchExists(repoRoot, worktree.branch)) {
3366
+ blocked.push({
3367
+ worktree: worktree.name,
3368
+ branch: worktree.branch,
3369
+ reason: 'missing_branch',
3370
+ summary: `branch ${worktree.branch} is not available in git`,
3371
+ command: `switchman worktree sync`,
3372
+ });
3373
+ continue;
3374
+ }
3375
+
3376
+ if (activeLeases.has(worktree.name)) {
3377
+ blocked.push({
3378
+ worktree: worktree.name,
3379
+ branch: worktree.branch,
3380
+ reason: 'active_lease',
3381
+ summary: 'an active lease is still running in this worktree',
3382
+ command: `switchman status`,
3383
+ });
3384
+ continue;
3385
+ }
3386
+
3387
+ if (alreadyQueued.has(worktree.name)) {
3388
+ skipped.push({
3389
+ worktree: worktree.name,
3390
+ branch: worktree.branch,
3391
+ reason: 'already_queued',
3392
+ summary: 'worktree is already in the landing queue',
3393
+ command: `switchman queue status`,
3394
+ });
3395
+ continue;
3396
+ }
3397
+
3398
+ const dirtyFiles = getWorktreeChangedFiles(worktree.path, repoRoot);
3399
+ if (dirtyFiles.length > 0) {
3400
+ blocked.push({
3401
+ worktree: worktree.name,
3402
+ branch: worktree.branch,
3403
+ path: worktree.path,
3404
+ files: dirtyFiles,
3405
+ reason: 'dirty_worktree',
3406
+ summary: `worktree has uncommitted changes: ${dirtyFiles.slice(0, 5).join(', ')}${dirtyFiles.length > 5 ? ` +${dirtyFiles.length - 5} more` : ''}`,
3407
+ command: `cd ${JSON.stringify(worktree.path)} && git status`,
3408
+ });
3409
+ continue;
3410
+ }
3411
+
3412
+ const freshness = gitAssessBranchFreshness(repoRoot, targetBranch, worktree.branch);
3413
+ eligible.push({
3414
+ worktree: worktree.name,
3415
+ branch: worktree.branch,
3416
+ path: worktree.path,
3417
+ done_task_count: doneTasks.length,
3418
+ done_task_titles: doneTasks.slice(0, 3).map((task) => task.title),
3419
+ freshness,
3420
+ });
3421
+ }
3422
+
3423
+ return { eligible, blocked, skipped, queue_items: queueItems };
3424
+ }
3425
+
3426
+ function printMergeDiscovery(discovery) {
3427
+ console.log('');
3428
+ console.log(chalk.bold(`Checking ${discovery.eligible.length + discovery.blocked.length + discovery.skipped.length} worktree(s)...`));
3429
+
3430
+ for (const entry of discovery.eligible) {
3431
+ const freshness = entry.freshness?.state && entry.freshness.state !== 'unknown'
3432
+ ? ` ${chalk.dim(`(${entry.freshness.state})`)}`
3433
+ : '';
3434
+ console.log(` ${chalk.green('✓')} ${chalk.cyan(entry.worktree)} ${chalk.dim(entry.branch)}${freshness}`);
3435
+ }
3436
+
3437
+ for (const entry of discovery.blocked) {
3438
+ console.log(` ${chalk.yellow('!')} ${chalk.cyan(entry.worktree)} ${chalk.dim(entry.branch || 'no branch')} ${chalk.dim(`— ${entry.summary}`)}`);
3439
+ }
3440
+
3441
+ for (const entry of discovery.skipped) {
3442
+ if (entry.reason === 'no_completed_tasks') continue;
3443
+ console.log(` ${chalk.dim('·')} ${chalk.cyan(entry.worktree)} ${chalk.dim(entry.branch || 'no branch')} ${chalk.dim(`— ${entry.summary}`)}`);
3444
+ }
3445
+ }
3446
+
3447
+ // ─── Program ──────────────────────────────────────────────────────────────────
3448
+
3449
+ program
3450
+ .name('switchman')
3451
+ .description('Conflict-aware task coordinator for parallel AI coding agents')
3452
+ .version('0.1.0');
3453
+
3454
+ program.showHelpAfterError('(run with --help for usage examples)');
3455
+ const ROOT_HELP_COMMANDS = new Set([
3456
+ 'advanced',
3457
+ 'claude',
3458
+ 'demo',
3459
+ 'setup',
3460
+ 'verify-setup',
3461
+ 'login',
3462
+ 'upgrade',
3463
+ 'plan',
3464
+ 'task',
3465
+ 'status',
3466
+ 'recover',
3467
+ 'merge',
3468
+ 'repair',
3469
+ 'help',
3470
+ ]);
3471
+ program.configureHelp({
3472
+ visibleCommands(cmd) {
3473
+ const commands = Help.prototype.visibleCommands.call(this, cmd);
3474
+ if (cmd.parent) return commands;
3475
+ return commands.filter((command) => ROOT_HELP_COMMANDS.has(command.name()) && !command._switchmanAdvanced);
3476
+ },
3477
+ });
3478
+ program.addHelpText('after', `
3479
+ Start here:
3480
+ switchman demo
3481
+ switchman setup --agents 3
3482
+ switchman task add "Your task" --priority 8
3483
+ switchman status --watch
3484
+ switchman recover
3485
+ switchman gate ci && switchman queue run
3486
+
3487
+ For you (the operator):
3488
+ switchman demo
3489
+ switchman setup
3490
+ switchman claude refresh
3491
+ switchman task add
3492
+ switchman status
3493
+ switchman recover
3494
+ switchman merge
3495
+ switchman repair
3496
+ switchman upgrade
3497
+ switchman login
3498
+ switchman plan "Add authentication" (Pro)
3499
+
3500
+ For your agents (via CLAUDE.md or MCP):
3501
+ switchman lease next
3502
+ switchman claim
3503
+ switchman task done
3504
+ switchman write
3505
+ switchman wrap
3506
+
3507
+ Docs:
3508
+ README.md
3509
+ docs/setup-claude-code.md
3510
+
3511
+ Power tools:
3512
+ switchman advanced --help
3513
+ `);
3514
+
3515
+ const advancedCmd = program
3516
+ .command('advanced')
3517
+ .description('Show advanced, experimental, and power-user command groups')
3518
+ .addHelpText('after', `
3519
+ Advanced operator commands:
3520
+ switchman pipeline <...>
3521
+ switchman audit <...>
3522
+ switchman policy <...>
3523
+ switchman monitor <...>
3524
+ switchman repair
3525
+
3526
+ Experimental commands:
3527
+ switchman semantic <...>
3528
+ switchman object <...>
3529
+
3530
+ Compatibility aliases:
3531
+ switchman doctor
3532
+
3533
+ Tip:
3534
+ The main help keeps the day-one workflow small on purpose.
3535
+ `)
3536
+ .action(() => {
3537
+ advancedCmd.outputHelp();
3538
+ });
3539
+ advancedCmd._switchmanAdvanced = false;
3540
+
3541
+ program
3542
+ .command('demo')
3543
+ .description('Create a throwaway repo that proves overlapping claims are blocked and safe landing works')
3544
+ .option('--path <dir>', 'Directory to create the demo repo in')
3545
+ .option('--cleanup', 'Delete the demo repo after the run finishes')
3546
+ .option('--json', 'Output raw JSON')
3547
+ .addHelpText('after', `
2456
3548
  Examples:
2457
3549
  switchman demo
2458
3550
  switchman demo --path /tmp/switchman-demo
@@ -2508,12 +3600,16 @@ program
2508
3600
  }
2509
3601
 
2510
3602
  const mcpConfigWrites = installMcpConfig([...new Set([repoRoot, ...gitWorktrees.map((wt) => wt.path)])]);
3603
+ const mcpExclude = ensureProjectLocalMcpGitExcludes(repoRoot);
2511
3604
 
2512
3605
  db.close();
2513
3606
  spinner.succeed(`Initialized in ${chalk.cyan(repoRoot)}`);
2514
3607
  console.log(chalk.dim(` Found and registered ${gitWorktrees.length} git worktree(s)`));
2515
3608
  console.log(chalk.dim(` Database: .switchman/switchman.db`));
2516
3609
  console.log(chalk.dim(` MCP config: ${mcpConfigWrites.filter((result) => result.changed).length} file(s) written`));
3610
+ if (mcpExclude.managed) {
3611
+ console.log(chalk.dim(` MCP excludes: ${mcpExclude.changed ? 'updated' : 'already set'} in .git/info/exclude`));
3612
+ }
2517
3613
  console.log('');
2518
3614
  console.log(`Next steps:`);
2519
3615
  console.log(` ${chalk.cyan('switchman task add "Fix the login bug"')} — add a task`);
@@ -2533,6 +3629,8 @@ program
2533
3629
  .description('One-command setup: create agent workspaces and initialise Switchman')
2534
3630
  .option('-a, --agents <n>', 'Number of agent workspaces to create (default: 3)', '3')
2535
3631
  .option('--prefix <prefix>', 'Branch prefix (default: switchman)', 'switchman')
3632
+ .option('--no-monitor', 'Do not start the background rogue-edit monitor')
3633
+ .option('--monitor-interval-ms <ms>', 'Polling interval for the background monitor', '2000')
2536
3634
  .addHelpText('after', `
2537
3635
  Examples:
2538
3636
  switchman setup --agents 5
@@ -2546,6 +3644,24 @@ Examples:
2546
3644
  process.exit(1);
2547
3645
  }
2548
3646
 
3647
+ if (agentCount > FREE_AGENT_LIMIT) {
3648
+ const licence = await checkLicence();
3649
+ if (!licence.valid) {
3650
+ console.log('');
3651
+ console.log(chalk.yellow(` ⚠ Free tier supports up to ${FREE_AGENT_LIMIT} agents.`));
3652
+ console.log('');
3653
+ console.log(` You requested ${chalk.cyan(agentCount)} agents — that requires ${chalk.bold('Switchman Pro')}.`);
3654
+ console.log('');
3655
+ console.log(` ${chalk.dim('Upgrade at:')} ${chalk.cyan(PRO_PAGE_URL)}`);
3656
+ console.log(` ${chalk.dim('Or run: ')} ${chalk.cyan('switchman upgrade')}`);
3657
+ console.log('');
3658
+ process.exit(1);
3659
+ }
3660
+ if (licence.offline) {
3661
+ console.log(chalk.dim(` Pro licence verified (offline cache · ${Math.ceil((7 * 24 * 60 * 60 * 1000 - (Date.now() - (licence.cached_at ?? 0))) / (24 * 60 * 60 * 1000))}d remaining)`));
3662
+ }
3663
+ }
3664
+
2549
3665
  const repoRoot = getRepo();
2550
3666
  const spinner = ora('Setting up Switchman...').start();
2551
3667
 
@@ -2591,6 +3707,12 @@ Examples:
2591
3707
  }
2592
3708
 
2593
3709
  const mcpConfigWrites = installMcpConfig([...new Set([repoRoot, ...created.map((wt) => wt.path)])]);
3710
+ const mcpExclude = ensureProjectLocalMcpGitExcludes(repoRoot);
3711
+
3712
+ const monitorIntervalMs = Math.max(100, Number.parseInt(opts.monitorIntervalMs, 10) || 2000);
3713
+ const monitorState = opts.monitor
3714
+ ? startBackgroundMonitor(repoRoot, { intervalMs: monitorIntervalMs, quarantine: false })
3715
+ : null;
2594
3716
 
2595
3717
  db.close();
2596
3718
 
@@ -2610,6 +3732,20 @@ Examples:
2610
3732
  const status = result.created ? 'created' : result.changed ? 'updated' : 'unchanged';
2611
3733
  console.log(` ${chalk.green('✓')} ${chalk.cyan(result.path)} ${chalk.dim(`(${status})`)}`);
2612
3734
  }
3735
+ if (mcpExclude.managed) {
3736
+ console.log(` ${chalk.green('✓')} ${chalk.cyan(mcpExclude.path)} ${chalk.dim(`(${mcpExclude.changed ? 'updated' : 'unchanged'})`)}`);
3737
+ }
3738
+
3739
+ if (opts.monitor) {
3740
+ console.log('');
3741
+ console.log(chalk.bold('Monitor:'));
3742
+ if (monitorState?.already_running) {
3743
+ console.log(` ${chalk.green('✓')} Background rogue-edit monitor already running ${chalk.dim(`(pid ${monitorState.state.pid})`)}`);
3744
+ } else {
3745
+ console.log(` ${chalk.green('✓')} Started background rogue-edit monitor ${chalk.dim(`(pid ${monitorState?.state?.pid ?? 'unknown'})`)}`);
3746
+ }
3747
+ console.log(` ${chalk.dim('interval:')} ${monitorIntervalMs}ms`);
3748
+ }
2613
3749
 
2614
3750
  console.log('');
2615
3751
  console.log(chalk.bold('Next steps:'));
@@ -2618,6 +3754,13 @@ Examples:
2618
3754
  console.log(` 2. Open Claude Code or Cursor in the workspaces above — the local MCP config will attach Switchman automatically`);
2619
3755
  console.log(` 3. Keep the repo dashboard open while work starts:`);
2620
3756
  console.log(` ${chalk.cyan('switchman status --watch')}`);
3757
+ console.log(` 4. Run the final check and land finished work:`);
3758
+ console.log(` ${chalk.cyan('switchman gate ci')}`);
3759
+ console.log(` ${chalk.cyan('switchman queue run')}`);
3760
+ if (opts.monitor) {
3761
+ console.log(` 5. Watch for rogue edits or direct writes in real time:`);
3762
+ console.log(` ${chalk.cyan('switchman monitor status')}`);
3763
+ }
2621
3764
  console.log('');
2622
3765
 
2623
3766
  const verification = collectSetupVerification(repoRoot);
@@ -2662,833 +3805,398 @@ Use this after setup or whenever editor/config wiring feels off.
2662
3805
  }, { homeDir: opts.home || null });
2663
3806
  if (!report.ok) process.exitCode = 1;
2664
3807
  });
3808
+ registerClaudeCommands(program, {
3809
+ chalk,
3810
+ existsSync,
3811
+ getRepo,
3812
+ join,
3813
+ renderClaudeGuide,
3814
+ writeFileSync,
3815
+ });
2665
3816
 
2666
3817
 
2667
3818
  // ── mcp ───────────────────────────────────────────────────────────────────────
2668
3819
 
2669
- const mcpCmd = program.command('mcp').description('Manage editor connections for Switchman');
2670
- const telemetryCmd = program.command('telemetry').description('Control anonymous opt-in telemetry for Switchman');
2671
-
2672
- telemetryCmd
2673
- .command('status')
2674
- .description('Show whether telemetry is enabled and where events would be sent')
2675
- .option('--home <path>', 'Override the home directory for telemetry config')
2676
- .option('--json', 'Output raw JSON')
2677
- .action((opts) => {
2678
- const config = loadTelemetryConfig(opts.home || undefined);
2679
- const runtime = getTelemetryRuntimeConfig();
2680
- const payload = {
2681
- enabled: config.telemetry_enabled === true,
2682
- configured: Boolean(runtime.apiKey) && !runtime.disabled,
2683
- install_id: config.telemetry_install_id,
2684
- destination: runtime.apiKey && !runtime.disabled ? runtime.host : null,
2685
- config_path: getTelemetryConfigPath(opts.home || undefined),
2686
- };
2687
-
2688
- if (opts.json) {
2689
- console.log(JSON.stringify(payload, null, 2));
2690
- return;
2691
- }
2692
-
2693
- console.log(`Telemetry: ${payload.enabled ? chalk.green('enabled') : chalk.yellow('disabled')}`);
2694
- console.log(`Configured destination: ${payload.configured ? chalk.cyan(payload.destination) : chalk.dim('not configured')}`);
2695
- console.log(`Config file: ${chalk.dim(payload.config_path)}`);
2696
- if (payload.install_id) {
2697
- console.log(`Install ID: ${chalk.dim(payload.install_id)}`);
2698
- }
2699
- });
2700
-
2701
- telemetryCmd
2702
- .command('enable')
2703
- .description('Enable anonymous telemetry for setup and operator workflows')
2704
- .option('--home <path>', 'Override the home directory for telemetry config')
2705
- .action((opts) => {
2706
- const runtime = getTelemetryRuntimeConfig();
2707
- if (!runtime.apiKey || runtime.disabled) {
2708
- printErrorWithNext('Telemetry destination is not configured. Set SWITCHMAN_TELEMETRY_API_KEY first.', 'switchman telemetry status');
2709
- process.exitCode = 1;
2710
- return;
2711
- }
2712
- const result = enableTelemetry(opts.home || undefined);
2713
- console.log(`${chalk.green('✓')} Telemetry enabled`);
2714
- console.log(` ${chalk.dim(result.path)}`);
2715
- });
2716
-
2717
- telemetryCmd
2718
- .command('disable')
2719
- .description('Disable anonymous telemetry')
2720
- .option('--home <path>', 'Override the home directory for telemetry config')
2721
- .action((opts) => {
2722
- const result = disableTelemetry(opts.home || undefined);
2723
- console.log(`${chalk.green('✓')} Telemetry disabled`);
2724
- console.log(` ${chalk.dim(result.path)}`);
2725
- });
2726
-
2727
- telemetryCmd
2728
- .command('test')
2729
- .description('Send one test telemetry event and report whether delivery succeeded')
2730
- .option('--home <path>', 'Override the home directory for telemetry config')
2731
- .option('--json', 'Output raw JSON')
2732
- .action(async (opts) => {
2733
- const result = await sendTelemetryEvent('telemetry_test', {
2734
- app_version: program.version(),
2735
- os: process.platform,
2736
- node_version: process.version,
2737
- source: 'switchman-cli-test',
2738
- }, { homeDir: opts.home || undefined });
2739
-
2740
- if (opts.json) {
2741
- console.log(JSON.stringify(result, null, 2));
2742
- if (!result.ok) process.exitCode = 1;
2743
- return;
2744
- }
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
+ });
2745
3835
 
2746
- if (result.ok) {
2747
- console.log(`${chalk.green('✓')} Telemetry test event delivered`);
2748
- console.log(` ${chalk.dim('destination:')} ${chalk.cyan(result.destination)}`);
2749
- if (result.status) {
2750
- console.log(` ${chalk.dim('status:')} ${result.status}`);
2751
- }
2752
- return;
2753
- }
2754
3836
 
2755
- printErrorWithNext(`Telemetry test failed (${result.reason || 'unknown_error'}).`, 'switchman telemetry status');
2756
- console.log(` ${chalk.dim('destination:')} ${result.destination || 'unknown'}`);
2757
- if (result.status) {
2758
- console.log(` ${chalk.dim('status:')} ${result.status}`);
2759
- }
2760
- if (result.error) {
2761
- console.log(` ${chalk.dim('error:')} ${result.error}`);
2762
- }
2763
- process.exitCode = 1;
2764
- });
3837
+ // ── plan ──────────────────────────────────────────────────────────────────────
2765
3838
 
2766
- mcpCmd
2767
- .command('install')
2768
- .description('Install editor-specific MCP config for Switchman')
2769
- .option('--windsurf', 'Write Windsurf MCP config to ~/.codeium/mcp_config.json')
2770
- .option('--home <path>', 'Override the home directory for config writes (useful for testing)')
3839
+ program
3840
+ .command('plan [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')
3846
+ .option('--apply', 'Create the suggested tasks in Switchman')
3847
+ .option('--max-tasks <n>', 'Maximum number of suggested tasks', '6')
2771
3848
  .option('--json', 'Output raw JSON')
2772
3849
  .addHelpText('after', `
2773
3850
  Examples:
2774
- switchman mcp install --windsurf
2775
- switchman mcp install --windsurf --json
3851
+ switchman plan "Add authentication"
3852
+ switchman plan --issue 47
3853
+ switchman plan "Add authentication" --apply
3854
+ switchman plan --issue 47 --apply --comment
2776
3855
  `)
2777
- .action((opts) => {
2778
- if (!opts.windsurf) {
2779
- console.error(chalk.red('Choose an editor install target, for example `switchman mcp install --windsurf`.'));
2780
- process.exitCode = 1;
2781
- return;
2782
- }
2783
-
2784
- const result = upsertWindsurfMcpConfig(opts.home);
2785
-
2786
- if (opts.json) {
2787
- console.log(JSON.stringify({
2788
- editor: 'windsurf',
2789
- path: result.path,
2790
- created: result.created,
2791
- changed: result.changed,
2792
- }, null, 2));
2793
- return;
2794
- }
2795
-
2796
- console.log(`${chalk.green('✓')} Windsurf MCP config ${result.changed ? 'written' : 'already up to date'}`);
2797
- console.log(` ${chalk.dim('path:')} ${chalk.cyan(result.path)}`);
2798
- console.log(` ${chalk.dim('open:')} Windsurf -> Settings -> Cascade -> MCP Servers`);
2799
- console.log(` ${chalk.dim('note:')} Windsurf reads the shared config from ${getWindsurfMcpConfigPath(opts.home)}`);
2800
- });
2801
-
2802
-
2803
- // ── task ──────────────────────────────────────────────────────────────────────
2804
-
2805
- const taskCmd = program.command('task').description('Manage the task list');
2806
- taskCmd.addHelpText('after', `
2807
- Examples:
2808
- switchman task add "Fix login bug" --priority 8
2809
- switchman task list --status pending
2810
- switchman task done task-123
2811
- `);
2812
-
2813
- taskCmd
2814
- .command('add <title>')
2815
- .description('Add a new task to the queue')
2816
- .option('-d, --description <desc>', 'Task description')
2817
- .option('-p, --priority <n>', 'Priority 1-10 (default 5)', '5')
2818
- .option('--id <id>', 'Custom task ID')
2819
- .action((title, opts) => {
3856
+ .action(async (goal, opts) => {
2820
3857
  const repoRoot = getRepo();
2821
- const db = getDb(repoRoot);
2822
- const taskId = createTask(db, {
2823
- id: opts.id,
2824
- title,
2825
- description: opts.description,
2826
- priority: parseInt(opts.priority),
2827
- });
2828
- db.close();
2829
- const scopeWarning = analyzeTaskScope(title, opts.description || '');
2830
- console.log(`${chalk.green('✓')} Task created: ${chalk.cyan(taskId)}`);
2831
- console.log(` ${chalk.dim(title)}`);
2832
- if (scopeWarning) {
2833
- console.log(chalk.yellow(` warning: ${scopeWarning.summary}`));
2834
- console.log(chalk.yellow(` next: ${scopeWarning.next_step}`));
2835
- console.log(chalk.cyan(` try: ${scopeWarning.command}`));
2836
- }
2837
- });
2838
-
2839
- taskCmd
2840
- .command('list')
2841
- .description('List all tasks')
2842
- .option('-s, --status <status>', 'Filter by status (pending|in_progress|done|failed)')
2843
- .action((opts) => {
2844
- const repoRoot = getRepo();
2845
- const db = getDb(repoRoot);
2846
- const tasks = listTasks(db, opts.status);
2847
- db.close();
2848
-
2849
- if (!tasks.length) {
2850
- console.log(chalk.dim('No tasks found.'));
2851
- return;
2852
- }
2853
-
2854
- console.log('');
2855
- for (const t of tasks) {
2856
- const badge = statusBadge(t.status);
2857
- const worktree = t.worktree ? chalk.cyan(t.worktree) : chalk.dim('unassigned');
2858
- console.log(`${badge} ${chalk.bold(t.title)}`);
2859
- console.log(` ${chalk.dim('id:')} ${t.id} ${chalk.dim('worktree:')} ${worktree} ${chalk.dim('priority:')} ${t.priority}`);
2860
- if (t.description) console.log(` ${chalk.dim(t.description)}`);
2861
- console.log('');
2862
- }
2863
- });
2864
-
2865
- taskCmd
2866
- .command('assign <taskId> <worktree>')
2867
- .description('Assign a task to a workspace (compatibility shim for lease acquire)')
2868
- .option('--agent <name>', 'Agent name (e.g. claude-code)')
2869
- .action((taskId, worktree, opts) => {
2870
- const repoRoot = getRepo();
2871
- const db = getDb(repoRoot);
2872
- const lease = startTaskLease(db, taskId, worktree, opts.agent);
2873
- db.close();
2874
- if (lease) {
2875
- console.log(`${chalk.green('✓')} Assigned ${chalk.cyan(taskId)} → ${chalk.cyan(worktree)} (${chalk.dim(lease.id)})`);
2876
- } else {
2877
- console.log(chalk.red(`Could not assign task. It may not exist or is not in 'pending' status.`));
2878
- }
2879
- });
2880
-
2881
- taskCmd
2882
- .command('retry <taskId>')
2883
- .description('Return a failed or stale completed task to pending so it can be revalidated')
2884
- .option('--reason <text>', 'Reason to record for the retry')
2885
- .option('--json', 'Output raw JSON')
2886
- .action((taskId, opts) => {
2887
- const repoRoot = getRepo();
2888
- const db = getDb(repoRoot);
2889
- const task = retryTask(db, taskId, opts.reason || 'manual retry');
2890
- db.close();
2891
-
2892
- if (!task) {
2893
- printErrorWithNext(`Task ${taskId} is not retryable.`, 'switchman task list --status failed');
2894
- process.exitCode = 1;
2895
- return;
2896
- }
2897
-
2898
- if (opts.json) {
2899
- console.log(JSON.stringify(task, null, 2));
2900
- return;
2901
- }
2902
-
2903
- console.log(`${chalk.green('✓')} Reset ${chalk.cyan(task.id)} to pending`);
2904
- console.log(` ${chalk.dim('title:')} ${task.title}`);
2905
- console.log(`${chalk.yellow('next:')} switchman task assign ${task.id} <workspace>`);
2906
- });
2907
-
2908
- taskCmd
2909
- .command('retry-stale')
2910
- .description('Return all currently stale tasks to pending so they can be revalidated together')
2911
- .option('--pipeline <id>', 'Only retry stale tasks for one pipeline')
2912
- .option('--reason <text>', 'Reason to record for the retry', 'bulk stale retry')
2913
- .option('--json', 'Output raw JSON')
2914
- .action((opts) => {
2915
- const repoRoot = getRepo();
2916
- const db = getDb(repoRoot);
2917
- const result = retryStaleTasks(db, {
2918
- pipelineId: opts.pipeline || null,
2919
- reason: opts.reason,
2920
- });
2921
- db.close();
2922
-
2923
- if (opts.json) {
2924
- console.log(JSON.stringify(result, null, 2));
2925
- return;
2926
- }
2927
-
2928
- if (result.retried.length === 0) {
2929
- const scope = result.pipeline_id ? ` for ${result.pipeline_id}` : '';
2930
- console.log(chalk.dim(`No stale tasks to retry${scope}.`));
2931
- return;
2932
- }
3858
+ const db = getOptionalDb(repoRoot);
2933
3859
 
2934
- console.log(`${chalk.green('✓')} Reset ${result.retried.length} stale task(s) to pending`);
2935
- if (result.pipeline_id) {
2936
- console.log(` ${chalk.dim('pipeline:')} ${result.pipeline_id}`);
2937
- }
2938
- console.log(` ${chalk.dim('tasks:')} ${result.retried.map((task) => task.id).join(', ')}`);
2939
- console.log(`${chalk.yellow('next:')} switchman status`);
2940
- });
2941
-
2942
- taskCmd
2943
- .command('done <taskId>')
2944
- .description('Mark a task as complete and release all file claims')
2945
- .action((taskId) => {
2946
- const repoRoot = getRepo();
2947
3860
  try {
2948
- completeTaskWithRetries(repoRoot, taskId);
2949
- console.log(`${chalk.green('✓')} Task ${chalk.cyan(taskId)} marked done — file claims released`);
2950
- } catch (err) {
2951
- console.error(chalk.red(err.message));
2952
- process.exitCode = 1;
2953
- }
2954
- });
2955
-
2956
- taskCmd
2957
- .command('fail <taskId> [reason]')
2958
- .description('Mark a task as failed')
2959
- .action((taskId, reason) => {
2960
- const repoRoot = getRepo();
2961
- const db = getDb(repoRoot);
2962
- failTask(db, taskId, reason);
2963
- releaseFileClaims(db, taskId);
2964
- db.close();
2965
- console.log(`${chalk.red('✗')} Task ${chalk.cyan(taskId)} marked failed`);
2966
- });
2967
-
2968
- taskCmd
2969
- .command('next')
2970
- .description('Get the next pending task quickly (use `lease next` for the full workflow)')
2971
- .option('--json', 'Output as JSON')
2972
- .option('--worktree <name>', 'Workspace to assign the task to (defaults to the current folder name)')
2973
- .option('--agent <name>', 'Agent identifier for logging (e.g. claude-code)')
2974
- .addHelpText('after', `
2975
- Examples:
2976
- switchman task next
2977
- switchman task next --json
2978
- `)
2979
- .action((opts) => {
2980
- const repoRoot = getRepo();
2981
- const worktreeName = getCurrentWorktreeName(opts.worktree);
2982
- const { task, lease, exhausted } = acquireNextTaskLeaseWithRetries(repoRoot, worktreeName, opts.agent || null);
2983
-
2984
- if (!task) {
2985
- if (opts.json) console.log(JSON.stringify({ task: null }));
2986
- else if (exhausted) console.log(chalk.dim('No pending tasks.'));
2987
- else console.log(chalk.yellow('Tasks were claimed by other agents during assignment. Run again to get the next one.'));
2988
- return;
2989
- }
2990
-
2991
- if (!lease) {
2992
- if (opts.json) console.log(JSON.stringify({ task: null, message: 'Task claimed by another agent — try again' }));
2993
- else console.log(chalk.yellow('Task was just claimed by another agent. Run again to get the next one.'));
2994
- return;
2995
- }
2996
-
2997
- if (opts.json) {
2998
- console.log(JSON.stringify(taskJsonWithLease(task, worktreeName, lease), null, 2));
2999
- } else {
3000
- console.log(`${chalk.green('✓')} Assigned: ${chalk.bold(task.title)}`);
3001
- 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}`);
3002
- }
3003
- });
3004
-
3005
- // ── queue ─────────────────────────────────────────────────────────────────────
3006
-
3007
- const queueCmd = program.command('queue').alias('land').description('Land finished work safely back onto main, one item at a time');
3008
- queueCmd.addHelpText('after', `
3009
- Examples:
3010
- switchman queue add --worktree agent1
3011
- switchman queue status
3012
- switchman queue run --watch
3013
- `);
3014
-
3015
- queueCmd
3016
- .command('add [branch]')
3017
- .description('Add a branch, workspace, or pipeline to the landing queue')
3018
- .option('--worktree <name>', 'Queue a registered workspace by name')
3019
- .option('--pipeline <pipelineId>', 'Queue a pipeline by id')
3020
- .option('--target <branch>', 'Target branch to merge into', 'main')
3021
- .option('--max-retries <n>', 'Maximum automatic retries', '1')
3022
- .option('--submitted-by <name>', 'Operator or automation name')
3023
- .option('--json', 'Output raw JSON')
3024
- .addHelpText('after', `
3025
- Examples:
3026
- switchman queue add feature/auth-hardening
3027
- switchman queue add --worktree agent2
3028
- switchman queue add --pipeline pipe-123
3029
-
3030
- Pipeline landing rule:
3031
- switchman queue add --pipeline <id>
3032
- lands the pipeline's inferred landing branch.
3033
- If completed work spans multiple branches, Switchman creates one synthetic landing branch first.
3034
- `)
3035
- .action(async (branch, opts) => {
3036
- const repoRoot = getRepo();
3037
- const db = getDb(repoRoot);
3861
+ const licence = await checkLicence();
3862
+ if (!licence.valid) {
3863
+ console.log('');
3864
+ console.log(chalk.yellow(' ⚠ AI planning requires Switchman Pro.'));
3865
+ console.log(` ${chalk.dim('Run:')} ${chalk.cyan('switchman upgrade')}`);
3866
+ console.log(` ${chalk.dim('Or visit:')} ${chalk.cyan(PRO_PAGE_URL)}`);
3867
+ console.log('');
3868
+ process.exitCode = 1;
3869
+ return;
3870
+ }
3038
3871
 
3039
- try {
3040
- let payload;
3041
- if (opts.worktree) {
3042
- const worktree = listWorktrees(db).find((entry) => entry.name === opts.worktree);
3043
- if (!worktree) {
3044
- throw new Error(`Workspace ${opts.worktree} is not registered.`);
3045
- }
3046
- payload = {
3047
- sourceType: 'worktree',
3048
- sourceRef: worktree.branch,
3049
- sourceWorktree: worktree.name,
3050
- targetBranch: opts.target,
3051
- maxRetries: opts.maxRetries,
3052
- submittedBy: opts.submittedBy || null,
3053
- };
3054
- } else if (opts.pipeline) {
3055
- const policyGate = await evaluatePipelinePolicyGate(db, repoRoot, opts.pipeline);
3056
- if (!policyGate.ok) {
3057
- throw new Error(`${policyGate.summary} Next: ${policyGate.next_action}`);
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;
3058
3880
  }
3059
- const landingTarget = preparePipelineLandingTarget(db, repoRoot, opts.pipeline, {
3060
- baseBranch: opts.target || 'main',
3061
- requireCompleted: true,
3062
- allowCurrentBranchFallback: false,
3063
- });
3064
- payload = {
3065
- sourceType: 'pipeline',
3066
- sourceRef: landingTarget.branch,
3067
- sourcePipelineId: opts.pipeline,
3068
- sourceWorktree: landingTarget.worktree || null,
3069
- targetBranch: opts.target,
3070
- maxRetries: opts.maxRetries,
3071
- submittedBy: opts.submittedBy || null,
3072
- eventDetails: policyGate.override_applied
3073
- ? {
3074
- policy_override_summary: policyGate.override_summary,
3075
- overridden_task_types: policyGate.policy_state?.overridden_task_types || [],
3076
- }
3077
- : null,
3078
- };
3079
- } else if (branch) {
3080
- payload = {
3081
- sourceType: 'branch',
3082
- sourceRef: branch,
3083
- targetBranch: opts.target,
3084
- maxRetries: opts.maxRetries,
3085
- submittedBy: opts.submittedBy || null,
3086
- };
3087
- } else {
3088
- throw new Error('Choose one source to land: a branch name, `--worktree`, or `--pipeline`.');
3089
3881
  }
3090
3882
 
3091
- const result = enqueueMergeItem(db, payload);
3092
- db.close();
3093
-
3094
- if (opts.json) {
3095
- console.log(JSON.stringify(result, null, 2));
3883
+ if ((!goal || !goal.trim()) && !issueContext) {
3884
+ console.log('');
3885
+ console.log(chalk.yellow(' ⚠ AI planning currently requires an explicit goal.'));
3886
+ console.log(` ${chalk.dim('Try:')} ${chalk.cyan('switchman plan "Add authentication"')}`);
3887
+ console.log(` ${chalk.dim('Or: ')} ${chalk.cyan('switchman plan --issue 47')}`);
3888
+ console.log(` ${chalk.dim('Then:')} ${chalk.cyan('switchman plan "Add authentication" --apply')}`);
3889
+ console.log('');
3890
+ process.exitCode = 1;
3096
3891
  return;
3097
3892
  }
3098
3893
 
3099
- console.log(`${chalk.green('✓')} Queued ${chalk.cyan(result.id)} for ${chalk.bold(result.target_branch)}`);
3100
- console.log(` ${chalk.dim('source:')} ${result.source_type} ${result.source_ref}`);
3101
- if (result.source_worktree) console.log(` ${chalk.dim('worktree:')} ${result.source_worktree}`);
3102
- if (payload.eventDetails?.policy_override_summary) {
3103
- console.log(` ${chalk.dim('policy override:')} ${payload.eventDetails.policy_override_summary}`);
3104
- }
3105
- } catch (err) {
3106
- db.close();
3107
- printErrorWithNext(err.message, 'switchman queue add --help');
3108
- process.exitCode = 1;
3109
- }
3110
- });
3111
-
3112
- queueCmd
3113
- .command('list')
3114
- .description('List merge queue items')
3115
- .option('--status <status>', 'Filter by queue status')
3116
- .option('--json', 'Output raw JSON')
3117
- .action((opts) => {
3118
- const repoRoot = getRepo();
3119
- const db = getDb(repoRoot);
3120
- const items = listMergeQueue(db, { status: opts.status || null });
3121
- db.close();
3122
-
3123
- if (opts.json) {
3124
- console.log(JSON.stringify(items, null, 2));
3125
- return;
3126
- }
3127
-
3128
- if (items.length === 0) {
3129
- console.log(chalk.dim('Merge queue is empty.'));
3130
- return;
3131
- }
3132
-
3133
- for (const item of items) {
3134
- const retryInfo = chalk.dim(`retries:${item.retry_count}/${item.max_retries}`);
3135
- const attemptInfo = item.last_attempt_at ? ` ${chalk.dim(`last-attempt:${item.last_attempt_at}`)}` : '';
3136
- const backoffInfo = item.backoff_until ? ` ${chalk.dim(`backoff-until:${item.backoff_until}`)}` : '';
3137
- const escalationInfo = item.escalated_at ? ` ${chalk.dim(`escalated:${item.escalated_at}`)}` : '';
3138
- console.log(` ${statusBadge(item.status)} ${item.id} ${item.source_type}:${item.source_ref} ${chalk.dim(`→ ${item.target_branch}`)} ${retryInfo}${attemptInfo}${backoffInfo}${escalationInfo}`);
3139
- if (item.last_error_summary) {
3140
- console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
3141
- }
3142
- if (item.next_action) {
3143
- console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
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;
3144
3901
  }
3145
- }
3146
- });
3147
-
3148
- queueCmd
3149
- .command('status')
3150
- .description('Show an operator-friendly merge queue summary')
3151
- .option('--json', 'Output raw JSON')
3152
- .addHelpText('after', `
3153
- Plain English:
3154
- Use this when finished branches are waiting to land and you want one safe queue view.
3155
-
3156
- Examples:
3157
- switchman queue status
3158
- switchman queue status --json
3159
-
3160
- What it helps you answer:
3161
- - what lands next
3162
- - what is blocked
3163
- - what command should I run now
3164
- `)
3165
- .action((opts) => {
3166
- const repoRoot = getRepo();
3167
- const db = getDb(repoRoot);
3168
- const items = listMergeQueue(db);
3169
- const summary = buildQueueStatusSummary(items, { db, repoRoot });
3170
- const recentEvents = items.slice(0, 5).flatMap((item) =>
3171
- listMergeQueueEvents(db, item.id, { limit: 3 }).map((event) => ({ ...event, queue_item_id: item.id })),
3172
- ).sort((a, b) => b.id - a.id).slice(0, 8);
3173
- db.close();
3174
3902
 
3175
- if (opts.json) {
3176
- console.log(JSON.stringify({ items, summary, recent_events: recentEvents }, null, 2));
3177
- return;
3178
- }
3179
-
3180
- const queueHealth = summary.counts.blocked > 0
3181
- ? 'block'
3182
- : summary.counts.retrying > 0 || summary.counts.held > 0 || summary.counts.wave_blocked > 0 || summary.counts.escalated > 0
3183
- ? 'warn'
3184
- : 'healthy';
3185
- const queueHealthColor = colorForHealth(queueHealth);
3186
- const retryingItems = items.filter((item) => item.status === 'retrying');
3187
- const focus = summary.blocked[0] || retryingItems[0] || summary.next || null;
3188
- const focusLine = focus
3189
- ? `${focus.id} ${focus.source_type}:${focus.source_ref}${focus.last_error_summary ? ` ${chalk.dim(`• ${focus.last_error_summary}`)}` : ''}`
3190
- : 'Nothing waiting. Landing queue is clear.';
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
+ }
3191
3912
 
3192
- console.log('');
3193
- console.log(queueHealthColor('='.repeat(72)));
3194
- console.log(`${queueHealthColor(healthLabel(queueHealth))} ${chalk.bold('switchman queue status')} ${chalk.dim('• landing mission control')}`);
3195
- console.log(queueHealthColor('='.repeat(72)));
3196
- console.log(renderSignalStrip([
3197
- renderChip('queued', summary.counts.queued, summary.counts.queued > 0 ? chalk.yellow : chalk.green),
3198
- renderChip('retrying', summary.counts.retrying, summary.counts.retrying > 0 ? chalk.yellow : chalk.green),
3199
- renderChip('held', summary.counts.held, summary.counts.held > 0 ? chalk.yellow : chalk.green),
3200
- renderChip('wave blocked', summary.counts.wave_blocked, summary.counts.wave_blocked > 0 ? chalk.yellow : chalk.green),
3201
- renderChip('escalated', summary.counts.escalated, summary.counts.escalated > 0 ? chalk.red : chalk.green),
3202
- renderChip('blocked', summary.counts.blocked, summary.counts.blocked > 0 ? chalk.red : chalk.green),
3203
- renderChip('merging', summary.counts.merging, summary.counts.merging > 0 ? chalk.blue : chalk.green),
3204
- renderChip('merged', summary.counts.merged, summary.counts.merged > 0 ? chalk.green : chalk.white),
3205
- ]));
3206
- console.log(renderMetricRow([
3207
- { label: 'items', value: items.length, color: chalk.white },
3208
- { label: 'validating', value: summary.counts.validating, color: chalk.blue },
3209
- { label: 'rebasing', value: summary.counts.rebasing, color: chalk.blue },
3210
- { label: 'target', value: summary.next?.target_branch || 'main', color: chalk.cyan },
3211
- ]));
3212
- console.log(`${chalk.bold('Focus now:')} ${focusLine}`);
3913
+ const context = collectPlanContext(repoRoot, goal || null, issueContext);
3914
+ const planningWorktrees = resolvePlanningWorktrees(repoRoot, db);
3915
+ const pipelineId = `plan-${slugifyValue(context.title)}-${Date.now().toString(36)}`;
3916
+ const plannedTasks = planPipelineTasks({
3917
+ pipelineId,
3918
+ title: context.title,
3919
+ description: context.description,
3920
+ worktrees: planningWorktrees,
3921
+ maxTasks: Math.max(1, parseInt(opts.maxTasks, 10) || 6),
3922
+ repoRoot,
3923
+ });
3213
3924
 
3214
- const queueFocusLines = summary.next
3215
- ? [
3216
- `${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}`)}` : ''}`,
3217
- ...(summary.next.queue_assessment?.reason ? [` ${chalk.dim('why next:')} ${summary.next.queue_assessment.reason}`] : []),
3218
- ...(summary.next.recommendation?.summary ? [` ${chalk.dim('decision:')} ${summary.next.recommendation.summary}`] : []),
3219
- ` ${chalk.yellow('run:')} ${summary.next.recommendation?.command || 'switchman queue run'}`,
3220
- ]
3221
- : [chalk.dim('No queued landing work right now.')];
3222
-
3223
- const queueHeldBackLines = summary.held_back.length > 0
3224
- ? summary.held_back.flatMap((item) => {
3225
- 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}`)}` : ''}`];
3226
- if (item.queue_assessment?.reason) lines.push(` ${chalk.dim('why later:')} ${item.queue_assessment.reason}`);
3227
- if (item.recommendation?.summary) lines.push(` ${chalk.dim('decision:')} ${item.recommendation.summary}`);
3228
- if (item.queue_assessment?.next_action) lines.push(` ${chalk.yellow('next:')} ${item.queue_assessment.next_action}`);
3229
- return lines;
3230
- })
3231
- : [chalk.green('Nothing significant is being held back.')];
3925
+ if (opts.json) {
3926
+ const payload = {
3927
+ title: context.title,
3928
+ context: {
3929
+ found: context.found,
3930
+ used: context.used,
3931
+ branch: context.branch,
3932
+ },
3933
+ planned_tasks: plannedTasks.map((task) => ({
3934
+ id: task.id,
3935
+ title: task.title,
3936
+ suggested_worktree: task.suggested_worktree || null,
3937
+ task_type: task.task_spec?.task_type || null,
3938
+ dependencies: task.dependencies || [],
3939
+ })),
3940
+ apply_ready: Boolean(db),
3941
+ };
3942
+ console.log(JSON.stringify(payload, null, 2));
3943
+ return;
3944
+ }
3232
3945
 
3233
- const queueBlockedLines = summary.blocked.length > 0
3234
- ? summary.blocked.slice(0, 4).flatMap((item) => {
3235
- const lines = [`${renderChip('BLOCKED', item.id, chalk.red)} ${item.source_type}:${item.source_ref} ${chalk.dim(`retries:${item.retry_count}/${item.max_retries}`)}`];
3236
- if (item.last_error_summary) lines.push(` ${chalk.red('why:')} ${item.last_error_summary}`);
3237
- if (item.next_action) lines.push(` ${chalk.yellow('next:')} ${item.next_action}`);
3238
- return lines;
3239
- })
3240
- : [chalk.green('Nothing blocked.')];
3946
+ console.log(chalk.bold('Reading repo context...'));
3947
+ if (context.found.length > 0) {
3948
+ console.log(`${chalk.dim('Found:')} ${context.found.join(', ')}`);
3949
+ } else {
3950
+ console.log(chalk.dim('Found: local repo context only'));
3951
+ }
3952
+ console.log('');
3953
+ console.log(`${chalk.bold('Suggested plan based on:')} ${context.used.length > 0 ? formatHumanList(context.used) : 'available repo context'}`);
3954
+ console.log('');
3241
3955
 
3242
- const queueWatchLines = items.filter((item) => ['retrying', 'held', 'wave_blocked', 'escalated', 'merging', 'rebasing', 'validating'].includes(item.status)).length > 0
3243
- ? items
3244
- .filter((item) => ['retrying', 'held', 'wave_blocked', 'escalated', 'merging', 'rebasing', 'validating'].includes(item.status))
3245
- .slice(0, 4)
3246
- .flatMap((item) => {
3247
- 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}`];
3248
- if (item.last_error_summary) lines.push(` ${chalk.dim(item.last_error_summary)}`);
3249
- if (item.next_action) lines.push(` ${chalk.yellow('next:')} ${item.next_action}`);
3250
- return lines;
3251
- })
3252
- : [chalk.green('No in-flight queue items right now.')];
3956
+ plannedTasks.forEach((task, index) => {
3957
+ const worktreeLabel = task.suggested_worktree ? chalk.cyan(task.suggested_worktree) : chalk.dim('unassigned');
3958
+ console.log(` ${chalk.green('')} ${chalk.bold(`${index + 1}.`)} ${task.title} ${chalk.dim('')} ${worktreeLabel}`);
3959
+ });
3253
3960
 
3254
- const queueCommandLines = [
3255
- `${chalk.cyan('$')} switchman queue run`,
3256
- `${chalk.cyan('$')} switchman queue status --json`,
3257
- ...(summary.blocked[0] ? [`${chalk.cyan('$')} switchman queue retry ${summary.blocked[0].id}`] : []),
3258
- ];
3961
+ if (!opts.apply) {
3962
+ console.log('');
3963
+ if (!db) {
3964
+ console.log(chalk.dim('Preview only — run `switchman setup --agents 3` first if you want Switchman to create and track these tasks.'));
3965
+ } else {
3966
+ console.log(chalk.dim('Preview only — rerun with `switchman plan --apply` to create these tasks.'));
3967
+ }
3968
+ return;
3969
+ }
3259
3970
 
3260
- const queuePlanLines = [
3261
- ...(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)}`) || []),
3262
- ...(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)}`) || []),
3263
- ...(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)}`) || []),
3264
- ...(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)}`) || []),
3265
- ...(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)}`) || []),
3266
- ];
3267
- const queueSequenceLines = summary.recommended_sequence?.length > 0
3268
- ? summary.recommended_sequence.map((item) => `${chalk.bold(`${item.stage}.`)} ${item.source_type}:${item.source_ref} ${chalk.dim(`[${item.lane}]`)} ${item.summary}`)
3269
- : [chalk.green('No recommended sequence beyond the current landing focus.')];
3971
+ if (!db) {
3972
+ console.log('');
3973
+ console.log(`${chalk.red('✗')} Switchman is not set up in this repo yet.`);
3974
+ console.log(` ${chalk.yellow('next:')} ${chalk.cyan('switchman setup --agents 3')}`);
3975
+ process.exitCode = 1;
3976
+ return;
3977
+ }
3270
3978
 
3271
- console.log('');
3272
- for (const block of [
3273
- renderPanel('Landing focus', queueFocusLines, chalk.green),
3274
- renderPanel('Recommended sequence', queueSequenceLines, summary.recommended_sequence?.length > 0 ? chalk.cyan : chalk.green),
3275
- renderPanel('Queue plan', queuePlanLines.length > 0 ? queuePlanLines : [chalk.green('Nothing else needs planning right now.')], queuePlanLines.length > 0 ? chalk.cyan : chalk.green),
3276
- renderPanel('Held back', queueHeldBackLines, summary.held_back.length > 0 ? chalk.yellow : chalk.green),
3277
- renderPanel('Blocked', queueBlockedLines, summary.counts.blocked > 0 ? chalk.red : chalk.green),
3278
- renderPanel('In flight', queueWatchLines, queueWatchLines[0] === 'No in-flight queue items right now.' ? chalk.green : chalk.blue),
3279
- renderPanel('Next commands', queueCommandLines, chalk.cyan),
3280
- ]) {
3281
- for (const line of block) console.log(line);
3282
3979
  console.log('');
3283
- }
3980
+ for (const task of plannedTasks) {
3981
+ createTask(db, {
3982
+ id: task.id,
3983
+ title: task.title,
3984
+ description: `Planned from: ${context.title}`,
3985
+ priority: planTaskPriority(task.task_spec),
3986
+ });
3987
+ upsertTaskSpec(db, task.id, task.task_spec);
3988
+ console.log(` ${chalk.green('✓')} Created ${chalk.cyan(task.id)} ${chalk.dim(task.title)}`);
3989
+ }
3284
3990
 
3285
- if (recentEvents.length > 0) {
3286
- console.log(chalk.bold('Recent Queue Events:'));
3287
- for (const event of recentEvents) {
3288
- console.log(` ${chalk.cyan(event.queue_item_id)} ${chalk.dim(event.event_type)} ${chalk.dim(event.status || '')} ${chalk.dim(event.created_at)}`.trim());
3991
+ console.log('');
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)}.`);
3289
4005
  }
4006
+ console.log(` ${chalk.yellow('next:')} ${chalk.cyan('switchman status --watch')}`);
4007
+ } finally {
4008
+ db?.close();
3290
4009
  }
3291
4010
  });
3292
4011
 
3293
- queueCmd
3294
- .command('run')
3295
- .description('Process landing-queue items one at a time')
3296
- .option('--max-items <n>', 'Maximum queue items to process', '1')
3297
- .option('--follow-plan', 'Only run queue items that are currently in the land_now lane')
3298
- .option('--merge-budget <n>', 'Maximum successful merges to allow in this run')
3299
- .option('--target <branch>', 'Default target branch', 'main')
3300
- .option('--watch', 'Keep polling for new queue items')
3301
- .option('--watch-interval-ms <n>', 'Polling interval for --watch mode', '1000')
3302
- .option('--max-cycles <n>', 'Maximum watch cycles before exiting (mainly for tests)')
4012
+
4013
+ // ── task ──────────────────────────────────────────────────────────────────────
4014
+
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
+ });
4035
+
4036
+ // ── queue ─────────────────────────────────────────────────────────────────────
4037
+
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
+ });
4065
+
4066
+ program
4067
+ .command('merge')
4068
+ .description('Queue finished worktrees and land safe work through one guided front door')
4069
+ .option('--target <branch>', 'Target branch to merge into', 'main')
4070
+ .option('--dry-run', 'Preview mergeable work without queueing or landing anything')
3303
4071
  .option('--json', 'Output raw JSON')
3304
4072
  .addHelpText('after', `
3305
4073
  Examples:
3306
- switchman queue run
3307
- switchman queue run --follow-plan --merge-budget 2
3308
- switchman queue run --watch
3309
- switchman queue run --watch --watch-interval-ms 1000
4074
+ switchman merge
4075
+ switchman merge --dry-run
4076
+ switchman merge --target release
3310
4077
  `)
3311
4078
  .action(async (opts) => {
3312
4079
  const repoRoot = getRepo();
4080
+ const db = getDb(repoRoot);
3313
4081
 
3314
4082
  try {
3315
- const watch = Boolean(opts.watch);
3316
- const followPlan = Boolean(opts.followPlan);
3317
- const watchIntervalMs = Math.max(0, Number.parseInt(opts.watchIntervalMs, 10) || 1000);
3318
- const maxCycles = opts.maxCycles ? Math.max(1, Number.parseInt(opts.maxCycles, 10) || 1) : null;
3319
- const mergeBudget = opts.mergeBudget !== undefined
3320
- ? Math.max(0, Number.parseInt(opts.mergeBudget, 10) || 0)
3321
- : null;
3322
- const aggregate = {
3323
- processed: [],
3324
- cycles: 0,
3325
- watch,
3326
- execution_policy: {
3327
- follow_plan: followPlan,
3328
- merge_budget: mergeBudget,
3329
- merged_count: 0,
3330
- },
3331
- };
4083
+ const discovery = discoverMergeCandidates(db, repoRoot, { targetBranch: opts.target || 'main' });
4084
+ const queued = [];
3332
4085
 
3333
- while (true) {
3334
- const db = getDb(repoRoot);
3335
- const result = await runMergeQueue(db, repoRoot, {
3336
- maxItems: Number.parseInt(opts.maxItems, 10) || 1,
4086
+ for (const entry of discovery.eligible) {
4087
+ queued.push(enqueueMergeItem(db, {
4088
+ sourceType: 'worktree',
4089
+ sourceRef: entry.branch,
4090
+ sourceWorktree: entry.worktree,
3337
4091
  targetBranch: opts.target || 'main',
3338
- followPlan,
3339
- mergeBudget,
3340
- });
3341
- db.close();
3342
-
3343
- aggregate.processed.push(...result.processed);
3344
- aggregate.summary = result.summary;
3345
- aggregate.deferred = result.deferred || aggregate.deferred || null;
3346
- aggregate.execution_policy = result.execution_policy || aggregate.execution_policy;
3347
- aggregate.cycles += 1;
3348
-
3349
- if (!watch) break;
3350
- if (maxCycles && aggregate.cycles >= maxCycles) break;
3351
- if (mergeBudget !== null && aggregate.execution_policy.merged_count >= mergeBudget) break;
3352
- if (result.processed.length === 0) {
3353
- sleepSync(watchIntervalMs);
3354
- }
4092
+ submittedBy: 'switchman merge',
4093
+ }));
3355
4094
  }
3356
4095
 
3357
- if (opts.json) {
3358
- console.log(JSON.stringify(aggregate, null, 2));
3359
- return;
3360
- }
4096
+ const queueItems = listMergeQueue(db);
4097
+ const summary = buildQueueStatusSummary(queueItems, { db, repoRoot });
4098
+ const mergeOrder = summary.recommended_sequence
4099
+ .filter((item) => ['land_now', 'prepare_next'].includes(item.lane))
4100
+ .map((item) => item.source_ref);
3361
4101
 
3362
- if (aggregate.processed.length === 0) {
3363
- const deferredFocus = aggregate.deferred || aggregate.summary?.next || null;
3364
- if (deferredFocus?.recommendation?.action) {
3365
- console.log(chalk.yellow('No landing candidate is ready to run right now.'));
3366
- console.log(` ${chalk.dim('focus:')} ${deferredFocus.id} ${deferredFocus.source_type}:${deferredFocus.source_ref}`);
3367
- if (followPlan) {
3368
- console.log(` ${chalk.dim('policy:')} following the queue plan, so only land_now items will run automatically`);
3369
- }
3370
- if (deferredFocus.recommendation?.summary) {
3371
- console.log(` ${chalk.dim('decision:')} ${deferredFocus.recommendation.summary}`);
3372
- }
3373
- if (deferredFocus.recommendation?.command) {
3374
- console.log(` ${chalk.yellow('next:')} ${deferredFocus.recommendation.command}`);
3375
- }
3376
- } else {
3377
- console.log(chalk.dim('No queued merge items.'));
4102
+ if (opts.json) {
4103
+ if (opts.dryRun) {
4104
+ console.log(JSON.stringify({ discovery, queued, summary, merge_order: mergeOrder, dry_run: true }, null, 2));
4105
+ db.close();
4106
+ return;
3378
4107
  }
3379
- await maybeCaptureTelemetry('queue_used', {
3380
- watch,
3381
- cycles: aggregate.cycles,
3382
- processed_count: 0,
3383
- merged_count: 0,
3384
- blocked_count: 0,
3385
- });
4108
+
4109
+ const gate = await evaluateQueueRepoGate(db, repoRoot);
4110
+ const runnableCount = listMergeQueue(db).filter((item) => ['queued', 'retrying'].includes(item.status)).length;
4111
+ const result = gate.ok
4112
+ ? await runMergeQueue(db, repoRoot, {
4113
+ targetBranch: opts.target || 'main',
4114
+ maxItems: Math.max(1, runnableCount),
4115
+ mergeBudget: Math.max(1, runnableCount),
4116
+ followPlan: false,
4117
+ })
4118
+ : null;
4119
+ console.log(JSON.stringify({ discovery, queued, summary, merge_order: mergeOrder, gate, result }, null, 2));
4120
+ db.close();
3386
4121
  return;
3387
4122
  }
3388
4123
 
3389
- for (const entry of aggregate.processed) {
3390
- const item = entry.item;
3391
- if (entry.status === 'merged') {
3392
- console.log(`${chalk.green('')} Merged ${chalk.cyan(item.id)} into ${chalk.bold(item.target_branch)}`);
3393
- console.log(` ${chalk.dim('commit:')} ${item.merged_commit}`);
3394
- } else {
3395
- console.log(`${chalk.red('✗')} Blocked ${chalk.cyan(item.id)}`);
3396
- console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
3397
- if (item.next_action) console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
4124
+ printMergeDiscovery(discovery);
4125
+
4126
+ if (discovery.blocked.length > 0) {
4127
+ console.log('');
4128
+ console.log(chalk.bold('Needs attention before landing:'));
4129
+ for (const entry of discovery.blocked) {
4130
+ console.log(` ${chalk.yellow(entry.worktree)}: ${entry.summary}`);
4131
+ console.log(` ${chalk.dim('run:')} ${chalk.cyan(entry.command)}`);
3398
4132
  }
3399
4133
  }
3400
4134
 
3401
- if (aggregate.execution_policy.follow_plan) {
3402
- 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)`);
4135
+ if (mergeOrder.length > 0) {
4136
+ console.log('');
4137
+ console.log(`${chalk.bold('Merge order:')} ${mergeOrder.join(' → ')}`);
3403
4138
  }
3404
4139
 
3405
- await maybeCaptureTelemetry('queue_used', {
3406
- watch,
3407
- cycles: aggregate.cycles,
3408
- processed_count: aggregate.processed.length,
3409
- merged_count: aggregate.processed.filter((entry) => entry.status === 'merged').length,
3410
- blocked_count: aggregate.processed.filter((entry) => entry.status !== 'merged').length,
3411
- });
3412
- } catch (err) {
3413
- console.error(chalk.red(err.message));
3414
- process.exitCode = 1;
3415
- }
3416
- });
3417
-
3418
- queueCmd
3419
- .command('retry <itemId>')
3420
- .description('Retry a blocked merge queue item')
3421
- .option('--json', 'Output raw JSON')
3422
- .action((itemId, opts) => {
3423
- const repoRoot = getRepo();
3424
- const db = getDb(repoRoot);
3425
- const item = retryMergeQueueItem(db, itemId);
3426
- db.close();
3427
-
3428
- if (!item) {
3429
- printErrorWithNext(`Queue item ${itemId} is not retryable.`, 'switchman queue status');
3430
- process.exitCode = 1;
3431
- return;
3432
- }
3433
-
3434
- if (opts.json) {
3435
- console.log(JSON.stringify(item, null, 2));
3436
- return;
3437
- }
4140
+ if (opts.dryRun) {
4141
+ console.log('');
4142
+ console.log(chalk.dim('Dry run only — nothing was landed.'));
4143
+ db.close();
4144
+ return;
4145
+ }
3438
4146
 
3439
- console.log(`${chalk.green('✓')} Queue item ${chalk.cyan(item.id)} reset to retrying`);
3440
- });
4147
+ if (discovery.eligible.length === 0) {
4148
+ console.log('');
4149
+ console.log(chalk.dim('No finished worktrees are ready to land yet.'));
4150
+ console.log(`${chalk.yellow('next:')} ${chalk.cyan('switchman status')}`);
4151
+ db.close();
4152
+ return;
4153
+ }
3441
4154
 
3442
- queueCmd
3443
- .command('escalate <itemId>')
3444
- .description('Mark a queue item as needing explicit operator review before landing')
3445
- .option('--reason <text>', 'Why this item is being escalated')
3446
- .option('--json', 'Output raw JSON')
3447
- .action((itemId, opts) => {
3448
- const repoRoot = getRepo();
3449
- const db = getDb(repoRoot);
3450
- const item = escalateMergeQueueItem(db, itemId, {
3451
- summary: opts.reason || null,
3452
- nextAction: `Run \`switchman explain queue ${itemId}\` to review the landing risk, then \`switchman queue retry ${itemId}\` when it is ready again.`,
3453
- });
3454
- db.close();
4155
+ const gate = await evaluateQueueRepoGate(db, repoRoot);
4156
+ if (!gate.ok) {
4157
+ console.log('');
4158
+ console.log(`${chalk.red('')} Merge gate blocked landing`);
4159
+ console.log(` ${chalk.dim(gate.summary)}`);
4160
+ console.log(` ${chalk.yellow('next:')} ${chalk.cyan('switchman gate ci')}`);
4161
+ db.close();
4162
+ return;
4163
+ }
3455
4164
 
3456
- if (!item) {
3457
- printErrorWithNext(`Queue item ${itemId} cannot be escalated.`, 'switchman queue status');
3458
- process.exitCode = 1;
3459
- return;
3460
- }
4165
+ console.log('');
4166
+ const runnableCount = listMergeQueue(db).filter((item) => ['queued', 'retrying'].includes(item.status)).length;
4167
+ const result = await runMergeQueue(db, repoRoot, {
4168
+ targetBranch: opts.target || 'main',
4169
+ maxItems: Math.max(1, runnableCount),
4170
+ mergeBudget: Math.max(1, runnableCount),
4171
+ followPlan: false,
4172
+ });
3461
4173
 
3462
- if (opts.json) {
3463
- console.log(JSON.stringify(item, null, 2));
3464
- return;
3465
- }
4174
+ const merged = result.processed.filter((item) => item.status === 'merged');
4175
+ for (const mergedItem of merged) {
4176
+ console.log(` ${chalk.green('✓')} Landed ${chalk.cyan(mergedItem.item.source_ref)} into ${chalk.bold(mergedItem.item.target_branch)}`);
4177
+ }
3466
4178
 
3467
- console.log(`${chalk.yellow('!')} Queue item ${chalk.cyan(item.id)} marked escalated for operator review`);
3468
- if (item.last_error_summary) {
3469
- console.log(` ${chalk.red('why:')} ${item.last_error_summary}`);
3470
- }
3471
- if (item.next_action) {
3472
- console.log(` ${chalk.yellow('next:')} ${item.next_action}`);
3473
- }
3474
- });
4179
+ if (merged.length > 0 && !result.deferred && result.processed.every((item) => item.status === 'merged')) {
4180
+ console.log('');
4181
+ console.log(`${chalk.green('')} Done. ${merged.length} worktree(s) landed cleanly.`);
4182
+ db.close();
4183
+ return;
4184
+ }
3475
4185
 
3476
- queueCmd
3477
- .command('remove <itemId>')
3478
- .description('Remove a merge queue item')
3479
- .action((itemId) => {
3480
- const repoRoot = getRepo();
3481
- const db = getDb(repoRoot);
3482
- const item = removeMergeQueueItem(db, itemId);
3483
- db.close();
4186
+ const blocked = result.processed.find((item) => item.status !== 'merged')?.item || result.deferred || null;
4187
+ if (blocked) {
4188
+ console.log('');
4189
+ console.log(`${chalk.yellow('!')} Landing stopped at ${chalk.cyan(blocked.source_ref)}`);
4190
+ if (blocked.last_error_summary) console.log(` ${chalk.dim(blocked.last_error_summary)}`);
4191
+ if (blocked.next_action) console.log(` ${chalk.yellow('next:')} ${blocked.next_action}`);
4192
+ }
3484
4193
 
3485
- if (!item) {
3486
- printErrorWithNext(`Queue item ${itemId} does not exist.`, 'switchman queue status');
4194
+ db.close();
4195
+ } catch (err) {
4196
+ db.close();
4197
+ console.error(chalk.red(err.message));
3487
4198
  process.exitCode = 1;
3488
- return;
3489
4199
  }
3490
-
3491
- console.log(`${chalk.green('✓')} Removed ${chalk.cyan(item.id)} from the merge queue`);
3492
4200
  });
3493
4201
 
3494
4202
  // ── explain ───────────────────────────────────────────────────────────────────
@@ -3771,6 +4479,7 @@ explainCmd
3771
4479
  // ── pipeline ──────────────────────────────────────────────────────────────────
3772
4480
 
3773
4481
  const pipelineCmd = program.command('pipeline').description('Create and summarize issue-to-PR execution pipelines');
4482
+ pipelineCmd._switchmanAdvanced = true;
3774
4483
  pipelineCmd.addHelpText('after', `
3775
4484
  Examples:
3776
4485
  switchman pipeline start "Harden auth API permissions"
@@ -4528,316 +5237,43 @@ Examples:
4528
5237
  } catch (err) {
4529
5238
  db.close();
4530
5239
  printErrorWithNext(err.message, `switchman pipeline status ${pipelineId}`);
4531
- process.exitCode = 1;
4532
- }
4533
- });
4534
-
4535
- // ── lease ────────────────────────────────────────────────────────────────────
4536
-
4537
- const leaseCmd = program.command('lease').alias('session').description('Manage active work sessions and keep long-running tasks alive');
4538
- leaseCmd.addHelpText('after', `
4539
- Plain English:
4540
- lease = a task currently checked out by an agent
4541
-
4542
- Examples:
4543
- switchman lease next --json
4544
- switchman lease heartbeat lease-123
4545
- switchman lease reap
4546
- `);
4547
-
4548
- leaseCmd
4549
- .command('acquire <taskId> <worktree>')
4550
- .description('Start a tracked work session for a specific pending task')
4551
- .option('--agent <name>', 'Agent identifier for logging')
4552
- .option('--json', 'Output as JSON')
4553
- .addHelpText('after', `
4554
- Examples:
4555
- switchman lease acquire task-123 agent2
4556
- switchman lease acquire task-123 agent2 --agent cursor
4557
- `)
4558
- .action((taskId, worktree, opts) => {
4559
- const repoRoot = getRepo();
4560
- const db = getDb(repoRoot);
4561
- const task = getTask(db, taskId);
4562
- const lease = startTaskLease(db, taskId, worktree, opts.agent || null);
4563
- db.close();
4564
-
4565
- if (!lease || !task) {
4566
- if (opts.json) console.log(JSON.stringify({ lease: null, task: null }));
4567
- else printErrorWithNext('Could not start a work session. The task may not exist or may already be in progress.', 'switchman task list --status pending');
4568
- process.exitCode = 1;
4569
- return;
4570
- }
4571
-
4572
- if (opts.json) {
4573
- console.log(JSON.stringify({
4574
- lease,
4575
- task: taskJsonWithLease(task, worktree, lease).task,
4576
- }, null, 2));
4577
- return;
4578
- }
4579
-
4580
- console.log(`${chalk.green('✓')} Lease acquired ${chalk.dim(lease.id)}`);
4581
- console.log(` ${chalk.dim('task:')} ${chalk.bold(task.title)}`);
4582
- console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(worktree)}`);
4583
- });
4584
-
4585
- leaseCmd
4586
- .command('next')
4587
- .description('Start the next pending task and open a tracked work session for it')
4588
- .option('--json', 'Output as JSON')
4589
- .option('--worktree <name>', 'Workspace to assign the task to (defaults to the current folder name)')
4590
- .option('--agent <name>', 'Agent identifier for logging')
4591
- .addHelpText('after', `
4592
- Examples:
4593
- switchman lease next
4594
- switchman lease next --json
4595
- switchman lease next --worktree agent2 --agent cursor
4596
- `)
4597
- .action((opts) => {
4598
- const repoRoot = getRepo();
4599
- const worktreeName = getCurrentWorktreeName(opts.worktree);
4600
- const { task, lease, exhausted } = acquireNextTaskLeaseWithRetries(repoRoot, worktreeName, opts.agent || null);
4601
-
4602
- if (!task) {
4603
- if (opts.json) console.log(JSON.stringify({ task: null, lease: null }));
4604
- else if (exhausted) console.log(chalk.dim('No pending tasks. Add one with `switchman task add "Your task"`.'));
4605
- else console.log(chalk.yellow('Tasks were claimed by other agents during assignment. Run again to get the next one.'));
4606
- return;
4607
- }
4608
-
4609
- if (!lease) {
4610
- if (opts.json) console.log(JSON.stringify({ task: null, lease: null, message: 'Task claimed by another agent — try again' }));
4611
- else console.log(chalk.yellow('Task was just claimed by another agent. Run again to get the next one.'));
4612
- return;
4613
- }
4614
-
4615
- if (opts.json) {
4616
- console.log(JSON.stringify({
4617
- lease,
4618
- ...taskJsonWithLease(task, worktreeName, lease),
4619
- }, null, 2));
4620
- return;
4621
- }
4622
-
4623
- console.log(`${chalk.green('✓')} Lease acquired: ${chalk.bold(task.title)}`);
4624
- console.log(` ${chalk.dim('task:')} ${task.id} ${chalk.dim('lease:')} ${lease.id}`);
4625
- console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(worktreeName)} ${chalk.dim('priority:')} ${task.priority}`);
4626
- });
4627
-
4628
- leaseCmd
4629
- .command('list')
4630
- .description('List leases, newest first')
4631
- .option('-s, --status <status>', 'Filter by status (active|completed|failed|expired)')
4632
- .action((opts) => {
4633
- const repoRoot = getRepo();
4634
- const db = getDb(repoRoot);
4635
- const leases = listLeases(db, opts.status);
4636
- db.close();
4637
-
4638
- if (!leases.length) {
4639
- console.log(chalk.dim('No leases found.'));
4640
- return;
4641
- }
4642
-
4643
- console.log('');
4644
- for (const lease of leases) {
4645
- console.log(`${statusBadge(lease.status)} ${chalk.bold(lease.task_title)}`);
4646
- console.log(` ${chalk.dim('lease:')} ${lease.id} ${chalk.dim('task:')} ${lease.task_id}`);
4647
- console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(lease.worktree)} ${chalk.dim('agent:')} ${lease.agent || 'unknown'}`);
4648
- console.log(` ${chalk.dim('started:')} ${lease.started_at} ${chalk.dim('heartbeat:')} ${lease.heartbeat_at}`);
4649
- if (lease.failure_reason) console.log(` ${chalk.red(lease.failure_reason)}`);
4650
- console.log('');
4651
- }
4652
- });
4653
-
4654
- leaseCmd
4655
- .command('heartbeat <leaseId>')
4656
- .description('Refresh the heartbeat timestamp for an active lease')
4657
- .option('--agent <name>', 'Agent identifier for logging')
4658
- .option('--json', 'Output as JSON')
4659
- .action((leaseId, opts) => {
4660
- const repoRoot = getRepo();
4661
- const db = getDb(repoRoot);
4662
- const lease = heartbeatLease(db, leaseId, opts.agent || null);
4663
- db.close();
4664
-
4665
- if (!lease) {
4666
- if (opts.json) console.log(JSON.stringify({ lease: null }));
4667
- else printErrorWithNext(`No active work session found for ${leaseId}.`, 'switchman lease list --status active');
4668
- process.exitCode = 1;
4669
- return;
4670
- }
4671
-
4672
- if (opts.json) {
4673
- console.log(JSON.stringify({ lease }, null, 2));
4674
- return;
4675
- }
4676
-
4677
- console.log(`${chalk.green('✓')} Heartbeat refreshed for ${chalk.dim(lease.id)}`);
4678
- console.log(` ${chalk.dim('task:')} ${lease.task_title} ${chalk.dim('worktree:')} ${chalk.cyan(lease.worktree)}`);
4679
- });
4680
-
4681
- leaseCmd
4682
- .command('reap')
4683
- .description('Clean up abandoned work sessions and release their file locks')
4684
- .option('--stale-after-minutes <minutes>', 'Age threshold for staleness')
4685
- .option('--json', 'Output as JSON')
4686
- .addHelpText('after', `
4687
- Examples:
4688
- switchman lease reap
4689
- switchman lease reap --stale-after-minutes 20
4690
- `)
4691
- .action((opts) => {
4692
- const repoRoot = getRepo();
4693
- const db = getDb(repoRoot);
4694
- const leasePolicy = loadLeasePolicy(repoRoot);
4695
- const staleAfterMinutes = opts.staleAfterMinutes
4696
- ? Number.parseInt(opts.staleAfterMinutes, 10)
4697
- : leasePolicy.stale_after_minutes;
4698
- const expired = reapStaleLeases(db, staleAfterMinutes, {
4699
- requeueTask: leasePolicy.requeue_task_on_reap,
4700
- });
4701
- db.close();
4702
-
4703
- if (opts.json) {
4704
- console.log(JSON.stringify({ stale_after_minutes: staleAfterMinutes, expired }, null, 2));
4705
- return;
4706
- }
4707
-
4708
- if (!expired.length) {
4709
- console.log(chalk.dim(`No stale leases older than ${staleAfterMinutes} minute(s).`));
4710
- return;
4711
- }
4712
-
4713
- console.log(`${chalk.green('✓')} Reaped ${expired.length} stale lease(s)`);
4714
- for (const lease of expired) {
4715
- console.log(` ${chalk.dim(lease.id)} ${chalk.cyan(lease.worktree)} → ${lease.task_title}`);
4716
- }
4717
- });
4718
-
4719
- const leasePolicyCmd = leaseCmd.command('policy').description('Inspect or update the stale-lease policy for this repo');
4720
-
4721
- leasePolicyCmd
4722
- .command('set')
4723
- .description('Persist a stale-lease policy for this repo')
4724
- .option('--heartbeat-interval-seconds <seconds>', 'Recommended heartbeat interval')
4725
- .option('--stale-after-minutes <minutes>', 'Age threshold for staleness')
4726
- .option('--reap-on-status-check <boolean>', 'Automatically reap stale leases during `switchman status`')
4727
- .option('--requeue-task-on-reap <boolean>', 'Return stale tasks to pending instead of failing them')
4728
- .option('--json', 'Output as JSON')
4729
- .action((opts) => {
4730
- const repoRoot = getRepo();
4731
- const current = loadLeasePolicy(repoRoot);
4732
- const next = {
4733
- ...current,
4734
- ...(opts.heartbeatIntervalSeconds ? { heartbeat_interval_seconds: Number.parseInt(opts.heartbeatIntervalSeconds, 10) } : {}),
4735
- ...(opts.staleAfterMinutes ? { stale_after_minutes: Number.parseInt(opts.staleAfterMinutes, 10) } : {}),
4736
- ...(opts.reapOnStatusCheck ? { reap_on_status_check: opts.reapOnStatusCheck === 'true' } : {}),
4737
- ...(opts.requeueTaskOnReap ? { requeue_task_on_reap: opts.requeueTaskOnReap === 'true' } : {}),
4738
- };
4739
- const path = writeLeasePolicy(repoRoot, next);
4740
- const saved = loadLeasePolicy(repoRoot);
4741
-
4742
- if (opts.json) {
4743
- console.log(JSON.stringify({ path, policy: saved }, null, 2));
4744
- return;
5240
+ process.exitCode = 1;
4745
5241
  }
4746
-
4747
- console.log(`${chalk.green('✓')} Lease policy updated`);
4748
- console.log(` ${chalk.dim(path)}`);
4749
- console.log(` ${chalk.dim('heartbeat_interval_seconds:')} ${saved.heartbeat_interval_seconds}`);
4750
- console.log(` ${chalk.dim('stale_after_minutes:')} ${saved.stale_after_minutes}`);
4751
- console.log(` ${chalk.dim('reap_on_status_check:')} ${saved.reap_on_status_check}`);
4752
- console.log(` ${chalk.dim('requeue_task_on_reap:')} ${saved.requeue_task_on_reap}`);
4753
5242
  });
4754
5243
 
4755
- leasePolicyCmd
4756
- .description('Show the active stale-lease policy for this repo')
4757
- .option('--json', 'Output as JSON')
4758
- .action((opts) => {
4759
- const repoRoot = getRepo();
4760
- const policy = loadLeasePolicy(repoRoot);
4761
- if (opts.json) {
4762
- console.log(JSON.stringify({ policy }, null, 2));
4763
- return;
4764
- }
5244
+ // ── lease ────────────────────────────────────────────────────────────────────
4765
5245
 
4766
- console.log(chalk.bold('Lease policy'));
4767
- console.log(` ${chalk.dim('heartbeat_interval_seconds:')} ${policy.heartbeat_interval_seconds}`);
4768
- console.log(` ${chalk.dim('stale_after_minutes:')} ${policy.stale_after_minutes}`);
4769
- console.log(` ${chalk.dim('reap_on_status_check:')} ${policy.reap_on_status_check}`);
4770
- console.log(` ${chalk.dim('requeue_task_on_reap:')} ${policy.requeue_task_on_reap}`);
4771
- });
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
+ });
4772
5263
 
4773
5264
  // ── worktree ───────────────────────────────────────────────────────────────────
4774
5265
 
4775
- const wtCmd = program.command('worktree').alias('workspace').description('Manage registered workspaces (Git worktrees)');
4776
- wtCmd.addHelpText('after', `
4777
- Plain English:
4778
- worktree = the Git feature behind each agent workspace
4779
-
4780
- Examples:
4781
- switchman worktree list
4782
- switchman workspace list
4783
- switchman worktree sync
4784
- `);
4785
-
4786
- wtCmd
4787
- .command('add <name> <path> <branch>')
4788
- .description('Register a workspace with Switchman')
4789
- .option('--agent <name>', 'Agent assigned to this worktree')
4790
- .action((name, path, branch, opts) => {
4791
- const repoRoot = getRepo();
4792
- const db = getDb(repoRoot);
4793
- registerWorktree(db, { name, path, branch, agent: opts.agent });
4794
- db.close();
4795
- console.log(`${chalk.green('✓')} Registered worktree: ${chalk.cyan(name)}`);
4796
- });
4797
-
4798
- wtCmd
4799
- .command('list')
4800
- .description('List all registered workspaces')
4801
- .action(() => {
4802
- const repoRoot = getRepo();
4803
- const db = getDb(repoRoot);
4804
- const worktrees = listWorktrees(db);
4805
- const gitWorktrees = listGitWorktrees(repoRoot);
4806
- db.close();
4807
-
4808
- if (!worktrees.length && !gitWorktrees.length) {
4809
- console.log(chalk.dim('No workspaces found. Run `switchman setup --agents 3` or `switchman worktree sync`.'));
4810
- return;
4811
- }
4812
-
4813
- // Show git worktrees (source of truth) annotated with db info
4814
- console.log('');
4815
- console.log(chalk.bold('Git Worktrees:'));
4816
- for (const wt of gitWorktrees) {
4817
- const dbInfo = worktrees.find(d => d.path === wt.path);
4818
- const agent = dbInfo?.agent ? chalk.cyan(dbInfo.agent) : chalk.dim('no agent');
4819
- const status = dbInfo?.status ? statusBadge(dbInfo.status) : chalk.dim('unregistered');
4820
- const compliance = dbInfo?.compliance_state ? statusBadge(dbInfo.compliance_state) : chalk.dim('unknown');
4821
- console.log(` ${chalk.bold(wt.name.padEnd(20))} ${status} ${compliance} branch: ${chalk.cyan(wt.branch || 'unknown')} agent: ${agent}`);
4822
- console.log(` ${chalk.dim(wt.path)}`);
4823
- }
4824
- console.log('');
4825
- });
4826
-
4827
- wtCmd
4828
- .command('sync')
4829
- .description('Sync Git workspaces into the Switchman database')
4830
- .action(() => {
4831
- const repoRoot = getRepo();
4832
- const db = getDb(repoRoot);
4833
- const gitWorktrees = listGitWorktrees(repoRoot);
4834
- for (const wt of gitWorktrees) {
4835
- registerWorktree(db, { name: wt.name, path: wt.path, branch: wt.branch || 'unknown' });
4836
- }
4837
- db.close();
4838
- installMcpConfig([...new Set([repoRoot, ...gitWorktrees.map((wt) => wt.path)])]);
4839
- console.log(`${chalk.green('✓')} Synced ${gitWorktrees.length} worktree(s) from git`);
4840
- });
5266
+ registerWorktreeCommands(program, {
5267
+ chalk,
5268
+ evaluateRepoCompliance,
5269
+ getDb,
5270
+ getRepo,
5271
+ installMcpConfig,
5272
+ listGitWorktrees,
5273
+ listWorktrees,
5274
+ registerWorktree,
5275
+ statusBadge,
5276
+ });
4841
5277
 
4842
5278
  // ── claim ──────────────────────────────────────────────────────────────────────
4843
5279
 
@@ -4845,13 +5281,14 @@ program
4845
5281
  .command('claim <taskId> <worktree> [files...]')
4846
5282
  .description('Lock files for a task before editing')
4847
5283
  .option('--agent <name>', 'Agent name')
4848
- .option('--force', 'Claim even if conflicts exist')
5284
+ .option('--force', 'Emergency override for manual recovery when a conflicting claim is known to be stale or wrong')
4849
5285
  .addHelpText('after', `
4850
5286
  Examples:
4851
5287
  switchman claim task-123 agent2 src/auth.js src/server.js
4852
5288
  switchman claim task-123 agent2 src/auth.js --agent cursor
4853
5289
 
4854
5290
  Use this before editing files in a shared repo.
5291
+ Only use --force for operator-led recovery after checking switchman status or switchman explain claim <path>.
4855
5292
  `)
4856
5293
  .action((taskId, worktree, files, opts) => {
4857
5294
  if (!files.length) {
@@ -4870,7 +5307,7 @@ Use this before editing files in a shared repo.
4870
5307
  for (const c of conflicts) {
4871
5308
  console.log(` ${chalk.yellow(c.file)} → already claimed by worktree ${chalk.cyan(c.claimedBy.worktree)} (task: ${c.claimedBy.task_title})`);
4872
5309
  }
4873
- console.log(chalk.dim('\nUse --force to claim anyway, or pick different files first.'));
5310
+ console.log(chalk.dim('\nDo not use --force as a shortcut. Check status or explain the claim first, then only override if the existing claim is known-bad.'));
4874
5311
  console.log(`${chalk.yellow('next:')} switchman status`);
4875
5312
  process.exitCode = 1;
4876
5313
  return;
@@ -4878,6 +5315,12 @@ Use this before editing files in a shared repo.
4878
5315
 
4879
5316
  const lease = claimFiles(db, taskId, worktree, files, opts.agent);
4880
5317
  console.log(`${chalk.green('✓')} Claimed ${files.length} file(s) for task ${chalk.cyan(taskId)} (${chalk.dim(lease.id)})`);
5318
+ pushSyncEvent('claim_added', {
5319
+ task_id: taskId,
5320
+ lease_id: lease.id,
5321
+ file_count: files.length,
5322
+ files: files.slice(0, 10),
5323
+ }, { worktree }).catch(() => {});
4881
5324
  files.forEach(f => console.log(` ${chalk.dim(f)}`));
4882
5325
  } catch (err) {
4883
5326
  printErrorWithNext(err.message, 'switchman task list --status in_progress');
@@ -4893,9 +5336,11 @@ program
4893
5336
  .action((taskId) => {
4894
5337
  const repoRoot = getRepo();
4895
5338
  const db = getDb(repoRoot);
5339
+ const task = getTask(db, taskId);
4896
5340
  releaseFileClaims(db, taskId);
4897
5341
  db.close();
4898
5342
  console.log(`${chalk.green('✓')} Released all claims for task ${chalk.cyan(taskId)}`);
5343
+ pushSyncEvent('claim_released', { task_id: taskId }, { worktree: task?.worktree || null }).catch(() => {});
4899
5344
  });
4900
5345
 
4901
5346
  program
@@ -5171,12 +5616,14 @@ program
5171
5616
  .description('Show one dashboard view of what is running, blocked, and ready next')
5172
5617
  .option('--json', 'Output raw JSON')
5173
5618
  .option('--watch', 'Keep refreshing status in the terminal')
5619
+ .option('--repair', 'Repair safe interrupted queue and pipeline state before rendering status')
5174
5620
  .option('--watch-interval-ms <n>', 'Polling interval for --watch mode', '2000')
5175
5621
  .option('--max-cycles <n>', 'Maximum refresh cycles before exiting', '0')
5176
5622
  .addHelpText('after', `
5177
5623
  Examples:
5178
5624
  switchman status
5179
5625
  switchman status --watch
5626
+ switchman status --repair
5180
5627
  switchman status --json
5181
5628
 
5182
5629
  Use this first when the repo feels stuck.
@@ -5194,13 +5641,40 @@ Use this first when the repo feels stuck.
5194
5641
  console.clear();
5195
5642
  }
5196
5643
 
5644
+ let repairResult = null;
5645
+ if (opts.repair) {
5646
+ const repairDb = getDb(repoRoot);
5647
+ try {
5648
+ repairResult = repairRepoState(repairDb, repoRoot);
5649
+ } finally {
5650
+ repairDb.close();
5651
+ }
5652
+ }
5653
+
5197
5654
  const report = await collectStatusSnapshot(repoRoot);
5655
+ const [teamActivity, teamState] = await Promise.all([
5656
+ pullActiveTeamMembers(),
5657
+ pullTeamState(),
5658
+ ]);
5659
+ const myUserId = readCredentials()?.user_id;
5660
+ const otherMembers = teamActivity.filter(e => e.user_id !== myUserId);
5661
+ const teamSummary = summarizeTeamCoordinationState(teamState, myUserId);
5198
5662
  cycles += 1;
5199
5663
 
5200
5664
  if (opts.json) {
5201
- console.log(JSON.stringify(watch ? { ...report, watch: true, cycles } : report, null, 2));
5665
+ const payload = watch ? { ...report, watch: true, cycles } : report;
5666
+ const withTeam = { ...payload, team_sync: { summary: teamSummary, recent_events: teamState.filter((event) => event.user_id !== myUserId).slice(0, 25) } };
5667
+ console.log(JSON.stringify(opts.repair ? { ...withTeam, repair: repairResult } : withTeam, null, 2));
5202
5668
  } else {
5203
- renderUnifiedStatusReport(report);
5669
+ if (opts.repair && repairResult) {
5670
+ printRepairSummary(repairResult, {
5671
+ repairedHeading: `${chalk.green('✓')} Repaired safe interrupted repo state before rendering status`,
5672
+ noRepairHeading: `${chalk.green('✓')} No repo repair action needed before rendering status`,
5673
+ limit: 6,
5674
+ });
5675
+ console.log('');
5676
+ }
5677
+ renderUnifiedStatusReport(report, { teamActivity: otherMembers, teamSummary });
5204
5678
  if (watch) {
5205
5679
  const signature = buildWatchSignature(report);
5206
5680
  const watchState = lastSignature === null
@@ -5229,6 +5703,42 @@ Use this first when the repo feels stuck.
5229
5703
  }
5230
5704
  });
5231
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,
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;
5739
+ }
5740
+ });
5741
+
5232
5742
  program
5233
5743
  .command('repair')
5234
5744
  .description('Repair safe interrupted queue and pipeline state across the repo')
@@ -5267,9 +5777,11 @@ program
5267
5777
  }
5268
5778
  });
5269
5779
 
5270
- program
5780
+ const doctorCmd = program
5271
5781
  .command('doctor')
5272
- .description('Show one operator-focused health view: what is running, what is blocked, and what to do next')
5782
+ .description('Show one operator-focused health view: what is running, what is blocked, and what to do next');
5783
+ doctorCmd._switchmanAdvanced = true;
5784
+ doctorCmd
5273
5785
  .option('--repair', 'Repair safe interrupted queue and pipeline state before reporting health')
5274
5786
  .option('--json', 'Output raw JSON')
5275
5787
  .addHelpText('after', `
@@ -5358,377 +5870,74 @@ Examples:
5358
5870
  ? report.attention.slice(0, 6).flatMap((item) => {
5359
5871
  const lines = [`${item.severity === 'block' ? renderChip('BLOCKED', item.kind || 'item', chalk.red) : renderChip('WATCH', item.kind || 'item', chalk.yellow)} ${item.title}`];
5360
5872
  if (item.detail) lines.push(` ${chalk.dim(item.detail)}`);
5361
- lines.push(` ${chalk.yellow('next:')} ${item.next_step}`);
5362
- if (item.command) lines.push(` ${chalk.cyan('run:')} ${item.command}`);
5363
- return lines;
5364
- })
5365
- : [chalk.green('Nothing urgent.')];
5366
-
5367
- const staleClusterLines = report.merge_readiness.stale_clusters?.length > 0
5368
- ? report.merge_readiness.stale_clusters.slice(0, 5).flatMap((cluster) => {
5369
- const lines = [`${cluster.severity === 'block' ? renderChip('STALE', cluster.affected_pipeline_id || cluster.affected_task_ids[0], chalk.red) : renderChip('WATCH', cluster.affected_pipeline_id || cluster.affected_task_ids[0], chalk.yellow)} ${cluster.title}`];
5370
- lines.push(` ${chalk.dim(cluster.detail)}`);
5371
- if (cluster.causal_group_size > 1) lines.push(` ${chalk.dim('cause:')} ${cluster.causal_group_summary} ${chalk.dim(`(${cluster.causal_group_rank}/${cluster.causal_group_size} in same stale wave)`)}${cluster.related_affected_pipelines?.length ? ` ${chalk.dim(`related:${cluster.related_affected_pipelines.join(', ')}`)}` : ''}`);
5372
- lines.push(` ${chalk.dim('areas:')} ${cluster.stale_areas.join(', ')}`);
5373
- lines.push(` ${chalk.dim('rerun priority:')} ${cluster.rerun_priority} ${chalk.dim(`score:${cluster.rerun_priority_score}`)}${cluster.highest_affected_priority ? ` ${chalk.dim(`affected-priority:${cluster.highest_affected_priority}`)}` : ''}${cluster.rerun_breadth_score ? ` ${chalk.dim(`breadth:${cluster.rerun_breadth_score}`)}` : ''}`);
5374
- lines.push(` ${chalk.yellow('next:')} ${cluster.next_step}`);
5375
- lines.push(` ${chalk.cyan('run:')} ${cluster.command}`);
5376
- return lines;
5377
- })
5378
- : [chalk.green('No stale dependency clusters.')];
5379
-
5380
- const nextStepLines = [
5381
- ...report.next_steps.slice(0, 4).map((step) => `- ${step}`),
5382
- '',
5383
- ...report.suggested_commands.slice(0, 4).map((command) => `${chalk.cyan('$')} ${command}`),
5384
- ];
5385
-
5386
- console.log('');
5387
- console.log(chalk.bold('Attention now:'));
5388
- for (const block of [
5389
- renderPanel('Running now', runningLines, chalk.cyan),
5390
- renderPanel('Attention now', attentionLines, report.attention.some((item) => item.severity === 'block') ? chalk.red : report.attention.length > 0 ? chalk.yellow : chalk.green),
5391
- renderPanel('Stale clusters', staleClusterLines, report.merge_readiness.stale_clusters?.some((cluster) => cluster.severity === 'block') ? chalk.red : (report.merge_readiness.stale_clusters?.length || 0) > 0 ? chalk.yellow : chalk.green),
5392
- renderPanel('Recommended next steps', nextStepLines, chalk.green),
5393
- ]) {
5394
- for (const line of block) console.log(line);
5395
- console.log('');
5396
- }
5397
- });
5398
-
5399
- // ── gate ─────────────────────────────────────────────────────────────────────
5400
-
5401
- const gateCmd = program.command('gate').description('Safety checks for edits, merges, and CI');
5402
- gateCmd.addHelpText('after', `
5403
- Examples:
5404
- switchman gate ci
5405
- switchman gate ai
5406
- switchman gate install-ci
5407
- `);
5408
-
5409
- const auditCmd = program.command('audit').description('Inspect and verify the tamper-evident audit trail');
5410
-
5411
- auditCmd
5412
- .command('change <pipelineId>')
5413
- .description('Show a signed, operator-friendly history for one pipeline')
5414
- .option('--json', 'Output raw JSON')
5415
- .action((pipelineId, options) => {
5416
- const repoRoot = getRepo();
5417
- const db = getDb(repoRoot);
5418
-
5419
- try {
5420
- const report = buildPipelineHistoryReport(db, repoRoot, pipelineId);
5421
- db.close();
5422
-
5423
- if (options.json) {
5424
- console.log(JSON.stringify(report, null, 2));
5425
- return;
5426
- }
5427
-
5428
- console.log(chalk.bold(`Audit history for pipeline ${report.pipeline_id}`));
5429
- console.log(` ${chalk.dim('title:')} ${report.title}`);
5430
- console.log(` ${chalk.dim('events:')} ${report.events.length}`);
5431
- console.log(` ${chalk.yellow('next:')} ${report.next_action}`);
5432
- for (const event of report.events.slice(-20)) {
5433
- const status = event.status ? ` ${statusBadge(event.status).trim()}` : '';
5434
- console.log(` ${chalk.dim(event.created_at)} ${chalk.cyan(event.label)}${status}`);
5435
- console.log(` ${event.summary}`);
5436
- }
5437
- } catch (err) {
5438
- db.close();
5439
- printErrorWithNext(err.message, `switchman pipeline status ${pipelineId}`);
5440
- process.exitCode = 1;
5441
- }
5442
- });
5443
-
5444
- auditCmd
5445
- .command('verify')
5446
- .description('Verify the audit log hash chain and project signatures')
5447
- .option('--json', 'Output verification details as JSON')
5448
- .action((options) => {
5449
- const repo = getRepo();
5450
- const db = getDb(repo);
5451
- const result = verifyAuditTrail(db);
5452
-
5453
- if (options.json) {
5454
- console.log(JSON.stringify(result, null, 2));
5455
- process.exit(result.ok ? 0 : 1);
5456
- }
5457
-
5458
- if (result.ok) {
5459
- console.log(chalk.green(`Audit trail verified: ${result.count} signed events in order.`));
5460
- return;
5461
- }
5462
-
5463
- console.log(chalk.red(`Audit trail verification failed: ${result.failures.length} problem(s) across ${result.count} events.`));
5464
- for (const failure of result.failures.slice(0, 10)) {
5465
- const prefix = failure.sequence ? `#${failure.sequence}` : `event ${failure.id}`;
5466
- console.log(` ${chalk.red(prefix)} ${failure.reason_code}: ${failure.message}`);
5467
- }
5468
- if (result.failures.length > 10) {
5469
- console.log(chalk.dim(` ...and ${result.failures.length - 10} more`));
5470
- }
5471
- process.exit(1);
5472
- });
5473
-
5474
- gateCmd
5475
- .command('commit')
5476
- .description('Validate current worktree changes against the active lease and claims')
5477
- .option('--json', 'Output raw JSON')
5478
- .action((opts) => {
5479
- const repoRoot = getRepo();
5480
- const db = getDb(repoRoot);
5481
- const result = runCommitGate(db, repoRoot);
5482
- db.close();
5483
-
5484
- if (opts.json) {
5485
- console.log(JSON.stringify(result, null, 2));
5486
- } else if (result.ok) {
5487
- console.log(`${chalk.green('✓')} ${result.summary}`);
5488
- } else {
5489
- console.log(chalk.red(`✗ ${result.summary}`));
5490
- for (const violation of result.violations) {
5491
- const label = violation.file || '(worktree)';
5492
- console.log(` ${chalk.yellow(label)} ${chalk.dim(violation.reason_code)}`);
5493
- }
5494
- }
5495
-
5496
- if (!result.ok) process.exitCode = 1;
5497
- });
5498
-
5499
- gateCmd
5500
- .command('merge')
5501
- .description('Validate current worktree changes before recording a merge commit')
5502
- .option('--json', 'Output raw JSON')
5503
- .action((opts) => {
5504
- const repoRoot = getRepo();
5505
- const db = getDb(repoRoot);
5506
- const result = runCommitGate(db, repoRoot);
5507
- db.close();
5508
-
5509
- if (opts.json) {
5510
- console.log(JSON.stringify(result, null, 2));
5511
- } else if (result.ok) {
5512
- console.log(`${chalk.green('✓')} Merge gate passed for ${chalk.cyan(result.worktree || 'current worktree')}.`);
5513
- } else {
5514
- console.log(chalk.red(`✗ Merge gate rejected changes in ${chalk.cyan(result.worktree || 'current worktree')}.`));
5515
- for (const violation of result.violations) {
5516
- const label = violation.file || '(worktree)';
5517
- console.log(` ${chalk.yellow(label)} ${chalk.dim(violation.reason_code)}`);
5518
- }
5519
- }
5520
-
5521
- if (!result.ok) process.exitCode = 1;
5522
- });
5523
-
5524
- gateCmd
5525
- .command('install')
5526
- .description('Install git hooks that run the Switchman commit and merge gates')
5527
- .action(() => {
5528
- const repoRoot = getRepo();
5529
- const hookPaths = installGateHooks(repoRoot);
5530
- console.log(`${chalk.green('✓')} Installed pre-commit hook at ${chalk.cyan(hookPaths.pre_commit)}`);
5531
- console.log(`${chalk.green('✓')} Installed pre-merge-commit hook at ${chalk.cyan(hookPaths.pre_merge_commit)}`);
5532
- });
5533
-
5534
- gateCmd
5535
- .command('ci')
5536
- .description('Run a repo-level enforcement gate suitable for CI, merges, or PR validation')
5537
- .option('--github', 'Write GitHub Actions step summary/output when GITHUB_* env vars are present')
5538
- .option('--github-step-summary <path>', 'Path to write GitHub Actions step summary markdown')
5539
- .option('--github-output <path>', 'Path to write GitHub Actions outputs')
5540
- .option('--json', 'Output raw JSON')
5541
- .action(async (opts) => {
5542
- const repoRoot = getRepo();
5543
- const db = getDb(repoRoot);
5544
- const report = await scanAllWorktrees(db, repoRoot);
5545
- const aiGate = await runAiMergeGate(db, repoRoot);
5546
- db.close();
5547
-
5548
- const ok = report.conflicts.length === 0
5549
- && report.fileConflicts.length === 0
5550
- && (report.ownershipConflicts?.length || 0) === 0
5551
- && (report.semanticConflicts?.length || 0) === 0
5552
- && report.unclaimedChanges.length === 0
5553
- && report.complianceSummary.non_compliant === 0
5554
- && report.complianceSummary.stale === 0
5555
- && aiGate.status !== 'blocked'
5556
- && (aiGate.dependency_invalidations?.filter((item) => item.severity === 'blocked').length || 0) === 0;
5557
-
5558
- const result = {
5559
- ok,
5560
- summary: ok
5561
- ? `Repo gate passed for ${report.worktrees.length} worktree(s).`
5562
- : 'Repo gate rejected unmanaged changes, stale leases, ownership conflicts, stale dependency invalidations, or boundary validation failures.',
5563
- compliance: report.complianceSummary,
5564
- unclaimed_changes: report.unclaimedChanges,
5565
- file_conflicts: report.fileConflicts,
5566
- ownership_conflicts: report.ownershipConflicts || [],
5567
- semantic_conflicts: report.semanticConflicts || [],
5568
- branch_conflicts: report.conflicts,
5569
- ai_gate_status: aiGate.status,
5570
- boundary_validations: aiGate.boundary_validations || [],
5571
- dependency_invalidations: aiGate.dependency_invalidations || [],
5572
- };
5573
-
5574
- const githubTargets = resolveGitHubOutputTargets(opts);
5575
- if (githubTargets.stepSummaryPath || githubTargets.outputPath) {
5576
- writeGitHubCiStatus({
5577
- result,
5578
- stepSummaryPath: githubTargets.stepSummaryPath,
5579
- outputPath: githubTargets.outputPath,
5580
- });
5581
- }
5582
-
5583
- if (opts.json) {
5584
- console.log(JSON.stringify(result, null, 2));
5585
- } else if (ok) {
5586
- console.log(`${chalk.green('✓')} ${result.summary}`);
5587
- } else {
5588
- console.log(chalk.red(`✗ ${result.summary}`));
5589
- if (result.unclaimed_changes.length > 0) {
5590
- console.log(chalk.bold(' Unclaimed changes:'));
5591
- for (const entry of result.unclaimed_changes) {
5592
- console.log(` ${chalk.cyan(entry.worktree)}: ${entry.files.join(', ')}`);
5593
- }
5594
- }
5595
- if (result.file_conflicts.length > 0) {
5596
- console.log(chalk.bold(' File conflicts:'));
5597
- for (const conflict of result.file_conflicts) {
5598
- console.log(` ${chalk.yellow(conflict.file)} ${chalk.dim(conflict.worktrees.join(', '))}`);
5599
- }
5600
- }
5601
- if (result.ownership_conflicts.length > 0) {
5602
- console.log(chalk.bold(' Ownership conflicts:'));
5603
- for (const conflict of result.ownership_conflicts) {
5604
- if (conflict.type === 'subsystem_overlap') {
5605
- console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)} ${chalk.dim(`subsystem:${conflict.subsystemTag}`)}`);
5606
- } else {
5607
- console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)} ${chalk.dim(`${conflict.scopeA} ↔ ${conflict.scopeB}`)}`);
5608
- }
5609
- }
5610
- }
5611
- if (result.semantic_conflicts.length > 0) {
5612
- console.log(chalk.bold(' Semantic conflicts:'));
5613
- for (const conflict of result.semantic_conflicts) {
5614
- console.log(` ${chalk.yellow(conflict.object_name)} ${chalk.dim(`${conflict.worktreeA} vs ${conflict.worktreeB}`)}`);
5615
- }
5616
- }
5617
- if (result.branch_conflicts.length > 0) {
5618
- console.log(chalk.bold(' Branch conflicts:'));
5619
- for (const conflict of result.branch_conflicts) {
5620
- console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)}`);
5621
- }
5622
- }
5623
- if (result.boundary_validations.length > 0) {
5624
- console.log(chalk.bold(' Boundary validations:'));
5625
- for (const validation of result.boundary_validations) {
5626
- console.log(` ${chalk.yellow(validation.task_id)} ${chalk.dim(validation.missing_task_types.join(', '))}`);
5627
- }
5628
- }
5629
- if (result.dependency_invalidations.length > 0) {
5630
- console.log(chalk.bold(' Stale dependency invalidations:'));
5631
- for (const invalidation of result.dependency_invalidations) {
5632
- console.log(` ${chalk.yellow(invalidation.affected_task_id)} ${chalk.dim(invalidation.stale_area)}`);
5633
- }
5634
- }
5635
- }
5636
-
5637
- await maybeCaptureTelemetry(ok ? 'gate_ci_passed' : 'gate_ci_failed', {
5638
- worktree_count: report.worktrees.length,
5639
- unclaimed_change_count: result.unclaimed_changes.length,
5640
- file_conflict_count: result.file_conflicts.length,
5641
- ownership_conflict_count: result.ownership_conflicts.length,
5642
- semantic_conflict_count: result.semantic_conflicts.length,
5643
- branch_conflict_count: result.branch_conflicts.length,
5644
- });
5645
-
5646
- if (!ok) process.exitCode = 1;
5647
- });
5648
-
5649
- gateCmd
5650
- .command('install-ci')
5651
- .description('Install a GitHub Actions workflow that runs the Switchman CI gate on PRs and pushes')
5652
- .option('--workflow-name <name>', 'Workflow file name', 'switchman-gate.yml')
5653
- .action((opts) => {
5654
- const repoRoot = getRepo();
5655
- const workflowPath = installGitHubActionsWorkflow(repoRoot, opts.workflowName);
5656
- console.log(`${chalk.green('✓')} Installed GitHub Actions workflow at ${chalk.cyan(workflowPath)}`);
5657
- });
5658
-
5659
- gateCmd
5660
- .command('ai')
5661
- .description('Run the AI-style merge check to assess risky overlap across workspaces')
5662
- .option('--json', 'Output raw JSON')
5663
- .action(async (opts) => {
5664
- const repoRoot = getRepo();
5665
- const db = getDb(repoRoot);
5666
- const result = await runAiMergeGate(db, repoRoot);
5667
- db.close();
5668
-
5669
- if (opts.json) {
5670
- console.log(JSON.stringify(result, null, 2));
5671
- } else {
5672
- const badge = result.status === 'pass'
5673
- ? chalk.green('PASS')
5674
- : result.status === 'warn'
5675
- ? chalk.yellow('WARN')
5676
- : chalk.red('BLOCK');
5677
- console.log(`${badge} ${result.summary}`);
5678
-
5679
- const riskyPairs = result.pairs.filter((pair) => pair.status !== 'pass');
5680
- if (riskyPairs.length > 0) {
5681
- console.log(chalk.bold(' Risky pairs:'));
5682
- for (const pair of riskyPairs) {
5683
- console.log(` ${chalk.cyan(pair.worktree_a)} ${chalk.dim('vs')} ${chalk.cyan(pair.worktree_b)} ${chalk.dim(pair.status)} ${chalk.dim(`score=${pair.score}`)}`);
5684
- for (const reason of pair.reasons.slice(0, 3)) {
5685
- console.log(` ${chalk.yellow(reason)}`);
5686
- }
5687
- }
5688
- }
5689
-
5690
- if ((result.boundary_validations?.length || 0) > 0) {
5691
- console.log(chalk.bold(' Boundary validations:'));
5692
- for (const validation of result.boundary_validations.slice(0, 5)) {
5693
- console.log(` ${chalk.cyan(validation.task_id)} ${chalk.dim(validation.severity)} ${chalk.dim(validation.missing_task_types.join(', '))}`);
5694
- if (validation.rationale?.[0]) {
5695
- console.log(` ${chalk.yellow(validation.rationale[0])}`);
5696
- }
5697
- }
5698
- }
5873
+ lines.push(` ${chalk.yellow('next:')} ${item.next_step}`);
5874
+ if (item.command) lines.push(` ${chalk.cyan('run:')} ${item.command}`);
5875
+ return lines;
5876
+ })
5877
+ : [chalk.green('Nothing urgent.')];
5699
5878
 
5700
- if ((result.dependency_invalidations?.length || 0) > 0) {
5701
- console.log(chalk.bold(' Stale dependency invalidations:'));
5702
- for (const invalidation of result.dependency_invalidations.slice(0, 5)) {
5703
- console.log(` ${chalk.cyan(invalidation.affected_task_id)} ${chalk.dim(invalidation.severity)} ${chalk.dim(invalidation.stale_area)}`);
5704
- }
5705
- }
5879
+ const staleClusterLines = report.merge_readiness.stale_clusters?.length > 0
5880
+ ? report.merge_readiness.stale_clusters.slice(0, 5).flatMap((cluster) => {
5881
+ const lines = [`${cluster.severity === 'block' ? renderChip('STALE', cluster.affected_pipeline_id || cluster.affected_task_ids[0], chalk.red) : renderChip('WATCH', cluster.affected_pipeline_id || cluster.affected_task_ids[0], chalk.yellow)} ${cluster.title}`];
5882
+ lines.push(` ${chalk.dim(cluster.detail)}`);
5883
+ if (cluster.causal_group_size > 1) lines.push(` ${chalk.dim('cause:')} ${cluster.causal_group_summary} ${chalk.dim(`(${cluster.causal_group_rank}/${cluster.causal_group_size} in same stale wave)`)}${cluster.related_affected_pipelines?.length ? ` ${chalk.dim(`related:${cluster.related_affected_pipelines.join(', ')}`)}` : ''}`);
5884
+ lines.push(` ${chalk.dim('areas:')} ${cluster.stale_areas.join(', ')}`);
5885
+ lines.push(` ${chalk.dim('rerun priority:')} ${cluster.rerun_priority} ${chalk.dim(`score:${cluster.rerun_priority_score}`)}${cluster.highest_affected_priority ? ` ${chalk.dim(`affected-priority:${cluster.highest_affected_priority}`)}` : ''}${cluster.rerun_breadth_score ? ` ${chalk.dim(`breadth:${cluster.rerun_breadth_score}`)}` : ''}`);
5886
+ lines.push(` ${chalk.yellow('next:')} ${cluster.next_step}`);
5887
+ lines.push(` ${chalk.cyan('run:')} ${cluster.command}`);
5888
+ return lines;
5889
+ })
5890
+ : [chalk.green('No stale dependency clusters.')];
5706
5891
 
5707
- if ((result.semantic_conflicts?.length || 0) > 0) {
5708
- console.log(chalk.bold(' Semantic conflicts:'));
5709
- for (const conflict of result.semantic_conflicts.slice(0, 5)) {
5710
- console.log(` ${chalk.cyan(conflict.object_name)} ${chalk.dim(conflict.type)} ${chalk.dim(`${conflict.worktreeA} vs ${conflict.worktreeB}`)}`);
5711
- }
5712
- }
5892
+ const nextStepLines = [
5893
+ ...report.next_steps.slice(0, 4).map((step) => `- ${step}`),
5894
+ '',
5895
+ ...report.suggested_commands.slice(0, 4).map((command) => `${chalk.cyan('$')} ${command}`),
5896
+ ];
5713
5897
 
5714
- const riskyWorktrees = result.worktrees.filter((worktree) => worktree.findings.length > 0);
5715
- if (riskyWorktrees.length > 0) {
5716
- console.log(chalk.bold(' Worktree signals:'));
5717
- for (const worktree of riskyWorktrees) {
5718
- console.log(` ${chalk.cyan(worktree.worktree)} ${chalk.dim(`score=${worktree.score}`)}`);
5719
- for (const finding of worktree.findings.slice(0, 2)) {
5720
- console.log(` ${chalk.yellow(finding)}`);
5721
- }
5722
- }
5723
- }
5898
+ console.log('');
5899
+ console.log(chalk.bold('Attention now:'));
5900
+ for (const block of [
5901
+ renderPanel('Running now', runningLines, chalk.cyan),
5902
+ renderPanel('Attention now', attentionLines, report.attention.some((item) => item.severity === 'block') ? chalk.red : report.attention.length > 0 ? chalk.yellow : chalk.green),
5903
+ renderPanel('Stale clusters', staleClusterLines, report.merge_readiness.stale_clusters?.some((cluster) => cluster.severity === 'block') ? chalk.red : (report.merge_readiness.stale_clusters?.length || 0) > 0 ? chalk.yellow : chalk.green),
5904
+ renderPanel('Recommended next steps', nextStepLines, chalk.green),
5905
+ ]) {
5906
+ for (const line of block) console.log(line);
5907
+ console.log('');
5724
5908
  }
5725
-
5726
- if (result.status === 'blocked') process.exitCode = 1;
5727
5909
  });
5728
5910
 
5911
+ // ── gate ─────────────────────────────────────────────────────────────────────
5912
+
5913
+ registerAuditCommands(program, {
5914
+ buildPipelineHistoryReport,
5915
+ chalk,
5916
+ getDb,
5917
+ getRepo,
5918
+ printErrorWithNext,
5919
+ statusBadge,
5920
+ verifyAuditTrail,
5921
+ });
5922
+
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
+ });
5936
+
5729
5937
  const semanticCmd = program
5730
5938
  .command('semantic')
5731
5939
  .description('Inspect or materialize the derived semantic code-object view');
5940
+ semanticCmd._switchmanAdvanced = true;
5732
5941
 
5733
5942
  semanticCmd
5734
5943
  .command('materialize')
@@ -5745,6 +5954,7 @@ semanticCmd
5745
5954
  const objectCmd = program
5746
5955
  .command('object')
5747
5956
  .description('Experimental object-source mode backed by canonical exported code objects');
5957
+ objectCmd._switchmanAdvanced = true;
5748
5958
 
5749
5959
  objectCmd
5750
5960
  .command('import')
@@ -5825,180 +6035,25 @@ objectCmd
5825
6035
 
5826
6036
  // ── monitor ──────────────────────────────────────────────────────────────────
5827
6037
 
5828
- const monitorCmd = program.command('monitor').description('Observe workspaces for runtime file changes');
5829
-
5830
- monitorCmd
5831
- .command('once')
5832
- .description('Capture one monitoring pass and log observed file changes')
5833
- .option('--json', 'Output raw JSON')
5834
- .option('--quarantine', 'Move or restore denied runtime changes immediately after detection')
5835
- .action((opts) => {
5836
- const repoRoot = getRepo();
5837
- const db = getDb(repoRoot);
5838
- const worktrees = listGitWorktrees(repoRoot);
5839
- const result = monitorWorktreesOnce(db, repoRoot, worktrees, { quarantine: opts.quarantine });
5840
- db.close();
5841
-
5842
- if (opts.json) {
5843
- console.log(JSON.stringify(result, null, 2));
5844
- return;
5845
- }
5846
-
5847
- if (result.events.length === 0) {
5848
- console.log(chalk.dim('No file changes observed since the last monitor snapshot.'));
5849
- return;
5850
- }
5851
-
5852
- console.log(`${chalk.green('✓')} Observed ${result.summary.total} file change(s)`);
5853
- for (const event of result.events) {
5854
- const badge = event.status === 'allowed' ? chalk.green('ALLOWED') : chalk.red('DENIED ');
5855
- const action = event.enforcement_action ? ` ${chalk.dim(event.enforcement_action)}` : '';
5856
- console.log(` ${badge} ${chalk.cyan(event.worktree)} ${chalk.yellow(event.file_path)} ${chalk.dim(event.change_type)}${event.reason_code ? ` ${chalk.dim(event.reason_code)}` : ''}${action}`);
5857
- }
5858
- });
5859
-
5860
- monitorCmd
5861
- .command('watch')
5862
- .description('Poll workspaces continuously and log observed file changes')
5863
- .option('--interval-ms <ms>', 'Polling interval in milliseconds', '2000')
5864
- .option('--quarantine', 'Move or restore denied runtime changes immediately after detection')
5865
- .option('--daemonized', 'Internal flag used by monitor start', false)
5866
- .action(async (opts) => {
5867
- const repoRoot = getRepo();
5868
- const intervalMs = Number.parseInt(opts.intervalMs, 10);
5869
-
5870
- if (!Number.isFinite(intervalMs) || intervalMs < 100) {
5871
- console.error(chalk.red('--interval-ms must be at least 100'));
5872
- process.exit(1);
5873
- }
5874
-
5875
- console.log(chalk.cyan(`Watching workspaces every ${intervalMs}ms. Press Ctrl+C to stop.`));
5876
-
5877
- let stopped = false;
5878
- const stop = () => {
5879
- stopped = true;
5880
- process.stdout.write('\n');
5881
- if (opts.daemonized) {
5882
- clearMonitorState(repoRoot);
5883
- }
5884
- };
5885
- process.on('SIGINT', stop);
5886
- process.on('SIGTERM', stop);
5887
-
5888
- while (!stopped) {
5889
- const db = getDb(repoRoot);
5890
- const worktrees = listGitWorktrees(repoRoot);
5891
- const result = monitorWorktreesOnce(db, repoRoot, worktrees, { quarantine: opts.quarantine });
5892
- db.close();
5893
-
5894
- for (const event of result.events) {
5895
- const badge = event.status === 'allowed' ? chalk.green('ALLOWED') : chalk.red('DENIED ');
5896
- const action = event.enforcement_action ? ` ${chalk.dim(event.enforcement_action)}` : '';
5897
- console.log(` ${badge} ${chalk.cyan(event.worktree)} ${chalk.yellow(event.file_path)} ${chalk.dim(event.change_type)}${event.reason_code ? ` ${chalk.dim(event.reason_code)}` : ''}${action}`);
5898
- }
5899
-
5900
- if (stopped) break;
5901
- await new Promise((resolvePromise) => setTimeout(resolvePromise, intervalMs));
5902
- }
5903
-
5904
- console.log(chalk.dim('Stopped worktree monitor.'));
5905
- });
5906
-
5907
- monitorCmd
5908
- .command('start')
5909
- .description('Start the worktree monitor as a background process')
5910
- .option('--interval-ms <ms>', 'Polling interval in milliseconds', '2000')
5911
- .option('--quarantine', 'Move or restore denied runtime changes immediately after detection')
5912
- .action((opts) => {
5913
- const repoRoot = getRepo();
5914
- const intervalMs = Number.parseInt(opts.intervalMs, 10);
5915
- const existingState = readMonitorState(repoRoot);
5916
-
5917
- if (existingState && isProcessRunning(existingState.pid)) {
5918
- console.log(chalk.yellow(`Monitor already running with pid ${existingState.pid}`));
5919
- return;
5920
- }
5921
-
5922
- const logPath = join(repoRoot, '.switchman', 'monitor.log');
5923
- const child = spawn(process.execPath, [
5924
- process.argv[1],
5925
- 'monitor',
5926
- 'watch',
5927
- '--interval-ms',
5928
- String(intervalMs),
5929
- ...(opts.quarantine ? ['--quarantine'] : []),
5930
- '--daemonized',
5931
- ], {
5932
- cwd: repoRoot,
5933
- detached: true,
5934
- stdio: 'ignore',
5935
- });
5936
- child.unref();
5937
-
5938
- const statePath = writeMonitorState(repoRoot, {
5939
- pid: child.pid,
5940
- interval_ms: intervalMs,
5941
- quarantine: Boolean(opts.quarantine),
5942
- log_path: logPath,
5943
- started_at: new Date().toISOString(),
5944
- });
5945
-
5946
- console.log(`${chalk.green('✓')} Started monitor pid ${chalk.cyan(String(child.pid))}`);
5947
- console.log(`${chalk.dim('State:')} ${statePath}`);
5948
- });
5949
-
5950
- monitorCmd
5951
- .command('stop')
5952
- .description('Stop the background worktree monitor')
5953
- .action(() => {
5954
- const repoRoot = getRepo();
5955
- const state = readMonitorState(repoRoot);
5956
-
5957
- if (!state) {
5958
- console.log(chalk.dim('Monitor is not running.'));
5959
- return;
5960
- }
5961
-
5962
- if (!isProcessRunning(state.pid)) {
5963
- clearMonitorState(repoRoot);
5964
- console.log(chalk.dim('Monitor state was stale and has been cleared.'));
5965
- return;
5966
- }
5967
-
5968
- process.kill(state.pid, 'SIGTERM');
5969
- clearMonitorState(repoRoot);
5970
- console.log(`${chalk.green('✓')} Stopped monitor pid ${chalk.cyan(String(state.pid))}`);
5971
- });
5972
-
5973
- monitorCmd
5974
- .command('status')
5975
- .description('Show background monitor process status')
5976
- .action(() => {
5977
- const repoRoot = getRepo();
5978
- const state = readMonitorState(repoRoot);
5979
-
5980
- if (!state) {
5981
- console.log(chalk.dim('Monitor is not running.'));
5982
- return;
5983
- }
5984
-
5985
- const running = isProcessRunning(state.pid);
5986
- if (!running) {
5987
- clearMonitorState(repoRoot);
5988
- console.log(chalk.yellow('Monitor state existed but the process is no longer running.'));
5989
- return;
5990
- }
5991
-
5992
- console.log(`${chalk.green('✓')} Monitor running`);
5993
- console.log(` ${chalk.dim('pid')} ${state.pid}`);
5994
- console.log(` ${chalk.dim('interval_ms')} ${state.interval_ms}`);
5995
- console.log(` ${chalk.dim('quarantine')} ${state.quarantine ? 'true' : 'false'}`);
5996
- console.log(` ${chalk.dim('started_at')} ${state.started_at}`);
5997
- });
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
+ });
5998
6052
 
5999
6053
  // ── policy ───────────────────────────────────────────────────────────────────
6000
6054
 
6001
6055
  const policyCmd = program.command('policy').description('Manage enforcement and change-governance policy');
6056
+ policyCmd._switchmanAdvanced = true;
6002
6057
 
6003
6058
  policyCmd
6004
6059
  .command('init')
@@ -6144,4 +6199,372 @@ policyCmd
6144
6199
  }
6145
6200
  });
6146
6201
 
6202
+
6203
+
6204
+ // ── login ──────────────────────────────────────────────────────────────────────
6205
+
6206
+ program
6207
+ .command('login')
6208
+ .description('Sign in with GitHub to activate Switchman Pro')
6209
+ .option('--invite <token>', 'Join a team with an invite token')
6210
+ .option('--status', 'Show current login status')
6211
+ .addHelpText('after', `
6212
+ Examples:
6213
+ switchman login
6214
+ switchman login --status
6215
+ switchman login --invite tk_8f3a2c
6216
+ `)
6217
+ .action(async (opts) => {
6218
+ // Show current status
6219
+ if (opts.status) {
6220
+ const creds = readCredentials();
6221
+ if (!creds?.access_token) {
6222
+ console.log('');
6223
+ console.log(` ${chalk.dim('Status:')} Not logged in`);
6224
+ console.log(` ${chalk.dim('Run: ')} ${chalk.cyan('switchman login')}`);
6225
+ console.log('');
6226
+ return;
6227
+ }
6228
+
6229
+ const licence = await checkLicence();
6230
+ console.log('');
6231
+ if (licence.valid) {
6232
+ console.log(` ${chalk.green('✓')} Logged in as ${chalk.cyan(creds.email ?? 'unknown')}`);
6233
+ console.log(` ${chalk.dim('Plan:')} ${licence.plan ?? 'Pro'}`);
6234
+ if (licence.current_period_end) {
6235
+ console.log(` ${chalk.dim('Renews:')} ${new Date(licence.current_period_end).toLocaleDateString()}`);
6236
+ }
6237
+ if (licence.offline) {
6238
+ console.log(` ${chalk.dim('(offline cache)')}`);
6239
+ }
6240
+ } else {
6241
+ console.log(` ${chalk.yellow('⚠')} Logged in as ${chalk.cyan(creds.email ?? 'unknown')} but no active Pro licence`);
6242
+ console.log(` ${chalk.dim('Upgrade at:')} ${chalk.cyan(PRO_PAGE_URL)}`);
6243
+ }
6244
+ console.log('');
6245
+ return;
6246
+ }
6247
+
6248
+ // Handle --invite token
6249
+ if (opts.invite) {
6250
+ const SUPABASE_URL = process.env.SWITCHMAN_SUPABASE_URL
6251
+ ?? 'https://afilbolhlkiingnsupgr.supabase.co';
6252
+ const SUPABASE_ANON = process.env.SWITCHMAN_SUPABASE_ANON
6253
+ ?? 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImFmaWxib2xobGtpaW5nbnN1cGdyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzM1OTIzOTIsImV4cCI6MjA4OTE2ODM5Mn0.8TBfHfRB0vEyKPMWBd6i1DNwx1nS9UqprIAsJf35n88';
6254
+
6255
+ const creds = readCredentials();
6256
+ if (!creds?.access_token) {
6257
+ console.log('');
6258
+ console.log(chalk.yellow(' You need to sign in first before accepting an invite.'));
6259
+ console.log(` ${chalk.dim('Run:')} ${chalk.cyan('switchman login')} ${chalk.dim('then try again with --invite')}`);
6260
+ console.log('');
6261
+ process.exit(1);
6262
+ }
6263
+
6264
+ const { createClient } = await import('@supabase/supabase-js');
6265
+ const sb = createClient(SUPABASE_URL, SUPABASE_ANON, {
6266
+ global: { headers: { Authorization: `Bearer ${creds.access_token}` } }
6267
+ });
6268
+
6269
+ const { data: { user } } = await sb.auth.getUser();
6270
+ if (!user) {
6271
+ console.log(chalk.red(' ✗ Could not verify your account. Run: switchman login'));
6272
+ process.exit(1);
6273
+ }
6274
+
6275
+ // Look up the invite
6276
+ const { data: invite, error: inviteError } = await sb
6277
+ .from('team_invites')
6278
+ .select('id, team_id, email, accepted')
6279
+ .eq('token', opts.invite)
6280
+ .maybeSingle();
6281
+
6282
+ if (inviteError || !invite) {
6283
+ console.log('');
6284
+ console.log(chalk.red(' ✗ Invite token not found or already used.'));
6285
+ console.log(` ${chalk.dim('Ask your teammate to send a new invite.')}`);
6286
+ console.log('');
6287
+ process.exit(1);
6288
+ }
6289
+
6290
+ if (invite.accepted) {
6291
+ console.log('');
6292
+ console.log(chalk.yellow(' ⚠ This invite has already been accepted.'));
6293
+ console.log('');
6294
+ process.exit(1);
6295
+ }
6296
+
6297
+ // Add user to the team
6298
+ const { error: memberError } = await sb
6299
+ .from('team_members')
6300
+ .insert({ team_id: invite.team_id, user_id: user.id, role: 'member' });
6301
+
6302
+ if (memberError && !memberError.message.includes('duplicate')) {
6303
+ console.log(chalk.red(` ✗ Could not join team: ${memberError.message}`));
6304
+ process.exit(1);
6305
+ }
6306
+
6307
+ // Mark invite as accepted
6308
+ await sb
6309
+ .from('team_invites')
6310
+ .update({ accepted: true })
6311
+ .eq('id', invite.id);
6312
+
6313
+ console.log('');
6314
+ console.log(` ${chalk.green('✓')} Joined the team successfully`);
6315
+ console.log(` ${chalk.dim('Your agents now share coordination with your teammates.')}`);
6316
+ console.log(` ${chalk.dim('Run:')} ${chalk.cyan('switchman status')} ${chalk.dim('to see the shared view.')}`);
6317
+ console.log('');
6318
+ return;
6319
+ }
6320
+
6321
+ // Already logged in?
6322
+ const existing = readCredentials();
6323
+ if (existing?.access_token) {
6324
+ const licence = await checkLicence();
6325
+ if (licence.valid) {
6326
+ console.log('');
6327
+ console.log(` ${chalk.green('✓')} Already logged in as ${chalk.cyan(existing.email ?? 'unknown')}`);
6328
+ console.log(` ${chalk.dim('Plan:')} ${licence.plan ?? 'Pro'}`);
6329
+ console.log(` ${chalk.dim('Run')} ${chalk.cyan('switchman login --status')} ${chalk.dim('to see full details')}`);
6330
+ console.log('');
6331
+ return;
6332
+ }
6333
+ }
6334
+
6335
+ console.log('');
6336
+ console.log(chalk.bold(' Switchman Pro — sign in with GitHub'));
6337
+ console.log('');
6338
+
6339
+ const spinner = ora('Waiting for GitHub sign-in...').start();
6340
+ spinner.stop();
6341
+
6342
+ const result = await loginWithGitHub();
6343
+
6344
+ if (!result.success) {
6345
+ console.log(` ${chalk.red('✗')} Sign in failed: ${result.error ?? 'unknown error'}`);
6346
+ console.log(` ${chalk.dim('Try again or visit:')} ${chalk.cyan(PRO_PAGE_URL)}`);
6347
+ console.log('');
6348
+ process.exit(1);
6349
+ }
6350
+
6351
+ // Verify the licence
6352
+ const licence = await checkLicence();
6353
+
6354
+ console.log(` ${chalk.green('✓')} Signed in as ${chalk.cyan(result.email ?? 'unknown')}`);
6355
+
6356
+ if (licence.valid) {
6357
+ console.log(` ${chalk.green('✓')} Switchman Pro active`);
6358
+ console.log(` ${chalk.dim('Plan:')} ${licence.plan ?? 'Pro'}`);
6359
+ console.log('');
6360
+ console.log(` ${chalk.dim('Credentials saved · valid 24h · 7-day offline grace')}`);
6361
+ console.log('');
6362
+ console.log(` Run ${chalk.cyan('switchman setup --agents 10')} to use unlimited agents.`);
6363
+ } else {
6364
+ console.log(` ${chalk.yellow('⚠')} No active Pro licence found`);
6365
+ console.log('');
6366
+ console.log(` If you just paid, it may take a moment to activate.`);
6367
+ console.log(` ${chalk.dim('Upgrade at:')} ${chalk.cyan(PRO_PAGE_URL)}`);
6368
+ }
6369
+
6370
+ console.log('');
6371
+ });
6372
+
6373
+
6374
+ // ── logout ─────────────────────────────────────────────────────────────────────
6375
+
6376
+ program
6377
+ .command('logout')
6378
+ .description('Sign out and remove saved credentials')
6379
+ .action(() => {
6380
+ clearCredentials();
6381
+ console.log('');
6382
+ console.log(` ${chalk.green('✓')} Signed out — credentials removed`);
6383
+ console.log('');
6384
+ });
6385
+
6386
+
6387
+ // ── upgrade ────────────────────────────────────────────────────────────────────
6388
+
6389
+ program
6390
+ .command('upgrade')
6391
+ .description('Open the Switchman Pro page in your browser')
6392
+ .action(async () => {
6393
+ console.log('');
6394
+ console.log(` Opening ${chalk.cyan(PRO_PAGE_URL)}...`);
6395
+ console.log('');
6396
+ const { default: open } = await import('open');
6397
+ await open(PRO_PAGE_URL);
6398
+ });
6399
+
6400
+ // ── team ───────────────────────────────────────────────────────────────────────
6401
+
6402
+ const teamCmd = program
6403
+ .command('team')
6404
+ .description('Manage your Switchman Pro team');
6405
+
6406
+ teamCmd
6407
+ .command('invite <email>')
6408
+ .description('Invite a teammate to your shared coordination')
6409
+ .addHelpText('after', `
6410
+ Examples:
6411
+ switchman team invite alice@example.com
6412
+ `)
6413
+ .action(async (email) => {
6414
+ const licence = await checkLicence();
6415
+ if (!licence.valid) {
6416
+ console.log('');
6417
+ console.log(chalk.yellow(' ⚠ Team invites require Switchman Pro.'));
6418
+ console.log(` ${chalk.dim('Run:')} ${chalk.cyan('switchman upgrade')}`);
6419
+ console.log('');
6420
+ process.exit(1);
6421
+ }
6422
+
6423
+ const repoRoot = getRepo();
6424
+ const creds = readCredentials();
6425
+ if (!creds?.access_token) {
6426
+ console.log('');
6427
+ console.log(chalk.yellow(' ⚠ You need to be logged in.'));
6428
+ console.log(` ${chalk.dim('Run:')} ${chalk.cyan('switchman login')}`);
6429
+ console.log('');
6430
+ process.exit(1);
6431
+ }
6432
+
6433
+ const SUPABASE_URL = process.env.SWITCHMAN_SUPABASE_URL
6434
+ ?? 'https://afilbolhlkiingnsupgr.supabase.co';
6435
+ const SUPABASE_ANON = process.env.SWITCHMAN_SUPABASE_ANON
6436
+ ?? 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImFmaWxib2xobGtpaW5nbnN1cGdyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzM1OTIzOTIsImV4cCI6MjA4OTE2ODM5Mn0.8TBfHfRB0vEyKPMWBd6i1DNwx1nS9UqprIAsJf35n88';
6437
+
6438
+ const { createClient } = await import('@supabase/supabase-js');
6439
+ const sb = createClient(SUPABASE_URL, SUPABASE_ANON, {
6440
+ global: { headers: { Authorization: `Bearer ${creds.access_token}` } }
6441
+ });
6442
+
6443
+ const { data: { user } } = await sb.auth.getUser();
6444
+ if (!user) {
6445
+ console.log(chalk.red(' ✗ Could not verify your account. Run: switchman login'));
6446
+ process.exit(1);
6447
+ }
6448
+
6449
+ // Get or create team
6450
+ let teamId;
6451
+ const { data: membership } = await sb
6452
+ .from('team_members')
6453
+ .select('team_id')
6454
+ .eq('user_id', user.id)
6455
+ .maybeSingle();
6456
+
6457
+ if (membership?.team_id) {
6458
+ teamId = membership.team_id;
6459
+ } else {
6460
+ // Create a new team
6461
+ const { data: team, error: teamError } = await sb
6462
+ .from('teams')
6463
+ .insert({ owner_id: user.id, name: 'My Team' })
6464
+ .select('id')
6465
+ .single();
6466
+
6467
+ if (teamError) {
6468
+ console.log(chalk.red(` ✗ Could not create team: ${teamError.message}`));
6469
+ process.exit(1);
6470
+ }
6471
+
6472
+ teamId = team.id;
6473
+
6474
+ // Add the owner as a member
6475
+ await sb.from('team_members').insert({
6476
+ team_id: teamId,
6477
+ user_id: user.id,
6478
+ role: 'owner',
6479
+ });
6480
+ }
6481
+
6482
+ // Create the invite
6483
+ const { data: invite, error: inviteError } = await sb
6484
+ .from('team_invites')
6485
+ .insert({
6486
+ team_id: teamId,
6487
+ invited_by: user.id,
6488
+ email,
6489
+ })
6490
+ .select('token')
6491
+ .single();
6492
+
6493
+ if (inviteError) {
6494
+ console.log(chalk.red(` ✗ Could not create invite: ${inviteError.message}`));
6495
+ process.exit(1);
6496
+ }
6497
+
6498
+ console.log('');
6499
+ console.log(` ${chalk.green('✓')} Invite created for ${chalk.cyan(email)}`);
6500
+ console.log('');
6501
+ console.log(` They can join with:`);
6502
+ console.log(` ${chalk.cyan(`switchman login --invite ${invite.token}`)}`);
6503
+ console.log('');
6504
+ });
6505
+
6506
+ teamCmd
6507
+ .command('list')
6508
+ .description('List your team members')
6509
+ .action(async () => {
6510
+ const licence = await checkLicence();
6511
+ if (!licence.valid) {
6512
+ console.log('');
6513
+ console.log(chalk.yellow(' ⚠ Team features require Switchman Pro.'));
6514
+ console.log(` ${chalk.dim('Run:')} ${chalk.cyan('switchman upgrade')}`);
6515
+ console.log('');
6516
+ process.exit(1);
6517
+ }
6518
+
6519
+ const creds = readCredentials();
6520
+ if (!creds?.access_token) {
6521
+ console.log(chalk.red(' ✗ Not logged in. Run: switchman login'));
6522
+ process.exit(1);
6523
+ }
6524
+
6525
+ const SUPABASE_URL = process.env.SWITCHMAN_SUPABASE_URL
6526
+ ?? 'https://afilbolhlkiingnsupgr.supabase.co';
6527
+ const SUPABASE_ANON = process.env.SWITCHMAN_SUPABASE_ANON
6528
+ ?? 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImFmaWxib2xobGtpaW5nbnN1cGdyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzM1OTIzOTIsImV4cCI6MjA4OTE2ODM5Mn0.8TBfHfRB0vEyKPMWBd6i1DNwx1nS9UqprIAsJf35n88';
6529
+
6530
+ const { createClient } = await import('@supabase/supabase-js');
6531
+ const sb = createClient(SUPABASE_URL, SUPABASE_ANON, {
6532
+ global: { headers: { Authorization: `Bearer ${creds.access_token}` } }
6533
+ });
6534
+
6535
+ const { data: membership } = await sb
6536
+ .from('team_members')
6537
+ .select('team_id')
6538
+ .eq('user_id', (await sb.auth.getUser()).data.user?.id)
6539
+ .maybeSingle();
6540
+
6541
+ if (!membership?.team_id) {
6542
+ console.log('');
6543
+ console.log(` ${chalk.dim('No team yet. Invite someone with:')} ${chalk.cyan('switchman team invite <email>')}`);
6544
+ console.log('');
6545
+ return;
6546
+ }
6547
+
6548
+ const { data: members } = await sb
6549
+ .from('team_members')
6550
+ .select('user_id, role, joined_at')
6551
+ .eq('team_id', membership.team_id);
6552
+
6553
+ const { data: invites } = await sb
6554
+ .from('team_invites')
6555
+ .select('email, token, accepted, created_at')
6556
+ .eq('team_id', membership.team_id)
6557
+ .eq('accepted', false);
6558
+
6559
+ console.log('');
6560
+ for (const m of members ?? []) {
6561
+ const roleLabel = m.role === 'owner' ? chalk.dim('(owner)') : chalk.dim('(member)');
6562
+ console.log(` ${chalk.green('✓')} ${chalk.cyan(m.user_id.slice(0, 8))} ${roleLabel}`);
6563
+ }
6564
+ for (const inv of invites ?? []) {
6565
+ console.log(` ${chalk.dim('○')} ${chalk.cyan(inv.email)} ${chalk.dim('(invited)')}`);
6566
+ }
6567
+ console.log('');
6568
+ });
6569
+
6147
6570
  program.parse();