@os-eco/overstory-cli 0.6.11 → 0.7.2

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 (87) hide show
  1. package/README.md +12 -13
  2. package/agents/builder.md +1 -1
  3. package/agents/coordinator.md +12 -11
  4. package/agents/lead.md +25 -24
  5. package/agents/monitor.md +4 -4
  6. package/agents/reviewer.md +1 -1
  7. package/agents/scout.md +5 -5
  8. package/agents/supervisor.md +36 -32
  9. package/package.json +5 -3
  10. package/src/agents/guard-rules.ts +97 -0
  11. package/src/agents/hooks-deployer.ts +7 -90
  12. package/src/agents/overlay.test.ts +30 -7
  13. package/src/agents/overlay.ts +10 -9
  14. package/src/commands/agents.test.ts +5 -0
  15. package/src/commands/clean.test.ts +3 -0
  16. package/src/commands/completions.ts +1 -1
  17. package/src/commands/coordinator.test.ts +1 -0
  18. package/src/commands/coordinator.ts +34 -18
  19. package/src/commands/costs.test.ts +6 -1
  20. package/src/commands/costs.ts +13 -20
  21. package/src/commands/dashboard.ts +38 -138
  22. package/src/commands/doctor.test.ts +1 -1
  23. package/src/commands/doctor.ts +2 -2
  24. package/src/commands/ecosystem.ts +2 -1
  25. package/src/commands/errors.test.ts +4 -5
  26. package/src/commands/errors.ts +4 -62
  27. package/src/commands/feed.test.ts +2 -2
  28. package/src/commands/feed.ts +12 -106
  29. package/src/commands/init.test.ts +1 -2
  30. package/src/commands/init.ts +1 -8
  31. package/src/commands/inspect.test.ts +14 -0
  32. package/src/commands/inspect.ts +10 -44
  33. package/src/commands/log.test.ts +14 -0
  34. package/src/commands/log.ts +39 -0
  35. package/src/commands/logs.ts +7 -63
  36. package/src/commands/mail.test.ts +5 -0
  37. package/src/commands/metrics.test.ts +2 -2
  38. package/src/commands/metrics.ts +3 -17
  39. package/src/commands/monitor.ts +30 -16
  40. package/src/commands/nudge.test.ts +1 -0
  41. package/src/commands/prime.test.ts +2 -0
  42. package/src/commands/prime.ts +6 -2
  43. package/src/commands/replay.test.ts +2 -2
  44. package/src/commands/replay.ts +12 -135
  45. package/src/commands/run.test.ts +1 -0
  46. package/src/commands/run.ts +7 -23
  47. package/src/commands/sling.test.ts +68 -1
  48. package/src/commands/sling.ts +62 -24
  49. package/src/commands/status.test.ts +1 -0
  50. package/src/commands/status.ts +4 -17
  51. package/src/commands/stop.test.ts +1 -0
  52. package/src/commands/supervisor.ts +35 -18
  53. package/src/commands/trace.test.ts +6 -6
  54. package/src/commands/trace.ts +11 -109
  55. package/src/commands/worktree.test.ts +9 -0
  56. package/src/config.ts +39 -0
  57. package/src/doctor/consistency.test.ts +14 -0
  58. package/src/e2e/init-sling-lifecycle.test.ts +3 -5
  59. package/src/index.ts +2 -1
  60. package/src/logging/format.ts +214 -0
  61. package/src/logging/theme.ts +132 -0
  62. package/src/mail/broadcast.test.ts +1 -0
  63. package/src/merge/resolver.ts +23 -4
  64. package/src/metrics/store.test.ts +46 -0
  65. package/src/metrics/store.ts +11 -0
  66. package/src/mulch/client.test.ts +20 -0
  67. package/src/mulch/client.ts +312 -45
  68. package/src/runtimes/claude.test.ts +616 -0
  69. package/src/runtimes/claude.ts +218 -0
  70. package/src/runtimes/pi-guards.test.ts +433 -0
  71. package/src/runtimes/pi-guards.ts +349 -0
  72. package/src/runtimes/pi.test.ts +620 -0
  73. package/src/runtimes/pi.ts +244 -0
  74. package/src/runtimes/registry.test.ts +86 -0
  75. package/src/runtimes/registry.ts +46 -0
  76. package/src/runtimes/types.ts +188 -0
  77. package/src/schema-consistency.test.ts +1 -0
  78. package/src/sessions/compat.ts +1 -0
  79. package/src/sessions/store.test.ts +31 -0
  80. package/src/sessions/store.ts +37 -4
  81. package/src/types.ts +21 -0
  82. package/src/watchdog/daemon.test.ts +7 -4
  83. package/src/watchdog/daemon.ts +1 -1
  84. package/src/watchdog/health.test.ts +1 -0
  85. package/src/watchdog/triage.ts +14 -4
  86. package/src/worktree/tmux.test.ts +28 -13
  87. package/src/worktree/tmux.ts +14 -28
