switchman-dev 0.1.0 → 0.1.2

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.
Files changed (38) hide show
  1. package/README.md +27 -10
  2. package/package.json +5 -5
  3. package/src/cli/index.js +227 -28
  4. package/src/core/db.js +452 -68
  5. package/src/core/git.js +8 -1
  6. package/src/mcp/server.js +170 -22
  7. package/CLAUDE.md +0 -98
  8. package/examples/taskapi/.switchman/switchman.db +0 -0
  9. package/examples/taskapi/package-lock.json +0 -4736
  10. package/examples/taskapi/tests/api.test.js +0 -112
  11. package/examples/worktrees/agent-rate-limiting/package-lock.json +0 -4736
  12. package/examples/worktrees/agent-rate-limiting/package.json +0 -18
  13. package/examples/worktrees/agent-rate-limiting/src/db.js +0 -179
  14. package/examples/worktrees/agent-rate-limiting/src/middleware/auth.js +0 -96
  15. package/examples/worktrees/agent-rate-limiting/src/middleware/validate.js +0 -133
  16. package/examples/worktrees/agent-rate-limiting/src/routes/tasks.js +0 -65
  17. package/examples/worktrees/agent-rate-limiting/src/routes/users.js +0 -38
  18. package/examples/worktrees/agent-rate-limiting/src/server.js +0 -7
  19. package/examples/worktrees/agent-rate-limiting/tests/api.test.js +0 -112
  20. package/examples/worktrees/agent-tests/package-lock.json +0 -4736
  21. package/examples/worktrees/agent-tests/package.json +0 -18
  22. package/examples/worktrees/agent-tests/src/db.js +0 -179
  23. package/examples/worktrees/agent-tests/src/middleware/auth.js +0 -96
  24. package/examples/worktrees/agent-tests/src/middleware/validate.js +0 -133
  25. package/examples/worktrees/agent-tests/src/routes/tasks.js +0 -65
  26. package/examples/worktrees/agent-tests/src/routes/users.js +0 -38
  27. package/examples/worktrees/agent-tests/src/server.js +0 -7
  28. package/examples/worktrees/agent-tests/tests/api.test.js +0 -112
  29. package/examples/worktrees/agent-validation/package-lock.json +0 -4736
  30. package/examples/worktrees/agent-validation/package.json +0 -18
  31. package/examples/worktrees/agent-validation/src/db.js +0 -179
  32. package/examples/worktrees/agent-validation/src/middleware/auth.js +0 -96
  33. package/examples/worktrees/agent-validation/src/middleware/validate.js +0 -133
  34. package/examples/worktrees/agent-validation/src/routes/tasks.js +0 -65
  35. package/examples/worktrees/agent-validation/src/routes/users.js +0 -38
  36. package/examples/worktrees/agent-validation/src/server.js +0 -7
  37. package/examples/worktrees/agent-validation/tests/api.test.js +0 -112
  38. package/tests/test.js +0 -259
package/README.md CHANGED
@@ -107,10 +107,11 @@ Paste this into your agent at the start of each session:
107
107
 
108
108
  ```
109
109
  Before starting any work:
110
- 1. Run `switchman task next --json` to get your assigned task
110
+ 1. Run `switchman lease next --json` to get your assigned task and lease
111
111
  2. Run `switchman claim <taskId> <worktreeName> <files...>` to lock the files you'll edit
112
112
  - If a file is already claimed, pick a different approach or different files
113
- 3. When finished, run `switchman task done <taskId>`
113
+ 3. If the task runs for a while, refresh the lease with `switchman lease heartbeat <leaseId>`
114
+ 4. When finished, run `switchman task done <taskId>`
114
115
 
115
116
  Never edit a file you haven't claimed. If a claim fails, do not use --force.
116
117
  ```
@@ -129,16 +130,16 @@ Here's what a normal session looks like with Switchman running:
129
130
 
