@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
@@ -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";
@@ -189,7 +190,7 @@ async function executeFeed(opts: FeedOpts): Promise<void> {
189
190
  const eventsFile = Bun.file(eventsDbPath);
190
191
  if (!(await eventsFile.exists())) {
191
192
  if (json) {
192
- process.stdout.write("[]\n");
193
+ jsonOutput("feed", { events: [] });
193
194
  } else {
194
195
  process.stdout.write("No events data yet.\n");
195
196
  }
@@ -228,7 +229,7 @@ async function executeFeed(opts: FeedOpts): Promise<void> {
228
229
  const events = queryEvents({ since, limit });
229
230
 
230
231
  if (json) {
231
- process.stdout.write(`${JSON.stringify(events)}\n`);
232
+ jsonOutput("feed", { events });
232
233
  return;
233
234
  }
234
235
 
@@ -263,7 +264,7 @@ async function executeFeed(opts: FeedOpts): Promise<void> {
263
264
  } else {
264
265
  // JSON mode: print each event as a line
265
266
  for (const event of initialEvents) {
266
- process.stdout.write(`${JSON.stringify(event)}\n`);
267
+ jsonOutput("feed", { event });
267
268
  }
268
269
  if (initialEvents.length > 0) {
269
270
  const lastEvent = initialEvents[initialEvents.length - 1];
@@ -307,7 +308,7 @@ async function executeFeed(opts: FeedOpts): Promise<void> {
307
308
  } else {
308
309
  // JSON mode: print each event as a line
309
310
  for (const event of newEvents) {
310
- process.stdout.write(`${JSON.stringify(event)}\n`);
311
+ jsonOutput("feed", { event });
311
312
  }
312
313
  }
313
314
 
@@ -11,7 +11,8 @@ import { join } from "node:path";
11
11
  import { Command } from "commander";
12
12
  import { loadConfig } from "../config.ts";
13
13
  import { GroupError, ValidationError } from "../errors.ts";
14
- import { printHint, printSuccess } from "../logging/color.ts";
14
+ import { jsonOutput } from "../json.ts";
15
+ import { accent, printHint, printSuccess } from "../logging/color.ts";
15
16
  import { createTrackerClient, resolveBackend, type TrackerClient } from "../tracker/factory.ts";
16
17
  import type { TaskGroup, TaskGroupProgress } from "../types.ts";
17
18
 
@@ -247,7 +248,9 @@ async function getGroupProgress(
247
248
  group.status = "completed";
248
249
  group.completedAt = new Date().toISOString();
249
250
  await saveGroups(projectRoot, groups);
250
- process.stdout.write(`Group "${group.name}" (${group.id}) auto-closed: all issues done\n`);
251
+ process.stdout.write(
252
+ `Group "${group.name}" (${accent(group.id)}) auto-closed: all issues done\n`,
253
+ );
251
254
 
252
255
  // Notify coordinator via mail (best-effort)
253
256
  try {
@@ -286,7 +289,7 @@ function printGroupProgress(progress: TaskGroupProgress): void {
286
289
  const w = process.stdout.write.bind(process.stdout);
287
290
  const { group, total, completed, inProgress, blocked, open } = progress;
288
291
  const status = group.status === "completed" ? "[completed]" : "[active]";
289
- w(`${group.name} (${group.id}) ${status}\n`);
292
+ w(`${group.name} (${accent(group.id)}) ${status}\n`);
290
293
  w(` Issues: ${total} total`);
291
294
  w(` | ${completed} completed`);
292
295
  w(` | ${inProgress} in_progress`);
@@ -325,10 +328,12 @@ export function createGroupCommand(): Command {
325
328
  tracker,
326
329
  );
327
330
  if (opts.json) {
328
- process.stdout.write(`${JSON.stringify(group, null, "\t")}\n`);
331
+ jsonOutput("group create", { ...group });
329
332
  } else {
330
333
  printSuccess("Created group", group.name);
331
- process.stdout.write(` Members: ${group.memberIssueIds.join(", ")}\n`);
334
+ process.stdout.write(
335
+ ` Members: ${group.memberIssueIds.map((id) => accent(id)).join(", ")}\n`,
336
+ );
332
337
  }
333
338
  },
334
339
  );
@@ -356,7 +361,7 @@ export function createGroupCommand(): Command {
356
361
  }
357
362
  const progress = await getGroupProgress(projectRoot, group, groups, tracker);
358
363
  if (json) {
359
- process.stdout.write(`${JSON.stringify(progress, null, "\t")}\n`);
364
+ jsonOutput("group status", { ...progress });
360
365
  } else {
361
366
  printGroupProgress(progress);
362
367
  }
@@ -364,7 +369,7 @@ export function createGroupCommand(): Command {
364
369
  const activeGroups = groups.filter((g) => g.status === "active");
365
370
  if (activeGroups.length === 0) {
366
371
  if (json) {
367
- process.stdout.write("[]\n");
372
+ jsonOutput("group status", { groups: [] });
368
373
  } else {
369
374
  printHint("No active groups");
370
375
  }
@@ -376,7 +381,7 @@ export function createGroupCommand(): Command {
376
381
  progressList.push(progress);
377
382
  }
378
383
  if (json) {
379
- process.stdout.write(`${JSON.stringify(progressList, null, "\t")}\n`);
384
+ jsonOutput("group status", { groups: progressList });
380
385
  } else {
381
386
  for (const progress of progressList) {
382
387
  printGroupProgress(progress);
@@ -413,10 +418,12 @@ export function createGroupCommand(): Command {
413
418
  tracker,
414
419
  );
415
420
  if (opts.json) {
416
- process.stdout.write(`${JSON.stringify(group, null, "\t")}\n`);
421
+ jsonOutput("group add", { ...group });
417
422
  } else {
418
423
  printSuccess("Added to group", group.name);
419
- process.stdout.write(` Members: ${group.memberIssueIds.join(", ")}\n`);
424
+ process.stdout.write(
425
+ ` Members: ${group.memberIssueIds.map((id) => accent(id)).join(", ")}\n`,
426
+ );
420
427
  }
421
428
  },
422
429
  );
@@ -433,10 +440,12 @@ export function createGroupCommand(): Command {
433
440
 
434
441
  const group = await removeFromGroup(projectRoot, groupId, ids);
435
442
  if (opts.json) {
436
- process.stdout.write(`${JSON.stringify(group, null, "\t")}\n`);
443
+ jsonOutput("group remove", { ...group });
437
444
  } else {
438
445
  printSuccess("Removed from group", group.name);
439
- process.stdout.write(` Members: ${group.memberIssueIds.join(", ")}\n`);
446
+ process.stdout.write(
447
+ ` Members: ${group.memberIssueIds.map((id) => accent(id)).join(", ")}\n`,
448
+ );
440
449
  }
441
450
  });
442
451
 
@@ -458,12 +467,12 @@ export function createGroupCommand(): Command {
458
467
  return;
459
468
  }
460
469
  if (opts.json) {
461
- process.stdout.write(`${JSON.stringify(groups, null, "\t")}\n`);
470
+ jsonOutput("group list", { groups });
462
471
  } else {
463
472
  for (const group of groups) {
464
473
  const status = group.status === "completed" ? "[completed]" : "[active]";
465
474
  process.stdout.write(
466
- `${group.id} ${status} "${group.name}" (${group.memberIssueIds.length} issues)\n`,
475
+ `${accent(group.id)} ${status} "${group.name}" (${group.memberIssueIds.length} issues)\n`,
467
476
  );
468
477
  }
469
478
  }
@@ -15,6 +15,7 @@ import { join } from "node:path";
15
15
  import { Command } from "commander";
16
16
  import { loadConfig } from "../config.ts";
17
17
  import { ValidationError } from "../errors.ts";
18
+ import { jsonOutput } from "../json.ts";
18
19
  import { printHint, printSuccess, printWarning } from "../logging/color.ts";
19
20
 
20
21
  interface HookEntry {
@@ -189,7 +190,7 @@ async function statusHooks(json: boolean): Promise<void> {
189
190
  }
190
191
 
191
192
  if (json) {
192
- process.stdout.write(`${JSON.stringify({ sourceExists, installed })}\n`);
193
+ jsonOutput("hooks status", { sourceExists, installed });
193
194
  } else {
194
195
  process.stdout.write(
195
196
  `Hooks source (.overstory/hooks.json): ${sourceExists ? "present" : "missing"}\n`,
@@ -345,3 +345,107 @@ describe("initCommand: canonical branch detection", () => {
345
345
  expect(content).toContain("canonicalBranch: main");
346
346
  });
347
347
  });
348
+
349
+ describe("initCommand: --yes flag", () => {
350
+ let tempDir: string;
351
+ let originalCwd: string;
352
+ let originalWrite: typeof process.stdout.write;
353
+
354
+ beforeEach(async () => {
355
+ tempDir = await createTempGitRepo();
356
+ originalCwd = process.cwd();
357
+ process.chdir(tempDir);
358
+
359
+ // Suppress stdout noise from initCommand
360
+ originalWrite = process.stdout.write;
361
+ process.stdout.write = (() => true) as typeof process.stdout.write;
362
+ });
363
+
364
+ afterEach(async () => {
365
+ process.chdir(originalCwd);
366
+ process.stdout.write = originalWrite;
367
+ await cleanupTempDir(tempDir);
368
+ });
369
+
370
+ test("--yes reinitializes when .overstory/ already exists", async () => {
371
+ // First init
372
+ await initCommand({});
373
+
374
+ // Tamper with config to verify reinit happens
375
+ const configPath = join(tempDir, ".overstory", "config.yaml");
376
+ await Bun.write(configPath, "# tampered\n");
377
+
378
+ // Second init with --yes should reinitialize (not return early)
379
+ await initCommand({ yes: true });
380
+
381
+ // Verify config was regenerated (not the tampered content)
382
+ const content = await Bun.file(configPath).text();
383
+ expect(content).not.toBe("# tampered\n");
384
+ expect(content).toContain("# Overstory configuration");
385
+ });
386
+
387
+ test("--yes works on fresh project (no .overstory/ yet)", async () => {
388
+ await initCommand({ yes: true });
389
+
390
+ const configPath = join(tempDir, ".overstory", "config.yaml");
391
+ const exists = await Bun.file(configPath).exists();
392
+ expect(exists).toBe(true);
393
+
394
+ const content = await Bun.file(configPath).text();
395
+ expect(content).toContain("# Overstory configuration");
396
+ });
397
+
398
+ test("--yes overwrites agent-defs on reinit", async () => {
399
+ // First init
400
+ await initCommand({});
401
+
402
+ // Tamper with an agent def
403
+ const scoutPath = join(tempDir, ".overstory", "agent-defs", "scout.md");
404
+ await Bun.write(scoutPath, "TAMPERED CONTENT");
405
+
406
+ // Reinit with --yes should overwrite
407
+ await initCommand({ yes: true });
408
+
409
+ const restored = await Bun.file(scoutPath).text();
410
+ expect(restored).not.toBe("TAMPERED CONTENT");
411
+ });
412
+ });
413
+
414
+ describe("initCommand: --name flag", () => {
415
+ let tempDir: string;
416
+ let originalCwd: string;
417
+ let originalWrite: typeof process.stdout.write;
418
+
419
+ beforeEach(async () => {
420
+ tempDir = await createTempGitRepo();
421
+ originalCwd = process.cwd();
422
+ process.chdir(tempDir);
423
+
424
+ // Suppress stdout noise from initCommand
425
+ originalWrite = process.stdout.write;
426
+ process.stdout.write = (() => true) as typeof process.stdout.write;
427
+ });
428
+
429
+ afterEach(async () => {
430
+ process.chdir(originalCwd);
431
+ process.stdout.write = originalWrite;
432
+ await cleanupTempDir(tempDir);
433
+ });
434
+
435
+ test("--name overrides auto-detected project name", async () => {
436
+ await initCommand({ name: "custom-project" });
437
+
438
+ const configPath = join(tempDir, ".overstory", "config.yaml");
439
+ const content = await Bun.file(configPath).text();
440
+ expect(content).toContain("name: custom-project");
441
+ });
442
+
443
+ test("--name combined with --yes works for fully non-interactive init", async () => {
444
+ await initCommand({ yes: true, name: "scripted-project" });
445
+
446
+ const configPath = join(tempDir, ".overstory", "config.yaml");
447
+ const content = await Bun.file(configPath).text();
448
+ expect(content).toContain("name: scripted-project");
449
+ expect(content).toContain("# Overstory configuration");
450
+ });
451
+ });
@@ -1,5 +1,5 @@
1
1
  /**
2
- * CLI command: ov init [--force]
2
+ * CLI command: ov init [--force] [--yes|-y] [--name <name>]
3
3
  *
4
4
  * Scaffolds the `.overstory/` directory in the current project with:
5
5
  * - config.yaml (serialized from DEFAULT_CONFIG)
@@ -516,6 +516,8 @@ export async function writeOverstoryReadme(overstoryPath: string): Promise<void>
516
516
  }
517
517
 
518
518
  export interface InitOptions {
519
+ yes?: boolean;
520
+ name?: string;
519
521
  force?: boolean;
520
522
  }
521
523
 
@@ -527,7 +529,7 @@ function printCreated(relativePath: string): void {
527
529
  }
528
530
 
529
531
  /**
530
- * Entry point for `ov init [--force]`.
532
+ * Entry point for `ov init [--force] [--yes|-y] [--name <name>]`.
531
533
  *
532
534
  * Scaffolds the .overstory/ directory structure in the current working directory.
533
535
  *
@@ -535,6 +537,7 @@ function printCreated(relativePath: string): void {
535
537
  */
536
538
  export async function initCommand(opts: InitOptions): Promise<void> {
537
539
  const force = opts.force ?? false;
540
+ const yes = opts.yes ?? false;
538
541
  const projectRoot = process.cwd();
539
542
  const overstoryPath = join(projectRoot, OVERSTORY_DIR);
540
543
 
@@ -554,18 +557,19 @@ export async function initCommand(opts: InitOptions): Promise<void> {
554
557
  // 1. Check if .overstory/ already exists
555
558
  const existingDir = Bun.file(join(overstoryPath, "config.yaml"));
556
559
  if (await existingDir.exists()) {
557
- if (!force) {
560
+ if (!force && !yes) {
558
561
  process.stdout.write(
559
562
  "Warning: .overstory/ already initialized in this project.\n" +
560
- "Use --force to reinitialize.\n",
563
+ "Use --force or --yes to reinitialize.\n",
561
564
  );
562
565
  return;
563
566
  }
564
- process.stdout.write("Reinitializing .overstory/ (--force)\n\n");
567
+ const flag = yes ? "--yes" : "--force";
568
+ process.stdout.write(`Reinitializing .overstory/ (${flag})\n\n`);
565
569
  }
566
570
 
567
571
  // 2. Detect project info
568
- const projectName = await detectProjectName(projectRoot);
572
+ const projectName = opts.name ?? (await detectProjectName(projectRoot));
569
573
  const canonicalBranch = await detectCanonicalBranch(projectRoot);
570
574
 
571
575
  process.stdout.write(`Initializing overstory for "${projectName}"...\n\n`);
@@ -629,7 +633,7 @@ export async function initCommand(opts: InitOptions): Promise<void> {
629
633
  printCreated(`${OVERSTORY_DIR}/README.md`);
630
634
 
631
635
  // 8. Migrate existing SQLite databases on --force reinit
632
- if (force) {
636
+ if (force || yes) {
633
637
  const migrated = await migrateExistingDatabases(overstoryPath);
634
638
  for (const dbName of migrated) {
635
639
  printSuccess("Migrated", dbName);
@@ -546,6 +546,8 @@ describe("inspectCommand", () => {
546
546
  const out = output();
547
547
 
548
548
  const parsed = JSON.parse(out);
549
+ expect(parsed.success).toBe(true);
550
+ expect(parsed.command).toBe("inspect");
549
551
  expect(parsed.session.agentName).toBe("builder-1");
550
552
  expect(parsed.timeSinceLastActivity).toBeGreaterThan(0);
551
553
  });
@@ -10,7 +10,8 @@ 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 { color } from "../logging/color.ts";
13
+ import { jsonOutput } from "../json.ts";
14
+ import { accent, color } from "../logging/color.ts";
14
15
  import { createMetricsStore } from "../metrics/store.ts";
15
16
  import { openSessionStore } from "../sessions/compat.ts";
16
17
  import type { AgentSession, StoredEvent, ToolStats } from "../types.ts";
@@ -252,21 +253,21 @@ export function printInspectData(data: InspectData): void {
252
253
  const w = process.stdout.write.bind(process.stdout);
253
254
  const { session } = data;
254
255
 
255
- w(`\nAgent Inspection: ${session.agentName}\n`);
256
+ w(`\nAgent Inspection: ${accent(session.agentName)}\n`);
256
257
  w(`${"═".repeat(80)}\n\n`);
257
258
 
258
259
  // Agent state and metadata
259
260
  const stateIcon = getStateIcon(session.state);
260
261
  w(`${stateIcon} State: ${session.state}\n`);
261
262
  w(`Last activity: ${formatDuration(data.timeSinceLastActivity)} ago\n`);
262
- w(`Task: ${session.taskId}\n`);
263
+ w(`Task: ${accent(session.taskId)}\n`);
263
264
  w(`Capability: ${session.capability}\n`);
264
- w(`Branch: ${session.branchName}\n`);
265
+ w(`Branch: ${accent(session.branchName)}\n`);
265
266
  if (session.parentAgent) {
266
- w(`Parent: ${session.parentAgent} (depth: ${session.depth})\n`);
267
+ w(`Parent: ${accent(session.parentAgent)} (depth: ${session.depth})\n`);
267
268
  }
268
269
  w(`Started: ${session.startedAt}\n`);
269
- w(`Tmux: ${session.tmuxSession}\n`);
270
+ w(`Tmux: ${accent(session.tmuxSession)}\n`);
270
271
  w("\n");
271
272
 
272
273
  // Current file
@@ -376,7 +377,7 @@ async function executeInspect(agentName: string, opts: InspectOpts): Promise<voi
376
377
  tmuxLines: 30,
377
378
  });
378
379
  if (json) {
379
- process.stdout.write(`${JSON.stringify(data, null, "\t")}\n`);
380
+ jsonOutput("inspect", data as unknown as Record<string, unknown>);
380
381
  } else {
381
382
  printInspectData(data);
382
383
  }
@@ -386,7 +387,7 @@ async function executeInspect(agentName: string, opts: InspectOpts): Promise<voi
386
387
  // Single snapshot
387
388
  const data = await gatherInspectData(root, agentName, { limit, noTmux, tmuxLines: 30 });
388
389
  if (json) {
389
- process.stdout.write(`${JSON.stringify(data, null, "\t")}\n`);
390
+ jsonOutput("inspect", data as unknown as Record<string, unknown>);
390
391
  } else {
391
392
  printInspectData(data);
392
393
  }
@@ -281,13 +281,12 @@ describe("logsCommand", () => {
281
281
  });
282
282
 
283
283
  // Parse JSON output
284
- const parsed: unknown = JSON.parse(output.trim());
285
- expect(Array.isArray(parsed)).toBe(true);
284
+ const parsed = JSON.parse(output.trim()) as { entries: LogEvent[] };
285
+ expect(Array.isArray(parsed.entries)).toBe(true);
286
286
 
287
- const arr = parsed as LogEvent[];
288
- expect(arr).toHaveLength(2);
289
- expect(arr[0]?.event).toBe("tool.start");
290
- expect(arr[1]?.event).toBe("spawn.failed");
287
+ expect(parsed.entries).toHaveLength(2);
288
+ expect(parsed.entries[0]?.event).toBe("tool.start");
289
+ expect(parsed.entries[1]?.event).toBe("spawn.failed");
291
290
  });
292
291
 
293
292
  test("filters by --since with ISO timestamp", async () => {
@@ -13,6 +13,7 @@ import { join } from "node:path";
13
13
  import { Command } from "commander";
14
14
  import { loadConfig } from "../config.ts";
15
15
  import { ValidationError } from "../errors.ts";
16
+ import { jsonOutput } from "../json.ts";
16
17
  import type { ColorFn } from "../logging/color.ts";
17
18
  import { color } from "../logging/color.ts";
18
19
  import type { LogEvent } from "../types.ts";
@@ -500,7 +501,7 @@ async function executeLogs(opts: LogsOpts): Promise<void> {
500
501
  const limited = filtered.slice(-limit);
501
502
 
502
503
  if (json) {
503
- process.stdout.write(`${JSON.stringify(limited)}\n`);
504
+ jsonOutput("logs", { entries: limited });
504
505
  return;
505
506
  }
506
507
 
@@ -10,6 +10,7 @@ import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises";
10
10
  import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
12
  import { createEventStore } from "../events/store.ts";
13
+ import { stripAnsi } from "../logging/color.ts";
13
14
  import { createMailClient } from "../mail/client.ts";
14
15
  import { createMailStore } from "../mail/store.ts";
15
16
  import type { StoredEvent } from "../types.ts";
@@ -851,9 +852,9 @@ describe("mailCommand", () => {
851
852
  ]);
852
853
 
853
854
  expect(output).toContain("Broadcast sent to 3 recipients (@all)");
854
- expect(output).toContain("→ builder-1");
855
- expect(output).toContain("→ builder-2");
856
- expect(output).toContain("→ scout-1");
855
+ expect(stripAnsi(output)).toContain("→ builder-1");
856
+ expect(stripAnsi(output)).toContain("→ builder-2");
857
+ expect(stripAnsi(output)).toContain("→ scout-1");
857
858
  expect(output).not.toContain("orchestrator"); // sender excluded
858
859
 
859
860
  // Verify messages were actually stored
@@ -881,8 +882,8 @@ describe("mailCommand", () => {
881
882
  ]);
882
883
 
883
884
  expect(output).toContain("Broadcast sent to 2 recipients (@builders)");
884
- expect(output).toContain("→ builder-1");
885
- expect(output).toContain("→ builder-2");
885
+ expect(stripAnsi(output)).toContain("→ builder-1");
886
+ expect(stripAnsi(output)).toContain("→ builder-2");
886
887
  expect(output).not.toContain("scout-1");
887
888
 
888
889
  // Verify messages
@@ -909,7 +910,7 @@ describe("mailCommand", () => {
909
910
  ]);
910
911
 
911
912
  expect(output).toContain("Broadcast sent to 1 recipient (@scouts)");
912
- expect(output).toContain("→ scout-1");
913
+ expect(stripAnsi(output)).toContain("→ scout-1");
913
914
 
914
915
  const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
915
916
  const client = createMailClient(store);
@@ -935,8 +936,8 @@ describe("mailCommand", () => {
935
936
  ]);
936
937
 
937
938
  expect(output).toContain("Broadcast sent to 2 recipients (@builder)");
938
- expect(output).toContain("→ builder-1");
939
- expect(output).toContain("→ builder-2");
939
+ expect(stripAnsi(output)).toContain("→ builder-1");
940
+ expect(stripAnsi(output)).toContain("→ builder-2");
940
941
  });
941
942
 
942
943
  test("sender is excluded from broadcast recipients", async () => {
@@ -956,8 +957,8 @@ describe("mailCommand", () => {
956
957
  ]);
957
958
 
958
959
  expect(output).toContain("Broadcast sent to 1 recipient (@builders)");
959
- expect(output).toContain("→ builder-2");
960
- expect(output).not.toContain("builder-1");
960
+ expect(stripAnsi(output)).toContain("→ builder-2");
961
+ expect(stripAnsi(output)).not.toContain("builder-1");
961
962
 
962
963
  const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
963
964
  const client = createMailClient(store);
@@ -11,7 +11,8 @@ import { Command } from "commander";
11
11
  import { resolveProjectRoot } from "../config.ts";
12
12
  import { ValidationError } from "../errors.ts";
13
13
  import { createEventStore } from "../events/store.ts";
14
- import { printHint, printSuccess } from "../logging/color.ts";
14
+ import { jsonOutput } from "../json.ts";
15
+ import { accent, printHint, printSuccess } from "../logging/color.ts";
15
16
  import { isGroupAddress, resolveGroupAddress } from "../mail/broadcast.ts";
16
17
  import { createMailClient } from "../mail/client.ts";
17
18
  import { createMailStore } from "../mail/store.ts";
@@ -36,7 +37,7 @@ function formatMessage(msg: MailMessage): string {
36
37
  const readMarker = msg.read ? " " : "*";
37
38
  const priorityTag = msg.priority !== "normal" ? ` [${msg.priority.toUpperCase()}]` : "";
38
39
  const lines: string[] = [
39
- `${readMarker} ${msg.id} From: ${msg.from} → To: ${msg.to}${priorityTag}`,
40
+ `${readMarker} ${accent(msg.id)} From: ${accent(msg.from)} → To: ${accent(msg.to)}${priorityTag}`,
40
41
  ` Subject: ${msg.subject} (${msg.type})`,
41
42
  ` ${msg.body}`,
42
43
  ];
@@ -369,9 +370,7 @@ async function handleSend(opts: SendOpts, cwd: string): Promise<void> {
369
370
 
370
371
  // Output broadcast summary
371
372
  if (opts.json) {
372
- process.stdout.write(
373
- `${JSON.stringify({ messageIds, recipientCount: recipients.length })}\n`,
374
- );
373
+ jsonOutput("mail send", { messageIds, recipientCount: recipients.length });
375
374
  } else {
376
375
  process.stdout.write(
377
376
  `Broadcast sent to ${recipients.length} recipient${recipients.length === 1 ? "" : "s"} (${to})\n`,
@@ -379,7 +378,7 @@ async function handleSend(opts: SendOpts, cwd: string): Promise<void> {
379
378
  for (let i = 0; i < recipients.length; i++) {
380
379
  const recipient = recipients[i];
381
380
  const msgId = messageIds[i];
382
- process.stdout.write(` → ${recipient} (${msgId})\n`);
381
+ process.stdout.write(` → ${accent(recipient)} (${accent(msgId)})\n`);
383
382
  }
384
383
  }
385
384
 
@@ -428,7 +427,7 @@ async function handleSend(opts: SendOpts, cwd: string): Promise<void> {
428
427
  }
429
428
 
430
429
  if (opts.json) {
431
- process.stdout.write(`${JSON.stringify({ id })}\n`);
430
+ jsonOutput("mail send", { id });
432
431
  } else {
433
432
  printSuccess("Sent message", id);
434
433
  }
@@ -557,7 +556,7 @@ async function handleCheck(opts: CheckOpts, cwd: string): Promise<void> {
557
556
  const messages = client.check(agent);
558
557
 
559
558
  if (json) {
560
- process.stdout.write(`${JSON.stringify(messages)}\n`);
559
+ jsonOutput("mail check", { messages });
561
560
  } else if (messages.length === 0) {
562
561
  printHint("No new messages");
563
562
  } else {
@@ -592,7 +591,7 @@ function handleList(opts: ListOpts, cwd: string): void {
592
591
  const messages = client.list({ from, to, unread });
593
592
 
594
593
  if (json) {
595
- process.stdout.write(`${JSON.stringify(messages)}\n`);
594
+ jsonOutput("mail list", { messages });
596
595
  } else if (messages.length === 0) {
597
596
  printHint("No messages found");
598
597
  } else {
@@ -614,7 +613,7 @@ function handleRead(id: string, cwd: string): void {
614
613
  try {
615
614
  const { alreadyRead } = client.markRead(id);
616
615
  if (alreadyRead) {
617
- printHint(`Message ${id} was already read`);
616
+ printHint(`Message ${accent(id)} was already read`);
618
617
  } else {
619
618
  printSuccess("Marked as read", id);
620
619
  }
@@ -633,7 +632,7 @@ function handleReply(id: string, opts: ReplyOpts, cwd: string): void {
633
632
  const replyId = client.reply(id, body, from);
634
633
 
635
634
  if (opts.json) {
636
- process.stdout.write(`${JSON.stringify({ id: replyId })}\n`);
635
+ jsonOutput("mail reply", { id: replyId });
637
636
  } else {
638
637
  printSuccess("Reply sent", replyId);
639
638
  }
@@ -673,7 +672,7 @@ function handlePurge(opts: PurgeOpts, cwd: string): void {
673
672
  const purged = store.purge({ all, olderThanMs, agent });
674
673
 
675
674
  if (json) {
676
- process.stdout.write(`${JSON.stringify({ purged })}\n`);
675
+ jsonOutput("mail purge", { purged });
677
676
  } else {
678
677
  printSuccess(`Purged ${purged} message(s)`);
679
678
  }