@@ -13,6 +13,7 @@ import { loadConfig } from "../config.ts";
13
13
  import { ValidationError } from "../errors.ts";
14
14
  import { jsonError, jsonOutput } from "../json.ts";
15
15
  import { color } from "../logging/color.ts";
16
+ import { renderHeader, separator } from "../logging/theme.ts";
16
17
  import { createMetricsStore } from "../metrics/store.ts";
17
18
  import { estimateCost, parseTranscriptUsage } from "../metrics/transcript.ts";
18
19
  import { openSessionStore } from "../sessions/compat.ts";
@@ -141,10 +142,8 @@ function groupByCapability(sessions: SessionMetrics[]): CapabilityGroup[] {
141
142
  /** Print the standard per-agent cost summary table. */
142
143
  function printCostSummary(sessions: SessionMetrics[]): void {
143
144
  const w = process.stdout.write.bind(process.stdout);
144
- const separator = "\u2500".repeat(70);
145
145
 
146
- w(`${color.bold("Cost Summary")}\n`);
147
- w(`${"=".repeat(70)}\n`);
146
+ w(`${renderHeader("Cost Summary")}\n`);
148
147
 
149
148
  if (sessions.length === 0) {
150
149
  w(`${color.dim("No session data found.")}\n`);
@@ -156,7 +155,7 @@ function printCostSummary(sessions: SessionMetrics[]): void {
156
155
  `${padLeft("Input", 10)}${padLeft("Output", 10)}` +
157
156
  `${padLeft("Cache", 10)}${padLeft("Cost", 10)}\n`,
158
157
  );
159
- w(`${color.dim(separator)}\n`);
158
+ w(`${color.dim(separator())}\n`);
160
159
 
161
160
  for (const s of sessions) {
162
161
  const cacheTotal = s.cacheReadTokens + s.cacheCreationTokens;
@@ -170,7 +169,7 @@ function printCostSummary(sessions: SessionMetrics[]): void {
170
169
  }
171
170
 
172
171
  const totals = computeTotals(sessions);
173
- w(`${color.dim(separator)}\n`);
172
+ w(`${color.dim(separator())}\n`);
174
173
  w(
175
174
  `${color.green(
176
175
  color.bold(
@@ -187,10 +186,8 @@ function printCostSummary(sessions: SessionMetrics[]): void {
187
186
  /** Print the capability-grouped cost table. */
188
187
  function printByCapability(sessions: SessionMetrics[]): void {
189
188
  const w = process.stdout.write.bind(process.stdout);
190
- const separator = "\u2500".repeat(70);
191
189
 
192
- w(`${color.bold("Cost by Capability")}\n`);
193
- w(`${"=".repeat(70)}\n`);
190
+ w(`${renderHeader("Cost by Capability")}\n`);
194
191
 
195
192
  if (sessions.length === 0) {
196
193
  w(`${color.dim("No session data found.")}\n`);
@@ -202,7 +199,7 @@ function printByCapability(sessions: SessionMetrics[]): void {
202
199
  `${padLeft("Input", 10)}${padLeft("Output", 10)}` +
203
200
  `${padLeft("Cache", 10)}${padLeft("Cost", 10)}\n`,
204
201
  );
205
- w(`${color.dim(separator)}\n`);
202
+ w(`${color.dim(separator())}\n`);
206
203
 
207
204
  const groups = groupByCapability(sessions);
208
205
 
@@ -218,7 +215,7 @@ function printByCapability(sessions: SessionMetrics[]): void {
218
215
  }
219
216
 
220
217
  const totals = computeTotals(sessions);
221
- w(`${color.dim(separator)}\n`);
218
+ w(`${color.dim(separator())}\n`);
222
219
  w(
223
220
  `${color.green(
224
221
  color.bold(
@@ -299,17 +296,15 @@ async function executeCosts(opts: CostsOpts): Promise<void> {
299
296
  });
300
297
  } else {
301
298
  const w = process.stdout.write.bind(process.stdout);
302
- const separator = "\u2500".repeat(70);
303
299
 
304
- w(`${color.bold("Orchestrator Session Cost")}\n`);
305
- w(`${"=".repeat(70)}\n`);
300
+ w(`${renderHeader("Orchestrator Session Cost")}\n`);
306
301
  w(`${padRight("Model:", 12)}${usage.modelUsed ?? "unknown"}\n`);
307
302
  w(`${padRight("Transcript:", 12)}${transcriptPath}\n`);
308
- w(`${color.dim(separator)}\n`);
303
+ w(`${color.dim(separator())}\n`);
309
304
  w(`${padRight("Input tokens:", 22)}${padLeft(formatNumber(usage.inputTokens), 12)}\n`);
310
305
  w(`${padRight("Output tokens:", 22)}${padLeft(formatNumber(usage.outputTokens), 12)}\n`);
311
306
  w(`${padRight("Cache tokens:", 22)}${padLeft(formatNumber(cacheTotal), 12)}\n`);
312
- w(`${color.dim(separator)}\n`);
307
+ w(`${color.dim(separator())}\n`);
313
308
  w(
314
309
  `${color.green(color.bold(padRight("Estimated cost:", 22) + padLeft(formatCost(cost), 12)))}\n`,
315
310
  );
@@ -453,16 +448,14 @@ async function executeCosts(opts: CostsOpts): Promise<void> {
453
448
  });
454
449
  } else {
455
450
  const w = process.stdout.write.bind(process.stdout);
456
- const separator = "\u2500".repeat(70);
457
451
 
458
- w(`${color.bold(`Live Token Usage (${agentData.length} active agents)`)}\n`);
459
- w(`${"=".repeat(70)}\n`);
452
+ w(`${renderHeader(`Live Token Usage (${agentData.length} active agents)`)}\n`);
460
453
  w(
461
454
  `${padRight("Agent", 19)}${padRight("Capability", 12)}` +
462
455
  `${padLeft("Input", 10)}${padLeft("Output", 10)}` +
463
456
  `${padLeft("Cache", 10)}${padLeft("Cost", 10)}\n`,
464
457
  );
465
- w(`${color.dim(separator)}\n`);
458
+ w(`${color.dim(separator())}\n`);
466
459
 
467
460
  for (const agent of agentData) {
468
461
  const cacheTotal = agent.cacheReadTokens + agent.cacheCreationTokens;
@@ -475,7 +468,7 @@ async function executeCosts(opts: CostsOpts): Promise<void> {
475
468
  );
476
469
  }
477
470
 
478
- w(`${color.dim(separator)}\n`);
471
+ w(`${color.dim(separator())}\n`);
479
472
  w(
480
473
  `${color.green(
481
474
  color.bold(
@@ -14,8 +14,14 @@ import { join, resolve } from "node:path";
14
14
  import { Command } from "commander";
15
15
  import { loadConfig } from "../config.ts";
16
16
  import { ValidationError } from "../errors.ts";
17
- import type { ColorFn } from "../logging/color.ts";
18
- import { accent, color, noColor, visibleLength } from "../logging/color.ts";
17
+ import { accent, brand, color, visibleLength } from "../logging/color.ts";
18
+ import {
19
+ formatDuration,
20
+ formatRelativeTime,
21
+ mergeStatusColor,
22
+ priorityColor,
23
+ } from "../logging/format.ts";
24
+ import { stateColor, stateIcon } from "../logging/theme.ts";
19
25
  import { createMailStore, type MailStore } from "../mail/store.ts";
20
26
  import { createMergeQueue, type MergeQueue } from "../merge/queue.ts";
21
27
  import { createMetricsStore, type MetricsStore } from "../metrics/store.ts";
@@ -54,38 +60,6 @@ const BOX = {
54
60
  cross: "┼",
55
61
  };
56
62
 
57
- /**
58
- * Format a duration in ms to a human-readable string.
59
- */
60
- function formatDuration(ms: number): string {
61
- const seconds = Math.floor(ms / 1000);
62
- if (seconds < 60) return `${seconds}s`;
63
- const minutes = Math.floor(seconds / 60);
64
- const remainSec = seconds % 60;
65
- if (minutes < 60) return `${minutes}m ${remainSec}s`;
66
- const hours = Math.floor(minutes / 60);
67
- const remainMin = minutes % 60;
68
- return `${hours}h ${remainMin}m`;
69
- }
70
-
71
- /**
72
- * Format a timestamp to "time ago" format.
73
- */
74
- function timeAgo(timestamp: string): string {
75
- const now = Date.now();
76
- const then = new Date(timestamp).getTime();
77
- const diffMs = now - then;
78
- const diffSec = Math.floor(diffMs / 1000);
79
-
80
- if (diffSec < 60) return `${diffSec}s ago`;
81
- const diffMin = Math.floor(diffSec / 60);
82
- if (diffMin < 60) return `${diffMin}m ago`;
83
- const diffHr = Math.floor(diffMin / 60);
84
- if (diffHr < 24) return `${diffHr}h ago`;
85
- const diffDay = Math.floor(diffHr / 24);
86
- return `${diffDay}d ago`;
87
- }
88
-
89
63
  /**
90
64
  * Truncate a string to fit within maxLen characters, adding ellipsis if needed.
91
65
  */
@@ -297,7 +271,7 @@ async function loadDashboardData(
297
271
  let recentMetricsCount = 0;
298
272
  if (stores.metricsStore) {
299
273
  try {
300
- recentMetricsCount = stores.metricsStore.getRecentSessions(100).length;
274
+ recentMetricsCount = stores.metricsStore.countSessions();
301
275
  } catch {
302
276
  // best effort
303
277
  }
@@ -357,32 +331,34 @@ async function loadDashboardData(
357
331
  const byCapability: Record<string, number> = {};
358
332
  if (stores.metricsStore) {
359
333
  try {
360
- const sessions = stores.metricsStore.getRecentSessions(100);
361
-
362
- const filtered =
363
- runId && filteredAgents.length > 0
364
- ? (() => {
365
- const agentNames = new Set(filteredAgents.map((a) => a.agentName));
366
- return sessions.filter((s) => agentNames.has(s.agentName));
367
- })()
368
- : sessions;
334
+ if (runId && filteredAgents.length > 0) {
335
+ // Run-scoped: filter sessions by agent names, compute all values from the filtered set
336
+ const agentNames = new Set(filteredAgents.map((a) => a.agentName));
337
+ const sessions = stores.metricsStore.getRecentSessions(100);
338
+ const filtered = sessions.filter((s) => agentNames.has(s.agentName));
369
339
 
370
- totalSessions = filtered.length;
340
+ totalSessions = filtered.length;
371
341
 
372
- // When run-scoped, compute avg duration from filtered sessions manually
373
- if (runId && filteredAgents.length > 0) {
374
342
  const completedSessions = filtered.filter((s) => s.completedAt !== null);
375
343
  if (completedSessions.length > 0) {
376
344
  avgDuration =
377
345
  completedSessions.reduce((sum, s) => sum + s.durationMs, 0) / completedSessions.length;
378
346
  }
347
+
348
+ for (const session of filtered) {
349
+ const cap = session.capability;
350
+ byCapability[cap] = (byCapability[cap] ?? 0) + 1;
351
+ }
379
352
  } else {
353
+ // All-runs view: use countSessions() to get accurate total (not capped at 100)
354
+ totalSessions = stores.metricsStore.countSessions();
380
355
  avgDuration = stores.metricsStore.getAverageDuration();
381
- }
382
356
 
383
- for (const session of filtered) {
384
- const cap = session.capability;
385
- byCapability[cap] = (byCapability[cap] ?? 0) + 1;
357
+ const sessions = stores.metricsStore.getRecentSessions(100);
358
+ for (const session of sessions) {
359
+ const cap = session.capability;
360
+ byCapability[cap] = (byCapability[cap] ?? 0) + 1;
361
+ }
386
362
  }
387
363
  } catch {
388
364
  // best effort
@@ -402,7 +378,7 @@ async function loadDashboardData(
402
378
  * Render the header bar (line 1).
403
379
  */
404
380
  function renderHeader(width: number, interval: number, currentRunId?: string | null): string {
405
- const left = color.bold(`ov dashboard v${PKG_VERSION}`);
381
+ const left = brand.bold(`ov dashboard v${PKG_VERSION}`);
406
382
  const now = new Date().toLocaleTimeString();
407
383
  const scope = currentRunId ? ` [run: ${accent(currentRunId.slice(0, 8))}]` : " [all runs]";
408
384
  const right = `${now}${scope} | refresh: ${interval}ms`;
@@ -412,46 +388,6 @@ function renderHeader(width: number, interval: number, currentRunId?: string | n
412
388
  return `${line}\n${separator}`;
413
389
  }
414
390
 
415
- /**
416
- * Get color function for agent state.
417
- */
418
- function getStateColor(state: string): ColorFn {
419
- switch (state) {
420
- case "working":
421
- return color.green;
422
- case "booting":
423
- return color.yellow;
424
- case "stalled":
425
- return color.red;
426
- case "zombie":
427
- return color.dim;
428
- case "completed":
429
- return color.cyan;
430
- default:
431
- return noColor;
432
- }
433
- }
434
-
435
- /**
436
- * Get status icon for agent state.
437
- */
438
- function getStateIcon(state: string): string {
439
- switch (state) {
440
- case "working":
441
- return ">";
442
- case "booting":
443
- return "-";
444
- case "stalled":
445
- return "!";
446
- case "zombie":
447
- return "x";
448
- case "completed":
449
- return "x";
450
- default:
451
- return "?";
452
- }
453
- }
454
-
455
391
  /**
456
392
  * Render the agent panel (top ~40% of screen).
457
393
  */
@@ -465,7 +401,7 @@ function renderAgentPanel(
465
401
  let output = "";
466
402
 
467
403
  // Panel header
468
- const headerLine = `${BOX.vertical} ${color.bold("Agents")} (${data.status.agents.length})`;
404
+ const headerLine = `${BOX.vertical} ${brand.bold("Agents")} (${data.status.agents.length})`;
469
405
  const headerPadding = " ".repeat(Math.max(0, width - visibleLength(headerLine) - 1));
470
406
  output += `${CURSOR.cursorTo(startRow, 1)}${headerLine}${headerPadding}${BOX.vertical}\n`;
471
407
 
@@ -495,8 +431,8 @@ function renderAgentPanel(
495
431
  const agent = visibleAgents[i];
496
432
  if (!agent) continue;
497
433
 
498
- const icon = getStateIcon(agent.state);
499
- const stateColor = getStateColor(agent.state);
434
+ const icon = stateIcon(agent.state);
435
+ const stateColorFn = stateColor(agent.state);
500
436
  const name = accent(pad(truncate(agent.agentName, 15), 15));
501
437
  const capability = pad(truncate(agent.capability, 12), 12);
502
438
  const state = pad(agent.state, 10);
@@ -510,7 +446,7 @@ function renderAgentPanel(
510
446
  const tmuxAlive = data.status.tmuxSessions.some((s) => s.name === agent.tmuxSession);
511
447
  const tmuxDot = tmuxAlive ? color.green(">") : color.red("x");
512
448
 
513
- const line = `${BOX.vertical} ${stateColor(icon)} ${name} ${capability} ${stateColor(state)} ${taskId} ${durationPadded} ${tmuxDot} ${BOX.vertical}`;
449
+ const line = `${BOX.vertical} ${stateColorFn(icon)} ${name} ${capability} ${stateColorFn(state)} ${taskId} ${durationPadded} ${tmuxDot} ${BOX.vertical}`;
514
450
  output += `${CURSOR.cursorTo(startRow + 3 + i, 1)}${line}\n`;
515
451
  }
516
452
 
@@ -527,24 +463,6 @@ function renderAgentPanel(
527
463
  return output;
528
464
  }
529
465
 
530
- /**
531
- * Get color function for mail priority.
532
- */
533
- function getPriorityColor(priority: string): ColorFn {
534
- switch (priority) {
535
- case "urgent":
536
- return color.red;
537
- case "high":
538
- return color.yellow;
539
- case "normal":
540
- return noColor;
541
- case "low":
542
- return color.dim;
543
- default:
544
- return noColor;
545
- }
546
- }
547
-
548
466
  /**
549
467
  * Render the mail panel (middle-left ~30% height, ~60% width).
550
468
  */
@@ -559,7 +477,7 @@ function renderMailPanel(
559
477
  let output = "";
560
478
 
561
479
  const unreadCount = data.status.unreadMailCount;
562
- const headerLine = `${BOX.vertical} ${color.bold("Mail")} (${unreadCount} unread)`;
480
+ const headerLine = `${BOX.vertical} ${brand.bold("Mail")} (${unreadCount} unread)`;
563
481
  const headerPadding = " ".repeat(Math.max(0, panelWidth - visibleLength(headerLine) - 1));
564
482
  output += `${CURSOR.cursorTo(startRow, 1)}${headerLine}${headerPadding}${BOX.vertical}\n`;
565
483
 
@@ -573,12 +491,12 @@ function renderMailPanel(
573
491
  const msg = messages[i];
574
492
  if (!msg) continue;
575
493
 
576
- const priorityColorFn = getPriorityColor(msg.priority);
494
+ const priorityColorFn = priorityColor(msg.priority);
577
495
  const priority = msg.priority === "normal" ? "" : `[${msg.priority}] `;
578
496
  const from = accent(truncate(msg.from, 12));
579
497
  const to = accent(truncate(msg.to, 12));
580
498
  const subject = truncate(msg.subject, panelWidth - 40);
581
- const time = timeAgo(msg.createdAt);
499
+ const time = formatRelativeTime(msg.createdAt);
582
500
 
583
501
  const coloredPriority = priority ? priorityColorFn(priority) : "";
584
502
  const line = `${BOX.vertical} ${coloredPriority}${from} → ${to}: ${subject} (${time})`;
@@ -595,24 +513,6 @@ function renderMailPanel(
595
513
  return output;
596
514
  }
597
515
 
598
- /**
599
- * Get color function for merge queue status.
600
- */
601
- function getMergeStatusColor(status: string): ColorFn {
602
- switch (status) {
603
- case "pending":
604
- return color.yellow;
605
- case "merging":
606
- return color.blue;
607
- case "conflict":
608
- return color.red;
609
- case "merged":
610
- return color.green;
611
- default:
612
- return noColor;
613
- }
614
- }
615
-
616
516
  /**
617
517
  * Render the merge queue panel (middle-right ~30% height, ~40% width).
618
518
  */
@@ -627,7 +527,7 @@ function renderMergeQueuePanel(
627
527
  const panelWidth = width - startCol + 1;
628
528
  let output = "";
629
529
 
630
- const headerLine = `${BOX.vertical} ${color.bold("Merge Queue")} (${data.mergeQueue.length})`;
530
+ const headerLine = `${BOX.vertical} ${brand.bold("Merge Queue")} (${data.mergeQueue.length})`;
631
531
  const headerPadding = " ".repeat(Math.max(0, panelWidth - visibleLength(headerLine) - 1));
632
532
  output += `${CURSOR.cursorTo(startRow, startCol)}${headerLine}${headerPadding}${BOX.vertical}\n`;
633
533
 
@@ -641,7 +541,7 @@ function renderMergeQueuePanel(
641
541
  const entry = entries[i];
642
542
  if (!entry) continue;
643
543
 
644
- const statusColorFn = getMergeStatusColor(entry.status);
544
+ const statusColorFn = mergeStatusColor(entry.status);
645
545
  const status = pad(entry.status, 10);
646
546
  const agent = accent(truncate(entry.agentName, 15));
647
547
  const branch = truncate(entry.branchName, panelWidth - 30);
@@ -674,7 +574,7 @@ function renderMetricsPanel(
674
574
  const separator = horizontalLine(width, BOX.tee, BOX.horizontal, BOX.teeRight);
675
575
  output += `${CURSOR.cursorTo(startRow, 1)}${separator}\n`;
676
576
 
677
- const headerLine = `${BOX.vertical} ${color.bold("Metrics")}`;
577
+ const headerLine = `${BOX.vertical} ${brand.bold("Metrics")}`;
678
578
  const headerPadding = " ".repeat(Math.max(0, width - visibleLength(headerLine) - 1));
679
579
  output += `${CURSOR.cursorTo(startRow + 1, 1)}${headerLine}${headerPadding}${BOX.vertical}\n`;
680
580
 
@@ -127,7 +127,7 @@ describe("doctorCommand", () => {
127
127
  const out = output();
128
128
 
129
129
  expect(out).toContain("Overstory Doctor");
130
- expect(out).toContain("================");
130
+ expect(out).toContain("────────────────");
131
131
  });
132
132
 
133
133
  test("shows summary line with zero counts", async () => {
@@ -21,6 +21,7 @@ import { checkVersion } from "../doctor/version.ts";
21
21
  import { ValidationError } from "../errors.ts";
22
22
  import { jsonOutput } from "../json.ts";
23
23
  import { color } from "../logging/color.ts";
24
+ import { renderHeader } from "../logging/theme.ts";
24
25
 
25
26
  /** Registry of all check modules in execution order. */
26
27
  const ALL_CHECKS: Array<{ category: DoctorCategory; fn: DoctorCheckFn }> = [
@@ -63,8 +64,7 @@ function printHumanReadable(
63
64
  ): void {
64
65
  const w = process.stdout.write.bind(process.stdout);
65
66
 
66
- w(`${color.bold("Overstory Doctor")}\n`);
67
- w("================\n\n");
67
+ w(`${renderHeader("Overstory Doctor")}\n\n`);
68
68
 
69
69
  // Group checks by category
70
70
  const byCategory = new Map<DoctorCategory, DoctorCheck[]>();
@@ -8,6 +8,7 @@
8
8
  import { Command } from "commander";
9
9
  import { jsonError, jsonOutput } from "../json.ts";
10
10
  import { accent, brand, color, muted } from "../logging/color.ts";
11
+ import { thickSeparator } from "../logging/theme.ts";
11
12
 
12
13
  const TOOLS = [
13
14
  { name: "overstory", cli: "ov", npm: "@os-eco/overstory-cli" },
@@ -167,7 +168,7 @@ function formatDoctorLine(summary: DoctorSummary): string {
167
168
 
168
169
  function printHumanOutput(results: ToolResult[]): void {
169
170
  process.stdout.write(`${brand.bold("os-eco Ecosystem")}\n`);
170
- process.stdout.write(`${"═".repeat(60)}\n`);
171
+ process.stdout.write(`${thickSeparator()}\n`);
171
172
  process.stdout.write("\n");
172
173
 
173
174
  for (const tool of results) {
@@ -226,7 +226,7 @@ describe("errorsCommand", () => {
226
226
  const out = output();
227
227
 
228
228
  expect(out).toContain("Errors");
229
- expect(out).toContain("=".repeat(70));
229
+ expect(out).toContain("".repeat(70));
230
230
  });
231
231
 
232
232
  test("shows error count", async () => {
@@ -344,8 +344,7 @@ describe("errorsCommand", () => {
344
344
  await errorsCommand([]);
345
345
  const out = output();
346
346
 
347
- expect(out).toContain("reason=disk full");
348
- expect(out).toContain("code=500");
347
+ expect(out).toContain('data={"reason":"disk full","code":500}');
349
348
  });
350
349
 
351
350
  test("long data values are truncated", async () => {
@@ -364,8 +363,8 @@ describe("errorsCommand", () => {
364
363
 
365
364
  // The full 200-char value should not appear
366
365
  expect(out).not.toContain(longValue);
367
- // But a truncated version with "..." should
368
- expect(out).toContain("...");
366
+ // But a truncated version with "" should
367
+ expect(out).toContain("");
369
368
  });
370
369
 
371
370
  test("non-JSON data is shown raw if short", async () => {
@@ -13,67 +13,10 @@ import { ValidationError } from "../errors.ts";
13
13
  import { createEventStore } from "../events/store.ts";
14
14
  import { jsonOutput } from "../json.ts";
15
15
  import { accent, color } from "../logging/color.ts";
16
+ import { buildEventDetail, formatAbsoluteTime, formatDate } from "../logging/format.ts";
17
+ import { separator } from "../logging/theme.ts";
16
18
  import type { StoredEvent } from "../types.ts";
17
19
 
18
- /**
19
- * Format an absolute time from an ISO timestamp.
20
- * Returns "HH:MM:SS" portion.
21
- */
22
- function formatAbsoluteTime(timestamp: string): string {
23
- const match = /T(\d{2}:\d{2}:\d{2})/.exec(timestamp);
24
- if (match?.[1]) {
25
- return match[1];
26
- }
27
- return timestamp;
28
- }
29
-
30
- /**
31
- * Format the date portion of an ISO timestamp.
32
- * Returns "YYYY-MM-DD".
33
- */
34
- function formatDate(timestamp: string): string {
35
- const match = /^(\d{4}-\d{2}-\d{2})/.exec(timestamp);
36
- if (match?.[1]) {
37
- return match[1];
38
- }
39
- return "";
40
- }
41
-
42
- /**
43
- * Build a detail string for an error event based on its fields.
44
- */
45
- function buildErrorDetail(event: StoredEvent): string {
46
- const parts: string[] = [];
47
-
48
- if (event.toolName) {
49
- parts.push(`tool=${event.toolName}`);
50
- }
51
-
52
- if (event.data) {
53
- try {
54
- const parsed: unknown = JSON.parse(event.data);
55
- if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
56
- const data = parsed as Record<string, unknown>;
57
- for (const [key, value] of Object.entries(data)) {
58
- if (value !== null && value !== undefined) {
59
- const strValue = typeof value === "string" ? value : JSON.stringify(value);
60
- // Truncate long values
61
- const truncated = strValue.length > 80 ? `${strValue.slice(0, 77)}...` : strValue;
62
- parts.push(`${key}=${truncated}`);
63
- }
64
- }
65
- }
66
- } catch {
67
- // data is not valid JSON; show it raw if short enough
68
- if (event.data.length <= 80) {
69
- parts.push(event.data);
70
- }
71
- }
72
- }
73
-
74
- return parts.join(" ");
75
- }
76
-
77
20
  /**
78
21
  * Group errors by agent name, preserving insertion order.
79
22
  */
@@ -96,8 +39,7 @@ function groupByAgent(events: StoredEvent[]): Map<string, StoredEvent[]> {
96
39
  function printErrors(events: StoredEvent[]): void {
97
40
  const w = process.stdout.write.bind(process.stdout);
98
41
 
99
- w(`${color.bold(color.red("Errors"))}\n`);
100
- w(`${"=".repeat(70)}\n`);
42
+ w(`${color.bold(color.red("Errors"))}\n${separator()}\n`);
101
43
 
102
44
  if (events.length === 0) {
103
45
  w(`${color.dim("No errors found.")}\n`);
@@ -124,7 +66,7 @@ function printErrors(events: StoredEvent[]): void {
124
66
  const time = formatAbsoluteTime(event.createdAt);
125
67
  const timestamp = date ? `${date} ${time}` : time;
126
68
 
127
- const detail = buildErrorDetail(event);
69
+ const detail = buildEventDetail(event);
128
70
  const detailSuffix = detail ? ` ${color.dim(detail)}` : "";
129
71
 
130
72
  w(` ${color.dim(timestamp)} ${color.red(color.bold("ERROR"))}${detailSuffix}\n`);
@@ -561,8 +561,8 @@ describe("feedCommand", () => {
561
561
 
562
562
  // The full 200-char value should not appear
563
563
  expect(out).not.toContain(longValue);
564
- // But a truncated version with "..." should
565
- expect(out).toContain("...");
564
+ // But a truncated version with "" should
565
+ expect(out).toContain("");
566
566
  });
567
567
 
568
568
  test("agent color assignment is stable", async () => {