@os-eco/overstory-cli 0.6.8 → 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 (50) hide show
  1. package/README.md +4 -2
  2. package/package.json +1 -1
  3. package/src/agents/hooks-deployer.test.ts +180 -0
  4. package/src/agents/hooks-deployer.ts +32 -1
  5. package/src/commands/agents.ts +9 -6
  6. package/src/commands/clean.ts +2 -1
  7. package/src/commands/completions.ts +3 -4
  8. package/src/commands/coordinator.test.ts +8 -0
  9. package/src/commands/coordinator.ts +11 -8
  10. package/src/commands/costs.test.ts +48 -38
  11. package/src/commands/costs.ts +48 -38
  12. package/src/commands/dashboard.ts +7 -7
  13. package/src/commands/doctor.test.ts +8 -0
  14. package/src/commands/doctor.ts +2 -6
  15. package/src/commands/errors.test.ts +47 -40
  16. package/src/commands/errors.ts +5 -4
  17. package/src/commands/feed.test.ts +40 -33
  18. package/src/commands/feed.ts +3 -2
  19. package/src/commands/group.ts +23 -14
  20. package/src/commands/hooks.ts +2 -1
  21. package/src/commands/init.test.ts +104 -0
  22. package/src/commands/init.ts +11 -7
  23. package/src/commands/inspect.test.ts +2 -0
  24. package/src/commands/inspect.ts +9 -8
  25. package/src/commands/logs.test.ts +5 -6
  26. package/src/commands/logs.ts +2 -1
  27. package/src/commands/mail.test.ts +11 -10
  28. package/src/commands/mail.ts +11 -12
  29. package/src/commands/merge.ts +11 -12
  30. package/src/commands/metrics.test.ts +15 -2
  31. package/src/commands/metrics.ts +3 -2
  32. package/src/commands/monitor.ts +5 -4
  33. package/src/commands/nudge.ts +2 -3
  34. package/src/commands/prime.test.ts +1 -6
  35. package/src/commands/prime.ts +2 -3
  36. package/src/commands/replay.test.ts +62 -55
  37. package/src/commands/replay.ts +3 -2
  38. package/src/commands/run.ts +17 -20
  39. package/src/commands/sling.ts +2 -1
  40. package/src/commands/status.test.ts +2 -1
  41. package/src/commands/status.ts +7 -6
  42. package/src/commands/stop.test.ts +2 -0
  43. package/src/commands/stop.ts +10 -11
  44. package/src/commands/supervisor.ts +7 -6
  45. package/src/commands/trace.test.ts +52 -44
  46. package/src/commands/trace.ts +5 -4
  47. package/src/commands/watch.ts +8 -10
  48. package/src/commands/worktree.test.ts +21 -15
  49. package/src/commands/worktree.ts +10 -4
  50. package/src/index.ts +3 -1
@@ -8,6 +8,7 @@
8
8
  import { join } from "node:path";
9
9
  import { Command } from "commander";
10
10
  import { loadConfig } from "../config.ts";
11
+ import { jsonOutput } from "../json.ts";
11
12
  import { createMetricsStore } from "../metrics/store.ts";
12
13
 