130
131
  ```
131
132
  # Agent 1 picks up a task
132
- switchman task next
133
- Assigned: "Add rate limiting to all routes" [task-abc-123]
133
+ switchman lease next
134
+ Lease acquired: "Add rate limiting to all routes" [task-abc-123 / lease-xyz-123]
134
135
 
135
136
  # Agent 1 locks its files
136
137
  switchman claim task-abc-123 agent1 src/middleware/auth.js src/server.js
137
138
  ✓ 2 files locked — no conflicts
138
139
 
139
140
  # Agent 2 picks up a different task
140
- switchman task next
141
- Assigned: "Add validation to POST /tasks" [task-def-456]
141
+ switchman lease next
142
+ Lease acquired: "Add validation to POST /tasks" [task-def-456 / lease-xyz-456]
142
143
 
143
144
  # Agent 2 tries to claim a file already locked by Agent 1
144
145
  switchman claim task-def-456 agent2 src/middleware/auth.js
@@ -175,10 +176,25 @@ Add a task to the queue.
175
176
  List all tasks. Filter with `--status pending|in_progress|done|failed`.
176
177
 
177
178
  ### `switchman task next`
178
- Get and assign the next pending task. Use `--json` for agent automation.
179
+ Get and assign the next pending task. This is a compatibility shim over the lease workflow. Use `--json` for agent automation.
179
180
  - `--worktree <name>` — worktree to assign to (defaults to current folder name)
180
181
  - `--agent <name>` — agent identifier for logging
181
182
 
183
+ ### `switchman lease next`
184
+ Acquire the next pending task as a first-class lease. Use `--json` for agent automation.
185
+ - `--worktree <name>` — worktree to assign to (defaults to current folder name)
186
+ - `--agent <name>` — agent identifier for logging
187
+
188
+ ### `switchman lease list`
189
+ List active and historical leases. Filter with `--status active|completed|failed|expired`.
190
+
191
+ ### `switchman lease heartbeat <leaseId>`
192
+ Refresh the heartbeat timestamp for a long-running lease so it does not get treated as stale.
193
+
194
+ ### `switchman lease reap`
195
+ Expire stale leases, release their claims, and return their tasks to `pending`.
196
+ - `--stale-after-minutes <n>` — staleness threshold (default: 15)
197
+
182
198
  ### `switchman task done <taskId>`
183
199
  Mark a task complete and release all file claims.
184
200
 
@@ -195,7 +211,7 @@ Release all file claims for a task.
195
211
  Check all worktrees for conflicts — both uncommitted file overlaps and branch-level merge conflicts. Run this before merging.
196
212
 
197
213
  ### `switchman status`
198
- Full overview: task counts, active tasks, locked files, and a quick conflict scan.
214
+ Full overview: task counts, active leases, stale leases, locked files, and a quick conflict scan.
199
215
 
200
216
  ### `switchman worktree list`
201
217
  List all git worktrees with their registered agents and status.
@@ -214,6 +230,7 @@ Re-sync git worktrees into the Switchman database (useful if you add worktrees a
214
230
  | `switchman_task_claim` | Claim files before editing (conflict check) |
215
231
  | `switchman_task_done` | Mark task complete, release file claims |
216
232
  | `switchman_task_fail` | Mark task failed, release file claims |
233
+ | `switchman_lease_heartbeat` | Refresh a long-running lease heartbeat |
217
234
  | `switchman_scan` | Scan all worktrees for conflicts |
218
235
  | `switchman_status` | Full system overview |
219
236
 
@@ -222,7 +239,7 @@ Re-sync git worktrees into the Switchman database (useful if you add worktrees a
222
239
  ## Roadmap
223
240
 
224
241
  - [ ] Merge queue — serialize worktree→main merges with auto-retry
225
- - [ ] Agent health monitoringreclaim tasks from dead/stalled agents
242
+ - [ ] Automatic stale-lease policiesconfigurable heartbeat/reap behaviour
226
243
  - [ ] Cursor and Windsurf native MCP integration
227
244
  - [ ] Web dashboard
228
245
  - [ ] `brew install switchman`
@@ -240,4 +257,4 @@ Building this in public — if you're running parallel agents and hit something
240
257
 
241
258
  ## License
242
259
 
243
- MIT — free to use, modify, and distribute.
260
+ MIT — free to use, modify, and distribute.
package/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "switchman-dev",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Route your AI agents so they don't collide",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "bin": {
8
- "switchman": "./src/cli/index.js",
9
- "switchman-mcp": "./src/mcp/server.js"
8
+ "switchman": "src/cli/index.js",
9
+ "switchman-mcp": "src/mcp/server.js"
10
10
  },
11
- "repository": {
11
+ "repository": {
12
12
  "type": "git",
13
- "url": "https://github.com/switchman-dev/switchman"
13
+ "url": "git+https://github.com/switchman-dev/switchman.git"
14
14
  },
15
15
  "scripts": {
16
16
  "start": "node src/cli/index.js",
package/src/cli/index.js CHANGED
@@ -27,10 +27,11 @@ import { execSync } from 'child_process';
27
27
  import { findRepoRoot, listGitWorktrees, createGitWorktree } from '../core/git.js';
28
28
  import {
29
29
  initDb, openDb,
30
- createTask, assignTask, completeTask, failTask, listTasks, getTask, getNextPendingTask,
30
+ DEFAULT_STALE_LEASE_MINUTES,
31
+ createTask, startTaskLease, completeTask, failTask, listTasks, getTask, getNextPendingTask,
32
+ listLeases, heartbeatLease, getStaleLeases, reapStaleLeases,
31
33
  registerWorktree, listWorktrees,
32
34
  claimFiles, releaseFileClaims, getActiveFileClaims, checkFileConflicts,
33
- logConflict,
34
35
  } from '../core/db.js';
35
36
  import { scanAllWorktrees } from '../core/detector.js';
36
37
 
@@ -60,14 +61,34 @@ function statusBadge(status) {
60
61
  const colors = {
61
62
  pending: chalk.yellow,
62
63
  in_progress: chalk.blue,
64
+ active: chalk.blue,
65
+ completed: chalk.green,
63
66
  done: chalk.green,
64
67
  failed: chalk.red,
68
+ expired: chalk.red,
65
69
  idle: chalk.gray,
66
70
  busy: chalk.blue,
67
71
  };
68
72
  return (colors[status] || chalk.white)(status.toUpperCase().padEnd(11));
69
73
  }
70
74
 
75
+ function getCurrentWorktreeName(explicitWorktree) {
76
+ return explicitWorktree || process.cwd().split('/').pop();
77
+ }
78
+
79
+ function taskJsonWithLease(task, worktree, lease) {
80
+ return {
81
+ task: {
82
+ ...task,
83
+ worktree,
84
+ status: 'in_progress',
85
+ lease_id: lease?.id ?? null,
86
+ lease_status: lease?.status ?? null,
87
+ heartbeat_at: lease?.heartbeat_at ?? null,
88
+ },
89
+ };
90
+ }
91
+
71
92
  function printTable(rows, columns) {
72
93
  if (!rows.length) return;
73
94
  const widths = columns.map(col =>
@@ -263,15 +284,15 @@ taskCmd
263
284
 
264
285
  taskCmd
265
286
  .command('assign <taskId> <worktree>')
266
- .description('Assign a task to a worktree')
287
+ .description('Assign a task to a worktree (compatibility shim for lease acquire)')
267
288
  .option('--agent <name>', 'Agent name (e.g. claude-code)')
268
289
  .action((taskId, worktree, opts) => {
269
290
  const repoRoot = getRepo();
270
291
  const db = getDb(repoRoot);
271
- const ok = assignTask(db, taskId, worktree, opts.agent);
292
+ const lease = startTaskLease(db, taskId, worktree, opts.agent);
272
293
  db.close();
273
- if (ok) {
274
- console.log(`${chalk.green('✓')} Assigned ${chalk.cyan(taskId)} → ${chalk.cyan(worktree)}`);
294
+ if (lease) {
295
+ console.log(`${chalk.green('✓')} Assigned ${chalk.cyan(taskId)} → ${chalk.cyan(worktree)} (${chalk.dim(lease.id)})`);
275
296
  } else {
276
297
  console.log(chalk.red(`Could not assign task. It may not exist or is not in 'pending' status.`));
277
298
  }
@@ -303,7 +324,7 @@ taskCmd
303
324
 
304
325
  taskCmd
305
326
  .command('next')
306
- .description('Get and assign the next pending task (for agent automation)')
327
+ .description('Get and assign the next pending task (compatibility shim for lease next)')
307
328
  .option('--json', 'Output as JSON')
308
329
  .option('--worktree <name>', 'Worktree to assign the task to (defaults to current worktree name)')
309
330
  .option('--agent <name>', 'Agent identifier for logging (e.g. claude-code)')
@@ -320,11 +341,11 @@ taskCmd
320
341
  }
321
342
 
322
343
  // Determine worktree name: explicit flag, or derive from cwd
323
- const worktreeName = opts.worktree || process.cwd().split('/').pop();
324
- const assigned = assignTask(db, task.id, worktreeName, opts.agent || null);
344
+ const worktreeName = getCurrentWorktreeName(opts.worktree);
345
+ const lease = startTaskLease(db, task.id, worktreeName, opts.agent || null);
325
346
  db.close();
326
347
 
327
- if (!assigned) {
348
+ if (!lease) {
328
349
  // Race condition: another agent grabbed it between get and assign
329
350
  if (opts.json) console.log(JSON.stringify({ task: null, message: 'Task claimed by another agent — try again' }));
330
351
  else console.log(chalk.yellow('Task was just claimed by another agent. Run again to get the next one.'));
@@ -332,10 +353,168 @@ taskCmd
332
353
  }
333
354
 
334
355
  if (opts.json) {
335
- console.log(JSON.stringify({ task: { ...task, worktree: worktreeName, status: 'in_progress' } }, null, 2));
356
+ console.log(JSON.stringify(taskJsonWithLease(task, worktreeName, lease), null, 2));
336
357
  } else {
337
358
  console.log(`${chalk.green('✓')} Assigned: ${chalk.bold(task.title)}`);
338
- console.log(` ${chalk.dim('id:')} ${task.id} ${chalk.dim('worktree:')} ${chalk.cyan(worktreeName)} ${chalk.dim('priority:')} ${task.priority}`);
359
+ 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}`);
360
+ }
361
+ });
362
+
363
+ // ── lease ────────────────────────────────────────────────────────────────────
364
+
365
+ const leaseCmd = program.command('lease').description('Manage active work leases');
366
+
367
+ leaseCmd
368
+ .command('acquire <taskId> <worktree>')
369
+ .description('Acquire a lease for a pending task')
370
+ .option('--agent <name>', 'Agent identifier for logging')
371
+ .option('--json', 'Output as JSON')
372
+ .action((taskId, worktree, opts) => {
373
+ const repoRoot = getRepo();
374
+ const db = getDb(repoRoot);
375
+ const task = getTask(db, taskId);
376
+ const lease = startTaskLease(db, taskId, worktree, opts.agent || null);
377
+ db.close();
378
+
379
+ if (!lease || !task) {
380
+ if (opts.json) console.log(JSON.stringify({ lease: null, task: null }));
381
+ else console.log(chalk.red(`Could not acquire lease. The task may not exist or is not pending.`));
382
+ process.exitCode = 1;
383
+ return;
384
+ }
385
+
386
+ if (opts.json) {
387
+ console.log(JSON.stringify({
388
+ lease,
389
+ task: taskJsonWithLease(task, worktree, lease).task,
390
+ }, null, 2));
391
+ return;
392
+ }
393
+
394
+ console.log(`${chalk.green('✓')} Lease acquired ${chalk.dim(lease.id)}`);
395
+ console.log(` ${chalk.dim('task:')} ${chalk.bold(task.title)}`);
396
+ console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(worktree)}`);
397
+ });
398
+
399
+ leaseCmd
400
+ .command('next')
401
+ .description('Claim the next pending task and acquire its lease')
402
+ .option('--json', 'Output as JSON')
403
+ .option('--worktree <name>', 'Worktree to assign the task to (defaults to current worktree name)')
404
+ .option('--agent <name>', 'Agent identifier for logging')
405
+ .action((opts) => {
406
+ const repoRoot = getRepo();
407
+ const db = getDb(repoRoot);
408
+ const task = getNextPendingTask(db);
409
+
410
+ if (!task) {
411
+ db.close();
412
+ if (opts.json) console.log(JSON.stringify({ task: null, lease: null }));
413
+ else console.log(chalk.dim('No pending tasks.'));
414
+ return;
415
+ }
416
+
417
+ const worktreeName = getCurrentWorktreeName(opts.worktree);
418
+ const lease = startTaskLease(db, task.id, worktreeName, opts.agent || null);
419
+ db.close();
420
+
421
+ if (!lease) {
422
+ if (opts.json) console.log(JSON.stringify({ task: null, lease: null, message: 'Task claimed by another agent — try again' }));
423
+ else console.log(chalk.yellow('Task was just claimed by another agent. Run again to get the next one.'));
424
+ return;
425
+ }
426
+
427
+ if (opts.json) {
428
+ console.log(JSON.stringify({
429
+ lease,
430
+ ...taskJsonWithLease(task, worktreeName, lease),
431
+ }, null, 2));
432
+ return;
433
+ }
434
+
435
+ console.log(`${chalk.green('✓')} Lease acquired: ${chalk.bold(task.title)}`);
436
+ console.log(` ${chalk.dim('task:')} ${task.id} ${chalk.dim('lease:')} ${lease.id}`);
437
+ console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(worktreeName)} ${chalk.dim('priority:')} ${task.priority}`);
438
+ });
439
+
440
+ leaseCmd
441
+ .command('list')
442
+ .description('List leases, newest first')
443
+ .option('-s, --status <status>', 'Filter by status (active|completed|failed|expired)')
444
+ .action((opts) => {
445
+ const repoRoot = getRepo();
446
+ const db = getDb(repoRoot);
447
+ const leases = listLeases(db, opts.status);
448
+ db.close();
449
+
450
+ if (!leases.length) {
451
+ console.log(chalk.dim('No leases found.'));
452
+ return;
453
+ }
454
+
455
+ console.log('');
456
+ for (const lease of leases) {
457
+ console.log(`${statusBadge(lease.status)} ${chalk.bold(lease.task_title)}`);
458
+ console.log(` ${chalk.dim('lease:')} ${lease.id} ${chalk.dim('task:')} ${lease.task_id}`);
459
+ console.log(` ${chalk.dim('worktree:')} ${chalk.cyan(lease.worktree)} ${chalk.dim('agent:')} ${lease.agent || 'unknown'}`);
460
+ console.log(` ${chalk.dim('started:')} ${lease.started_at} ${chalk.dim('heartbeat:')} ${lease.heartbeat_at}`);
461
+ if (lease.failure_reason) console.log(` ${chalk.red(lease.failure_reason)}`);
462
+ console.log('');
463
+ }
464
+ });
465
+
466
+ leaseCmd
467
+ .command('heartbeat <leaseId>')
468
+ .description('Refresh the heartbeat timestamp for an active lease')
469
+ .option('--agent <name>', 'Agent identifier for logging')
470
+ .option('--json', 'Output as JSON')
471
+ .action((leaseId, opts) => {
472
+ const repoRoot = getRepo();
473
+ const db = getDb(repoRoot);
474
+ const lease = heartbeatLease(db, leaseId, opts.agent || null);
475
+ db.close();
476
+
477
+ if (!lease) {
478
+ if (opts.json) console.log(JSON.stringify({ lease: null }));
479
+ else console.log(chalk.red(`No active lease found for ${leaseId}`));
480
+ process.exitCode = 1;
481
+ return;
482
+ }
483
+
484
+ if (opts.json) {
485
+ console.log(JSON.stringify({ lease }, null, 2));
486
+ return;
487
+ }
488
+
489
+ console.log(`${chalk.green('✓')} Heartbeat refreshed for ${chalk.dim(lease.id)}`);
490
+ console.log(` ${chalk.dim('task:')} ${lease.task_title} ${chalk.dim('worktree:')} ${chalk.cyan(lease.worktree)}`);
491
+ });
492
+
493
+ leaseCmd
494
+ .command('reap')
495
+ .description('Expire stale leases, release their claims, and return their tasks to pending')
496
+ .option('--stale-after-minutes <minutes>', 'Age threshold for staleness', String(DEFAULT_STALE_LEASE_MINUTES))
497
+ .option('--json', 'Output as JSON')
498
+ .action((opts) => {
499
+ const repoRoot = getRepo();
500
+ const db = getDb(repoRoot);
501
+ const staleAfterMinutes = Number.parseInt(opts.staleAfterMinutes, 10);
502
+ const expired = reapStaleLeases(db, staleAfterMinutes);
503
+ db.close();
504
+
505
+ if (opts.json) {
506
+ console.log(JSON.stringify({ stale_after_minutes: staleAfterMinutes, expired }, null, 2));
507
+ return;
508
+ }
509
+
510
+ if (!expired.length) {
511
+ console.log(chalk.dim(`No stale leases older than ${staleAfterMinutes} minute(s).`));
512
+ return;
513
+ }
514
+
515
+ console.log(`${chalk.green('✓')} Reaped ${expired.length} stale lease(s)`);
516
+ for (const lease of expired) {
517
+ console.log(` ${chalk.dim(lease.id)} ${chalk.cyan(lease.worktree)} → ${lease.task_title}`);
339
518
  }
340
519
  });
