@os-eco/overstory-cli 0.6.8 → 0.6.10

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 (69) hide show
  1. package/README.md +19 -5
  2. package/agents/builder.md +6 -15
  3. package/agents/lead.md +4 -6
  4. package/agents/merger.md +5 -13
  5. package/agents/reviewer.md +2 -9
  6. package/package.json +1 -1
  7. package/src/agents/hooks-deployer.test.ts +232 -0
  8. package/src/agents/hooks-deployer.ts +54 -8
  9. package/src/agents/overlay.test.ts +156 -1
  10. package/src/agents/overlay.ts +67 -7
  11. package/src/commands/agents.ts +9 -6
  12. package/src/commands/clean.ts +2 -1
  13. package/src/commands/completions.test.ts +8 -20
  14. package/src/commands/completions.ts +7 -6
  15. package/src/commands/coordinator.test.ts +8 -0
  16. package/src/commands/coordinator.ts +11 -8
  17. package/src/commands/costs.test.ts +48 -38
  18. package/src/commands/costs.ts +48 -38
  19. package/src/commands/dashboard.ts +7 -7
  20. package/src/commands/doctor.test.ts +8 -0
  21. package/src/commands/doctor.ts +96 -51
  22. package/src/commands/ecosystem.ts +291 -0
  23. package/src/commands/errors.test.ts +47 -40
  24. package/src/commands/errors.ts +5 -4
  25. package/src/commands/feed.test.ts +40 -33
  26. package/src/commands/feed.ts +5 -4
  27. package/src/commands/group.ts +23 -14
  28. package/src/commands/hooks.ts +2 -1
  29. package/src/commands/init.test.ts +104 -0
  30. package/src/commands/init.ts +11 -7
  31. package/src/commands/inspect.test.ts +2 -0
  32. package/src/commands/inspect.ts +9 -8
  33. package/src/commands/logs.test.ts +5 -6
  34. package/src/commands/logs.ts +2 -1
  35. package/src/commands/mail.test.ts +11 -10
  36. package/src/commands/mail.ts +11 -12
  37. package/src/commands/merge.ts +11 -12
  38. package/src/commands/metrics.test.ts +15 -2
  39. package/src/commands/metrics.ts +3 -2
  40. package/src/commands/monitor.ts +5 -4
  41. package/src/commands/nudge.ts +2 -3
  42. package/src/commands/prime.test.ts +1 -6
  43. package/src/commands/prime.ts +2 -3
  44. package/src/commands/replay.test.ts +62 -55
  45. package/src/commands/replay.ts +3 -2
  46. package/src/commands/run.ts +17 -20
  47. package/src/commands/sling.ts +3 -2
  48. package/src/commands/status.test.ts +2 -1
  49. package/src/commands/status.ts +7 -6
  50. package/src/commands/stop.test.ts +2 -0
  51. package/src/commands/stop.ts +10 -11
  52. package/src/commands/supervisor.ts +7 -6
  53. package/src/commands/trace.test.ts +52 -44
  54. package/src/commands/trace.ts +5 -4
  55. package/src/commands/upgrade.test.ts +46 -0
  56. package/src/commands/upgrade.ts +259 -0
  57. package/src/commands/watch.ts +8 -10
  58. package/src/commands/worktree.test.ts +21 -15
  59. package/src/commands/worktree.ts +10 -4
  60. package/src/doctor/databases.test.ts +38 -0
  61. package/src/doctor/databases.ts +7 -10
  62. package/src/doctor/ecosystem.test.ts +307 -0
  63. package/src/doctor/ecosystem.ts +155 -0
  64. package/src/doctor/merge-queue.test.ts +98 -0
  65. package/src/doctor/merge-queue.ts +23 -0
  66. package/src/doctor/structure.test.ts +130 -1
  67. package/src/doctor/structure.ts +87 -1
  68. package/src/doctor/types.ts +5 -2
  69. package/src/index.ts +25 -1
@@ -116,7 +116,14 @@ describe("costsCommand", () => {
116
116
  await costsCommand(["--json"]);
117
117
  const out = output();
118
118
 
119
- expect(out).toBe("[]\n");
119
+ const parsed = JSON.parse(out.trim()) as {
120
+ success: boolean;
121
+ command: string;
122
+ sessions: unknown[];
123
+ };
124
+ expect(parsed.success).toBe(true);
125
+ expect(parsed.command).toBe("costs");
126
+ expect(parsed.sessions).toEqual([]);
120
127
  });
