@os-eco/overstory-cli 0.6.7 → 0.6.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +5 -2
  2. package/agents/coordinator.md +5 -5
  3. package/agents/lead.md +1 -6
  4. package/agents/merger.md +3 -3
  5. package/agents/reviewer.md +2 -2
  6. package/agents/scout.md +3 -3
  7. package/agents/supervisor.md +16 -16
  8. package/package.json +1 -1
  9. package/src/agents/hooks-deployer.test.ts +180 -0
  10. package/src/agents/hooks-deployer.ts +32 -1
  11. package/src/commands/agents.ts +9 -6
  12. package/src/commands/clean.ts +5 -3
  13. package/src/commands/completions.ts +3 -4
  14. package/src/commands/coordinator.test.ts +15 -12
  15. package/src/commands/coordinator.ts +28 -25
  16. package/src/commands/costs.test.ts +48 -38
  17. package/src/commands/costs.ts +48 -38
  18. package/src/commands/dashboard.ts +7 -7
  19. package/src/commands/doctor.test.ts +8 -0
  20. package/src/commands/doctor.ts +2 -6
  21. package/src/commands/errors.test.ts +47 -40
  22. package/src/commands/errors.ts +5 -4
  23. package/src/commands/feed.test.ts +40 -33
  24. package/src/commands/feed.ts +3 -2
  25. package/src/commands/group.ts +28 -18
  26. package/src/commands/hooks.test.ts +1 -1
  27. package/src/commands/hooks.ts +9 -9
  28. package/src/commands/init.test.ts +105 -5
  29. package/src/commands/init.ts +17 -12
  30. package/src/commands/inspect.test.ts +2 -0
  31. package/src/commands/inspect.ts +9 -8
  32. package/src/commands/logs.test.ts +5 -6
  33. package/src/commands/logs.ts +2 -1
  34. package/src/commands/mail.test.ts +17 -16
  35. package/src/commands/mail.ts +17 -17
  36. package/src/commands/merge.ts +12 -12
  37. package/src/commands/metrics.test.ts +15 -2
  38. package/src/commands/metrics.ts +3 -2
  39. package/src/commands/monitor.ts +9 -7
  40. package/src/commands/nudge.ts +4 -4
  41. package/src/commands/prime.test.ts +1 -6
  42. package/src/commands/prime.ts +2 -3
  43. package/src/commands/replay.test.ts +62 -55
  44. package/src/commands/replay.ts +3 -2
  45. package/src/commands/run.ts +24 -26
  46. package/src/commands/sling.ts +4 -2
  47. package/src/commands/spec.test.ts +10 -8
  48. package/src/commands/spec.ts +3 -2
  49. package/src/commands/status.test.ts +2 -1
  50. package/src/commands/status.ts +7 -6
  51. package/src/commands/stop.test.ts +10 -6
  52. package/src/commands/stop.ts +13 -13
  53. package/src/commands/supervisor.ts +12 -10
  54. package/src/commands/trace.test.ts +52 -44
  55. package/src/commands/trace.ts +5 -4
  56. package/src/commands/watch.ts +8 -10
  57. package/src/commands/worktree.test.ts +27 -20
  58. package/src/commands/worktree.ts +29 -30
  59. package/src/doctor/version.ts +2 -2
  60. package/src/e2e/init-sling-lifecycle.test.ts +1 -5
  61. package/src/index.ts +99 -14
  62. package/src/json.test.ts +72 -0
  63. package/src/json.ts +24 -0
  64. package/src/logging/color.test.ts +127 -0
  65. package/src/logging/color.ts +28 -0
  66. package/src/mulch/client.test.ts +22 -22
  67. package/src/worktree/tmux.test.ts +123 -5
  68. package/src/worktree/tmux.ts +38 -8
  69. package/agents/issue-reviews.md +0 -71
  70. package/agents/pr-reviews.md +0 -60
  71. package/agents/prioritize.md +0 -110
  72. package/agents/release.md +0 -56