341
520
 
@@ -412,23 +591,27 @@ program
412
591
  const repoRoot = getRepo();
413
592
  const db = getDb(repoRoot);
414
593
 
415
- // Check for existing claims
416
- const conflicts = checkFileConflicts(db, files, worktree);
594
+ try {
595
+ const conflicts = checkFileConflicts(db, files, worktree);
417
596
 
418
- if (conflicts.length > 0 && !opts.force) {
419
- console.log(chalk.red(`\n⚠ Claim conflicts detected:`));
420
- for (const c of conflicts) {
421
- console.log(` ${chalk.yellow(c.file)} → already claimed by worktree ${chalk.cyan(c.claimedBy.worktree)} (task: ${c.claimedBy.task_title})`);
597
+ if (conflicts.length > 0 && !opts.force) {
598
+ console.log(chalk.red(`\n⚠ Claim conflicts detected:`));
599
+ for (const c of conflicts) {
600
+ console.log(` ${chalk.yellow(c.file)} → already claimed by worktree ${chalk.cyan(c.claimedBy.worktree)} (task: ${c.claimedBy.task_title})`);
601
+ }
602
+ console.log(chalk.dim('\nUse --force to claim anyway, or resolve conflicts first.'));
603
+ return;
422
604
  }
423
- console.log(chalk.dim('\nUse --force to claim anyway, or resolve conflicts first.'));
605
+
606
+ const lease = claimFiles(db, taskId, worktree, files, opts.agent);
607
+ console.log(`${chalk.green('✓')} Claimed ${files.length} file(s) for task ${chalk.cyan(taskId)} (${chalk.dim(lease.id)})`);
608
+ files.forEach(f => console.log(` ${chalk.dim(f)}`));
609
+ } catch (err) {
610
+ console.error(chalk.red(err.message));
611
+ process.exitCode = 1;
612
+ } finally {
424
613
  db.close();
425
- return;
426
614
  }
427
-
428
- claimFiles(db, taskId, worktree, files, opts.agent);
429
- db.close();
430
- console.log(`${chalk.green('✓')} Claimed ${files.length} file(s) for task ${chalk.cyan(taskId)}`);
431
- files.forEach(f => console.log(` ${chalk.dim(f)}`));
432
615
  });
