@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
@@ -27,6 +27,7 @@ import { writeOverlay } from "../agents/overlay.ts";
27
27
  import { loadConfig } from "../config.ts";
28
28
  import { AgentError, HierarchyError, ValidationError } from "../errors.ts";
29
29
  import { inferDomain } from "../insights/analyzer.ts";
30
+ import { jsonOutput } from "../json.ts";
30
31
  import { printSuccess } from "../logging/color.ts";
31
32
  import { createMailClient } from "../mail/client.ts";
32
33
  import { createMailStore } from "../mail/store.ts";
@@ -560,7 +561,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
560
561
  }
561
562
 
562
563
  // 9. Deploy hooks config (capability-specific guards)
563
- await deployHooks(worktreePath, name, capability);
564
+ await deployHooks(worktreePath, name, capability, config.project.qualityGates);
564
565
 
565
566
  // 9b. Send auto-dispatch mail so it exists when SessionStart hook fires.
566
567
  // This eliminates the race where coordinator sends dispatch AFTER agent boots.
@@ -708,7 +709,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
708
709
  };
709
710
 
710
711
  if (opts.json ?? false) {
711
- process.stdout.write(`${JSON.stringify(output)}\n`);
712
+ jsonOutput("sling", output);
712
713
  } else {
713
714
  printSuccess("Agent launched", name);
714
715
  process.stdout.write(` Task: ${taskId}\n`);
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { mkdir, mkdtemp, rm } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { stripAnsi } from "../logging/color.ts";
5
6
  import { createSessionStore } from "../sessions/store.ts";
6
7
  import { createTempGitRepo } from "../test-helpers.ts";
7
8
  import type { AgentSession } from "../types.ts";
@@ -258,7 +259,7 @@ describe("run scoping", () => {
258
259
  test("printStatus shows run ID when currentRunId is set", () => {
259
260
  const data = makeStatusData({ currentRunId: "run-123" });
260
261
  printStatus(data);
261
- expect(output()).toContain("Run: run-123");
262
+ expect(stripAnsi(output())).toContain("Run: run-123");
262
263
  });
263
264
 
264
265
  test("printStatus does not show run line when currentRunId is undefined", () => {
@@ -9,7 +9,8 @@ import { join } from "node:path";
9
9
  import { Command } from "commander";
10
10
  import { loadConfig } from "../config.ts";
11
11
  import { ValidationError } from "../errors.ts";
12
- import { color } from "../logging/color.ts";
12
+ import { jsonOutput } from "../json.ts";
13
+ import { accent, color } from "../logging/color.ts";
13
14
  import { createMailStore } from "../mail/store.ts";
14
15
  import { createMergeQueue } from "../merge/queue.ts";
15
16
  import { createMetricsStore } from "../metrics/store.ts";
@@ -258,7 +259,7 @@ export function printStatus(data: StatusData): void {
258
259
  w("Overstory Status\n");
259
260
  w(`${"═".repeat(60)}\n\n`);
260
261
  if (data.currentRunId) {
261
- w(`Run: ${data.currentRunId}\n`);
262
+ w(`Run: ${accent(data.currentRunId)}\n`);
262
263
  }
263
264
 
264
265
  // Active agents
@@ -274,8 +275,8 @@ export function printStatus(data: StatusData): void {
274
275
  const duration = formatDuration(endTime - new Date(agent.startedAt).getTime());
275
276
  const tmuxAlive = tmuxSessionNames.has(agent.tmuxSession);
276
277
  const aliveMarker = tmuxAlive ? color.green(">") : color.red("x");
277
- w(` ${aliveMarker} ${agent.agentName} [${agent.capability}] `);
278
- w(`${agent.state} | ${agent.taskId} | ${duration}\n`);
278
+ w(` ${aliveMarker} ${accent(agent.agentName)} [${agent.capability}] `);
279
+ w(`${agent.state} | ${accent(agent.taskId)} | ${duration}\n`);
279
280
 
280
281
  const detail = data.verboseDetails?.[agent.agentName];
281
282
  if (detail) {
@@ -357,7 +358,7 @@ async function executeStatus(opts: StatusOpts): Promise<void> {
357
358
  process.stdout.write("\x1b[2J\x1b[H");
358
359
  const data = await gatherStatus(root, agentName, verbose, runId);
359
360
  if (json) {
360
- process.stdout.write(`${JSON.stringify(data, null, "\t")}\n`);
361
+ jsonOutput("status", data as unknown as Record<string, unknown>);
361
362
  } else {
362
363
  printStatus(data);
363
364
  }
@@ -366,7 +367,7 @@ async function executeStatus(opts: StatusOpts): Promise<void> {
366
367
  } else {
367
368
  const data = await gatherStatus(root, agentName, verbose, runId);
368
369
  if (json) {
369
- process.stdout.write(`${JSON.stringify(data, null, "\t")}\n`);
370
+ jsonOutput("status", data as unknown as Record<string, unknown>);
370
371
  } else {
371
372
  printStatus(data);
372
373
  }
@@ -314,6 +314,8 @@ describe("stopCommand --json output", () => {
314
314
  const output = await captureStdout(() => stopCommand("my-builder", { json: true }, deps));
315
315
 
316
316
  const parsed = JSON.parse(output.trim()) as Record<string, unknown>;
317
+ expect(parsed.success).toBe(true);
318
+ expect(parsed.command).toBe("stop");
317
319
  expect(parsed.stopped).toBe(true);
318
320
  expect(parsed.agentName).toBe("my-builder");
319
321
  expect(parsed.sessionId).toBe(session.id);
@@ -11,6 +11,7 @@
11
11
  import { join } from "node:path";
12
12
  import { loadConfig } from "../config.ts";
13
13
  import { AgentError, ValidationError } from "../errors.ts";
14
+ import { jsonOutput } from "../json.ts";
14
15
  import { printSuccess, printWarning } from "../logging/color.ts";
15
16
  import { openSessionStore } from "../sessions/compat.ts";
16
17
  import { removeWorktree } from "../worktree/manager.ts";
@@ -109,17 +110,15 @@ export async function stopCommand(
109
110
  }
110
111
 
111
112
  if (json) {
112
- process.stdout.write(
113
- `${JSON.stringify({
114
- stopped: true,
115
- agentName,
116
- sessionId: session.id,
117
- capability: session.capability,
118
- tmuxKilled: alive,
119
- worktreeRemoved,
120
- force,
121
- })}\n`,
122
- );
113
+ jsonOutput("stop", {
114
+ stopped: true,
115
+ agentName,
116
+ sessionId: session.id,
117
+ capability: session.capability,
118
+ tmuxKilled: alive,
119
+ worktreeRemoved,
120
+ force,
121
+ });
123
122
  } else {
124
123
  printSuccess("Agent stopped", agentName);
125
124
  if (alive) {
@@ -20,6 +20,7 @@ import { createIdentity, loadIdentity } from "../agents/identity.ts";
20
20
  import { createManifestLoader, resolveModel } from "../agents/manifest.ts";
21
21
  import { loadConfig } from "../config.ts";
22
22
  import { AgentError, ValidationError } from "../errors.ts";
23
+ import { jsonOutput } from "../json.ts";
23
24
  import { printHint, printSuccess } from "../logging/color.ts";
24
25
  import { openSessionStore } from "../sessions/compat.ts";
25
26
  import { createTrackerClient, resolveBackend, trackerCliName } from "../tracker/factory.ts";
@@ -230,7 +231,7 @@ async function startSupervisor(opts: {
230
231
  };
231
232
 
232
233
  if (opts.json) {
233
- process.stdout.write(`${JSON.stringify(output)}\n`);
234
+ jsonOutput("supervisor start", output);
234
235
  } else {
235
236
  printSuccess("Supervisor started", opts.name);
236
237
  process.stdout.write(` Tmux: ${tmuxSession}\n`);
@@ -291,7 +292,7 @@ async function stopSupervisor(opts: { name: string; json: boolean }): Promise<vo
291
292
  store.updateLastActivity(opts.name);
292
293
 
293
294
  if (opts.json) {
294
- process.stdout.write(`${JSON.stringify({ stopped: true, sessionId: session.id })}\n`);
295
+ jsonOutput("supervisor stop", { stopped: true, sessionId: session.id });
295
296
  } else {
296
297
  printSuccess("Supervisor stopped", opts.name);
297
298
  }
@@ -325,7 +326,7 @@ async function statusSupervisor(opts: { name?: string; json: boolean }): Promise
325
326
  session.state === "zombie"
326
327
  ) {
327
328
  if (opts.json) {
328
- process.stdout.write(`${JSON.stringify({ running: false })}\n`);
329
+ jsonOutput("supervisor status", { running: false });
329
330
  } else {
330
331
  printHint("Supervisor not running");
331
332
  }
@@ -357,7 +358,7 @@ async function statusSupervisor(opts: { name?: string; json: boolean }): Promise
357
358
  };
358
359
 
359
360
  if (opts.json) {
360
- process.stdout.write(`${JSON.stringify(status)}\n`);
361
+ jsonOutput("supervisor status", status);
361
362
  } else {
362
363
  const stateLabel = alive ? "running" : session.state;
363
364
  process.stdout.write(`Supervisor '${opts.name}': ${stateLabel}\n`);
@@ -377,7 +378,7 @@ async function statusSupervisor(opts: { name?: string; json: boolean }): Promise
377
378
 
378
379
  if (supervisors.length === 0) {
379
380
  if (opts.json) {
380
- process.stdout.write(`${JSON.stringify([])}\n`);
381
+ jsonOutput("supervisor status", { supervisors: [] });
381
382
  } else {
382
383
  printHint("No supervisor sessions found");
383
384
  }
@@ -411,7 +412,7 @@ async function statusSupervisor(opts: { name?: string; json: boolean }): Promise
411
412
  );
412
413
 
413
414
  if (opts.json) {
414
- process.stdout.write(`${JSON.stringify(statuses)}\n`);
415
+ jsonOutput("supervisor status", { supervisors: statuses });
415
416
  } else {
416
417
  process.stdout.write("Supervisor sessions:\n");
417
418
  for (const status of statuses) {
@@ -14,6 +14,7 @@ import { tmpdir } from "node:os";
14
14
  import { join } from "node:path";
15
15
  import { ValidationError } from "../errors.ts";
16
16
  import { createEventStore } from "../events/store.ts";
17
+ import { stripAnsi } from "../logging/color.ts";
17
18
  import { createSessionStore } from "../sessions/store.ts";
18
19
  import type { InsertEvent } from "../types.ts";
19
20
  import { traceCommand } from "./trace.ts";
@@ -149,8 +150,8 @@ describe("traceCommand", () => {
149
150
 
150
151
  await traceCommand(["--json", "--limit", "50", "my-agent"]);
151
152
  const out = output();
152
- const parsed = JSON.parse(out.trim()) as unknown[];
153
- expect(parsed).toHaveLength(1);
153
+ const parsed = JSON.parse(out.trim()) as { events: unknown[] };
154
+ expect(parsed.events).toHaveLength(1);
154
155
  });
155
156
 
156
157
  test("target is extracted correctly when flags come after", async () => {
@@ -161,8 +162,8 @@ describe("traceCommand", () => {
161
162
 
162
163
  await traceCommand(["my-agent", "--json", "--limit", "50"]);
163
164
  const out = output();
164
- const parsed = JSON.parse(out.trim()) as unknown[];
165
- expect(parsed).toHaveLength(1);
165
+ const parsed = JSON.parse(out.trim()) as { events: unknown[] };
166
+ expect(parsed.events).toHaveLength(1);
166
167
  });
167
168
  });
168
169
 
@@ -180,7 +181,14 @@ describe("traceCommand", () => {
180
181
  await traceCommand(["builder-1", "--json"]);
181
182
  const out = output();
182
183
 
183
- expect(out).toBe("[]\n");
184
+ const parsed = JSON.parse(out.trim()) as {
185
+ success: boolean;
186
+ command: string;
187
+ events: unknown[];
188
+ };
189
+ expect(parsed.success).toBe(true);
190
+ expect(parsed.command).toBe("trace");
191
+ expect(parsed.events).toEqual([]);
184
192
  });
185
193
  });
186
194
 
@@ -198,9 +206,9 @@ describe("traceCommand", () => {
198
206
  await traceCommand(["builder-1", "--json"]);
199
207
  const out = output();
200
208
 
201
- const parsed = JSON.parse(out.trim()) as unknown[];
202
- expect(parsed).toHaveLength(3);
203
- expect(Array.isArray(parsed)).toBe(true);
209
+ const parsed = JSON.parse(out.trim()) as { events: unknown[] };
210
+ expect(parsed.events).toHaveLength(3);
211
+ expect(Array.isArray(parsed.events)).toBe(true);
204
212
  });
205
213
 
206
214
  test("JSON output includes expected fields", async () => {
@@ -219,9 +227,9 @@ describe("traceCommand", () => {
219
227
  await traceCommand(["builder-1", "--json"]);
220
228
  const out = output();
221
229
 
222
- const parsed = JSON.parse(out.trim()) as Record<string, unknown>[];
223
- expect(parsed).toHaveLength(1);
224
- const event = parsed[0];
230
+ const parsed = JSON.parse(out.trim()) as { events: Record<string, unknown>[] };
231
+ expect(parsed.events).toHaveLength(1);
232
+ const event = parsed.events[0];
225
233
  expect(event).toBeDefined();
226
234
  expect(event?.agentName).toBe("builder-1");
227
235
  expect(event?.eventType).toBe("tool_start");
@@ -239,8 +247,8 @@ describe("traceCommand", () => {
239
247
  await traceCommand(["builder-1", "--json"]);
240
248
  const out = output();
241
249
 
242
- const parsed = JSON.parse(out.trim()) as unknown[];
243
- expect(parsed).toEqual([]);
250
+ const parsed = JSON.parse(out.trim()) as { events: unknown[] };
251
+ expect(parsed.events).toEqual([]);
244
252
  });
245
253
  });
246
254
 
@@ -256,7 +264,7 @@ describe("traceCommand", () => {
256
264
  await traceCommand(["builder-1"]);
257
265
  const out = output();
258
266
 
259
- expect(out).toContain("Timeline for builder-1");
267
+ expect(stripAnsi(out)).toContain("Timeline for builder-1");
260
268
  });
261
269
 
262
270
  test("shows event count", async () => {
@@ -413,8 +421,8 @@ describe("traceCommand", () => {
413
421
  await traceCommand(["builder-1", "--json", "--limit", "3"]);
414
422
  const out = output();
415
423
 
416
- const parsed = JSON.parse(out.trim()) as unknown[];
417
- expect(parsed).toHaveLength(3);
424
+ const parsed = JSON.parse(out.trim()) as { events: unknown[] };
425
+ expect(parsed.events).toHaveLength(3);
418
426
  });
419
427
 
420
428
  test("default limit is 100", async () => {
@@ -428,8 +436,8 @@ describe("traceCommand", () => {
428
436
  await traceCommand(["builder-1", "--json"]);
429
437
  const out = output();
430
438
 
431
- const parsed = JSON.parse(out.trim()) as unknown[];
432
- expect(parsed).toHaveLength(100);
439
+ const parsed = JSON.parse(out.trim()) as { events: unknown[] };
440
+ expect(parsed.events).toHaveLength(100);
433
441
  });
434
442
  });
435
443
 
@@ -448,8 +456,8 @@ describe("traceCommand", () => {
448
456
  await traceCommand(["builder-1", "--json", "--since", "2099-01-01T00:00:00Z"]);
449
457
  const out = output();
450
458
 
451
- const parsed = JSON.parse(out.trim()) as unknown[];
452
- expect(parsed).toEqual([]);
459
+ const parsed = JSON.parse(out.trim()) as { events: unknown[] };
460
+ expect(parsed.events).toEqual([]);
453
461
  });
454
462
 
455
463
  test("--since with past timestamp returns all events", async () => {
@@ -462,8 +470,8 @@ describe("traceCommand", () => {
462
470
  await traceCommand(["builder-1", "--json", "--since", "2020-01-01T00:00:00Z"]);
463
471
  const out = output();
464
472
 
465
- const parsed = JSON.parse(out.trim()) as unknown[];
466
- expect(parsed).toHaveLength(2);
473
+ const parsed = JSON.parse(out.trim()) as { events: unknown[] };
474
+ expect(parsed.events).toHaveLength(2);
467
475
  });
468
476
 
469
477
  test("--until with past timestamp returns no events", async () => {
@@ -475,8 +483,8 @@ describe("traceCommand", () => {
475
483
  await traceCommand(["builder-1", "--json", "--until", "2000-01-01T00:00:00Z"]);
476
484
  const out = output();
477
485
 
478
- const parsed = JSON.parse(out.trim()) as unknown[];
479
- expect(parsed).toEqual([]);
486
+ const parsed = JSON.parse(out.trim()) as { events: unknown[] };
487
+ expect(parsed.events).toEqual([]);
480
488
  });
481
489
 
482
490
  test("--since causes absolute timestamps in text mode", async () => {
@@ -518,9 +526,9 @@ describe("traceCommand", () => {
518
526
  await traceCommand(["my-custom-agent", "--json"]);
519
527
  const out = output();
520
528
 
521
- const parsed = JSON.parse(out.trim()) as Record<string, unknown>[];
522
- expect(parsed).toHaveLength(1);
523
- expect(parsed[0]?.agentName).toBe("my-custom-agent");
529
+ const parsed = JSON.parse(out.trim()) as { events: Record<string, unknown>[] };
530
+ expect(parsed.events).toHaveLength(1);
531
+ expect(parsed.events[0]?.agentName).toBe("my-custom-agent");
524
532
  });
525
533
 
526
534
  test("task ID pattern is detected and resolved to agent name via SessionStore", async () => {
@@ -556,9 +564,9 @@ describe("traceCommand", () => {
556
564
  await traceCommand(["overstory-rj1k", "--json"]);
557
565
  const out = output();
558
566
 
559
- const parsed = JSON.parse(out.trim()) as Record<string, unknown>[];
560
- expect(parsed).toHaveLength(1);
561
- expect(parsed[0]?.agentName).toBe("builder-for-task");
567
+ const parsed = JSON.parse(out.trim()) as { events: Record<string, unknown>[] };
568
+ expect(parsed.events).toHaveLength(1);
569
+ expect(parsed.events[0]?.agentName).toBe("builder-for-task");
562
570
  });
563
571
 
564
572
  test("unresolved task ID falls back to using task ID as agent name", async () => {
@@ -575,8 +583,8 @@ describe("traceCommand", () => {
575
583
  await traceCommand(["myproj-abc1", "--json"]);
576
584
  const out = output();
577
585
 
578
- const parsed = JSON.parse(out.trim()) as unknown[];
579
- expect(parsed).toEqual([]);
586
+ const parsed = JSON.parse(out.trim()) as { events: unknown[] };
587
+ expect(parsed.events).toEqual([]);
580
588
  });
581
589
 
582
590
  test("short agent names without task pattern are not resolved as task IDs", async () => {
@@ -589,9 +597,9 @@ describe("traceCommand", () => {
589
597
  await traceCommand(["scout", "--json"]);
590
598
  const out = output();
591
599
 
592
- const parsed = JSON.parse(out.trim()) as Record<string, unknown>[];
593
- expect(parsed).toHaveLength(1);
594
- expect(parsed[0]?.agentName).toBe("scout");
600
+ const parsed = JSON.parse(out.trim()) as { events: Record<string, unknown>[] };
601
+ expect(parsed.events).toHaveLength(1);
602
+ expect(parsed.events[0]?.agentName).toBe("scout");
595
603
  });
596
604
  });
597
605
 
@@ -610,9 +618,9 @@ describe("traceCommand", () => {
610
618
  await traceCommand(["builder-1", "--json"]);
611
619
  const out = output();
612
620
 
613
- const parsed = JSON.parse(out.trim()) as Record<string, unknown>[];
614
- expect(parsed).toHaveLength(2);
615
- for (const event of parsed) {
621
+ const parsed = JSON.parse(out.trim()) as { events: Record<string, unknown>[] };
622
+ expect(parsed.events).toHaveLength(2);
623
+ for (const event of parsed.events) {
616
624
  expect(event.agentName).toBe("builder-1");
617
625
  }
618
626
  });
@@ -710,11 +718,11 @@ describe("traceCommand", () => {
710
718
  await traceCommand(["builder-1", "--json"]);
711
719
  const out = output();
712
720
 
713
- const parsed = JSON.parse(out.trim()) as Record<string, unknown>[];
714
- expect(parsed).toHaveLength(3);
715
- expect(parsed[0]?.eventType).toBe("session_start");
716
- expect(parsed[1]?.eventType).toBe("tool_start");
717
- expect(parsed[2]?.eventType).toBe("session_end");
721
+ const parsed = JSON.parse(out.trim()) as { events: Record<string, unknown>[] };
722
+ expect(parsed.events).toHaveLength(3);
723
+ expect(parsed.events[0]?.eventType).toBe("session_start");
724
+ expect(parsed.events[1]?.eventType).toBe("tool_start");
725
+ expect(parsed.events[2]?.eventType).toBe("session_end");
718
726
  });
719
727
 
720
728
  test("handles event with all null optional fields", async () => {
@@ -738,7 +746,7 @@ describe("traceCommand", () => {
738
746
  await traceCommand(["builder-1"]);
739
747
  const out = output();
740
748
 
741
- expect(out).toContain("Timeline for builder-1");
749
+ expect(stripAnsi(out)).toContain("Timeline for builder-1");
742
750
  expect(out).toContain("1 event");
743
751
  });
744
752
  });
@@ -10,8 +10,9 @@ import { Command } from "commander";
10
10
  import { loadConfig } from "../config.ts";
11
11
  import { ValidationError } from "../errors.ts";
12
12
  import { createEventStore } from "../events/store.ts";
13
+ import { jsonOutput } from "../json.ts";
13
14
  import type { ColorFn } from "../logging/color.ts";
14
- import { color } from "../logging/color.ts";
15
+ import { accent, color } from "../logging/color.ts";
15
16
  import { openSessionStore } from "../sessions/compat.ts";
16
17
  import type { EventType, StoredEvent } from "../types.ts";
17
18
 
@@ -129,7 +130,7 @@ function buildEventDetail(event: StoredEvent): string {
129
130
  function printTimeline(events: StoredEvent[], agentName: string, useAbsoluteTime: boolean): void {
130
131
  const w = process.stdout.write.bind(process.stdout);
131
132
 
132
- w(`${color.bold(`Timeline for ${agentName}`)}\n`);
133
+ w(`${color.bold(`Timeline for ${accent(agentName)}`)}\n`);
133
134
  w(`${"=".repeat(70)}\n`);
134
135
 
135
136
  if (events.length === 0) {
@@ -243,7 +244,7 @@ async function executeTrace(target: string, opts: TraceOpts): Promise<void> {
243
244
  const eventsFile = Bun.file(eventsDbPath);
244
245
  if (!(await eventsFile.exists())) {
245
246
  if (json) {
246
- process.stdout.write("[]\n");
247
+ jsonOutput("trace", { events: [] });
247
248
  } else {
248
249
  process.stdout.write("No events data yet.\n");
249
250
  }
@@ -260,7 +261,7 @@ async function executeTrace(target: string, opts: TraceOpts): Promise<void> {
260
261
  });
261
262
 
262
263
  if (json) {
263
- process.stdout.write(`${JSON.stringify(events)}\n`);
264
+ jsonOutput("trace", { events });
264
265
  return;
265
266
  }
266
267
 
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Tests for the ov upgrade command.
3
+ *
4
+ * Structural tests for CLI registration and option parsing.
5
+ * Network calls and subprocess execution happen via real implementations;
6
+ * this file tests command structure and output format rather than side effects.
7
+ */
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import { createUpgradeCommand } from "./upgrade.ts";
11
+
12
+ describe("createUpgradeCommand — CLI structure", () => {
13
+ test("command has correct name", () => {
14
+ const cmd = createUpgradeCommand();
15
+ expect(cmd.name()).toBe("upgrade");
16
+ });
17
+
18
+ test("description mentions overstory", () => {
19
+ const cmd = createUpgradeCommand();
20
+ expect(cmd.description().toLowerCase()).toContain("overstory");
21
+ });
22
+
23
+ test("has --check option", () => {
24
+ const cmd = createUpgradeCommand();
25
+ const optionNames = cmd.options.map((o) => o.long);
26
+ expect(optionNames).toContain("--check");
27
+ });
28
+
29
+ test("has --json option", () => {
30
+ const cmd = createUpgradeCommand();
31
+ const optionNames = cmd.options.map((o) => o.long);
32
+ expect(optionNames).toContain("--json");
33
+ });
34
+
35
+ test("has --all option", () => {
36
+ const cmd = createUpgradeCommand();
37
+ const optionNames = cmd.options.map((o) => o.long);
38
+ expect(optionNames).toContain("--all");
39
+ });
40
+
41
+ test("returns a Command instance", () => {
42
+ const cmd = createUpgradeCommand();
43
+ // Commander Command instances have a .parse method
44
+ expect(typeof cmd.parse).toBe("function");
45
+ });
46
+ });