@os-eco/overstory-cli 0.9.3 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +49 -18
  2. package/agents/builder.md +9 -8
  3. package/agents/coordinator.md +6 -6
  4. package/agents/lead.md +98 -82
  5. package/agents/merger.md +25 -14
  6. package/agents/reviewer.md +22 -16
  7. package/agents/scout.md +17 -12
  8. package/package.json +6 -3
  9. package/src/agents/capabilities.test.ts +85 -0
  10. package/src/agents/capabilities.ts +125 -0
  11. package/src/agents/headless-mail-injector.test.ts +448 -0
  12. package/src/agents/headless-mail-injector.ts +211 -0
  13. package/src/agents/headless-prompt.test.ts +102 -0
  14. package/src/agents/headless-prompt.ts +68 -0
  15. package/src/agents/hooks-deployer.test.ts +514 -14
  16. package/src/agents/hooks-deployer.ts +141 -0
  17. package/src/agents/overlay.test.ts +4 -4
  18. package/src/agents/overlay.ts +30 -8
  19. package/src/agents/turn-lock.test.ts +181 -0
  20. package/src/agents/turn-lock.ts +235 -0
  21. package/src/agents/turn-runner-dispatch.test.ts +182 -0
  22. package/src/agents/turn-runner-dispatch.ts +105 -0
  23. package/src/agents/turn-runner.test.ts +1450 -0
  24. package/src/agents/turn-runner.ts +1166 -0
  25. package/src/commands/clean.ts +56 -1
  26. package/src/commands/completions.test.ts +4 -1
  27. package/src/commands/coordinator.test.ts +127 -0
  28. package/src/commands/coordinator.ts +205 -6
  29. package/src/commands/dashboard.test.ts +188 -0
  30. package/src/commands/dashboard.ts +13 -3
  31. package/src/commands/doctor.ts +94 -77
  32. package/src/commands/group.test.ts +94 -0
  33. package/src/commands/group.ts +49 -20
  34. package/src/commands/init.test.ts +8 -0
  35. package/src/commands/init.ts +8 -1
  36. package/src/commands/log.test.ts +56 -11
  37. package/src/commands/log.ts +134 -69
  38. package/src/commands/mail.test.ts +162 -0
  39. package/src/commands/mail.ts +64 -9
  40. package/src/commands/merge.test.ts +112 -1
  41. package/src/commands/merge.ts +17 -4
  42. package/src/commands/monitor.ts +2 -1
  43. package/src/commands/nudge.test.ts +351 -4
  44. package/src/commands/nudge.ts +356 -34
  45. package/src/commands/run.test.ts +43 -7
  46. package/src/commands/serve/build.test.ts +202 -0
  47. package/src/commands/serve/build.ts +206 -0
  48. package/src/commands/serve/coordinator-actions.test.ts +339 -0
  49. package/src/commands/serve/coordinator-actions.ts +408 -0
  50. package/src/commands/serve/dev.test.ts +168 -0
  51. package/src/commands/serve/dev.ts +117 -0
  52. package/src/commands/serve/mail-actions.test.ts +312 -0
  53. package/src/commands/serve/mail-actions.ts +167 -0
  54. package/src/commands/serve/rest.test.ts +1323 -0
  55. package/src/commands/serve/rest.ts +708 -0
  56. package/src/commands/serve/static.ts +51 -0
  57. package/src/commands/serve/ws.test.ts +361 -0
  58. package/src/commands/serve/ws.ts +332 -0
  59. package/src/commands/serve.test.ts +459 -0
  60. package/src/commands/serve.ts +565 -0
  61. package/src/commands/sling.test.ts +85 -1
  62. package/src/commands/sling.ts +153 -64
  63. package/src/commands/status.test.ts +9 -0
  64. package/src/commands/status.ts +12 -4
  65. package/src/commands/stop.test.ts +174 -1
  66. package/src/commands/stop.ts +107 -8
  67. package/src/commands/supervisor.ts +2 -1
  68. package/src/commands/watch.test.ts +49 -4
  69. package/src/commands/watch.ts +153 -28
  70. package/src/commands/worktree.test.ts +319 -3
  71. package/src/commands/worktree.ts +86 -0
  72. package/src/config.test.ts +78 -0
  73. package/src/config.ts +43 -1
  74. package/src/doctor/consistency.test.ts +106 -0
  75. package/src/doctor/consistency.ts +50 -3
  76. package/src/doctor/serve.test.ts +95 -0
  77. package/src/doctor/serve.ts +86 -0
  78. package/src/doctor/types.ts +2 -1
  79. package/src/doctor/watchdog.ts +57 -1
  80. package/src/events/tailer.test.ts +234 -1
  81. package/src/events/tailer.ts +90 -0
  82. package/src/index.ts +53 -6
  83. package/src/json.ts +29 -0
  84. package/src/mail/client.ts +15 -2
  85. package/src/mail/store.test.ts +82 -0
  86. package/src/mail/store.ts +41 -4
  87. package/src/merge/lock.test.ts +149 -0
  88. package/src/merge/lock.ts +140 -0
  89. package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
  90. package/src/runtimes/claude.test.ts +791 -1
  91. package/src/runtimes/claude.ts +323 -1
  92. package/src/runtimes/connections.test.ts +141 -1
  93. package/src/runtimes/connections.ts +73 -4
  94. package/src/runtimes/headless-connection.test.ts +264 -0
  95. package/src/runtimes/headless-connection.ts +158 -0
  96. package/src/runtimes/types.ts +10 -0
  97. package/src/schema-consistency.test.ts +1 -0
  98. package/src/sessions/store.test.ts +390 -24
  99. package/src/sessions/store.ts +184 -19
  100. package/src/test-setup.test.ts +31 -0
  101. package/src/test-setup.ts +28 -0
  102. package/src/types.ts +56 -1
  103. package/src/utils/pid.test.ts +85 -1
  104. package/src/utils/pid.ts +86 -1
  105. package/src/utils/process-scan.test.ts +53 -0
  106. package/src/utils/process-scan.ts +76 -0
  107. package/src/watchdog/daemon.test.ts +1520 -411
  108. package/src/watchdog/daemon.ts +442 -83
  109. package/src/watchdog/health.test.ts +157 -0
  110. package/src/watchdog/health.ts +92 -25
  111. package/src/worktree/process.test.ts +71 -0
  112. package/src/worktree/process.ts +25 -5
  113. package/src/worktree/tmux.test.ts +39 -0
  114. package/src/worktree/tmux.ts +23 -3
  115. package/templates/CLAUDE.md.tmpl +19 -8
  116. package/templates/overlay.md.tmpl +3 -2