433
616
 
434
617
  program
@@ -536,6 +719,8 @@ program
536
719
  const inProgress = tasks.filter(t => t.status === 'in_progress');
537
720
  const done = tasks.filter(t => t.status === 'done');
538
721
  const failed = tasks.filter(t => t.status === 'failed');
722
+ const activeLeases = listLeases(db, 'active');
723
+ const staleLeases = getStaleLeases(db);
539
724
 
540
725
  console.log(chalk.bold('Tasks:'));
541
726
  console.log(` ${chalk.yellow('Pending')} ${pending.length}`);
@@ -543,14 +728,28 @@ program
543
728
  console.log(` ${chalk.green('Done')} ${done.length}`);
544
729
  console.log(` ${chalk.red('Failed')} ${failed.length}`);
545
730
 
546
- if (inProgress.length > 0) {
731
+ if (activeLeases.length > 0) {
547
732
  console.log('');
548
- console.log(chalk.bold('Active Tasks:'));
733
+ console.log(chalk.bold('Active Leases:'));
734
+ for (const lease of activeLeases) {
735
+ console.log(` ${chalk.cyan(lease.worktree)} → ${lease.task_title} ${chalk.dim(lease.id)}`);
736
+ }
737
+ } else if (inProgress.length > 0) {
738
+ console.log('');
739
+ console.log(chalk.bold('In-Progress Tasks Without Lease:'));
549
740
  for (const t of inProgress) {
550
741
  console.log(` ${chalk.cyan(t.worktree || 'unassigned')} → ${t.title}`);
551
742
  }
552
743
  }
553
744
 
745
+ if (staleLeases.length > 0) {
746
+ console.log('');
747
+ console.log(chalk.bold('Stale Leases:'));
748
+ for (const lease of staleLeases) {
749
+ console.log(` ${chalk.red(lease.worktree)} → ${lease.task_title} ${chalk.dim(lease.id)} ${chalk.dim(lease.heartbeat_at)}`);
750
+ }
751
+ }
752
+
554
753
  if (pending.length > 0) {
555
754
  console.log('');
556
755
  console.log(chalk.bold('Next Up:'));
@@ -599,4 +798,4 @@ program
599
798
  console.log('');
600
799
  });
601
800
 
602
- program.parse();
801
+ program.parse();