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.
@@ -0,0 +1,77 @@
1
+ export function registerAuditCommands(program, {
2
+ buildPipelineHistoryReport,
3
+ chalk,
4
+ getDb,
5
+ getRepo,
6
+ printErrorWithNext,
7
+ statusBadge,
8
+ verifyAuditTrail,
9
+ }) {
10
+ const auditCmd = program.command('audit').description('Inspect and verify the tamper-evident audit trail');
11
+ auditCmd._switchmanAdvanced = true;
12
+
13
+ auditCmd
14
+ .command('change <pipelineId>')
15
+ .description('Show a signed, operator-friendly history for one pipeline')
16
+ .option('--json', 'Output raw JSON')
17
+ .action((pipelineId, options) => {
18
+ const repoRoot = getRepo();
19
+ const db = getDb(repoRoot);
20
+
21
+ try {
22
+ const report = buildPipelineHistoryReport(db, repoRoot, pipelineId);
23
+ db.close();
24
+
25
+ if (options.json) {
26
+ console.log(JSON.stringify(report, null, 2));
27
+ return;
28
+ }
29
+
30
+ console.log(chalk.bold(`Audit history for pipeline ${report.pipeline_id}`));
31
+ console.log(` ${chalk.dim('title:')} ${report.title}`);
32
+ console.log(` ${chalk.dim('events:')} ${report.events.length}`);
33
+ console.log(` ${chalk.yellow('next:')} ${report.next_action}`);
34
+ for (const event of report.events.slice(-20)) {
35
+ const status = event.status ? ` ${statusBadge(event.status).trim()}` : '';
36
+ console.log(` ${chalk.dim(event.created_at)} ${chalk.cyan(event.label)}${status}`);
37
+ console.log(` ${event.summary}`);
38
+ }
39
+ } catch (err) {
40
+ db.close();
41
+ printErrorWithNext(err.message, `switchman pipeline status ${pipelineId}`);
42
+ process.exitCode = 1;
43
+ }
44
+ });
45
+
46
+ auditCmd
47
+ .command('verify')
48
+ .description('Verify the audit log hash chain and project signatures')
49
+ .option('--json', 'Output verification details as JSON')
50
+ .action((options) => {
51
+ const repo = getRepo();
52
+ const db = getDb(repo);
53
+ const result = verifyAuditTrail(db);
54
+
55
+ if (options.json) {
56
+ console.log(JSON.stringify(result, null, 2));
57
+ process.exit(result.ok ? 0 : 1);
58
+ }
59
+
60
+ if (result.ok) {
61
+ console.log(chalk.green(`Audit trail verified: ${result.count} signed events in order.`));
62
+ return;
63
+ }
64
+
65
+ console.log(chalk.red(`Audit trail verification failed: ${result.failures.length} problem(s) across ${result.count} events.`));
66
+ for (const failure of result.failures.slice(0, 10)) {
67
+ const prefix = failure.sequence ? `#${failure.sequence}` : `event ${failure.id}`;
68
+ console.log(` ${chalk.red(prefix)} ${failure.reason_code}: ${failure.message}`);
69
+ }
70
+ if (result.failures.length > 10) {
71
+ console.log(chalk.dim(` ...and ${result.failures.length - 10} more`));
72
+ }
73
+ process.exit(1);
74
+ });
75
+
76
+ return auditCmd;
77
+ }
@@ -0,0 +1,37 @@
1
+ export function registerClaudeCommands(program, {
2
+ chalk,
3
+ existsSync,
4
+ getRepo,
5
+ join,
6
+ renderClaudeGuide,
7
+ writeFileSync,
8
+ }) {
9
+ const claudeCmd = program.command('claude').description('Generate or refresh Claude Code instructions for this repo');
10
+
11
+ claudeCmd
12
+ .command('refresh')
13
+ .description('Generate a repo-aware CLAUDE.md for this repository')
14
+ .option('--print', 'Print the generated guide instead of writing CLAUDE.md')
15
+ .addHelpText('after', `
16
+ Examples:
17
+ switchman claude refresh
18
+ switchman claude refresh --print
19
+ `)
20
+ .action((opts) => {
21
+ const repoRoot = getRepo();
22
+ const claudeGuidePath = join(repoRoot, 'CLAUDE.md');
23
+ const content = renderClaudeGuide(repoRoot);
24
+
25
+ if (opts.print) {
26
+ process.stdout.write(content);
27
+ return;
28
+ }
29
+
30
+ const existed = existsSync(claudeGuidePath);
31
+ writeFileSync(claudeGuidePath, content, 'utf8');
32
+ console.log(`${chalk.green('✓')} ${existed ? 'Refreshed' : 'Created'} ${chalk.cyan(claudeGuidePath)}`);
33
+ console.log(` ${chalk.dim('next:')} ${chalk.cyan('switchman verify-setup')}`);
34
+ });
35
+
36
+ return claudeCmd;
37
+ }
@@ -0,0 +1,278 @@
1
+ export function registerGateCommands(program, {
2
+ chalk,
3
+ getDb,
4
+ getRepo,
5
+ installGateHooks,
6
+ installGitHubActionsWorkflow,
7
+ maybeCaptureTelemetry,
8
+ resolveGitHubOutputTargets,
9
+ runAiMergeGate,
10
+ runCommitGate,
11
+ scanAllWorktrees,
12
+ writeGitHubCiStatus,
13
+ }) {
14
+ const gateCmd = program.command('gate').description('Safety checks for edits, merges, and CI');
15
+ gateCmd.addHelpText('after', `
16
+ Examples:
17
+ switchman gate ci
18
+ switchman gate ai
19
+ switchman gate install-ci
20
+ `);
21
+
22
+ gateCmd
23
+ .command('commit')
24
+ .description('Validate current worktree changes against the active lease and claims')
25
+ .option('--json', 'Output raw JSON')
26
+ .action((opts) => {
27
+ const repoRoot = getRepo();
28
+ const db = getDb(repoRoot);
29
+ const result = runCommitGate(db, repoRoot);
30
+ db.close();
31
+
32
+ if (opts.json) {
33
+ console.log(JSON.stringify(result, null, 2));
34
+ } else if (result.ok) {
35
+ console.log(`${chalk.green('✓')} ${result.summary}`);
36
+ } else {
37
+ console.log(chalk.red(`✗ ${result.summary}`));
38
+ for (const violation of result.violations) {
39
+ const label = violation.file || '(worktree)';
40
+ console.log(` ${chalk.yellow(label)} ${chalk.dim(violation.reason_code)}`);
41
+ }
42
+ }
43
+
44
+ if (!result.ok) process.exitCode = 1;
45
+ });
46
+
47
+ gateCmd
48
+ .command('merge')
49
+ .description('Validate current worktree changes before recording a merge commit')
50
+ .option('--json', 'Output raw JSON')
51
+ .action((opts) => {
52
+ const repoRoot = getRepo();
53
+ const db = getDb(repoRoot);
54
+ const result = runCommitGate(db, repoRoot);
55
+ db.close();
56
+
57
+ if (opts.json) {
58
+ console.log(JSON.stringify(result, null, 2));
59
+ } else if (result.ok) {
60
+ console.log(`${chalk.green('✓')} Merge gate passed for ${chalk.cyan(result.worktree || 'current worktree')}.`);
61
+ } else {
62
+ console.log(chalk.red(`✗ Merge gate rejected changes in ${chalk.cyan(result.worktree || 'current worktree')}.`));
63
+ for (const violation of result.violations) {
64
+ const label = violation.file || '(worktree)';
65
+ console.log(` ${chalk.yellow(label)} ${chalk.dim(violation.reason_code)}`);
66
+ }
67
+ }
68
+
69
+ if (!result.ok) process.exitCode = 1;
70
+ });
71
+
72
+ gateCmd
73
+ .command('install')
74
+ .description('Install git hooks that run the Switchman commit and merge gates')
75
+ .action(() => {
76
+ const repoRoot = getRepo();
77
+ const hookPaths = installGateHooks(repoRoot);
78
+ console.log(`${chalk.green('✓')} Installed pre-commit hook at ${chalk.cyan(hookPaths.pre_commit)}`);
79
+ console.log(`${chalk.green('✓')} Installed pre-merge-commit hook at ${chalk.cyan(hookPaths.pre_merge_commit)}`);
80
+ });
81
+
82
+ gateCmd
83
+ .command('ci')
84
+ .description('Run a repo-level enforcement gate suitable for CI, merges, or PR validation')
85
+ .option('--github', 'Write GitHub Actions step summary/output when GITHUB_* env vars are present')
86
+ .option('--github-step-summary <path>', 'Path to write GitHub Actions step summary markdown')
87
+ .option('--github-output <path>', 'Path to write GitHub Actions outputs')
88
+ .option('--json', 'Output raw JSON')
89
+ .action(async (opts) => {
90
+ const repoRoot = getRepo();
91
+ const db = getDb(repoRoot);
92
+ const report = await scanAllWorktrees(db, repoRoot);
93
+ const aiGate = await runAiMergeGate(db, repoRoot);
94
+ db.close();
95
+
96
+ const ok = report.conflicts.length === 0
97
+ && report.fileConflicts.length === 0
98
+ && (report.ownershipConflicts?.length || 0) === 0
99
+ && (report.semanticConflicts?.length || 0) === 0
100
+ && report.unclaimedChanges.length === 0
101
+ && report.complianceSummary.non_compliant === 0
102
+ && report.complianceSummary.stale === 0
103
+ && aiGate.status !== 'blocked'
104
+ && (aiGate.dependency_invalidations?.filter((item) => item.severity === 'blocked').length || 0) === 0;
105
+
106
+ const result = {
107
+ ok,
108
+ summary: ok
109
+ ? `Repo gate passed for ${report.worktrees.length} worktree(s).`
110
+ : 'Repo gate rejected unmanaged changes, stale leases, ownership conflicts, stale dependency invalidations, or boundary validation failures.',
111
+ compliance: report.complianceSummary,
112
+ unclaimed_changes: report.unclaimedChanges,
113
+ file_conflicts: report.fileConflicts,
114
+ ownership_conflicts: report.ownershipConflicts || [],
115
+ semantic_conflicts: report.semanticConflicts || [],
116
+ branch_conflicts: report.conflicts,
117
+ ai_gate_status: aiGate.status,
118
+ boundary_validations: aiGate.boundary_validations || [],
119
+ dependency_invalidations: aiGate.dependency_invalidations || [],
120
+ };
121
+
122
+ const githubTargets = resolveGitHubOutputTargets(opts);
123
+ if (githubTargets.stepSummaryPath || githubTargets.outputPath) {
124
+ writeGitHubCiStatus({
125
+ result,
126
+ stepSummaryPath: githubTargets.stepSummaryPath,
127
+ outputPath: githubTargets.outputPath,
128
+ });
129
+ }
130
+
131
+ if (opts.json) {
132
+ console.log(JSON.stringify(result, null, 2));
133
+ } else if (ok) {
134
+ console.log(`${chalk.green('✓')} ${result.summary}`);
135
+ } else {
136
+ console.log(chalk.red(`✗ ${result.summary}`));
137
+ if (result.unclaimed_changes.length > 0) {
138
+ console.log(chalk.bold(' Unclaimed changes:'));
139
+ for (const entry of result.unclaimed_changes) {
140
+ console.log(` ${chalk.cyan(entry.worktree)}: ${entry.files.join(', ')}`);
141
+ }
142
+ }
143
+ if (result.file_conflicts.length > 0) {
144
+ console.log(chalk.bold(' File conflicts:'));
145
+ for (const conflict of result.file_conflicts) {
146
+ console.log(` ${chalk.yellow(conflict.file)} ${chalk.dim(conflict.worktrees.join(', '))}`);
147
+ }
148
+ }
149
+ if (result.ownership_conflicts.length > 0) {
150
+ console.log(chalk.bold(' Ownership conflicts:'));
151
+ for (const conflict of result.ownership_conflicts) {
152
+ if (conflict.type === 'subsystem_overlap') {
153
+ console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)} ${chalk.dim(`subsystem:${conflict.subsystemTag}`)}`);
154
+ } else {
155
+ console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)} ${chalk.dim(`${conflict.scopeA} ↔ ${conflict.scopeB}`)}`);
156
+ }
157
+ }
158
+ }
159
+ if (result.semantic_conflicts.length > 0) {
160
+ console.log(chalk.bold(' Semantic conflicts:'));
161
+ for (const conflict of result.semantic_conflicts) {
162
+ console.log(` ${chalk.yellow(conflict.object_name)} ${chalk.dim(`${conflict.worktreeA} vs ${conflict.worktreeB}`)}`);
163
+ }
164
+ }
165
+ if (result.branch_conflicts.length > 0) {
166
+ console.log(chalk.bold(' Branch conflicts:'));
167
+ for (const conflict of result.branch_conflicts) {
168
+ console.log(` ${chalk.yellow(conflict.worktreeA)} ${chalk.dim('vs')} ${chalk.yellow(conflict.worktreeB)}`);
169
+ }
170
+ }
171
+ if (result.boundary_validations.length > 0) {
172
+ console.log(chalk.bold(' Boundary validations:'));
173
+ for (const validation of result.boundary_validations) {
174
+ console.log(` ${chalk.yellow(validation.task_id)} ${chalk.dim(validation.missing_task_types.join(', '))}`);
175
+ }
176
+ }
177
+ if (result.dependency_invalidations.length > 0) {
178
+ console.log(chalk.bold(' Stale dependency invalidations:'));
179
+ for (const invalidation of result.dependency_invalidations) {
180
+ console.log(` ${chalk.yellow(invalidation.affected_task_id)} ${chalk.dim(invalidation.stale_area)}`);
181
+ }
182
+ }
183
+ }
184
+
185
+ await maybeCaptureTelemetry(ok ? 'gate_ci_passed' : 'gate_ci_failed', {
186
+ worktree_count: report.worktrees.length,
187
+ unclaimed_change_count: result.unclaimed_changes.length,
188
+ file_conflict_count: result.file_conflicts.length,
189
+ ownership_conflict_count: result.ownership_conflicts.length,
190
+ semantic_conflict_count: result.semantic_conflicts.length,
191
+ branch_conflict_count: result.branch_conflicts.length,
192
+ });
193
+
194
+ if (!ok) process.exitCode = 1;
195
+ });
196
+
197
+ gateCmd
198
+ .command('install-ci')
199
+ .description('Install a GitHub Actions workflow that runs the Switchman CI gate on PRs and pushes')
200
+ .option('--workflow-name <name>', 'Workflow file name', 'switchman-gate.yml')
201
+ .action((opts) => {
202
+ const repoRoot = getRepo();
203
+ const workflowPath = installGitHubActionsWorkflow(repoRoot, opts.workflowName);
204
+ console.log(`${chalk.green('✓')} Installed GitHub Actions workflow at ${chalk.cyan(workflowPath)}`);
205
+ });
206
+
207
+ gateCmd
208
+ .command('ai')
209
+ .description('Run the AI-style merge check to assess risky overlap across workspaces')
210
+ .option('--json', 'Output raw JSON')
211
+ .action(async (opts) => {
212
+ const repoRoot = getRepo();
213
+ const db = getDb(repoRoot);
214
+ const result = await runAiMergeGate(db, repoRoot);
215
+ db.close();
216
+
217
+ if (opts.json) {
218
+ console.log(JSON.stringify(result, null, 2));
219
+ } else {
220
+ const badge = result.status === 'pass'
221
+ ? chalk.green('PASS')
222
+ : result.status === 'warn'
223
+ ? chalk.yellow('WARN')
224
+ : chalk.red('BLOCK');
225
+ console.log(`${badge} ${result.summary}`);
226
+
227
+ const riskyPairs = result.pairs.filter((pair) => pair.status !== 'pass');
228
+ if (riskyPairs.length > 0) {
229
+ console.log(chalk.bold(' Risky pairs:'));
230
+ for (const pair of riskyPairs) {
231
+ console.log(` ${chalk.cyan(pair.worktree_a)} ${chalk.dim('vs')} ${chalk.cyan(pair.worktree_b)} ${chalk.dim(pair.status)} ${chalk.dim(`score=${pair.score}`)}`);
232
+ for (const reason of pair.reasons.slice(0, 3)) {
233
+ console.log(` ${chalk.yellow(reason)}`);
234
+ }
235
+ }
236
+ }
237
+
238
+ if ((result.boundary_validations?.length || 0) > 0) {
239
+ console.log(chalk.bold(' Boundary validations:'));
240
+ for (const validation of result.boundary_validations.slice(0, 5)) {
241
+ console.log(` ${chalk.cyan(validation.task_id)} ${chalk.dim(validation.severity)} ${chalk.dim(validation.missing_task_types.join(', '))}`);
242
+ if (validation.rationale?.[0]) {
243
+ console.log(` ${chalk.yellow(validation.rationale[0])}`);
244
+ }
245
+ }
246
+ }
247
+
248
+ if ((result.dependency_invalidations?.length || 0) > 0) {
249
+ console.log(chalk.bold(' Stale dependency invalidations:'));
250
+ for (const invalidation of result.dependency_invalidations.slice(0, 5)) {
251
+ console.log(` ${chalk.cyan(invalidation.affected_task_id)} ${chalk.dim(invalidation.severity)} ${chalk.dim(invalidation.stale_area)}`);
252
+ }
253
+ }
254
+
255
+ if ((result.semantic_conflicts?.length || 0) > 0) {
256
+ console.log(chalk.bold(' Semantic conflicts:'));
257
+ for (const conflict of result.semantic_conflicts.slice(0, 5)) {
258
+ console.log(` ${chalk.cyan(conflict.object_name)} ${chalk.dim(conflict.type)} ${chalk.dim(`${conflict.worktreeA} vs ${conflict.worktreeB}`)}`);
259
+ }
260
+ }
261
+
262
+ const riskyWorktrees = result.worktrees.filter((worktree) => worktree.findings.length > 0);
263
+ if (riskyWorktrees.length > 0) {
264
+ console.log(chalk.bold(' Worktree signals:'));
265
+ for (const worktree of riskyWorktrees) {
266
+ console.log(` ${chalk.cyan(worktree.worktree)} ${chalk.dim(`score=${worktree.score}`)}`);
267
+ for (const finding of worktree.findings.slice(0, 2)) {
268
+ console.log(` ${chalk.yellow(finding)}`);
269
+ }
270
+ }
271
+ }
272
+ }
273
+
274
+ if (result.status === 'blocked') process.exitCode = 1;
275
+ });
276
+
277
+ return gateCmd;
278
+ }
@@ -0,0 +1,256 @@
1
+ export function registerLeaseCommands(program, {
2
+ acquireNextTaskLeaseWithRetries,
3
+ chalk,
4
+ getCurrentWorktreeName,
5
+ getDb,
6
+ getRepo,
7
+ getTask,
8
+ heartbeatLease,
9
+ listLeases,
10
+ loadLeasePolicy,
11
+ pushSyncEvent,
12
+ reapStaleLeases,
13
+ startTaskLease,
14
+ statusBadge,
15
+ taskJsonWithLease,
16
+ writeLeasePolicy,
17
+ }) {
18
+ const leaseCmd = program.command('lease').alias('session').description('Manage active work sessions and keep long-running tasks alive');
19
+ leaseCmd.addHelpText('after', `
20
+ Plain English:
21
+ lease = a task currently checked out by an agent
22
+
23
+ Examples:
24
+ switchman lease next --json
25
+ switchman lease heartbeat lease-123
26
+ switchman lease reap
27
+ `);
28
+
29
+ leaseCmd
30
+ .command('acquire <taskId> <worktree>')
31
+ .description('Start a tracked work session for a specific pending task')
32
+ .option('--agent <name>', 'Agent identifier for logging')
33
+ .option('--json', 'Output as JSON')
34
+ .addHelpText('after', `
35
+ Examples:
36
+ switchman lease acquire task-123 agent2
37
+ switchman lease acquire task-123 agent2 --agent cursor
38
+ `)
39
+ .action((taskId, worktree, opts) => {
40
+ const repoRoot = getRepo();
41
+ const db = getDb(repoRoot);
42
+ const task = getTask(db, taskId);
43
+ const lease = startTaskLease(db, taskId, worktree, opts.agent || null);
44
+ db.close();
45
+
46
+ if (!lease || !task) {
47
+ if (opts.json) console.log(JSON.stringify({ lease: null, task: null }));
48
+ else console.log(chalk.red('Could not start a work session. The task may not exist or may already be in progress.'));
49
+ process.exitCode = 1;
50
+ return;
51
+ }
52
+
53
+ if (opts.json) {
54
+ console.log(JSON.stringify({
55
+ lease,
56
+ task: taskJsonWithLease(task, worktree, lease).task,
57
+ }, null, 2));
58
+ return;
59
+ }
60
+
61
+ console.log(`${chalk.green('✓')} Lease acquired ${chalk.dim(lease.id)}`);
62
+ console.log(` ${chalk.dim('task:')} ${chalk.bold(task.title)}`);
63
+ console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(worktree)}`);
64
+ });
65
+
66
+ leaseCmd
67
+ .command('next')
68
+ .description('Start the next pending task and open a tracked work session for it')
69
+ .option('--json', 'Output as JSON')
70
+ .option('--worktree <name>', 'Workspace to assign the task to (defaults to the current folder name)')
71
+ .option('--agent <name>', 'Agent identifier for logging')
72
+ .addHelpText('after', `
73
+ Examples:
74
+ switchman lease next
75
+ switchman lease next --json
76
+ switchman lease next --worktree agent2 --agent cursor
77
+ `)
78
+ .action((opts) => {
79
+ const repoRoot = getRepo();
80
+ const worktreeName = getCurrentWorktreeName(opts.worktree);
81
+ const { task, lease, exhausted } = acquireNextTaskLeaseWithRetries(repoRoot, worktreeName, opts.agent || null);
82
+
83
+ if (!task) {
84
+ if (opts.json) console.log(JSON.stringify({ task: null, lease: null }));
85
+ else if (exhausted) console.log(chalk.dim('No pending tasks. Add one with `switchman task add "Your task"`.'));
86
+ else console.log(chalk.yellow('Tasks were claimed by other agents during assignment. Run again to get the next one.'));
87
+ return;
88
+ }
89
+
90
+ if (!lease) {
91
+ if (opts.json) console.log(JSON.stringify({ task: null, lease: null, message: 'Task claimed by another agent — try again' }));
92
+ else console.log(chalk.yellow('Task was just claimed by another agent. Run again to get the next one.'));
93
+ return;
94
+ }
95
+
96
+ if (opts.json) {
97
+ console.log(JSON.stringify({
98
+ lease,
99
+ ...taskJsonWithLease(task, worktreeName, lease),
100
+ }, null, 2));
101
+ return;
102
+ }
103
+
104
+ console.log(`${chalk.green('✓')} Lease acquired: ${chalk.bold(task.title)}`);
105
+ pushSyncEvent('lease_acquired', { task_id: task.id, title: task.title }, { worktree: worktreeName }).catch(() => {});
106
+ console.log(` ${chalk.dim('task:')} ${task.id} ${chalk.dim('lease:')} ${lease.id}`);
107
+ console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(worktreeName)} ${chalk.dim('priority:')} ${task.priority}`);
108
+ });
109
+
110
+ leaseCmd
111
+ .command('list')
112
+ .description('List leases, newest first')
113
+ .option('-s, --status <status>', 'Filter by status (active|completed|failed|expired)')
114
+ .action((opts) => {
115
+ const repoRoot = getRepo();
116
+ const db = getDb(repoRoot);
117
+ const leases = listLeases(db, opts.status);
118
+ db.close();
119
+
120
+ if (!leases.length) {
121
+ console.log(chalk.dim('No leases found.'));
122
+ return;
123
+ }
124
+
125
+ console.log('');
126
+ for (const lease of leases) {
127
+ console.log(`${statusBadge(lease.status)} ${chalk.bold(lease.task_title)}`);
128
+ console.log(` ${chalk.dim('lease:')} ${lease.id} ${chalk.dim('task:')} ${lease.task_id}`);
129
+ console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(lease.worktree)} ${chalk.dim('agent:')} ${lease.agent || 'unknown'}`);
130
+ console.log(` ${chalk.dim('started:')} ${lease.started_at} ${chalk.dim('heartbeat:')} ${lease.heartbeat_at}`);
131
+ if (lease.failure_reason) console.log(` ${chalk.red(lease.failure_reason)}`);
132
+ console.log('');
133
+ }
134
+ });
135
+
136
+ leaseCmd
137
+ .command('heartbeat <leaseId>')
138
+ .description('Refresh the heartbeat timestamp for an active lease')
139
+ .option('--agent <name>', 'Agent identifier for logging')
140
+ .option('--json', 'Output as JSON')
141
+ .action((leaseId, opts) => {
142
+ const repoRoot = getRepo();
143
+ const db = getDb(repoRoot);
144
+ const lease = heartbeatLease(db, leaseId, opts.agent || null);
145
+ db.close();
146
+
147
+ if (!lease) {
148
+ if (opts.json) console.log(JSON.stringify({ lease: null }));
149
+ else console.log(chalk.red(`No active work session found for ${leaseId}.`));
150
+ process.exitCode = 1;
151
+ return;
152
+ }
153
+
154
+ if (opts.json) {
155
+ console.log(JSON.stringify({ lease }, null, 2));
156
+ return;
157
+ }
158
+
159
+ console.log(`${chalk.green('✓')} Heartbeat refreshed for ${chalk.dim(lease.id)}`);
160
+ console.log(` ${chalk.dim('task:')} ${lease.task_title} ${chalk.dim('worktree:')} ${chalk.cyan(lease.worktree)}`);
161
+ });
162
+
163
+ leaseCmd
164
+ .command('reap')
165
+ .description('Clean up abandoned work sessions and release their file locks')
166
+ .option('--stale-after-minutes <minutes>', 'Age threshold for staleness')
167
+ .option('--json', 'Output as JSON')
168
+ .addHelpText('after', `
169
+ Examples:
170
+ switchman lease reap
171
+ switchman lease reap --stale-after-minutes 20
172
+ `)
173
+ .action((opts) => {
174
+ const repoRoot = getRepo();
175
+ const db = getDb(repoRoot);
176
+ const leasePolicy = loadLeasePolicy(repoRoot);
177
+ const staleAfterMinutes = opts.staleAfterMinutes
178
+ ? Number.parseInt(opts.staleAfterMinutes, 10)
179
+ : leasePolicy.stale_after_minutes;
180
+ const expired = reapStaleLeases(db, staleAfterMinutes, {
181
+ requeueTask: leasePolicy.requeue_task_on_reap,
182
+ });
183
+ db.close();
184
+
185
+ if (opts.json) {
186
+ console.log(JSON.stringify({ stale_after_minutes: staleAfterMinutes, expired }, null, 2));
187
+ return;
188
+ }
189
+
190
+ if (!expired.length) {
191
+ console.log(chalk.dim(`No stale leases older than ${staleAfterMinutes} minute(s).`));
192
+ return;
193
+ }
194
+
195
+ console.log(`${chalk.green('✓')} Reaped ${expired.length} stale lease(s)`);
196
+ for (const lease of expired) {
197
+ console.log(` ${chalk.dim(lease.id)} ${chalk.cyan(lease.worktree)} → ${lease.task_title}`);
198
+ }
199
+ });
200
+
201
+ const leasePolicyCmd = leaseCmd.command('policy').description('Inspect or update the stale-lease policy for this repo');
202
+
203
+ leasePolicyCmd
204
+ .command('set')
205
+ .description('Persist a stale-lease policy for this repo')
206
+ .option('--heartbeat-interval-seconds <seconds>', 'Recommended heartbeat interval')
207
+ .option('--stale-after-minutes <minutes>', 'Age threshold for staleness')
208
+ .option('--reap-on-status-check <boolean>', 'Automatically reap stale leases during `switchman status`')
209
+ .option('--requeue-task-on-reap <boolean>', 'Return stale tasks to pending instead of failing them')
210
+ .option('--json', 'Output as JSON')
211
+ .action((opts) => {
212
+ const repoRoot = getRepo();
213
+ const current = loadLeasePolicy(repoRoot);
214
+ const next = {
215
+ ...current,
216
+ ...(opts.heartbeatIntervalSeconds ? { heartbeat_interval_seconds: Number.parseInt(opts.heartbeatIntervalSeconds, 10) } : {}),
217
+ ...(opts.staleAfterMinutes ? { stale_after_minutes: Number.parseInt(opts.staleAfterMinutes, 10) } : {}),
218
+ ...(opts.reapOnStatusCheck ? { reap_on_status_check: opts.reapOnStatusCheck === 'true' } : {}),
219
+ ...(opts.requeueTaskOnReap ? { requeue_task_on_reap: opts.requeueTaskOnReap === 'true' } : {}),
220
+ };
221
+ const path = writeLeasePolicy(repoRoot, next);
222
+ const saved = loadLeasePolicy(repoRoot);
223
+
224
+ if (opts.json) {
225
+ console.log(JSON.stringify({ path, policy: saved }, null, 2));
226
+ return;
227
+ }
228
+
229
+ console.log(`${chalk.green('✓')} Lease policy updated`);
230
+ console.log(` ${chalk.dim(path)}`);
231
+ console.log(` ${chalk.dim('heartbeat_interval_seconds:')} ${saved.heartbeat_interval_seconds}`);
232
+ console.log(` ${chalk.dim('stale_after_minutes:')} ${saved.stale_after_minutes}`);
233
+ console.log(` ${chalk.dim('reap_on_status_check:')} ${saved.reap_on_status_check}`);
234
+ console.log(` ${chalk.dim('requeue_task_on_reap:')} ${saved.requeue_task_on_reap}`);
235
+ });
236
+
237
+ leasePolicyCmd
238
+ .description('Show the active stale-lease policy for this repo')
239
+ .option('--json', 'Output as JSON')
240
+ .action((opts) => {
241
+ const repoRoot = getRepo();
242
+ const policy = loadLeasePolicy(repoRoot);
243
+ if (opts.json) {
244
+ console.log(JSON.stringify({ policy }, null, 2));
245
+ return;
246
+ }
247
+
248
+ console.log(chalk.bold('Lease policy'));
249
+ console.log(` ${chalk.dim('heartbeat_interval_seconds:')} ${policy.heartbeat_interval_seconds}`);
250
+ console.log(` ${chalk.dim('stale_after_minutes:')} ${policy.stale_after_minutes}`);
251
+ console.log(` ${chalk.dim('reap_on_status_check:')} ${policy.reap_on_status_check}`);
252
+ console.log(` ${chalk.dim('requeue_task_on_reap:')} ${policy.requeue_task_on_reap}`);
253
+ });
254
+
255
+ return leaseCmd;
256
+ }