@@ -295,6 +295,7 @@ function makeDashboardData(
295
295
  worktrees: [],
296
296
  tmuxSessions: [],
297
297
  unreadMailCount: 0,
298
+ unreadMailScope: "orchestrator",
298
299
  mergeQueueCount: 0,
299
300
  recentMetricsCount: 0,
300
301
  },
@@ -447,6 +448,7 @@ describe("renderAgentPanel", () => {
447
448
  worktrees: [],
448
449
  tmuxSessions: [], // no tmux sessions
449
450
  unreadMailCount: 0,
451
+ unreadMailScope: "orchestrator",
450
452
  mergeQueueCount: 0,
451
453
  recentMetricsCount: 0,
452
454
  },
@@ -487,6 +489,7 @@ describe("renderAgentPanel", () => {
487
489
  worktrees: [],
488
490
  tmuxSessions: [],
489
491
  unreadMailCount: 0,
492
+ unreadMailScope: "orchestrator",
490
493
  mergeQueueCount: 0,
491
494
  recentMetricsCount: 0,
492
495
  },
@@ -495,6 +498,191 @@ describe("renderAgentPanel", () => {
495
498
  expect(out).toContain("x");
496
499
  expect(out).toContain("dead-headless");
497
500
  });
501
+
502
+ test("renders mixed tmux + headless agents in same frame with correct liveness", () => {
503
+ const data = {
504
+ ...makeDashboardData({}),
505
+ status: {
506
+ currentRunId: null,
507
+ agents: [
508
+ {
509
+ id: "sess-tmux-1",
510
+ agentName: "pane-agent",
511
+ capability: "builder",
512
+ worktreePath: "/tmp/wt/pane-agent",
513
+ branchName: "overstory/pane-agent/task-t1",
514
+ taskId: "task-t1",
515
+ tmuxSession: "overstory-pane-agent",
516
+ state: "working" as const,
517
+ pid: 99999,
518
+ parentAgent: null,
519
+ depth: 0,
520
+ runId: null,
521
+ startedAt: new Date(Date.now() - 10_000).toISOString(),
522
+ lastActivity: new Date().toISOString(),
523
+ escalationLevel: 0,
524
+ stalledSince: null,
525
+ transcriptPath: null,
526
+ },
527
+ {
528
+ id: "sess-headless-1",
529
+ agentName: "live-headless",
530
+ capability: "builder",
531
+ worktreePath: "/tmp/wt/live-headless",
532
+ branchName: "overstory/live-headless/task-h1",
533
+ taskId: "task-h1",
534
+ tmuxSession: "", // headless
535
+ state: "working" as const,
536
+ pid: process.pid, // own PID — guaranteed alive
537
+ parentAgent: null,
538
+ depth: 0,
539
+ runId: null,
540
+ startedAt: new Date(Date.now() - 10_000).toISOString(),
541
+ lastActivity: new Date().toISOString(),
542
+ escalationLevel: 0,
543
+ stalledSince: null,
544
+ transcriptPath: null,
545
+ },
546
+ ],
547
+ worktrees: [],
548
+ tmuxSessions: [{ name: "overstory-pane-agent", pid: 99998 }],
549
+ unreadMailCount: 0,
550
+ unreadMailScope: "orchestrator",
551
+ mergeQueueCount: 0,
552
+ recentMetricsCount: 0,
553
+ },
554
+ };
555
+ const out = renderAgentPanel(data, 100, 12, 3);
556
+ expect(out).toContain("pane-agent");
557
+ expect(out).toContain("live-headless");
558
+ const aliveMarkers = (out.match(/>/g) ?? []).length;
559
+ expect(aliveMarkers).toBeGreaterThanOrEqual(2);
560
+ expect(out).not.toContain("x");
561
+ });
562
+
563
+ test("spawn-per-turn worker (no tmux, no pid) renders alive when state is non-terminal (overstory-7a34)", () => {
564
+ // Repro: freshly slung headless lead has tmuxSession='' and pid=null.
565
+ // Previously fell into the tmux path → never matched → red "x" while
566
+ // ov feed showed live tool events from the same agent.
567
+ const data = {
568
+ ...makeDashboardData({}),
569
+ status: {
570
+ currentRunId: null,
571
+ agents: [
572
+ {
573
+ id: "sess-spt-1",
574
+ agentName: "freshly-slung",
575
+ capability: "lead",
576
+ worktreePath: "/tmp/wt/freshly-slung",
577
+ branchName: "overstory/freshly-slung/task-l1",
578
+ taskId: "task-l1",
579
+ tmuxSession: "", // headless
580
+ state: "working" as const,
581
+ pid: null, // spawn-per-turn: no persistent process between turns
582
+ parentAgent: null,
583
+ depth: 0,
584
+ runId: null,
585
+ startedAt: new Date(Date.now() - 5_000).toISOString(),
586
+ lastActivity: new Date().toISOString(),
587
+ escalationLevel: 0,
588
+ stalledSince: null,
589
+ transcriptPath: null,
590
+ },
591
+ ],
592
+ worktrees: [],
593
+ tmuxSessions: [],
594
+ unreadMailCount: 0,
595
+ unreadMailScope: "orchestrator",
596
+ mergeQueueCount: 0,
597
+ recentMetricsCount: 0,
598
+ },
599
+ };
600
+ const out = renderAgentPanel(data, 100, 12, 3);
601
+ expect(out).toContain("freshly-slung");
602
+ // Green ">" — agent is logically alive between turns
603
+ expect(out).toContain(">");
604
+ // No red marker should be present (name 'freshly-slung' has no 'x')
605
+ expect(out).not.toContain("x");
606
+ });
607
+
608
+ test("spawn-per-turn worker in zombie state renders dead marker (overstory-7a34)", () => {
609
+ const data = {
610
+ ...makeDashboardData({}),
611
+ status: {
612
+ currentRunId: null,
613
+ agents: [
614
+ {
615
+ id: "sess-spt-2",
616
+ agentName: "abandoned-spt",
617
+ capability: "builder",
618
+ worktreePath: "/tmp/wt/abandoned-spt",
619
+ branchName: "overstory/abandoned-spt/task-a1",
620
+ taskId: "task-a1",
621
+ tmuxSession: "",
622
+ state: "zombie" as const,
623
+ pid: null,
624
+ parentAgent: null,
625
+ depth: 0,
626
+ runId: null,
627
+ startedAt: new Date(Date.now() - 600_000).toISOString(),
628
+ lastActivity: new Date(Date.now() - 600_000).toISOString(),
629
+ escalationLevel: 0,
630
+ stalledSince: null,
631
+ transcriptPath: null,
632
+ },
633
+ ],
634
+ worktrees: [],
635
+ tmuxSessions: [],
636
+ unreadMailCount: 0,
637
+ unreadMailScope: "orchestrator",
638
+ mergeQueueCount: 0,
639
+ recentMetricsCount: 0,
640
+ },
641
+ };
642
+ const out = renderAgentPanel(data, 100, 12, 3);
643
+ expect(out).toContain("abandoned-spt");
644
+ expect(out).toContain("x");
645
+ });
646
+
647
+ test("headless agent renders dead marker when tmux session list is non-empty", () => {
648
+ const deadPid = 2_147_483_647;
649
+ const data = {
650
+ ...makeDashboardData({}),
651
+ status: {
652
+ currentRunId: null,
653
+ agents: [
654
+ {
655
+ id: "sess-dead-headless-1",
656
+ agentName: "gone-headless",
657
+ capability: "builder",
658
+ worktreePath: "/tmp/wt/gone-headless",
659
+ branchName: "overstory/gone-headless/task-g1",
660
+ taskId: "task-g1",
661
+ tmuxSession: "", // headless
662
+ state: "working" as const,
663
+ pid: deadPid,
664
+ parentAgent: null,
665
+ depth: 0,
666
+ runId: null,
667
+ startedAt: new Date(Date.now() - 10_000).toISOString(),
668
+ lastActivity: new Date().toISOString(),
669
+ escalationLevel: 0,
670
+ stalledSince: null,
671
+ transcriptPath: null,
672
+ },
673
+ ],
674
+ worktrees: [],
675
+ tmuxSessions: [{ name: "overstory-other-tmux", pid: 11111 }],
676
+ unreadMailCount: 0,
677
+ unreadMailScope: "orchestrator",
678
+ mergeQueueCount: 0,
679
+ recentMetricsCount: 0,
680
+ },
681
+ };
682
+ const out = renderAgentPanel(data, 100, 12, 3);
683
+ expect(out).toContain("x");
684
+ expect(out).toContain("gone-headless");
685
+ });
498
686
  });