121
128
  });
122
129
 
@@ -149,9 +156,10 @@ describe("costsCommand", () => {
149
156
  await costsCommand(["--json"]);
150
157
  const out = output();
151
158
 
152
- const parsed = JSON.parse(out.trim()) as unknown[];
153
- expect(Array.isArray(parsed)).toBe(true);
154
- expect(parsed).toHaveLength(2);
159
+ const parsed = JSON.parse(out.trim()) as { success: boolean; sessions: unknown[] };
160
+ expect(parsed.success).toBe(true);
161
+ expect(Array.isArray(parsed.sessions)).toBe(true);
162
+ expect(parsed.sessions).toHaveLength(2);
155
163
  });
156
164
 
157
165
  test("JSON output includes expected token fields", async () => {
@@ -173,9 +181,12 @@ describe("costsCommand", () => {
173
181
  await costsCommand(["--json"]);
174
182
  const out = output();
175
183
 
176
- const parsed = JSON.parse(out.trim()) as Record<string, unknown>[];
177
- expect(parsed).toHaveLength(1);
178
- const session = parsed[0];
184
+ const parsed = JSON.parse(out.trim()) as {
185
+ success: boolean;
186
+ sessions: Record<string, unknown>[];
187
+ };
188
+ expect(parsed.sessions).toHaveLength(1);
189
+ const session = parsed.sessions[0];
179
190
  expect(session).toBeDefined();
180
191
  expect(session?.inputTokens).toBe(100);
181
192
  expect(session?.outputTokens).toBe(50);
@@ -193,8 +204,8 @@ describe("costsCommand", () => {
193
204
  await costsCommand(["--json", "--agent", "nonexistent"]);
194
205
  const out = output();
195
206
 
196
- const parsed = JSON.parse(out.trim()) as unknown[];
197
- expect(parsed).toEqual([]);
207
+ const parsed = JSON.parse(out.trim()) as { success: boolean; sessions: unknown[] };
208
+ expect(parsed.sessions).toEqual([]);
198
209
  });
199
210
 
200
211
  test("JSON --by-capability outputs grouped object", async () => {
@@ -221,12 +232,12 @@ describe("costsCommand", () => {
221
232
  await costsCommand(["--json", "--by-capability"]);
222
233
  const out = output();
223
234
 
224
- const parsed = JSON.parse(out.trim()) as Record<string, unknown>;
225
- expect(parsed).toBeDefined();
226
- expect(parsed.builder).toBeDefined();
227
- expect(parsed.scout).toBeDefined();
235
+ const parsed = JSON.parse(out.trim()) as { grouped: Record<string, unknown> };
236
+ expect(parsed.grouped).toBeDefined();
237
+ expect(parsed.grouped.builder).toBeDefined();
238
+ expect(parsed.grouped.scout).toBeDefined();
228
239
 
229
- const builderGroup = parsed.builder as Record<string, unknown>;
240
+ const builderGroup = parsed.grouped.builder as Record<string, unknown>;
230
241
  expect(builderGroup.sessions).toBeDefined();
231
242
  expect(builderGroup.totals).toBeDefined();
232
243
  });
@@ -415,9 +426,9 @@ describe("costsCommand", () => {
415
426
  await costsCommand(["--json", "--agent", "builder-1"]);
416
427
  const out = output();
417
428
 
418
- const parsed = JSON.parse(out.trim()) as Record<string, unknown>[];
419
- expect(parsed).toHaveLength(1);
420
- expect(parsed[0]?.agentName).toBe("builder-1");
429
+ const parsed = JSON.parse(out.trim()) as { sessions: Record<string, unknown>[] };
430
+ expect(parsed.sessions).toHaveLength(1);
431
+ expect(parsed.sessions[0]?.agentName).toBe("builder-1");
421
432
  });
422
433
 
423
434
  test("returns empty for non-existent agent", async () => {
@@ -429,8 +440,8 @@ describe("costsCommand", () => {
429
440
  await costsCommand(["--json", "--agent", "nonexistent"]);
430
441
  const out = output();
431
442
 
432
- const parsed = JSON.parse(out.trim()) as unknown[];
433
- expect(parsed).toEqual([]);
443
+ const parsed = JSON.parse(out.trim()) as { sessions: unknown[] };
444
+ expect(parsed.sessions).toEqual([]);
434
445
  });
435
446
  });
436
447
 
@@ -456,9 +467,9 @@ describe("costsCommand", () => {
456
467
  await costsCommand(["--json", "--run", "run-2026-01-01"]);
457
468
  const out = output();
458
469
 
459
- const parsed = JSON.parse(out.trim()) as Record<string, unknown>[];
460
- expect(parsed).toHaveLength(1);
461
- expect(parsed[0]?.agentName).toBe("builder-1");
470
+ const parsed = JSON.parse(out.trim()) as { sessions: Record<string, unknown>[] };
471
+ expect(parsed.sessions).toHaveLength(1);
472
+ expect(parsed.sessions[0]?.agentName).toBe("builder-1");
462
473
  });
463
474
 
464
475
  test("returns empty when no sessions match run ID", async () => {
@@ -472,8 +483,8 @@ describe("costsCommand", () => {
472
483
  await costsCommand(["--json", "--run", "run-nonexistent"]);
473
484
  const out = output();
474
485
 
475
- const parsed = JSON.parse(out.trim()) as unknown[];
476
- expect(parsed).toEqual([]);
486
+ const parsed = JSON.parse(out.trim()) as { sessions: unknown[] };
487
+ expect(parsed.sessions).toEqual([]);
477
488
  });
478
489
  });
479
490
 
@@ -557,12 +568,11 @@ describe("costsCommand", () => {
557
568
  await costsCommand(["--json", "--by-capability"]);
558
569
  const out = output();
559
570
 
560
- const parsed = JSON.parse(out.trim()) as Record<
561
- string,
562
- { sessions: unknown[]; totals: Record<string, unknown> }
563
- >;
564
- expect(parsed.builder?.sessions).toHaveLength(3);
565
- expect(parsed.scout?.sessions).toHaveLength(1);
571
+ const parsed = JSON.parse(out.trim()) as {
572
+ grouped: Record<string, { sessions: unknown[]; totals: Record<string, unknown> }>;
573
+ };
574
+ expect(parsed.grouped.builder?.sessions).toHaveLength(3);
575
+ expect(parsed.grouped.scout?.sessions).toHaveLength(1);
566
576
  });
567
577
 
568
578
  test("empty data shows no session data message", async () => {
@@ -591,8 +601,8 @@ describe("costsCommand", () => {
591
601
  await costsCommand(["--json", "--last", "3"]);
592
602
  const out = output();
593
603
 
594
- const parsed = JSON.parse(out.trim()) as unknown[];
595
- expect(parsed).toHaveLength(3);
604
+ const parsed = JSON.parse(out.trim()) as { sessions: unknown[] };
605
+ expect(parsed.sessions).toHaveLength(3);
596
606
  });
597
607
 
598
608
  test("default limit is 20", async () => {
@@ -606,8 +616,8 @@ describe("costsCommand", () => {
606
616
  await costsCommand(["--json"]);
607
617
  const out = output();
608
618
 
609
- const parsed = JSON.parse(out.trim()) as unknown[];
610
- expect(parsed).toHaveLength(20);
619
+ const parsed = JSON.parse(out.trim()) as { sessions: unknown[] };
620
+ expect(parsed.sessions).toHaveLength(20);
611
621
  });
612
622
  });
613
623
 
@@ -705,9 +715,9 @@ describe("costsCommand", () => {
705
715
  await costsCommand(["--json"]);
706
716
  const out = output();
707
717
 
708
- const parsed = JSON.parse(out.trim()) as SessionMetrics[];
709
- const totalInput = parsed.reduce((sum, s) => sum + s.inputTokens, 0);
710
- const totalOutput = parsed.reduce((sum, s) => sum + s.outputTokens, 0);
718
+ const parsed = JSON.parse(out.trim()) as { sessions: SessionMetrics[] };
719
+ const totalInput = parsed.sessions.reduce((sum, s) => sum + s.inputTokens, 0);
720
+ const totalOutput = parsed.sessions.reduce((sum, s) => sum + s.outputTokens, 0);
711
721
  expect(totalInput).toBe(300);
712
722
  expect(totalOutput).toBe(150);
713
723
  });
@@ -1106,7 +1116,7 @@ describe("costsCommand", () => {
1106
1116
  const out = output();
1107
1117
 
1108
1118
  const parsed = JSON.parse(out.trim()) as Record<string, unknown>;
1109
- expect(parsed.error).toBe("no_transcript");
1119
+ expect(parsed.error).toBe("No orchestrator transcript found");
1110
1120
  });
1111
1121
 
1112
1122
  test("--self in help text", async () => {
@@ -11,6 +11,7 @@ import { join } from "node:path";
11
11
  import { Command } from "commander";
12
12
  import { loadConfig } from "../config.ts";
13
13
  import { ValidationError } from "../errors.ts";
14
+ import { jsonError, jsonOutput } from "../json.ts";
14
15
  import { color } from "../logging/color.ts";
15
16
  import { createMetricsStore } from "../metrics/store.ts";
16
17
  import { estimateCost, parseTranscriptUsage } from "../metrics/transcript.ts";
@@ -272,10 +273,7 @@ async function executeCosts(opts: CostsOpts): Promise<void> {
272
273
  const transcriptPath = await discoverOrchestratorTranscript(config.project.root);
273
274
  if (!transcriptPath) {
274
275
  if (json) {
275
- process.stdout.write(
276
- JSON.stringify({ error: "no_transcript", message: "No orchestrator transcript found" }) +
277
- "\n",
278
- );
276
+ jsonError("costs", "No orchestrator transcript found");
279
277
  } else {
280
278
  process.stdout.write(
281
279
  "No orchestrator transcript found.\nExpected at: ~/.claude/projects/{project-key}/*.jsonl\n",
@@ -289,18 +287,16 @@ async function executeCosts(opts: CostsOpts): Promise<void> {
289
287
  const cacheTotal = usage.cacheReadTokens + usage.cacheCreationTokens;
290
288
 
291
289
  if (json) {
292
- process.stdout.write(
293
- `${JSON.stringify({
294
- source: "self",
295
- transcriptPath,
296
- model: usage.modelUsed,
297
- inputTokens: usage.inputTokens,
298
- outputTokens: usage.outputTokens,
299
- cacheReadTokens: usage.cacheReadTokens,
300
- cacheCreationTokens: usage.cacheCreationTokens,
301
- estimatedCostUsd: cost,
302
- })}\n`,
303
- );
290
+ jsonOutput("costs", {
291
+ source: "self",
292
+ transcriptPath,
293
+ model: usage.modelUsed,
294
+ inputTokens: usage.inputTokens,
295
+ outputTokens: usage.outputTokens,
296
+ cacheReadTokens: usage.cacheReadTokens,
297
+ cacheCreationTokens: usage.cacheCreationTokens,
298
+ estimatedCostUsd: cost,
299
+ });
304
300
  } else {
305
301
  const w = process.stdout.write.bind(process.stdout);
306
302
  const separator = "\u2500".repeat(70);
@@ -327,9 +323,17 @@ async function executeCosts(opts: CostsOpts): Promise<void> {
327
323
  const metricsFile = Bun.file(metricsDbPath);
328
324
  if (!(await metricsFile.exists())) {
329
325
  if (json) {
330
- process.stdout.write(
331
- `${JSON.stringify({ agents: [], totals: { inputTokens: 0, outputTokens: 0, cacheTokens: 0, costUsd: 0, burnRatePerMin: 0, tokensPerMin: 0 } })}\n`,
332
- );
326
+ jsonOutput("costs", {
327
+ agents: [],
328
+ totals: {
329
+ inputTokens: 0,
330
+ outputTokens: 0,
331
+ cacheTokens: 0,
332
+ costUsd: 0,
333
+ burnRatePerMin: 0,
334
+ tokensPerMin: 0,
335
+ },
336
+ });
333
337
  } else {
334
338
  process.stdout.write(
335
339
  "No live data available. Token snapshots begin after first tool call.\n",
@@ -345,9 +349,17 @@ async function executeCosts(opts: CostsOpts): Promise<void> {
345
349
  const snapshots = metricsStore.getLatestSnapshots();
346
350
  if (snapshots.length === 0) {
347
351
  if (json) {
348
- process.stdout.write(
349
- `${JSON.stringify({ agents: [], totals: { inputTokens: 0, outputTokens: 0, cacheTokens: 0, costUsd: 0, burnRatePerMin: 0, tokensPerMin: 0 } })}\n`,
350
- );
352
+ jsonOutput("costs", {
353
+ agents: [],
354
+ totals: {
355
+ inputTokens: 0,
356
+ outputTokens: 0,
357
+ cacheTokens: 0,
358
+ costUsd: 0,
359
+ burnRatePerMin: 0,
360
+ tokensPerMin: 0,
361
+ },
362
+ });
351
363
  } else {
352
364
  process.stdout.write(
353
365
  "No live data available. Token snapshots begin after first tool call.\n",
@@ -428,19 +440,17 @@ async function executeCosts(opts: CostsOpts): Promise<void> {
428
440
  const tokensPerMin = avgElapsedMs > 0 ? totalTokens / (avgElapsedMs / 60_000) : 0;
429
441
 
430
442
  if (json) {
431
- process.stdout.write(
432
- `${JSON.stringify({
433
- agents: agentData,
434
- totals: {
435
- inputTokens: totalInput,
436
- outputTokens: totalOutput,
437
- cacheTokens: totalCacheTokens,
438
- costUsd: totalCost,
439
- burnRatePerMin,
440
- tokensPerMin,
441
- },
442
- })}\n`,
443
- );
443
+ jsonOutput("costs", {
444
+ agents: agentData,
445
+ totals: {
446
+ inputTokens: totalInput,
447
+ outputTokens: totalOutput,
448
+ cacheTokens: totalCacheTokens,
449
+ costUsd: totalCost,
450
+ burnRatePerMin,
451
+ tokensPerMin,
452
+ },
453
+ });
444
454
  } else {
445
455
  const w = process.stdout.write.bind(process.stdout);
446
456
  const separator = "\u2500".repeat(70);
@@ -502,7 +512,7 @@ async function executeCosts(opts: CostsOpts): Promise<void> {
502
512
  const metricsFile = Bun.file(metricsDbPath);
503
513
  if (!(await metricsFile.exists())) {
504
514
  if (json) {
505
- process.stdout.write("[]\n");
515
+ jsonOutput("costs", { sessions: [] });
506
516
  } else {
507
517
  process.stdout.write("No metrics data yet.\n");
508
518
  }
@@ -532,9 +542,9 @@ async function executeCosts(opts: CostsOpts): Promise<void> {
532
542
  totals: group.totals,
533
543
  };
534
544
  }
535
- process.stdout.write(`${JSON.stringify(grouped)}\n`);
545
+ jsonOutput("costs", { grouped });
536
546
  } else {
537
- process.stdout.write(`${JSON.stringify(sessions)}\n`);
547
+ jsonOutput("costs", { sessions });
538
548
  }
539
549
  return;
540
550
  }
@@ -15,7 +15,7 @@ import { Command } from "commander";
15
15
  import { loadConfig } from "../config.ts";
16
16
  import { ValidationError } from "../errors.ts";
17
17
  import type { ColorFn } from "../logging/color.ts";
18
- import { color, noColor, visibleLength } from "../logging/color.ts";
18
+ import { accent, color, noColor, visibleLength } from "../logging/color.ts";
19
19
  import { createMailStore, type MailStore } from "../mail/store.ts";
20
20
  import { createMergeQueue, type MergeQueue } from "../merge/queue.ts";
21
21
  import { createMetricsStore, type MetricsStore } from "../metrics/store.ts";
@@ -404,7 +404,7 @@ async function loadDashboardData(
404
404
  function renderHeader(width: number, interval: number, currentRunId?: string | null): string {
405
405
  const left = color.bold(`ov dashboard v${PKG_VERSION}`);
406
406
  const now = new Date().toLocaleTimeString();
407
- const scope = currentRunId ? ` [run: ${currentRunId.slice(0, 8)}]` : " [all runs]";
407
+ const scope = currentRunId ? ` [run: ${accent(currentRunId.slice(0, 8))}]` : " [all runs]";
408
408
  const right = `${now}${scope} | refresh: ${interval}ms`;
409
409
  const padding = width - visibleLength(left) - right.length;
410
410
  const line = left + " ".repeat(Math.max(0, padding)) + right;
@@ -497,10 +497,10 @@ function renderAgentPanel(
497
497
 
498
498
  const icon = getStateIcon(agent.state);
499
499
  const stateColor = getStateColor(agent.state);
500
- const name = pad(truncate(agent.agentName, 15), 15);
500
+ const name = accent(pad(truncate(agent.agentName, 15), 15));
501
501
  const capability = pad(truncate(agent.capability, 12), 12);
502
502
  const state = pad(agent.state, 10);
503
- const taskId = pad(truncate(agent.taskId, 16), 16);
503
+ const taskId = accent(pad(truncate(agent.taskId, 16), 16));
504
504
  const endTime =
505
505
  agent.state === "completed" || agent.state === "zombie"
506
506
  ? new Date(agent.lastActivity).getTime()
@@ -575,8 +575,8 @@ function renderMailPanel(
575
575
 
576
576
  const priorityColorFn = getPriorityColor(msg.priority);
577
577
  const priority = msg.priority === "normal" ? "" : `[${msg.priority}] `;
578
- const from = truncate(msg.from, 12);
579
- const to = truncate(msg.to, 12);
578
+ const from = accent(truncate(msg.from, 12));
579
+ const to = accent(truncate(msg.to, 12));
580
580
  const subject = truncate(msg.subject, panelWidth - 40);
581
581
  const time = timeAgo(msg.createdAt);
582
582
 
@@ -643,7 +643,7 @@ function renderMergeQueuePanel(
643
643
 
644
644
  const statusColorFn = getMergeStatusColor(entry.status);
645
645
  const status = pad(entry.status, 10);
646
- const agent = truncate(entry.agentName, 15);
646
+ const agent = accent(truncate(entry.agentName, 15));
647
647
  const branch = truncate(entry.branchName, panelWidth - 30);
648
648
 
649
649
  const line = `${BOX.vertical} ${statusColorFn(status)} ${agent} ${branch}`;
@@ -85,10 +85,14 @@ describe("doctorCommand", () => {
85
85
  const out = output();
86
86
 
87
87
  const parsed = JSON.parse(out.trim()) as {
88
+ success: boolean;
89
+ command: string;
88
90
  checks: unknown[];
89
91
  summary: { pass: number; warn: number; fail: number };
90
92
  };
91
93
  expect(parsed).toBeDefined();
94
+ expect(parsed.success).toBe(true);
95
+ expect(parsed.command).toBe("doctor");
92
96
  expect(Array.isArray(parsed.checks)).toBe(true);
93
97
  expect(parsed.summary).toBeDefined();
94
98
  expect(typeof parsed.summary.pass).toBe("number");
@@ -101,9 +105,13 @@ describe("doctorCommand", () => {
101
105
  const out = output();
102
106
 
103
107
  const parsed = JSON.parse(out.trim()) as {
108
+ success: boolean;
109
+ command: string;
104
110
  checks: unknown[];
105
111
  summary: { pass: number; warn: number; fail: number };
106
112
  };
113
+ expect(parsed.success).toBe(true);
114
+ expect(parsed.command).toBe("doctor");
107
115
  expect(parsed.checks).toEqual([]);
108
116
  expect(parsed.summary.pass).toBe(0);
109
117
  expect(parsed.summary.warn).toBe(0);
@@ -12,12 +12,14 @@ import { checkConfig } from "../doctor/config-check.ts";
12
12
  import { checkConsistency } from "../doctor/consistency.ts";
13
13
  import { checkDatabases } from "../doctor/databases.ts";
14
14
  import { checkDependencies } from "../doctor/dependencies.ts";
15
+ import { checkEcosystem } from "../doctor/ecosystem.ts";
15
16
  import { checkLogs } from "../doctor/logs.ts";
16
17
  import { checkMergeQueue } from "../doctor/merge-queue.ts";
17
18
  import { checkStructure } from "../doctor/structure.ts";
18
19
  import type { DoctorCategory, DoctorCheck, DoctorCheckFn } from "../doctor/types.ts";
19
20
  import { checkVersion } from "../doctor/version.ts";
20
21
  import { ValidationError } from "../errors.ts";
22
+ import { jsonOutput } from "../json.ts";
21
23
  import { color } from "../logging/color.ts";
22
24
 
23
25
  /** Registry of all check modules in execution order. */
@@ -31,8 +33,25 @@ const ALL_CHECKS: Array<{ category: DoctorCategory; fn: DoctorCheckFn }> = [
31
33
  { category: "merge", fn: checkMergeQueue },
32
34
  { category: "logs", fn: checkLogs },
33
35
  { category: "version", fn: checkVersion },
36
+ { category: "ecosystem", fn: checkEcosystem },
34
37
  ];
35
38
 
39
+ /**
40
+ * Execute all fix functions on non-passing fixable checks.
41
+ * Returns a list of human-readable actions taken.
42
+ */
43
+ async function applyFixes(checks: DoctorCheck[]): Promise<string[]> {
44
+ const fixable = checks.filter((c) => c.fixable && c.status !== "pass" && c.fix);
45
+ const fixed: string[] = [];
46
+ for (const check of fixable) {
47
+ if (check.fix) {
48
+ const actions = await check.fix();
49
+ fixed.push(...actions);
50
+ }
51
+ }
52
+ return fixed;
53
+ }
54
+
36
55
  /**
37
56
  * Format human-readable output for doctor checks.
38
57
  */
@@ -40,6 +59,7 @@ function printHumanReadable(
40
59
  checks: DoctorCheck[],
41
60
  verbose: boolean,
42
61
  checkRegistry: Array<{ category: DoctorCategory; fn: DoctorCheckFn }>,
62
+ fixedItems?: string[],
43
63
  ): void {
44
64
  const w = process.stdout.write.bind(process.stdout);
45
65
 
@@ -104,22 +124,28 @@ function printHumanReadable(
104
124
  w(
105
125
  `${color.bold("Summary:")} ${color.green(`${pass} passed`)}, ${color.yellow(`${warn} warning${warn === 1 ? "" : "s"}`)}, ${color.red(`${fail} failure${fail === 1 ? "" : "s"}`)}\n`,
106
126
  );
127
+
128
+ if (fixedItems && fixedItems.length > 0) {
129
+ w(`\n${color.bold("Fixed:")}\n`);
130
+ for (const item of fixedItems) {
131
+ w(` ${color.green("-")} ${item}\n`);
132
+ }
133
+ }
107
134
  }
108
135
 
109
136
  /**
110
137
  * Format JSON output for doctor checks.
111
138
  */
112
- function printJSON(checks: DoctorCheck[]): void {
139
+ function printJSON(checks: DoctorCheck[], fixed?: string[]): void {
113
140
  const pass = checks.filter((c) => c.status === "pass").length;
114
141
  const warn = checks.filter((c) => c.status === "warn").length;
115
142
  const fail = checks.filter((c) => c.status === "fail").length;
116
143
 
117
- const output = {
144
+ jsonOutput("doctor", {
118
145
  checks,
119
146
  summary: { pass, warn, fail },
120
- };
121
-
122
- process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
147
+ ...(fixed && fixed.length > 0 ? { fixed } : {}),
148
+ });
123
149
  }
124
150
 
125
151
  /** Options for dependency injection in doctorCommand. */
@@ -137,59 +163,78 @@ export function createDoctorCommand(options?: DoctorCommandOptions): Command {
137
163
  .option("--json", "Output as JSON")
138
164
  .option("--verbose", "Show passing checks (default: only problems)")
139
165
  .option("--category <name>", "Run only one category")
166
+ .option("--fix", "Attempt to auto-fix issues")
140
167
  .addHelpText(
141
168
  "after",
142
- "\nCategories: dependencies, structure, config, databases, consistency, agents, merge, logs, version",
169
+ "\nCategories: dependencies, structure, config, databases, consistency, agents, merge, logs, version, ecosystem",
143
170
  )
144
- .action(async (opts: { json?: boolean; verbose?: boolean; category?: string }) => {
145
- const json = opts.json ?? false;
146
- const verbose = opts.verbose ?? false;
147
- const categoryFilter = opts.category;
148
-
149
- // Validate category filter if provided
150
- if (categoryFilter !== undefined) {
151
- const validCategories = ALL_CHECKS.map((c) => c.category);
152
- if (!validCategories.includes(categoryFilter as DoctorCategory)) {
153
- throw new ValidationError(
154
- `Invalid category: ${categoryFilter}. Valid categories: ${validCategories.join(", ")}`,
155
- {
156
- field: "category",
157
- value: categoryFilter,
158
- },
159
- );
171
+ .action(
172
+ async (opts: { json?: boolean; verbose?: boolean; category?: string; fix?: boolean }) => {
173
+ const json = opts.json ?? false;
174
+ const verbose = opts.verbose ?? false;
175
+ const categoryFilter = opts.category;
176
+ const fix = opts.fix ?? false;
177
+
178
+ // Validate category filter if provided
179
+ if (categoryFilter !== undefined) {
180
+ const validCategories = ALL_CHECKS.map((c) => c.category);
181
+ if (!validCategories.includes(categoryFilter as DoctorCategory)) {
182
+ throw new ValidationError(
183
+ `Invalid category: ${categoryFilter}. Valid categories: ${validCategories.join(", ")}`,
184
+ {
185
+ field: "category",
186
+ value: categoryFilter,
187
+ },
188
+ );
189
+ }
160
190
  }
161
- }
162
191
 
163
- const cwd = process.cwd();
164
- const config = await loadConfig(cwd);
165
- const overstoryDir = join(config.project.root, ".overstory");
166
-
167
- // Filter checks by category if specified
168
- const allChecks = options?.checkRunners ?? ALL_CHECKS;
169
- const checksToRun = categoryFilter
170
- ? allChecks.filter((c) => c.category === categoryFilter)
171
- : allChecks;
172
-
173
- // Run all checks sequentially
174
- const results: DoctorCheck[] = [];
175
- for (const { fn } of checksToRun) {
176
- const checkResults = await fn(config, overstoryDir);
177
- results.push(...checkResults);
178
- }
192
+ const cwd = process.cwd();
193
+ const config = await loadConfig(cwd);
194
+ const overstoryDir = join(config.project.root, ".overstory");
179
195
 
180
- // Output results
181
- if (json) {
182
- printJSON(results);
183
- } else {
184
- printHumanReadable(results, verbose, allChecks);
185
- }
196
+ // Filter checks by category if specified
197
+ const allChecks = options?.checkRunners ?? ALL_CHECKS;
198
+ const checksToRun = categoryFilter
199
+ ? allChecks.filter((c) => c.category === categoryFilter)
200
+ : allChecks;
186
201
 
187
- // Set exit code if any check failed
188
- const hasFailures = results.some((c) => c.status === "fail");
189
- if (hasFailures) {
190
- process.exitCode = 1;
191
- }
192
- });
202
+ // Run all checks sequentially
203
+ let results: DoctorCheck[] = [];
204
+ for (const { fn } of checksToRun) {
205
+ const checkResults = await fn(config, overstoryDir);
206
+ results.push(...checkResults);
207
+ }
208
+
209
+ // Apply fixes if requested
210
+ let fixedItems: string[] | undefined;
211
+ if (fix) {
212
+ const applied = await applyFixes(results);
213
+ if (applied.length > 0) {
214
+ fixedItems = applied;
215
+ // Re-run all checks to get fresh results after fixes
216
+ results = [];
217
+ for (const { fn } of checksToRun) {
218
+ const checkResults = await fn(config, overstoryDir);
219
+ results.push(...checkResults);
220
+ }
221
+ }
222
+ }
223
+
224
+ // Output results
225
+ if (json) {
226
+ printJSON(results, fixedItems);
227
+ } else {
228
+ printHumanReadable(results, verbose, allChecks, fixedItems);
229
+ }
230
+
231
+ // Set exit code if any check failed
232
+ const hasFailures = results.some((c) => c.status === "fail");
233
+ if (hasFailures) {
234
+ process.exitCode = 1;
235
+ }
236
+ },
237
+ );
193
238
  }
194
239
 
195
240
  /**