@os-eco/overstory-cli 0.6.10 → 0.7.0

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 (66) hide show
  1. package/README.md +156 -274
  2. package/agents/lead.md +29 -19
  3. package/package.json +5 -3
  4. package/src/agents/hooks-deployer.test.ts +53 -0
  5. package/src/agents/hooks-deployer.ts +4 -4
  6. package/src/agents/manifest.test.ts +1 -0
  7. package/src/agents/overlay.test.ts +102 -0
  8. package/src/agents/overlay.ts +45 -6
  9. package/src/commands/completions.ts +3 -3
  10. package/src/commands/coordinator.ts +25 -13
  11. package/src/commands/costs.test.ts +1 -1
  12. package/src/commands/costs.ts +13 -20
  13. package/src/commands/dashboard.ts +38 -138
  14. package/src/commands/doctor.test.ts +1 -1
  15. package/src/commands/doctor.ts +2 -2
  16. package/src/commands/ecosystem.ts +2 -1
  17. package/src/commands/errors.test.ts +4 -5
  18. package/src/commands/errors.ts +4 -62
  19. package/src/commands/feed.test.ts +2 -2
  20. package/src/commands/feed.ts +12 -106
  21. package/src/commands/group.ts +4 -4
  22. package/src/commands/inspect.ts +10 -44
  23. package/src/commands/logs.ts +7 -63
  24. package/src/commands/mail.test.ts +63 -1
  25. package/src/commands/mail.ts +18 -1
  26. package/src/commands/merge.ts +2 -2
  27. package/src/commands/metrics.test.ts +2 -2
  28. package/src/commands/metrics.ts +3 -17
  29. package/src/commands/monitor.ts +19 -9
  30. package/src/commands/replay.test.ts +2 -2
  31. package/src/commands/replay.ts +12 -135
  32. package/src/commands/run.ts +7 -23
  33. package/src/commands/sling.test.ts +227 -27
  34. package/src/commands/sling.ts +120 -21
  35. package/src/commands/status.ts +5 -18
  36. package/src/commands/supervisor.ts +22 -12
  37. package/src/commands/trace.test.ts +5 -6
  38. package/src/commands/trace.ts +13 -111
  39. package/src/config.test.ts +22 -0
  40. package/src/config.ts +22 -0
  41. package/src/doctor/agents.test.ts +1 -0
  42. package/src/doctor/config-check.test.ts +1 -0
  43. package/src/doctor/consistency.test.ts +1 -0
  44. package/src/doctor/databases.test.ts +1 -0
  45. package/src/doctor/dependencies.test.ts +1 -0
  46. package/src/doctor/ecosystem.test.ts +1 -0
  47. package/src/doctor/logs.test.ts +1 -0
  48. package/src/doctor/merge-queue.test.ts +1 -0
  49. package/src/doctor/structure.test.ts +1 -0
  50. package/src/doctor/version.test.ts +1 -0
  51. package/src/index.ts +8 -4
  52. package/src/logging/format.ts +214 -0
  53. package/src/logging/theme.ts +132 -0
  54. package/src/metrics/store.test.ts +46 -0
  55. package/src/metrics/store.ts +11 -0
  56. package/src/mulch/client.test.ts +20 -0
  57. package/src/mulch/client.ts +312 -45
  58. package/src/runtimes/claude.test.ts +616 -0
  59. package/src/runtimes/claude.ts +218 -0
  60. package/src/runtimes/registry.test.ts +53 -0
  61. package/src/runtimes/registry.ts +33 -0
  62. package/src/runtimes/types.ts +125 -0
  63. package/src/types.ts +15 -0
  64. package/src/worktree/tmux.test.ts +28 -13
  65. package/src/worktree/tmux.ts +14 -28
  66. package/templates/overlay.md.tmpl +3 -1
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Shared formatting utilities for overstory CLI output.
3
+ *
4
+ * Duration, timestamp, event detail, agent color mapping, and status color
5
+ * helpers used across all observability commands.
6
+ */
7
+
8
+ import type { StoredEvent } from "../types.ts";
9
+ import type { ColorFn } from "./color.ts";
10
+ import { color, noColor } from "./color.ts";
11
+ import { AGENT_COLORS } from "./theme.ts";
12
+
13
+ // === Duration ===
14
+
15
+ /**
16
+ * Formats a duration in milliseconds to a human-readable string.
17
+ * Examples: "0s", "12s", "3m 45s", "2h 15m"
18
+ */
19
+ export function formatDuration(ms: number): string {
20
+ if (ms === 0) return "0s";
21
+ const totalSeconds = Math.floor(ms / 1000);
22
+ const hours = Math.floor(totalSeconds / 3600);
23
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
24
+ const seconds = totalSeconds % 60;
25
+ if (hours > 0) {
26
+ return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
27
+ }
28
+ if (minutes > 0) {
29
+ return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
30
+ }
31
+ return `${seconds}s`;
32
+ }
33
+
34
+ // === Timestamps ===
35
+
36
+ /**
37
+ * Extracts "HH:MM:SS" from an ISO 8601 timestamp string.
38
+ * Returns the raw substring if the timestamp is well-formed.
39
+ */
40
+ export function formatAbsoluteTime(timestamp: string): string {
41
+ // ISO format: "YYYY-MM-DDTHH:MM:SS..." or "YYYY-MM-DD HH:MM:SS..."
42
+ const match = timestamp.match(/T?(\d{2}:\d{2}:\d{2})/);
43
+ return match?.[1] ?? timestamp;
44
+ }
45
+
46
+ /**
47
+ * Extracts "YYYY-MM-DD" from an ISO 8601 timestamp string.
48
+ */
49
+ export function formatDate(timestamp: string): string {
50
+ const match = timestamp.match(/^(\d{4}-\d{2}-\d{2})/);
51
+ return match?.[1] ?? timestamp;
52
+ }
53
+
54
+ /**
55
+ * Formats a timestamp as a human-readable relative time string.
56
+ * Examples: "12s ago", "3m ago", "2h ago", "5d ago"
57
+ */
58
+ export function formatRelativeTime(timestamp: string): string {
59
+ const now = Date.now();
60
+ const then = new Date(timestamp).getTime();
61
+ const diffMs = now - then;
62
+ if (diffMs < 0) return "just now";
63
+ const diffSeconds = Math.floor(diffMs / 1000);
64
+ const diffMinutes = Math.floor(diffSeconds / 60);
65
+ const diffHours = Math.floor(diffMinutes / 60);
66
+ const diffDays = Math.floor(diffHours / 24);
67
+ if (diffDays > 0) return `${diffDays}d ago`;
68
+ if (diffHours > 0) return `${diffHours}h ago`;
69
+ if (diffMinutes > 0) return `${diffMinutes}m ago`;
70
+ return `${diffSeconds}s ago`;
71
+ }
72
+
73
+ // === Event Details ===
74
+
75
+ /**
76
+ * Builds a compact "key=value" detail string from a StoredEvent's fields.
77
+ * Values are truncated to maxValueLen (default 80) characters.
78
+ */
79
+ export function buildEventDetail(event: StoredEvent, maxValueLen = 80): string {
80
+ const parts: string[] = [];
81
+
82
+ if (event.toolName) {
83
+ parts.push(`tool=${event.toolName}`);
84
+ }
85
+ if (event.toolArgs) {
86
+ const truncated =
87
+ event.toolArgs.length > maxValueLen
88
+ ? `${event.toolArgs.slice(0, maxValueLen)}…`
89
+ : event.toolArgs;
90
+ parts.push(`args=${truncated}`);
91
+ }
92
+ if (event.toolDurationMs !== null && event.toolDurationMs !== undefined) {
93
+ parts.push(`dur=${event.toolDurationMs}ms`);
94
+ }
95
+ if (event.data) {
96
+ const truncated =
97
+ event.data.length > maxValueLen ? `${event.data.slice(0, maxValueLen)}…` : event.data;
98
+ parts.push(`data=${truncated}`);
99
+ }
100
+
101
+ return parts.join(" ");
102
+ }
103
+
104
+ // === Agent Color Mapping ===
105
+
106
+ /**
107
+ * Builds a stable color map for agents by first-appearance order in events.
108
+ * Agents are assigned colors from AGENT_COLORS cycling as needed.
109
+ */
110
+ export function buildAgentColorMap(events: StoredEvent[]): Map<string, ColorFn> {
111
+ const colorMap = new Map<string, ColorFn>();
112
+ let idx = 0;
113
+ for (const event of events) {
114
+ if (!colorMap.has(event.agentName)) {
115
+ const colorFn = AGENT_COLORS[idx % AGENT_COLORS.length] ?? noColor;
116
+ colorMap.set(event.agentName, colorFn);
117
+ idx++;
118
+ }
119
+ }
120
+ return colorMap;
121
+ }
122
+
123
+ /**
124
+ * Extends an existing agent color map with new agents from the given events.
125
+ * Used in follow mode to add agents discovered in incremental event batches.
126
+ */
127
+ export function extendAgentColorMap(colorMap: Map<string, ColorFn>, events: StoredEvent[]): void {
128
+ let idx = colorMap.size;
129
+ for (const event of events) {
130
+ if (!colorMap.has(event.agentName)) {
131
+ const colorFn = AGENT_COLORS[idx % AGENT_COLORS.length] ?? noColor;
132
+ colorMap.set(event.agentName, colorFn);
133
+ idx++;
134
+ }
135
+ }
136
+ }
137
+
138
+ // === Status Colors ===
139
+
140
+ /**
141
+ * Returns a color function for a merge status string.
142
+ * pending=yellow, merging=blue, conflict=red, merged=green
143
+ */
144
+ export function mergeStatusColor(status: string): ColorFn {
145
+ switch (status) {
146
+ case "pending":
147
+ return color.yellow;
148
+ case "merging":
149
+ return color.blue;
150
+ case "conflict":
151
+ return color.red;
152
+ case "merged":
153
+ return color.green;
154
+ default:
155
+ return (text: string) => text;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Returns a color function for a priority string.
161
+ * urgent=red, high=yellow, normal=identity, low=dim
162
+ */
163
+ export function priorityColor(priority: string): ColorFn {
164
+ switch (priority) {
165
+ case "urgent":
166
+ return color.red;
167
+ case "high":
168
+ return color.yellow;
169
+ case "normal":
170
+ return (text: string) => text;
171
+ case "low":
172
+ return color.dim;
173
+ default:
174
+ return (text: string) => text;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Returns a color function for a log level string.
180
+ * debug=gray, info=blue, warn=yellow, error=red
181
+ */
182
+ export function logLevelColor(level: string): ColorFn {
183
+ switch (level) {
184
+ case "debug":
185
+ return color.gray;
186
+ case "info":
187
+ return color.blue;
188
+ case "warn":
189
+ return color.yellow;
190
+ case "error":
191
+ return color.red;
192
+ default:
193
+ return (text: string) => text;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Returns a 3-character label for a log level string.
199
+ * debug="DBG", info="INF", warn="WRN", error="ERR"
200
+ */
201
+ export function logLevelLabel(level: string): string {
202
+ switch (level) {
203
+ case "debug":
204
+ return "DBG";
205
+ case "info":
206
+ return "INF";
207
+ case "warn":
208
+ return "WRN";
209
+ case "error":
210
+ return "ERR";
211
+ default:
212
+ return level.slice(0, 3).toUpperCase();
213
+ }
214
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Canonical visual theme for overstory CLI output.
3
+ *
4
+ * Single source of truth for state colors, event labels, agent palette,
5
+ * separators, and header rendering. All observability commands import from here.
6
+ */
7
+
8
+ import type { AgentState, EventType } from "../types.ts";
9
+ import type { ColorFn } from "./color.ts";
10
+ import { brand, color, noColor, visibleLength } from "./color.ts";
11
+
12
+ // === Agent State Theme ===
13
+
14
+ /** Maps agent states to their visual color functions. */
15
+ const STATE_COLORS: Record<AgentState, ColorFn> = {
16
+ working: color.green,
17
+ booting: color.yellow,
18
+ stalled: color.red,
19
+ zombie: color.dim,
20
+ completed: color.cyan,
21
+ };
22
+
23
+ /** Maps agent states to their icon characters. */
24
+ const STATE_ICONS: Record<AgentState, string> = {
25
+ working: ">",
26
+ booting: "~",
27
+ stalled: "!",
28
+ zombie: "x",
29
+ completed: "\u2713",
30
+ };
31
+
32
+ /** Returns the color function for a given agent state. Falls back to noColor. */
33
+ export function stateColor(state: string): ColorFn {
34
+ return STATE_COLORS[state as AgentState] ?? noColor;
35
+ }
36
+
37
+ /** Returns the raw icon character for a given agent state. Falls back to "?". */
38
+ export function stateIcon(state: string): string {
39
+ return STATE_ICONS[state as AgentState] ?? "?";
40
+ }
41
+
42
+ /** Returns a colored icon string for a given agent state. */
43
+ export function stateIconColored(state: string): string {
44
+ return stateColor(state)(stateIcon(state));
45
+ }
46
+
47
+ // === Event Label Theme ===
48
+
49
+ export interface EventLabel {
50
+ /** 5-character compact label (for feed). */
51
+ compact: string;
52
+ /** 10-character full label (for trace/replay). */
53
+ full: string;
54
+ /** Color function for this event type. */
55
+ color: ColorFn;
56
+ }
57
+
58
+ /** Maps event types to their compact (5-char) and full (10-char) labels, plus color. */
59
+ const EVENT_LABELS: Record<EventType, EventLabel> = {
60
+ tool_start: { compact: "TOOL+", full: "TOOL START", color: color.blue },
61
+ tool_end: { compact: "TOOL-", full: "TOOL END ", color: color.blue },
62
+ session_start: { compact: "SESS+", full: "SESSION +", color: color.green },
63
+ session_end: { compact: "SESS-", full: "SESSION -", color: color.yellow },
64
+ mail_sent: { compact: "MAIL>", full: "MAIL SENT ", color: color.cyan },
65
+ mail_received: { compact: "MAIL<", full: "MAIL RECV ", color: color.cyan },
66
+ spawn: { compact: "SPAWN", full: "SPAWN ", color: color.magenta },
67
+ error: { compact: "ERROR", full: "ERROR ", color: color.red },
68
+ custom: { compact: "CUSTM", full: "CUSTOM ", color: color.gray },
69
+ };
70
+
71
+ /** Returns the EventLabel for a given event type. */
72
+ export function eventLabel(eventType: EventType): EventLabel {
73
+ return EVENT_LABELS[eventType];
74
+ }
75
+
76
+ // === Agent Colors (for multi-agent displays) ===
77
+
78
+ /** Stable palette for assigning distinct colors to agents in multi-agent displays. */
79
+ export const AGENT_COLORS: readonly ColorFn[] = [
80
+ color.blue,
81
+ color.green,
82
+ color.yellow,
83
+ color.cyan,
84
+ color.magenta,
85
+ ] as const;
86
+
87
+ // === Separators ===
88
+
89
+ /** Unicode thin horizontal box-drawing character. */
90
+ export const SEPARATOR_CHAR = "\u2500";
91
+
92
+ /** Unicode double horizontal box-drawing character (thick). */
93
+ export const THICK_SEPARATOR_CHAR = "\u2550";
94
+
95
+ /** Default line width for separators and headers. */
96
+ export const DEFAULT_WIDTH = 70;
97
+
98
+ /** Returns a thin separator line of the given width (default 70). */
99
+ export function separator(width?: number): string {
100
+ return SEPARATOR_CHAR.repeat(width ?? DEFAULT_WIDTH);
101
+ }
102
+
103
+ /** Returns a thick (double-line) separator of the given width (default 70). */
104
+ export function thickSeparator(width?: number): string {
105
+ return THICK_SEPARATOR_CHAR.repeat(width ?? DEFAULT_WIDTH);
106
+ }
107
+
108
+ // === Header Rendering ===
109
+
110
+ /**
111
+ * Pads a string to the given visible width, accounting for ANSI escape codes.
112
+ * If the string is already wider than width, returns it unchanged.
113
+ */
114
+ export function padVisible(str: string, width: number): string {
115
+ const visible = visibleLength(str);
116
+ if (visible >= width) return str;
117
+ return str + " ".repeat(width - visible);
118
+ }
119
+
120
+ /**
121
+ * Renders a primary header: brand bold title + newline + thin separator.
122
+ */
123
+ export function renderHeader(title: string, width?: number): string {
124
+ return `${brand.bold(title)}\n${separator(width)}`;
125
+ }
126
+
127
+ /**
128
+ * Renders a secondary header: color bold title + newline + dim thin separator.
129
+ */
130
+ export function renderSubHeader(title: string, width?: number): string {
131
+ return `${color.bold(title)}\n${color.dim(separator(width))}`;
132
+ }
@@ -399,6 +399,52 @@ describe("getSessionsByRun", () => {
399
399
  });
400
400
  });
401
401
 
402
+ // === countSessions ===
403
+
404
+ describe("countSessions", () => {
405
+ test("returns 0 for empty database", () => {
406
+ expect(store.countSessions()).toBe(0);
407
+ });
408
+
409
+ test("returns total count of sessions", () => {
410
+ store.recordSession(makeSession({ agentName: "a1", taskId: "t1" }));
411
+ store.recordSession(makeSession({ agentName: "a2", taskId: "t2" }));
412
+ store.recordSession(makeSession({ agentName: "a3", taskId: "t3" }));
413
+
414
+ expect(store.countSessions()).toBe(3);
415
+ });
416
+
417
+ test("returns accurate count beyond getRecentSessions default limit", () => {
418
+ // Insert 25 sessions (more than the default limit of 20)
419
+ for (let i = 0; i < 25; i++) {
420
+ store.recordSession(
421
+ makeSession({
422
+ agentName: `agent-${i}`,
423
+ taskId: `task-${i}`,
424
+ startedAt: new Date(Date.now() + i * 1000).toISOString(),
425
+ }),
426
+ );
427
+ }
428
+
429
+ // getRecentSessions is capped at 20 by default
430
+ expect(store.getRecentSessions().length).toBe(20);
431
+ // countSessions returns the true total without a cap
432
+ expect(store.countSessions()).toBe(25);
433
+ });
434
+
435
+ test("count updates after purge", () => {
436
+ store.recordSession(makeSession({ agentName: "a1", taskId: "t1" }));
437
+ store.recordSession(makeSession({ agentName: "a2", taskId: "t2" }));
438
+ expect(store.countSessions()).toBe(2);
439
+
440
+ store.purge({ agent: "a1" });
441
+ expect(store.countSessions()).toBe(1);
442
+
443
+ store.purge({ all: true });
444
+ expect(store.countSessions()).toBe(0);
445
+ });
446
+ });
447
+
402
448
  // === purge ===
403
449
 
404
450
  describe("purge", () => {
@@ -14,6 +14,8 @@ export interface MetricsStore {
14
14
  getSessionsByAgent(agentName: string): SessionMetrics[];
15
15
  getSessionsByRun(runId: string): SessionMetrics[];
16
16
  getAverageDuration(capability?: string): number;
17
+ /** Count the total number of sessions in the database (no limit cap). */
18
+ countSessions(): number;
17
19
  /** Delete metrics matching the given criteria. Returns the number of rows deleted. */
18
20
  purge(options: { all?: boolean; agent?: string }): number;
19
21
  /** Record a token usage snapshot for a running agent. */
@@ -252,6 +254,10 @@ export function createMetricsStore(dbPath: string): MetricsStore {
252
254
  SELECT AVG(duration_ms) AS avg_duration FROM sessions WHERE completed_at IS NOT NULL
253
255
  `);
254
256
 
257
+ const countSessionsStmt = db.prepare<{ cnt: number }, Record<string, never>>(`
258
+ SELECT COUNT(*) as cnt FROM sessions
259
+ `);
260
+
255
261
  const avgDurationByCapStmt = db.prepare<
256
262
  { avg_duration: number | null },
257
263
  { $capability: string }
@@ -345,6 +351,11 @@ export function createMetricsStore(dbPath: string): MetricsStore {
345
351
  return row?.avg_duration ?? 0;
346
352
  },
347
353
 
354
+ countSessions(): number {
355
+ const row = countSessionsStmt.get({});
356
+ return row?.cnt ?? 0;
357
+ },
358
+
348
359
  purge(options: { all?: boolean; agent?: string }): number {
349
360
  if (options.all) {
350
361
  const countRow = db
@@ -451,6 +451,26 @@ describe("createMulchClient", () => {
451
451
  const result = await client.search("test", { file: "src/config.ts", sortByScore: true });
452
452
  expect(typeof result).toBe("string");
453
453
  });
454
+
455
+ test.skipIf(!hasMulch)("roundtrip: record via API then search and find it", async () => {
456
+ await initMulch();
457
+ const addProc = Bun.spawn(["ml", "add", "roundtrip"], {
458
+ cwd: tempDir,
459
+ stdout: "pipe",
460
+ stderr: "pipe",
461
+ });
462
+ await addProc.exited;
463
+
464
+ const client = createMulchClient(tempDir);
465
+ await client.record("roundtrip", {
466
+ type: "convention",
467
+ description: "unique-roundtrip-marker-xyz",
468
+ });
469
+
470
+ const result = await client.search("unique-roundtrip-marker-xyz");
471
+ expect(typeof result).toBe("string");
472
+ expect(result).toContain("unique-roundtrip-marker-xyz");
473
+ });
454
474
  });
455
475
 
456
476
  describe("diff", () => {