@@ -11,6 +11,7 @@
11
11
  import { mkdir } from "node:fs/promises";
12
12
  import { join } from "node:path";
13
13
  import { ValidationError } from "../errors.ts";
14
+ import { printSuccess } from "../logging/color.ts";
14
15
 
15
16
  export interface SpecWriteOptions {
16
17
  body?: string;
@@ -93,6 +94,6 @@ export async function specWriteCommand(taskId: string, opts: SpecWriteOptions):
93
94
  const { resolveProjectRoot } = await import("../config.ts");
94
95
  const projectRoot = await resolveProjectRoot(process.cwd());
95
96
 
96
- const specPath = await writeSpec(projectRoot, taskId, body, opts.agent);
97
- process.stdout.write(`${specPath}\n`);
97
+ await writeSpec(projectRoot, taskId, body, opts.agent);
98
+ printSuccess("Spec written", taskId);
98
99
  }
@@ -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
  }
@@ -245,7 +245,8 @@ describe("stopCommand stop behavior", () => {
245
245
  const { deps, tmuxCalls } = makeDeps({ [session.tmuxSession]: true });
246
246
  const output = await captureStdout(() => stopCommand("my-builder", {}, deps));
247
247
 
248
- expect(output).toContain(`Agent "my-builder" stopped`);
248
+ expect(output).toContain("Agent stopped");
249
+ expect(output).toContain("my-builder");
249
250
  expect(output).toContain(`Tmux session killed: ${session.tmuxSession}`);
250
251
  expect(tmuxCalls.killSession).toHaveLength(1);
251
252
  expect(tmuxCalls.killSession[0]?.name).toBe(session.tmuxSession);
@@ -313,6 +314,8 @@ describe("stopCommand --json output", () => {
313
314
  const output = await captureStdout(() => stopCommand("my-builder", { json: true }, deps));
314
315
 
315
316
  const parsed = JSON.parse(output.trim()) as Record<string, unknown>;
317
+ expect(parsed.success).toBe(true);
318
+ expect(parsed.command).toBe("stop");
316
319
  expect(parsed.stopped).toBe(true);
317
320
  expect(parsed.agentName).toBe("my-builder");
318
321
  expect(parsed.sessionId).toBe(session.id);
@@ -365,19 +368,20 @@ describe("stopCommand --clean-worktree", () => {
365
368
  expect(worktreeCalls.remove[0]?.options?.forceBranch).toBe(true);
366
369
  });
367
370
 
368
- test("--clean-worktree failure is non-fatal (agent still stopped, warning on stderr)", async () => {
371
+ test("--clean-worktree failure is non-fatal (agent still stopped, warning on stdout)", async () => {
369
372
  const session = makeAgentSession({ state: "working" });
370
373
  saveSessionsToDb([session]);
371
374
 
372
375
  const { deps } = makeDeps({ [session.tmuxSession]: true }, { shouldFail: true });
373
- const { stderr, stdout } = await captureStderr(() =>
376
+ const { stdout } = await captureStderr(() =>
374
377
  stopCommand("my-builder", { cleanWorktree: true }, deps),
375
378
  );
376
379
 
377
380
  // Agent was still stopped
378
- expect(stdout).toContain(`Agent "my-builder" stopped`);
379
- // Warning written to stderr
380
- expect(stderr).toContain("Warning: failed to remove worktree");
381
+ expect(stdout).toContain("Agent stopped");
382
+ expect(stdout).toContain("my-builder");
383
+ // Warning written to stdout (via printWarning)
384
+ expect(stdout).toContain("Failed to remove worktree");
381
385
 
382
386
  // Session is marked completed despite worktree failure
383
387
  const { store } = openSessionStore(overstoryDir);
@@ -11,6 +11,8 @@
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";
15
+ import { printSuccess, printWarning } from "../logging/color.ts";
14
16
  import { openSessionStore } from "../sessions/compat.ts";
15
17
  import { removeWorktree } from "../worktree/manager.ts";
16
18
  import { isSessionAlive, killSession } from "../worktree/tmux.ts";
@@ -103,24 +105,22 @@ export async function stopCommand(
103
105
  worktreeRemoved = true;
104
106
  } catch (err) {
105
107
  const msg = err instanceof Error ? err.message : String(err);
106
- process.stderr.write(`Warning: failed to remove worktree: ${msg}\n`);
108
+ if (!json) printWarning("Failed to remove worktree", msg);
107
109
  }
108
110
  }
109
111
 
110
112
  if (json) {
111
- process.stdout.write(
112
- `${JSON.stringify({
113
- stopped: true,
114
- agentName,
115
- sessionId: session.id,
116
- capability: session.capability,
117
- tmuxKilled: alive,
118
- worktreeRemoved,
119
- force,
120
- })}\n`,
121
- );
113
+ jsonOutput("stop", {
114
+ stopped: true,
115
+ agentName,
116
+ sessionId: session.id,
117
+ capability: session.capability,
118
+ tmuxKilled: alive,
119
+ worktreeRemoved,
120
+ force,
121
+ });
122
122
  } else {
123
- process.stdout.write(`Agent "${agentName}" stopped (session: ${session.id})\n`);
123
+ printSuccess("Agent stopped", agentName);
124
124
  if (alive) {
125
125
  process.stdout.write(` Tmux session killed: ${session.tmuxSession}\n`);
126
126
  } else {
@@ -20,6 +20,8 @@ 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";
24
+ import { printHint, printSuccess } from "../logging/color.ts";
23
25
  import { openSessionStore } from "../sessions/compat.ts";
24
26
  import { createTrackerClient, resolveBackend, trackerCliName } from "../tracker/factory.ts";
25
27
  import type { AgentSession } from "../types.ts";
@@ -229,9 +231,9 @@ async function startSupervisor(opts: {
229
231
  };
230
232
 
231
233
  if (opts.json) {
232
- process.stdout.write(`${JSON.stringify(output)}\n`);
234
+ jsonOutput("supervisor start", output);
233
235
  } else {
234
- process.stdout.write(`Supervisor '${opts.name}' started\n`);
236
+ printSuccess("Supervisor started", opts.name);
235
237
  process.stdout.write(` Tmux: ${tmuxSession}\n`);
236
238
  process.stdout.write(` Root: ${projectRoot}\n`);
237
239
  process.stdout.write(` Task: ${opts.task}\n`);
@@ -290,9 +292,9 @@ async function stopSupervisor(opts: { name: string; json: boolean }): Promise<vo
290
292
  store.updateLastActivity(opts.name);
291
293
 
292
294
  if (opts.json) {
293
- process.stdout.write(`${JSON.stringify({ stopped: true, sessionId: session.id })}\n`);
295
+ jsonOutput("supervisor stop", { stopped: true, sessionId: session.id });
294
296
  } else {
295
- process.stdout.write(`Supervisor '${opts.name}' stopped (session: ${session.id})\n`);
297
+ printSuccess("Supervisor stopped", opts.name);
296
298
  }
297
299
  } finally {
298
300
  store.close();
@@ -324,9 +326,9 @@ async function statusSupervisor(opts: { name?: string; json: boolean }): Promise
324
326
  session.state === "zombie"
325
327
  ) {
326
328
  if (opts.json) {
327
- process.stdout.write(`${JSON.stringify({ running: false })}\n`);
329
+ jsonOutput("supervisor status", { running: false });
328
330
  } else {
329
- process.stdout.write(`Supervisor '${opts.name}' is not running\n`);
331
+ printHint("Supervisor not running");
330
332
  }
331
333
  return;
332
334
  }
@@ -356,7 +358,7 @@ async function statusSupervisor(opts: { name?: string; json: boolean }): Promise
356
358
  };
357
359
 
358
360
  if (opts.json) {
359
- process.stdout.write(`${JSON.stringify(status)}\n`);
361
+ jsonOutput("supervisor status", status);
360
362
  } else {
361
363
  const stateLabel = alive ? "running" : session.state;
362
364
  process.stdout.write(`Supervisor '${opts.name}': ${stateLabel}\n`);
@@ -376,9 +378,9 @@ async function statusSupervisor(opts: { name?: string; json: boolean }): Promise
376
378
 
377
379
  if (supervisors.length === 0) {
378
380
  if (opts.json) {
379
- process.stdout.write(`${JSON.stringify([])}\n`);
381
+ jsonOutput("supervisor status", { supervisors: [] });
380
382
  } else {
381
- process.stdout.write("No supervisor sessions found\n");
383
+ printHint("No supervisor sessions found");
382
384
  }
383
385
  return;
384
386
  }
@@ -410,7 +412,7 @@ async function statusSupervisor(opts: { name?: string; json: boolean }): Promise
410
412
  );
411
413
 
412
414
  if (opts.json) {
413
- process.stdout.write(`${JSON.stringify(statuses)}\n`);
415
+ jsonOutput("supervisor status", { supervisors: statuses });
414
416
  } else {
415
417
  process.stdout.write("Supervisor sessions:\n");
416
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
 
@@ -10,6 +10,7 @@ import { join } from "node:path";
10
10
  import { Command } from "commander";
11
11
  import { loadConfig } from "../config.ts";
12
12
  import { OverstoryError } from "../errors.ts";
13
+ import { printError, printHint, printSuccess } from "../logging/color.ts";
13
14
  import type { HealthCheck } from "../types.ts";
14
15
  import { startDaemon } from "../watchdog/daemon.ts";
15
16
  import { isProcessRunning } from "../watchdog/health.ts";
@@ -130,9 +131,8 @@ async function runWatch(opts: { interval?: string; background?: boolean }): Prom
130
131
  // Check if a watchdog is already running
131
132
  const existingPid = await readPidFile(pidFilePath);
132
133
  if (existingPid !== null && isProcessRunning(existingPid)) {
133
- process.stderr.write(
134
- `Error: Watchdog already running (PID: ${existingPid}). ` +
135
- `Kill it first or remove ${pidFilePath}\n`,
134
+ printError(
135
+ `Watchdog already running (PID: ${existingPid}). Kill it first or remove ${pidFilePath}`,
136
136
  );
137
137
  process.exitCode = 1;
138
138
  return;
@@ -168,16 +168,14 @@ async function runWatch(opts: { interval?: string; background?: boolean }): Prom
168
168
  // Write PID file for later cleanup
169
169
  await writePidFile(pidFilePath, childPid);
170
170
 
171
- process.stdout.write(
172
- `Watchdog started in background (PID: ${childPid}, interval: ${intervalMs}ms)\n`,
173
- );
174
- process.stdout.write(`PID file: ${pidFilePath}\n`);
171
+ printSuccess("Watchdog started in background", `PID: ${childPid}, interval: ${intervalMs}ms`);
172
+ printHint(`PID file: ${pidFilePath}`);
175
173
  return;
176
174
  }
177
175
 
178
176
  // Foreground mode: show real-time health checks
179
- process.stdout.write(`Watchdog running (interval: ${intervalMs}ms)\n`);
180
- process.stdout.write("Press Ctrl+C to stop.\n\n");
177
+ printSuccess("Watchdog running", `interval: ${intervalMs}ms`);
178
+ printHint("Press Ctrl+C to stop.");
181
179
 
182
180
  // Write PID file so `--background` check and external tools can find us
183
181
  await writePidFile(pidFilePath, process.pid);
@@ -200,7 +198,7 @@ async function runWatch(opts: { interval?: string; background?: boolean }): Prom
200
198
  stop();
201
199
  // Clean up PID file on graceful shutdown
202
200
  removePidFile(pidFilePath).finally(() => {
203
- process.stdout.write("\nWatchdog stopped.\n");
201
+ printSuccess("Watchdog stopped.");
204
202
  process.exit(0);
205
203
  });
206
204
  });
@@ -131,7 +131,7 @@ describe("worktreeCommand", () => {
131
131
  await worktreeCommand(["list"]);
132
132
  const out = output();
133
133
 
134
- expect(out).toBe("No agent worktrees found.\n");
134
+ expect(out).toContain("No agent worktrees found");
135
135
  });
136
136
 
137
137
  test("with overstory worktrees lists them with agent info", async () => {
@@ -220,21 +220,27 @@ describe("worktreeCommand", () => {
220
220
  await worktreeCommand(["list", "--json"]);
221
221
  const out = output();
222
222
 
223
- const parsed = JSON.parse(out.trim()) as Array<{
224
- path: string;
225
- branch: string;
226
- head: string;
227
- agentName: string | null;
228
- state: string | null;
229
- taskId: string | null;
230
- }>;
231
-
232
- expect(parsed).toHaveLength(1);
233
- expect(parsed[0]?.path).toBe(worktreePath);
234
- expect(parsed[0]?.branch).toBe("overstory/test-agent/task-1");
235
- expect(parsed[0]?.agentName).toBe("test-agent");
236
- expect(parsed[0]?.state).toBe("working");
237
- expect(parsed[0]?.taskId).toBe("task-1");
223
+ const parsed = JSON.parse(out.trim()) as {
224
+ success: boolean;
225
+ command: string;
226
+ worktrees: Array<{
227
+ path: string;
228
+ branch: string;
229
+ head: string;
230
+ agentName: string | null;
231
+ state: string | null;
232
+ taskId: string | null;
233
+ }>;
234
+ };
235
+
236
+ expect(parsed.success).toBe(true);
237
+ expect(parsed.command).toBe("worktree list");
238
+ expect(parsed.worktrees).toHaveLength(1);
239
+ expect(parsed.worktrees[0]?.path).toBe(worktreePath);
240
+ expect(parsed.worktrees[0]?.branch).toBe("overstory/test-agent/task-1");
241
+ expect(parsed.worktrees[0]?.agentName).toBe("test-agent");
242
+ expect(parsed.worktrees[0]?.state).toBe("working");
243
+ expect(parsed.worktrees[0]?.taskId).toBe("task-1");
238
244
  });
239
245
 
240
246
  test("worktrees without sessions show unknown state", async () => {
@@ -266,7 +272,7 @@ describe("worktreeCommand", () => {
266
272
  await worktreeCommand(["clean"]);
267
273
  const out = output();
268
274
 
269
- expect(out).toBe("No worktrees to clean.\n");
275
+ expect(out).toContain("No worktrees to clean");
270
276
  });
271
277
 
272
278
  test("with completed agent worktree removes it and reports count", async () => {
@@ -308,7 +314,8 @@ describe("worktreeCommand", () => {
308
314
  await worktreeCommand(["clean"]);
309
315
  const out = output();
310
316
 
311
- expect(out).toContain("Removed: overstory/completed-agent/task-done");
317
+ expect(out).toContain("Removed");
318
+ expect(out).toContain("overstory/completed-agent/task-done");
312
319
  expect(out).toContain("Cleaned 1 worktree");
313
320
 
314
321
  // Verify the worktree directory is gone
@@ -455,7 +462,7 @@ describe("worktreeCommand", () => {
455
462
  const out = output();
456
463
 
457
464
  // Stalled agents should not be cleaned by default (only completed/zombie are cleaned)
458
- expect(out).toBe("No worktrees to clean.\n");
465
+ expect(out).toContain("No worktrees to clean");
459
466
  });
460
467
 
461
468
  test("--completed flag only cleans completed agents", async () => {
@@ -704,7 +711,7 @@ describe("worktreeCommand", () => {
704
711
 
705
712
  // Worktree should be removed
706
713
  expect(existsSync(wtPath)).toBe(false);
707
- expect(out).toContain("Removed: overstory/unmerged-agent/task-force");
714
+ expect(out).toContain("overstory/unmerged-agent/task-force");
708
715
  });
709
716
 
710
717
  test("without --force, removes worktrees whose branches ARE merged", async () => {