499
687
 
500
688
  describe("openDashboardStores", () => {
@@ -434,6 +434,7 @@ async function loadDashboardData(
434
434
  worktrees,
435
435
  tmuxSessions,
436
436
  unreadMailCount,
437
+ unreadMailScope: "orchestrator",
437
438
  mergeQueueCount,
438
439
  recentMetricsCount,
439
440
  };
@@ -644,10 +645,19 @@ export function renderAgentPanel(
644
645
  : now;
645
646
  const duration = formatDuration(endTime - new Date(agent.startedAt).getTime());
646
647
  const durationPadded = pad(duration, 9);
648
+ // Three liveness topologies (overstory-7a34):
649
+ // tmux: tmuxSession !== "" → tmux session must exist
650
+ // long-lived headless: tmuxSession === "" && pid !== null → PID must be alive
651
+ // spawn-per-turn: tmuxSession === "" && pid === null → no process between
652
+ // turns is normal, so liveness reduces to "state is non-terminal".
653
+ // Time-based stale/zombie classification is handled in evaluateHealth.
647
654
  const isHeadless = agent.tmuxSession === "" && agent.pid !== null;
648
- const alive = isHeadless
649
- ? agent.pid !== null && isProcessAlive(agent.pid)
650
- : data.status.tmuxSessions.some((s) => s.name === agent.tmuxSession);
655
+ const isSpawnPerTurn = agent.tmuxSession === "" && agent.pid === null;
656
+ const alive = isSpawnPerTurn
657
+ ? agent.state !== "zombie" && agent.state !== "completed"
658
+ : isHeadless
659
+ ? agent.pid !== null && isProcessAlive(agent.pid)
660
+ : data.status.tmuxSessions.some((s) => s.name === agent.tmuxSession);
651
661
  const aliveDot = alive ? color.green(">") : color.red("x");
652
662
 
653
663
  const lineContent = `${dimBox.vertical} ${stateColorFn(icon)} ${name} ${capability} ${color.dim(runtime)} ${stateColorFn(state)} ${taskId} ${durationPadded} ${aliveDot} `;
@@ -16,6 +16,7 @@ import { checkEcosystem } from "../doctor/ecosystem.ts";
16
16
  import { checkLogs } from "../doctor/logs.ts";
17
17
  import { checkMergeQueue } from "../doctor/merge-queue.ts";
18
18
  import { checkProviders } from "../doctor/providers.ts";
19
+ import { checkServe } from "../doctor/serve.ts";
19
20
  import { checkStructure } from "../doctor/structure.ts";
20
21
  import type { DoctorCategory, DoctorCheck, DoctorCheckFn } from "../doctor/types.ts";
21
22
  import { checkVersion } from "../doctor/version.ts";
@@ -39,6 +40,7 @@ const ALL_CHECKS: Array<{ category: DoctorCategory; fn: DoctorCheckFn }> = [
39
40
  { category: "ecosystem", fn: checkEcosystem },
40
41
  { category: "providers", fn: checkProviders },
41
42
  { category: "watchdog", fn: checkWatchdog },
43
+ { category: "serve", fn: checkServe },
42
44
  ];
43
45
 
44
46
  /**
@@ -158,10 +160,81 @@ export interface DoctorCommandOptions {
158
160
  checkRunners?: Array<{ category: DoctorCategory; fn: DoctorCheckFn }>;
159
161
  }
160
162
 
163
+ interface DoctorActionOpts {
164
+ json?: boolean;
165
+ verbose?: boolean;
166
+ category?: string;
167
+ fix?: boolean;
168
+ }
169
+
161
170
  /**
162
- * Create the Commander command for `overstory doctor`.
171
+ * Run the doctor checks. Returns true if any check failed.
172
+ * Shared by both the Commander action and the programmatic entry point so the
173
+ * exit-code signal never has to travel through `process.exitCode` (which is
174
+ * global mutable state and races with other tests in parallel bun test runs).
163
175
  */
164
- export function createDoctorCommand(options?: DoctorCommandOptions): Command {
176
+ async function runDoctorChecks(
177
+ opts: DoctorActionOpts,
178
+ checkRunners: Array<{ category: DoctorCategory; fn: DoctorCheckFn }>,
179
+ ): Promise<boolean> {
180
+ const json = opts.json ?? false;
181
+ const verbose = opts.verbose ?? false;
182
+ const categoryFilter = opts.category;
183
+ const fix = opts.fix ?? false;
184
+
185
+ if (categoryFilter !== undefined) {
186
+ const validCategories = ALL_CHECKS.map((c) => c.category);
187
+ if (!validCategories.includes(categoryFilter as DoctorCategory)) {
188
+ throw new ValidationError(
189
+ `Invalid category: ${categoryFilter}. Valid categories: ${validCategories.join(", ")}`,
190
+ {
191
+ field: "category",
192
+ value: categoryFilter,
193
+ },
194
+ );
195
+ }
196
+ }
197
+
198
+ const cwd = process.cwd();
199
+ const config = await loadConfig(cwd);
200
+ const overstoryDir = join(config.project.root, ".overstory");
201
+
202
+ const checksToRun = categoryFilter
203
+ ? checkRunners.filter((c) => c.category === categoryFilter)
204
+ : checkRunners;
205
+
206
+ let results: DoctorCheck[] = [];
207
+ for (const { fn } of checksToRun) {
208
+ const checkResults = await fn(config, overstoryDir);
209
+ results.push(...checkResults);
210
+ }
211
+
212
+ let fixedItems: string[] | undefined;
213
+ if (fix) {
214
+ const applied = await applyFixes(results);
215
+ if (applied.length > 0) {
216
+ fixedItems = applied;
217
+ results = [];
218
+ for (const { fn } of checksToRun) {
219
+ const checkResults = await fn(config, overstoryDir);
220
+ results.push(...checkResults);
221
+ }
222
+ }
223
+ }
224
+
225
+ if (json) {
226
+ printJSON(results, fixedItems);
227
+ } else {
228
+ printHumanReadable(results, verbose, checkRunners, fixedItems);
229
+ }
230
+
231
+ return results.some((c) => c.status === "fail");
232
+ }
233
+
234
+ function buildDoctorCommand(
235
+ onResult: (hasFailures: boolean) => void,
236
+ checkRunners: Array<{ category: DoctorCategory; fn: DoctorCheckFn }>,
237
+ ): Command {
165
238
  return new Command("doctor")
166
239
  .description("Run health checks on overstory setup")
167
240
  .option("--json", "Output as JSON")
@@ -170,75 +243,22 @@ export function createDoctorCommand(options?: DoctorCommandOptions): Command {
170
243
  .option("--fix", "Attempt to auto-fix issues")
171
244
  .addHelpText(
172
245
  "after",
173
- "\nCategories: dependencies, structure, config, databases, consistency, agents, merge, logs, version, ecosystem, providers, watchdog",
246
+ "\nCategories: dependencies, structure, config, databases, consistency, agents, merge, logs, version, ecosystem, providers, watchdog, serve",
174
247
  )
175
- .action(
176
- async (opts: { json?: boolean; verbose?: boolean; category?: string; fix?: boolean }) => {
177
- const json = opts.json ?? false;
178
- const verbose = opts.verbose ?? false;
179
- const categoryFilter = opts.category;
180
- const fix = opts.fix ?? false;
181
-
182
- // Validate category filter if provided
183
- if (categoryFilter !== undefined) {
184
- const validCategories = ALL_CHECKS.map((c) => c.category);
185
- if (!validCategories.includes(categoryFilter as DoctorCategory)) {
186
- throw new ValidationError(
187
- `Invalid category: ${categoryFilter}. Valid categories: ${validCategories.join(", ")}`,
188
- {
189
- field: "category",
190
- value: categoryFilter,
191
- },
192
- );
193
- }
194
- }
195
-
196
- const cwd = process.cwd();
197
- const config = await loadConfig(cwd);
198
- const overstoryDir = join(config.project.root, ".overstory");
199
-
200
- // Filter checks by category if specified
201
- const allChecks = options?.checkRunners ?? ALL_CHECKS;
202
- const checksToRun = categoryFilter
203
- ? allChecks.filter((c) => c.category === categoryFilter)
204
- : allChecks;
205
-
206
- // Run all checks sequentially
207
- let results: DoctorCheck[] = [];
208
- for (const { fn } of checksToRun) {
209
- const checkResults = await fn(config, overstoryDir);
210
- results.push(...checkResults);
211
- }
212
-
213
- // Apply fixes if requested
214
- let fixedItems: string[] | undefined;
215
- if (fix) {
216
- const applied = await applyFixes(results);
217
- if (applied.length > 0) {
218
- fixedItems = applied;
219
- // Re-run all checks to get fresh results after fixes
220
- results = [];
221
- for (const { fn } of checksToRun) {
222
- const checkResults = await fn(config, overstoryDir);
223
- results.push(...checkResults);
224
- }
225
- }
226
- }
227
-
228
- // Output results
229
- if (json) {
230
- printJSON(results, fixedItems);
231
- } else {
232
- printHumanReadable(results, verbose, allChecks, fixedItems);
233
- }
248
+ .action(async (opts: DoctorActionOpts) => {
249
+ onResult(await runDoctorChecks(opts, checkRunners));
250
+ });
251
+ }
234
252
 
235
- // Set exit code if any check failed
236
- const hasFailures = results.some((c) => c.status === "fail");
237
- if (hasFailures) {
238
- process.exitCode = 1;
239
- }
240
- },
241
- );
253
+ /**
254
+ * Create the Commander command for `overstory doctor`.
255
+ */
256
+ export function createDoctorCommand(options?: DoctorCommandOptions): Command {
257
+ return buildDoctorCommand((hasFailures) => {
258
+ if (hasFailures) {
259
+ process.exitCode = 1;
260
+ }
261
+ }, options?.checkRunners ?? ALL_CHECKS);
242
262
  }
243
263
 
244
264
  /**
@@ -250,16 +270,15 @@ export async function doctorCommand(
250
270
  args: string[],
251
271
  options?: DoctorCommandOptions,
252
272
  ): Promise<number | undefined> {
253
- const cmd = createDoctorCommand(options);
273
+ let hasFailures = false;
274
+ const cmd = buildDoctorCommand((result) => {
275
+ hasFailures = result;
276
+ }, options?.checkRunners ?? ALL_CHECKS);
254
277
  cmd.exitOverride();
255
278
 
256
- const prevExitCode = process.exitCode as number | undefined;
257
- process.exitCode = undefined;
258
-
259
279
  try {
260
280
  await cmd.parseAsync(args, { from: "user" });
261
281
  } catch (err: unknown) {
262
- process.exitCode = prevExitCode;
263
282
  if (err && typeof err === "object" && "code" in err) {
264
283
  const code = (err as { code: string }).code;
265
284
  if (code === "commander.helpDisplayed" || code === "commander.version") {
@@ -269,7 +288,5 @@ export async function doctorCommand(
269
288
  throw err;
270
289
  }
271
290
 
272
- const exitCode = process.exitCode === 1 ? 1 : undefined;
273
- process.exitCode = prevExitCode;
274
- return exitCode;
291
+ return hasFailures ? 1 : undefined;
275
292
  }
@@ -20,6 +20,7 @@ import {
20
20
  loadGroups,
21
21
  printGroupProgress,
22
22
  removeFromGroup,
23
+ resolveGroup,
23
24
  } from "./group.ts";
24
25
 
25
26
  let tempDir: string;
@@ -379,3 +380,96 @@ describe("printGroupProgress", () => {
379
380
  expect(output).toContain("2026-01-15T10:00:00.000Z");
380
381
  });
381
382
  });
383
+
384
+ // -- resolveGroup --
385
+
386
+ describe("resolveGroup", () => {
387
+ test("resolves by exact UUID", () => {
388
+ const a = makeGroup({ id: "group-aaaaaaaa", name: "alpha" });
389
+ const b = makeGroup({ id: "group-bbbbbbbb", name: "beta" });
390
+ expect(resolveGroup([a, b], "group-aaaaaaaa")).toBe(a);
391
+ });
392
+
393
+ test("resolves by unique name", () => {
394
+ const a = makeGroup({ id: "group-aaaaaaaa", name: "alpha" });
395
+ const b = makeGroup({ id: "group-bbbbbbbb", name: "beta" });
396
+ expect(resolveGroup([a, b], "beta")).toBe(b);
397
+ });
398
+
399
+ test("ID match wins when name === some-other-group's-id", () => {
400
+ const a = makeGroup({ id: "group-aaaaaaaa", name: "group-bbbbbbbb" });
401
+ const b = makeGroup({ id: "group-bbbbbbbb", name: "beta" });
402
+ expect(resolveGroup([a, b], "group-bbbbbbbb")).toBe(b);
403
+ });
404
+
405
+ test("name match prefers the active group when others are completed", () => {
406
+ const old = makeGroup({ id: "group-aaaaaaaa", name: "dup", status: "completed" });
407
+ const live = makeGroup({ id: "group-bbbbbbbb", name: "dup", status: "active" });
408
+ expect(resolveGroup([old, live], "dup")).toBe(live);
409
+ });
410
+
411
+ test("ambiguous when multiple active groups share a name", () => {
412
+ const x = makeGroup({ id: "group-aaaaaaaa", name: "dup", status: "active" });
413
+ const y = makeGroup({ id: "group-bbbbbbbb", name: "dup", status: "active" });
414
+ expect(() => resolveGroup([x, y], "dup")).toThrow(GroupError);
415
+ try {
416
+ resolveGroup([x, y], "dup");
417
+ } catch (err) {
418
+ expect(err).toBeInstanceOf(GroupError);
419
+ const message = (err as GroupError).message;
420
+ expect(message).toContain("ambiguous");
421
+ expect(message).toContain("group-aaaaaaaa");
422
+ expect(message).toContain("group-bbbbbbbb");
423
+ }
424
+ });
425
+
426
+ test("ambiguous when zero active groups share the name", () => {
427
+ const x = makeGroup({ id: "group-aaaaaaaa", name: "dup", status: "completed" });
428
+ const y = makeGroup({ id: "group-bbbbbbbb", name: "dup", status: "completed" });
429
+ expect(() => resolveGroup([x, y], "dup")).toThrow(GroupError);
430
+ });
431
+
432
+ test("throws not-found for unknown identifier", () => {
433
+ const a = makeGroup({ id: "group-aaaaaaaa", name: "alpha" });
434
+ expect(() => resolveGroup([a], "nope")).toThrow(/not found/);
435
+ });
436
+ });
437
+
438
+ // -- name-or-id lookup in addToGroup / removeFromGroup --
439
+
440
+ describe("name-or-id lookup", () => {
441
+ test("addToGroup resolves by name", async () => {
442
+ const group = makeGroup({ id: "group-aaaaaaaa", name: "alpha", memberIssueIds: ["i1"] });
443
+ await writeGroups([group]);
444
+ const tracker = stubTrackerOk();
445
+ const updated = await addToGroup(tempDir, "alpha", ["i2"], false, tracker);
446
+ expect(updated.id).toBe("group-aaaaaaaa");
447
+ expect(updated.memberIssueIds).toEqual(["i1", "i2"]);
448
+ });
449
+
450
+ test("removeFromGroup resolves by name", async () => {
451
+ const group = makeGroup({ id: "group-aaaaaaaa", name: "alpha", memberIssueIds: ["i1", "i2"] });
452
+ await writeGroups([group]);
453
+ const updated = await removeFromGroup(tempDir, "alpha", ["i2"]);
454
+ expect(updated.id).toBe("group-aaaaaaaa");
455
+ expect(updated.memberIssueIds).toEqual(["i1"]);
456
+ });
457
+ });
458
+
459
+ function stubTrackerOk(): import("../tracker/types.ts").TrackerClient {
460
+ return {
461
+ ready: async () => [],
462
+ show: async (id: string): Promise<TrackerIssue> => ({
463
+ id,
464
+ title: id,
465
+ status: "open",
466
+ priority: 2,
467
+ type: "task",
468
+ }),
469
+ create: async () => "stub-id",
470
+ claim: async () => undefined,
471
+ close: async () => undefined,
472
+ list: async () => [],
473
+ sync: async () => undefined,
474
+ };
475
+ }