13
14
  interface MetricsOpts {
@@ -41,7 +42,7 @@ async function executeMetrics(opts: MetricsOpts): Promise<void> {
41
42
  const dbFile = Bun.file(dbPath);
42
43
  if (!(await dbFile.exists())) {
43
44
  if (json) {
44
- process.stdout.write('{"sessions":[]}\n');
45
+ jsonOutput("metrics", { sessions: [] });
45
46
  } else {
46
47
  process.stdout.write("No metrics data yet.\n");
47
48
  }
@@ -54,7 +55,7 @@ async function executeMetrics(opts: MetricsOpts): Promise<void> {
54
55
  const sessions = store.getRecentSessions(limit);
55
56
 
56
57
  if (json) {
57
- process.stdout.write(`${JSON.stringify({ sessions })}\n`);
58
+ jsonOutput("metrics", { sessions } as Record<string, unknown>);
58
59
  return;
59
60
  }
60
61
 
@@ -21,6 +21,7 @@ import { createIdentity, loadIdentity } from "../agents/identity.ts";
21
21
  import { createManifestLoader, resolveModel } from "../agents/manifest.ts";
22
22
  import { loadConfig } from "../config.ts";
23
23
  import { AgentError, ValidationError } from "../errors.ts";
24
+ import { jsonOutput } from "../json.ts";
24
25
  import { printHint, printSuccess } from "../logging/color.ts";
25
26
  import { openSessionStore } from "../sessions/compat.ts";
26
27
  import type { AgentSession } from "../types.ts";
@@ -190,7 +191,7 @@ async function startMonitor(opts: { json: boolean; attach: boolean }): Promise<v
190
191
  };
191
192
 
192
193
  if (json) {
193
- process.stdout.write(`${JSON.stringify(output)}\n`);
194
+ jsonOutput("monitor start", output);
194
195
  } else {
195
196
  printSuccess("Monitor started");
196
197
  process.stdout.write(` Tmux: ${tmuxSession}\n`);
@@ -248,7 +249,7 @@ async function stopMonitor(opts: { json: boolean }): Promise<void> {
248
249
  store.updateLastActivity(MONITOR_NAME);
249
250
 
250
251
  if (json) {
251
- process.stdout.write(`${JSON.stringify({ stopped: true, sessionId: session.id })}\n`);
252
+ jsonOutput("monitor stop", { stopped: true, sessionId: session.id });
252
253
  } else {
253
254
  printSuccess("Monitor stopped", session.id);
254
255
  }
@@ -280,7 +281,7 @@ async function statusMonitor(opts: { json: boolean }): Promise<void> {
280
281
  session.state === "zombie"
281
282
  ) {
282
283
  if (json) {
283
- process.stdout.write(`${JSON.stringify({ running: false })}\n`);
284
+ jsonOutput("monitor status", { running: false });
284
285
  } else {
285
286
  printHint("Monitor is not running");
286
287
  }
@@ -307,7 +308,7 @@ async function statusMonitor(opts: { json: boolean }): Promise<void> {
307
308
  };
308
309
 
309
310
  if (json) {
310
- process.stdout.write(`${JSON.stringify(status)}\n`);
311
+ jsonOutput("monitor status", status);
311
312
  } else {
312
313
  const stateLabel = alive ? "running" : session.state;
313
314
  process.stdout.write(`Monitor: ${stateLabel}\n`);
@@ -13,6 +13,7 @@ import { join } from "node:path";
13
13
  import { Command } from "commander";
14
14
  import { AgentError } from "../errors.ts";
15
15
  import { createEventStore } from "../events/store.ts";
16
+ import { jsonOutput } from "../json.ts";
16
17
  import { printSuccess } from "../logging/color.ts";
17
18
  import { openSessionStore } from "../sessions/compat.ts";
18
19
  import type { EventStore } from "../types.ts";
@@ -310,9 +311,7 @@ export async function nudgeCommand(args: string[]): Promise<void> {
310
311
  const result = await nudgeAgent(projectRoot, agentName, message, opts.force ?? false);
311
312
 
312
313
  if (opts.json) {
313
- process.stdout.write(
314
- `${JSON.stringify({ agentName, delivered: result.delivered, reason: result.reason })}\n`,
315
- );
314
+ jsonOutput("nudge", { agentName, delivered: result.delivered, reason: result.reason });
316
315
  } else if (result.delivered) {
317
316
  printSuccess("Nudge delivered", agentName);
318
317
  } else {
@@ -62,10 +62,6 @@ describe("primeCommand", () => {
62
62
  return chunks.join("");
63
63
  }
64
64
 
65
- function stderr(): string {
66
- return stderrChunks.join("");
67
- }
68
-
69
65
  describe("Orchestrator priming (no --agent flag)", () => {
70
66
  test("default prime outputs project context", async () => {
71
67
  await primeCommand({});
@@ -113,12 +109,11 @@ describe("primeCommand", () => {
113
109
  test("unknown agent outputs basic context and warns", async () => {
114
110
  await primeCommand({ agent: "unknown-agent" });
115
111
  const out = output();
116
- const err = stderr();
117
112
 
118
113
  expect(out).toContain("# Agent Context: unknown-agent");
119
114
  expect(out).toContain("## Identity");
120
115
  expect(out).toContain("New agent - no prior sessions");
121
- expect(err).toContain('Warning: agent "unknown-agent" not found');
116
+ expect(out).toContain('agent "unknown-agent" not found');
122
117
  });
123
118
 
124
119
  test("agent with identity.yaml shows identity details", async () => {
@@ -12,6 +12,7 @@ import { loadCheckpoint } from "../agents/checkpoint.ts";
12
12
  import { loadIdentity } from "../agents/identity.ts";
13
13
  import { createManifestLoader } from "../agents/manifest.ts";
14
14
  import { loadConfig } from "../config.ts";
15
+ import { printWarning } from "../logging/color.ts";
15
16
  import { createMetricsStore } from "../metrics/store.ts";
16
17
  import { createMulchClient } from "../mulch/client.ts";
17
18
  import { openSessionStore } from "../sessions/compat.ts";
@@ -211,9 +212,7 @@ async function outputAgentContext(
211
212
 
212
213
  // Warn if agent is completely unknown (no session and no identity)
213
214
  if (!sessionExists && identity === null) {
214
- process.stderr.write(
215
- `Warning: agent "${agentName}" not found in sessions or identity store.\n`,
216
- );
215
+ printWarning(`agent "${agentName}" not found in sessions or identity store.`);
217
216
  }
218
217
 
219
218
  sections.push("\n## Identity");
@@ -133,7 +133,14 @@ describe("replayCommand", () => {
133
133
  await replayCommand(["--json"]);
134
134
  const out = output();
135
135
 
136
- expect(out).toBe("[]\n");
136
+ const parsed = JSON.parse(out.trim()) as {
137
+ success: boolean;
138
+ command: string;
139
+ events: unknown[];
140
+ };
141
+ expect(parsed.success).toBe(true);
142
+ expect(parsed.command).toBe("replay");
143
+ expect(parsed.events).toEqual([]);
137
144
  });
138
145
  });
139
146
 
@@ -151,9 +158,9 @@ describe("replayCommand", () => {
151
158
  await replayCommand(["--run", "run-001", "--json"]);
152
159
  const out = output();
153
160
 
154
- const parsed = JSON.parse(out.trim()) as unknown[];
155
- expect(parsed).toHaveLength(3);
156
- expect(Array.isArray(parsed)).toBe(true);
161
+ const parsed = JSON.parse(out.trim()) as { events: unknown[] };
162
+ expect(parsed.events).toHaveLength(3);
163
+ expect(Array.isArray(parsed.events)).toBe(true);
157
164
  });
158
165
 
159
166
  test("JSON output includes expected fields", async () => {
@@ -172,9 +179,9 @@ describe("replayCommand", () => {
172
179
  await replayCommand(["--run", "run-001", "--json"]);
173
180
  const out = output();
174
181
 
175
- const parsed = JSON.parse(out.trim()) as Record<string, unknown>[];
176
- expect(parsed).toHaveLength(1);
177
- const event = parsed[0];
182
+ const parsed = JSON.parse(out.trim()) as { events: Record<string, unknown>[] };
183
+ expect(parsed.events).toHaveLength(1);
184
+ const event = parsed.events[0];
178
185
  expect(event).toBeDefined();
179
186
  expect(event?.agentName).toBe("builder-1");
180
187
  expect(event?.eventType).toBe("tool_start");
@@ -192,8 +199,8 @@ describe("replayCommand", () => {
192
199
  await replayCommand(["--run", "run-001", "--json"]);
193
200
  const out = output();
194
201
 
195
- const parsed = JSON.parse(out.trim()) as unknown[];
196
- expect(parsed).toEqual([]);
202
+ const parsed = JSON.parse(out.trim()) as { events: unknown[] };
203
+ expect(parsed.events).toEqual([]);
197
204
  });
198
205
  });
199
206
 
@@ -332,9 +339,9 @@ describe("replayCommand", () => {
332
339
  await replayCommand(["--run", "run-001", "--json"]);
333
340
  const out = output();
334
341
 
335
- const parsed = JSON.parse(out.trim()) as Record<string, unknown>[];
336
- expect(parsed).toHaveLength(2);
337
- for (const event of parsed) {
342
+ const parsed = JSON.parse(out.trim()) as { events: Record<string, unknown>[] };
343
+ expect(parsed.events).toHaveLength(2);
344
+ for (const event of parsed.events) {
338
345
  expect(event.runId).toBe("run-001");
339
346
  }
340
347
  });
@@ -349,8 +356,8 @@ describe("replayCommand", () => {
349
356
  await replayCommand(["--run", "run-001", "--json", "--since", "2099-01-01T00:00:00Z"]);
350
357
  const out = output();
351
358
 
352
- const parsed = JSON.parse(out.trim()) as unknown[];
353
- expect(parsed).toEqual([]);
359
+ const parsed = JSON.parse(out.trim()) as { events: unknown[] };
360
+ expect(parsed.events).toEqual([]);
354
361
  });
355
362
  });
356
363
 
@@ -368,9 +375,9 @@ describe("replayCommand", () => {
368
375
  await replayCommand(["--agent", "builder-1", "--json"]);
369
376
  const out = output();
370
377
 
371
- const parsed = JSON.parse(out.trim()) as Record<string, unknown>[];
372
- expect(parsed).toHaveLength(2);
373
- for (const event of parsed) {
378
+ const parsed = JSON.parse(out.trim()) as { events: Record<string, unknown>[] };
379
+ expect(parsed.events).toHaveLength(2);
380
+ for (const event of parsed.events) {
374
381
  expect(event.agentName).toBe("builder-1");
375
382
  }
376
383
  });
@@ -387,9 +394,9 @@ describe("replayCommand", () => {
387
394
  await replayCommand(["--agent", "builder-1", "--agent", "scout-1", "--json"]);
388
395
  const out = output();
389
396
 
390
- const parsed = JSON.parse(out.trim()) as Record<string, unknown>[];
391
- expect(parsed).toHaveLength(3);
392
- const agents = new Set(parsed.map((e) => e.agentName));
397
+ const parsed = JSON.parse(out.trim()) as { events: Record<string, unknown>[] };
398
+ expect(parsed.events).toHaveLength(3);
399
+ const agents = new Set(parsed.events.map((e) => e.agentName));
393
400
  expect(agents.has("builder-1")).toBe(true);
394
401
  expect(agents.has("scout-1")).toBe(true);
395
402
  expect(agents.has("reviewer-1")).toBe(false);
@@ -407,12 +414,12 @@ describe("replayCommand", () => {
407
414
  await replayCommand(["--agent", "builder-1", "--agent", "scout-1", "--json"]);
408
415
  const out = output();
409
416
 
410
- const parsed = JSON.parse(out.trim()) as Record<string, unknown>[];
411
- expect(parsed).toHaveLength(3);
417
+ const parsed = JSON.parse(out.trim()) as { events: Record<string, unknown>[] };
418
+ expect(parsed.events).toHaveLength(3);
412
419
  // They should be in chronological order
413
- for (let i = 1; i < parsed.length; i++) {
414
- const prev = parsed[i - 1];
415
- const curr = parsed[i];
420
+ for (let i = 1; i < parsed.events.length; i++) {
421
+ const prev = parsed.events[i - 1];
422
+ const curr = parsed.events[i];
416
423
  if (prev && curr) {
417
424
  expect(
418
425
  (prev.createdAt as string).localeCompare(curr.createdAt as string),
@@ -430,8 +437,8 @@ describe("replayCommand", () => {
430
437
  await replayCommand(["--agent", "nonexistent", "--json"]);
431
438
  const out = output();
432
439
 
433
- const parsed = JSON.parse(out.trim()) as unknown[];
434
- expect(parsed).toEqual([]);
440
+ const parsed = JSON.parse(out.trim()) as { events: unknown[] };
441
+ expect(parsed.events).toEqual([]);
435
442
  });
436
443
  });
437
444
 
@@ -451,9 +458,9 @@ describe("replayCommand", () => {
451
458
  await replayCommand(["--json"]);
452
459
  const out = output();
453
460
 
454
- const parsed = JSON.parse(out.trim()) as Record<string, unknown>[];
455
- expect(parsed).toHaveLength(1);
456
- expect(parsed[0]?.runId).toBe("run-from-file");
461
+ const parsed = JSON.parse(out.trim()) as { events: Record<string, unknown>[] };
462
+ expect(parsed.events).toHaveLength(1);
463
+ expect(parsed.events[0]?.runId).toBe("run-from-file");
457
464
  });
458
465
 
459
466
  test("falls back to 24h timeline when no current-run.txt", async () => {
@@ -467,8 +474,8 @@ describe("replayCommand", () => {
467
474
  await replayCommand(["--json"]);
468
475
  const out = output();
469
476
 
470
- const parsed = JSON.parse(out.trim()) as unknown[];
471
- expect(parsed).toHaveLength(2);
477
+ const parsed = JSON.parse(out.trim()) as { events: unknown[] };
478
+ expect(parsed.events).toHaveLength(2);
472
479
  });
473
480
 
474
481
  test("falls back to timeline when current-run.txt is empty", async () => {
@@ -482,8 +489,8 @@ describe("replayCommand", () => {
482
489
  await replayCommand(["--json"]);
483
490
  const out = output();
484
491
 
485
- const parsed = JSON.parse(out.trim()) as unknown[];
486
- expect(parsed).toHaveLength(1);
492
+ const parsed = JSON.parse(out.trim()) as { events: unknown[] };
493
+ expect(parsed.events).toHaveLength(1);
487
494
  });
488
495
  });
489
496
 
@@ -501,8 +508,8 @@ describe("replayCommand", () => {
501
508
  await replayCommand(["--run", "run-001", "--json", "--limit", "3"]);
502
509
  const out = output();
503
510
 
504
- const parsed = JSON.parse(out.trim()) as unknown[];
505
- expect(parsed).toHaveLength(3);
511
+ const parsed = JSON.parse(out.trim()) as { events: unknown[] };
512
+ expect(parsed.events).toHaveLength(3);
506
513
  });
507
514
 
508
515
  test("default limit is 200", async () => {
@@ -516,8 +523,8 @@ describe("replayCommand", () => {
516
523
  await replayCommand(["--run", "run-001", "--json"]);
517
524
  const out = output();
518
525
 
519
- const parsed = JSON.parse(out.trim()) as unknown[];
520
- expect(parsed).toHaveLength(200);
526
+ const parsed = JSON.parse(out.trim()) as { events: unknown[] };
527
+ expect(parsed.events).toHaveLength(200);
521
528
  });
522
529
 
523
530
  test("--limit applies to merged --agent queries", async () => {
@@ -532,8 +539,8 @@ describe("replayCommand", () => {
532
539
  await replayCommand(["--agent", "builder-1", "--agent", "scout-1", "--json", "--limit", "4"]);
533
540
  const out = output();
534
541
 
535
- const parsed = JSON.parse(out.trim()) as unknown[];
536
- expect(parsed).toHaveLength(4);
542
+ const parsed = JSON.parse(out.trim()) as { events: unknown[] };
543
+ expect(parsed.events).toHaveLength(4);
537
544
  });
538
545
  });
539
546
 
@@ -550,8 +557,8 @@ describe("replayCommand", () => {
550
557
  await replayCommand(["--run", "run-001", "--json", "--since", "2099-01-01T00:00:00Z"]);
551
558
  const out = output();
552
559
 
553
- const parsed = JSON.parse(out.trim()) as unknown[];
554
- expect(parsed).toEqual([]);
560
+ const parsed = JSON.parse(out.trim()) as { events: unknown[] };
561
+ expect(parsed.events).toEqual([]);
555
562
  });
556
563
 
557
564
  test("--since with past timestamp returns all events", async () => {
@@ -564,8 +571,8 @@ describe("replayCommand", () => {
564
571
  await replayCommand(["--run", "run-001", "--json", "--since", "2020-01-01T00:00:00Z"]);
565
572
  const out = output();
566
573
 
567
- const parsed = JSON.parse(out.trim()) as unknown[];
568
- expect(parsed).toHaveLength(2);
574
+ const parsed = JSON.parse(out.trim()) as { events: unknown[] };
575
+ expect(parsed.events).toHaveLength(2);
569
576
  });
570
577
 
571
578
  test("--until with past timestamp returns no events", async () => {
@@ -577,8 +584,8 @@ describe("replayCommand", () => {
577
584
  await replayCommand(["--run", "run-001", "--json", "--until", "2000-01-01T00:00:00Z"]);
578
585
  const out = output();
579
586
 
580
- const parsed = JSON.parse(out.trim()) as unknown[];
581
- expect(parsed).toEqual([]);
587
+ const parsed = JSON.parse(out.trim()) as { events: unknown[] };
588
+ expect(parsed.events).toEqual([]);
582
589
  });
583
590
 
584
591
  test("--since causes absolute timestamps in text mode", async () => {
@@ -611,12 +618,12 @@ describe("replayCommand", () => {
611
618
  await replayCommand(["--run", "run-001", "--json"]);
612
619
  const out = output();
613
620
 
614
- const parsed = JSON.parse(out.trim()) as Record<string, unknown>[];
615
- expect(parsed).toHaveLength(4);
621
+ const parsed = JSON.parse(out.trim()) as { events: Record<string, unknown>[] };
622
+ expect(parsed.events).toHaveLength(4);
616
623
  // Verify chronological order
617
- for (let i = 1; i < parsed.length; i++) {
618
- const prev = parsed[i - 1];
619
- const curr = parsed[i];
624
+ for (let i = 1; i < parsed.events.length; i++) {
625
+ const prev = parsed.events[i - 1];
626
+ const curr = parsed.events[i];
620
627
  if (prev && curr) {
621
628
  expect(
622
629
  (prev.createdAt as string).localeCompare(curr.createdAt as string),
@@ -624,10 +631,10 @@ describe("replayCommand", () => {
624
631
  }
625
632
  }
626
633
  // Verify interleaving: agents should alternate
627
- expect(parsed[0]?.agentName).toBe("builder-1");
628
- expect(parsed[1]?.agentName).toBe("scout-1");
629
- expect(parsed[2]?.agentName).toBe("builder-1");
630
- expect(parsed[3]?.agentName).toBe("scout-1");
634
+ expect(parsed.events[0]?.agentName).toBe("builder-1");
635
+ expect(parsed.events[1]?.agentName).toBe("scout-1");
636
+ expect(parsed.events[2]?.agentName).toBe("builder-1");
637
+ expect(parsed.events[3]?.agentName).toBe("scout-1");
631
638
  });
632
639
  });
633
640
 
@@ -11,6 +11,7 @@ import { Command } from "commander";
11
11
  import { loadConfig } from "../config.ts";
12
12
  import { ValidationError } from "../errors.ts";
13
13
  import { createEventStore } from "../events/store.ts";
14
+ import { jsonOutput } from "../json.ts";
14
15
  import type { ColorFn } from "../logging/color.ts";
15
16
  import { color } from "../logging/color.ts";
16
17
  import type { EventType, StoredEvent } from "../types.ts";
@@ -246,7 +247,7 @@ async function executeReplay(opts: ReplayOpts): Promise<void> {
246
247
  const eventsFile = Bun.file(eventsDbPath);
247
248
  if (!(await eventsFile.exists())) {
248
249
  if (json) {
249
- process.stdout.write("[]\n");
250
+ jsonOutput("replay", { events: [] });
250
251
  } else {
251
252
  process.stdout.write("No events data yet.\n");
252
253
  }
@@ -305,7 +306,7 @@ async function executeReplay(opts: ReplayOpts): Promise<void> {
305
306
  }
306
307
 
307
308
  if (json) {
308
- process.stdout.write(`${JSON.stringify(events)}\n`);
309
+ jsonOutput("replay", { events });
309
310
  return;
310
311
  }
311
312
 
@@ -15,7 +15,8 @@ import { join } from "node:path";
15
15
  import { Command, CommanderError } from "commander";
16
16
  import { loadConfig } from "../config.ts";
17
17
  import { ValidationError } from "../errors.ts";
18
- import { printError, printHint, printSuccess } from "../logging/color.ts";
18
+ import { jsonError, jsonOutput } from "../json.ts";
19
+ import { accent, printError, printHint, printSuccess } from "../logging/color.ts";
19
20
  import { createRunStore, createSessionStore } from "../sessions/store.ts";
20
21
  import type { AgentSession, Run } from "../types.ts";
21
22
 
@@ -83,7 +84,7 @@ async function showCurrentRun(overstoryDir: string, json: boolean): Promise<void
83
84
  const runId = await readCurrentRunId(overstoryDir);
84
85
  if (!runId) {
85
86
  if (json) {
86
- process.stdout.write('{"run":null,"message":"No active run"}\n');
87
+ jsonOutput("run", { run: null, message: "No active run" });
87
88
  } else {
88
89
  printHint("No active run");
89
90
  }
@@ -96,23 +97,21 @@ async function showCurrentRun(overstoryDir: string, json: boolean): Promise<void
96
97
  const run = runStore.getRun(runId);
97
98
  if (!run) {
98
99
  if (json) {
99
- process.stdout.write(
100
- `${JSON.stringify({ run: null, message: `Run ${runId} not found in store` })}\n`,
101
- );
100
+ jsonOutput("run", { run: null, message: `Run ${runId} not found in store` });
102
101
  } else {
103
- process.stdout.write(`Run ${runId} not found in store\n`);
102
+ process.stdout.write(`Run ${accent(runId)} not found in store\n`);
104
103
  }
105
104
  return;
106
105
  }
107
106
 
108
107
  if (json) {
109
- process.stdout.write(`${JSON.stringify({ run, duration: runDuration(run) })}\n`);
108
+ jsonOutput("run", { run, duration: runDuration(run) });
110
109
  return;
111
110
  }
112
111
 
113
112
  process.stdout.write("Current Run\n");
114
113
  process.stdout.write(`${"=".repeat(50)}\n`);
115
- process.stdout.write(` ID: ${run.id}\n`);
114
+ process.stdout.write(` ID: ${accent(run.id)}\n`);
116
115
  process.stdout.write(` Status: ${run.status}\n`);
117
116
  process.stdout.write(` Started: ${run.startedAt}\n`);
118
117
  process.stdout.write(` Agents: ${run.agentCount}\n`);
@@ -130,7 +129,7 @@ async function listRuns(overstoryDir: string, limit: number, json: boolean): Pro
130
129
  const dbFile = Bun.file(dbPath);
131
130
  if (!(await dbFile.exists())) {
132
131
  if (json) {
133
- process.stdout.write('{"runs":[]}\n');
132
+ jsonOutput("run list", { runs: [] });
134
133
  } else {
135
134
  printHint("No runs recorded yet");
136
135
  }
@@ -143,7 +142,7 @@ async function listRuns(overstoryDir: string, limit: number, json: boolean): Pro
143
142
 
144
143
  if (json) {
145
144
  const runsWithDuration = runs.map((r) => ({ ...r, duration: runDuration(r) }));
146
- process.stdout.write(`${JSON.stringify({ runs: runsWithDuration })}\n`);
145
+ jsonOutput("run list", { runs: runsWithDuration });
147
146
  return;
148
147
  }
149
148
 
@@ -160,7 +159,7 @@ async function listRuns(overstoryDir: string, limit: number, json: boolean): Pro
160
159
  process.stdout.write(`${"-".repeat(70)}\n`);
161
160
 
162
161
  for (const run of runs) {
163
- const id = run.id.length > 35 ? `${run.id.slice(0, 32)}...` : run.id.padEnd(36);
162
+ const id = accent(run.id.length > 35 ? `${run.id.slice(0, 32)}...` : run.id.padEnd(36));
164
163
  const status = run.status.padEnd(10);
165
164
  const agents = String(run.agentCount).padEnd(7);
166
165
  const duration = runDuration(run);
@@ -178,7 +177,7 @@ async function completeCurrentRun(overstoryDir: string, json: boolean): Promise<
178
177
  const runId = await readCurrentRunId(overstoryDir);
179
178
  if (!runId) {
180
179
  if (json) {
181
- process.stdout.write('{"success":false,"message":"No active run to complete"}\n');
180
+ jsonError("run complete", "No active run to complete");
182
181
  } else {
183
182
  printError("No active run to complete");
184
183
  }
@@ -203,7 +202,7 @@ async function completeCurrentRun(overstoryDir: string, json: boolean): Promise<
203
202
  }
204
203
 
205
204
  if (json) {
206
- process.stdout.write(`${JSON.stringify({ success: true, runId, status: "completed" })}\n`);
205
+ jsonOutput("run complete", { runId, status: "completed" });
207
206
  } else {
208
207
  printSuccess("Run completed", runId);
209
208
  }
@@ -217,7 +216,7 @@ async function showRun(overstoryDir: string, runId: string, json: boolean): Prom
217
216
  const dbFile = Bun.file(dbPath);
218
217
  if (!(await dbFile.exists())) {
219
218
  if (json) {
220
- process.stdout.write(`${JSON.stringify({ run: null, message: `Run ${runId} not found` })}\n`);
219
+ jsonError("run show", `Run ${runId} not found`);
221
220
  } else {
222
221
  printError("Run not found", runId);
223
222
  }
@@ -231,9 +230,7 @@ async function showRun(overstoryDir: string, runId: string, json: boolean): Prom
231
230
  const run = runStore.getRun(runId);
232
231
  if (!run) {
233
232
  if (json) {
234
- process.stdout.write(
235
- `${JSON.stringify({ run: null, message: `Run ${runId} not found` })}\n`,
236
- );
233
+ jsonError("run show", `Run ${runId} not found`);
237
234
  } else {
238
235
  printError("Run not found", runId);
239
236
  }
@@ -244,13 +241,13 @@ async function showRun(overstoryDir: string, runId: string, json: boolean): Prom
244
241
  const agents = sessionStore.getByRun(runId);
245
242
 
246
243
  if (json) {
247
- process.stdout.write(`${JSON.stringify({ run, duration: runDuration(run), agents })}\n`);
244
+ jsonOutput("run show", { run, duration: runDuration(run), agents });
248
245
  return;
249
246
  }
250
247
 
251
248
  process.stdout.write("Run Details\n");
252
249
  process.stdout.write(`${"=".repeat(60)}\n`);
253
- process.stdout.write(` ID: ${run.id}\n`);
250
+ process.stdout.write(` ID: ${accent(run.id)}\n`);
254
251
  process.stdout.write(` Status: ${run.status}\n`);
255
252
  process.stdout.write(` Started: ${run.startedAt}\n`);
256
253
  if (run.completedAt) {
@@ -265,7 +262,7 @@ async function showRun(overstoryDir: string, runId: string, json: boolean): Prom
265
262
  for (const agent of agents) {
266
263
  const agentDuration = formatAgentDuration(agent);
267
264
  process.stdout.write(
268
- ` ${agent.agentName} [${agent.capability}] ${agent.state} | ${agentDuration}\n`,
265
+ ` ${accent(agent.agentName)} [${agent.capability}] ${agent.state} | ${agentDuration}\n`,
269
266
  );
270
267
  }
271
268
  } else {
@@ -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";
@@ -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);