@os-eco/overstory-cli 0.6.1

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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +381 -0
  3. package/agents/builder.md +137 -0
  4. package/agents/coordinator.md +263 -0
  5. package/agents/lead.md +301 -0
  6. package/agents/merger.md +160 -0
  7. package/agents/monitor.md +214 -0
  8. package/agents/reviewer.md +140 -0
  9. package/agents/scout.md +119 -0
  10. package/agents/supervisor.md +423 -0
  11. package/package.json +47 -0
  12. package/src/agents/checkpoint.test.ts +88 -0
  13. package/src/agents/checkpoint.ts +101 -0
  14. package/src/agents/hooks-deployer.test.ts +2040 -0
  15. package/src/agents/hooks-deployer.ts +607 -0
  16. package/src/agents/identity.test.ts +603 -0
  17. package/src/agents/identity.ts +384 -0
  18. package/src/agents/lifecycle.test.ts +196 -0
  19. package/src/agents/lifecycle.ts +183 -0
  20. package/src/agents/manifest.test.ts +746 -0
  21. package/src/agents/manifest.ts +354 -0
  22. package/src/agents/overlay.test.ts +676 -0
  23. package/src/agents/overlay.ts +308 -0
  24. package/src/beads/client.test.ts +217 -0
  25. package/src/beads/client.ts +202 -0
  26. package/src/beads/molecules.test.ts +338 -0
  27. package/src/beads/molecules.ts +198 -0
  28. package/src/commands/agents.test.ts +322 -0
  29. package/src/commands/agents.ts +287 -0
  30. package/src/commands/clean.test.ts +670 -0
  31. package/src/commands/clean.ts +618 -0
  32. package/src/commands/completions.test.ts +342 -0
  33. package/src/commands/completions.ts +887 -0
  34. package/src/commands/coordinator.test.ts +1530 -0
  35. package/src/commands/coordinator.ts +733 -0
  36. package/src/commands/costs.test.ts +1119 -0
  37. package/src/commands/costs.ts +564 -0
  38. package/src/commands/dashboard.test.ts +308 -0
  39. package/src/commands/dashboard.ts +838 -0
  40. package/src/commands/doctor.test.ts +294 -0
  41. package/src/commands/doctor.ts +213 -0
  42. package/src/commands/errors.test.ts +647 -0
  43. package/src/commands/errors.ts +248 -0
  44. package/src/commands/feed.test.ts +578 -0
  45. package/src/commands/feed.ts +361 -0
  46. package/src/commands/group.test.ts +262 -0
  47. package/src/commands/group.ts +511 -0
  48. package/src/commands/hooks.test.ts +458 -0
  49. package/src/commands/hooks.ts +253 -0
  50. package/src/commands/init.test.ts +347 -0
  51. package/src/commands/init.ts +650 -0
  52. package/src/commands/inspect.test.ts +670 -0
  53. package/src/commands/inspect.ts +431 -0
  54. package/src/commands/log.test.ts +1454 -0
  55. package/src/commands/log.ts +724 -0
  56. package/src/commands/logs.test.ts +379 -0
  57. package/src/commands/logs.ts +546 -0
  58. package/src/commands/mail.test.ts +1270 -0
  59. package/src/commands/mail.ts +771 -0
  60. package/src/commands/merge.test.ts +670 -0
  61. package/src/commands/merge.ts +355 -0
  62. package/src/commands/metrics.test.ts +444 -0
  63. package/src/commands/metrics.ts +143 -0
  64. package/src/commands/monitor.test.ts +191 -0
  65. package/src/commands/monitor.ts +390 -0
  66. package/src/commands/nudge.test.ts +230 -0
  67. package/src/commands/nudge.ts +372 -0
  68. package/src/commands/prime.test.ts +470 -0
  69. package/src/commands/prime.ts +381 -0
  70. package/src/commands/replay.test.ts +741 -0
  71. package/src/commands/replay.ts +360 -0
  72. package/src/commands/run.test.ts +431 -0
  73. package/src/commands/run.ts +351 -0
  74. package/src/commands/sling.test.ts +657 -0
  75. package/src/commands/sling.ts +661 -0
  76. package/src/commands/spec.test.ts +203 -0
  77. package/src/commands/spec.ts +168 -0
  78. package/src/commands/status.test.ts +430 -0
  79. package/src/commands/status.ts +398 -0
  80. package/src/commands/stop.test.ts +420 -0
  81. package/src/commands/stop.ts +151 -0
  82. package/src/commands/supervisor.test.ts +187 -0
  83. package/src/commands/supervisor.ts +535 -0
  84. package/src/commands/trace.test.ts +745 -0
  85. package/src/commands/trace.ts +325 -0
  86. package/src/commands/watch.test.ts +145 -0
  87. package/src/commands/watch.ts +247 -0
  88. package/src/commands/worktree.test.ts +786 -0
  89. package/src/commands/worktree.ts +311 -0
  90. package/src/config.test.ts +822 -0
  91. package/src/config.ts +829 -0
  92. package/src/doctor/agents.test.ts +454 -0
  93. package/src/doctor/agents.ts +396 -0
  94. package/src/doctor/config-check.test.ts +190 -0
  95. package/src/doctor/config-check.ts +183 -0
  96. package/src/doctor/consistency.test.ts +651 -0
  97. package/src/doctor/consistency.ts +294 -0
  98. package/src/doctor/databases.test.ts +290 -0
  99. package/src/doctor/databases.ts +218 -0
  100. package/src/doctor/dependencies.test.ts +184 -0
  101. package/src/doctor/dependencies.ts +175 -0
  102. package/src/doctor/logs.test.ts +251 -0
  103. package/src/doctor/logs.ts +295 -0
  104. package/src/doctor/merge-queue.test.ts +216 -0
  105. package/src/doctor/merge-queue.ts +144 -0
  106. package/src/doctor/structure.test.ts +291 -0
  107. package/src/doctor/structure.ts +198 -0
  108. package/src/doctor/types.ts +37 -0
  109. package/src/doctor/version.test.ts +136 -0
  110. package/src/doctor/version.ts +129 -0
  111. package/src/e2e/init-sling-lifecycle.test.ts +277 -0
  112. package/src/errors.ts +217 -0
  113. package/src/events/store.test.ts +660 -0
  114. package/src/events/store.ts +369 -0
  115. package/src/events/tool-filter.test.ts +330 -0
  116. package/src/events/tool-filter.ts +126 -0
  117. package/src/index.ts +316 -0
  118. package/src/insights/analyzer.test.ts +466 -0
  119. package/src/insights/analyzer.ts +203 -0
  120. package/src/logging/color.test.ts +142 -0
  121. package/src/logging/color.ts +71 -0
  122. package/src/logging/logger.test.ts +813 -0
  123. package/src/logging/logger.ts +266 -0
  124. package/src/logging/reporter.test.ts +259 -0
  125. package/src/logging/reporter.ts +109 -0
  126. package/src/logging/sanitizer.test.ts +190 -0
  127. package/src/logging/sanitizer.ts +57 -0
  128. package/src/mail/broadcast.test.ts +203 -0
  129. package/src/mail/broadcast.ts +92 -0
  130. package/src/mail/client.test.ts +773 -0
  131. package/src/mail/client.ts +223 -0
  132. package/src/mail/store.test.ts +705 -0
  133. package/src/mail/store.ts +387 -0
  134. package/src/merge/queue.test.ts +359 -0
  135. package/src/merge/queue.ts +231 -0
  136. package/src/merge/resolver.test.ts +1345 -0
  137. package/src/merge/resolver.ts +645 -0
  138. package/src/metrics/store.test.ts +667 -0
  139. package/src/metrics/store.ts +445 -0
  140. package/src/metrics/summary.test.ts +398 -0
  141. package/src/metrics/summary.ts +178 -0
  142. package/src/metrics/transcript.test.ts +356 -0
  143. package/src/metrics/transcript.ts +175 -0
  144. package/src/mulch/client.test.ts +671 -0
  145. package/src/mulch/client.ts +332 -0
  146. package/src/sessions/compat.test.ts +280 -0
  147. package/src/sessions/compat.ts +104 -0
  148. package/src/sessions/store.test.ts +873 -0
  149. package/src/sessions/store.ts +494 -0
  150. package/src/test-helpers.test.ts +124 -0
  151. package/src/test-helpers.ts +126 -0
  152. package/src/tracker/beads.ts +56 -0
  153. package/src/tracker/factory.test.ts +80 -0
  154. package/src/tracker/factory.ts +64 -0
  155. package/src/tracker/seeds.ts +182 -0
  156. package/src/tracker/types.ts +52 -0
  157. package/src/types.ts +724 -0
  158. package/src/watchdog/daemon.test.ts +1975 -0
  159. package/src/watchdog/daemon.ts +671 -0
  160. package/src/watchdog/health.test.ts +431 -0
  161. package/src/watchdog/health.ts +264 -0
  162. package/src/watchdog/triage.test.ts +164 -0
  163. package/src/watchdog/triage.ts +179 -0
  164. package/src/worktree/manager.test.ts +439 -0
  165. package/src/worktree/manager.ts +198 -0
  166. package/src/worktree/tmux.test.ts +1009 -0
  167. package/src/worktree/tmux.ts +509 -0
  168. package/templates/CLAUDE.md.tmpl +89 -0
  169. package/templates/hooks.json.tmpl +105 -0
  170. package/templates/overlay.md.tmpl +81 -0
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Multi-format logger that writes to multiple outputs simultaneously.
3
+ *
4
+ * Output files (all created in the provided logDir):
5
+ * - session.log — Human-readable: [TIMESTAMP] EVENT key=value
6
+ * - events.ndjson — Machine-parseable NDJSON stream (all events)
7
+ * - tools.ndjson — Tool use log (toolStart / toolEnd events only)
8
+ * - errors.log — Stack traces with context (error events only)
9
+ *
10
+ * Log directory structure: .overstory/logs/{agent-name}/{session-timestamp}/
11
+ */
12
+
13
+ import { appendFile, mkdir } from "node:fs/promises";
14
+ import { join } from "node:path";
15
+ import type { LogEvent } from "../types.ts";
16
+ import { printToConsole } from "./reporter.ts";
17
+ import { sanitize, sanitizeObject } from "./sanitizer.ts";
18
+
19
+ export interface Logger {
20
+ info(event: string, data?: Record<string, unknown>): void;
21
+ warn(event: string, data?: Record<string, unknown>): void;
22
+ error(event: string, error: Error, data?: Record<string, unknown>): void;
23
+ debug(event: string, data?: Record<string, unknown>): void;
24
+ toolStart(toolName: string, args: Record<string, unknown>): void;
25
+ toolEnd(toolName: string, durationMs: number, result?: string): void;
26
+ close(): void;
27
+ }
28
+
29
+ interface LoggerOptions {
30
+ logDir: string;
31
+ agentName: string;
32
+ verbose?: boolean;
33
+ redactSecrets?: boolean;
34
+ }
35
+
36
+ /**
37
+ * Create a multi-format logger that writes to session.log, events.ndjson,
38
+ * tools.ndjson, and errors.log simultaneously.
39
+ *
40
+ * The logDir is created if it does not exist. All writes are fire-and-forget
41
+ * (errors during file I/O are silently ignored to avoid log-induced crashes).
42
+ */
43
+ export function createLogger(options: LoggerOptions): Logger {
44
+ const { logDir, agentName, verbose = false, redactSecrets = true } = options;
45
+
46
+ const sessionLogPath = join(logDir, "session.log");
47
+ const eventsPath = join(logDir, "events.ndjson");
48
+ const toolsPath = join(logDir, "tools.ndjson");
49
+ const errorsPath = join(logDir, "errors.log");
50
+
51
+ // Pending writes queue. We chain promises to guarantee ordering
52
+ // within each file, but different files write independently.
53
+ let dirReady: Promise<void> | null = null;
54
+ let closed = false;
55
+
56
+ /**
57
+ * Ensure the log directory exists. Called lazily on first write.
58
+ * Subsequent calls return the cached promise.
59
+ */
60
+ function ensureDir(): Promise<void> {
61
+ if (dirReady === null) {
62
+ dirReady = mkdir(logDir, { recursive: true }).then(() => undefined);
63
+ }
64
+ return dirReady;
65
+ }
66
+
67
+ /**
68
+ * Append text to a file, ensuring the directory exists first.
69
+ * Errors are silently swallowed — logging must never crash the app.
70
+ */
71
+ function safeAppend(filePath: string, content: string): void {
72
+ if (closed) return;
73
+ ensureDir()
74
+ .then(() => appendFile(filePath, content, "utf-8"))
75
+ .catch(() => {
76
+ // Silently ignore write errors — logging should never crash the host
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Conditionally redact secrets from a string.
82
+ */
83
+ function maybeRedact(input: string): string {
84
+ return redactSecrets ? sanitize(input) : input;
85
+ }
86
+
87
+ /**
88
+ * Conditionally redact secrets from an object.
89
+ */
90
+ function maybeRedactObject(obj: Record<string, unknown>): Record<string, unknown> {
91
+ return redactSecrets ? sanitizeObject(obj) : obj;
92
+ }
93
+
94
+ /**
95
+ * Build a LogEvent and dispatch it to all relevant outputs.
96
+ */
97
+ function emit(
98
+ level: LogEvent["level"],
99
+ event: string,
100
+ data: Record<string, unknown>,
101
+ error?: Error,
102
+ ): void {
103
+ const safeData = maybeRedactObject(data);
104
+
105
+ const logEvent: LogEvent = {
106
+ timestamp: new Date().toISOString(),
107
+ level,
108
+ event,
109
+ agentName,
110
+ data: safeData,
111
+ };
112
+
113
+ // 1. Console output
114
+ printToConsole(logEvent, verbose);
115
+
116
+ // 2. session.log — human-readable line
117
+ const kvPairs = formatKeyValues(safeData);
118
+ const kvSuffix = kvPairs.length > 0 ? ` ${kvPairs}` : "";
119
+ const sessionLine = `[${logEvent.timestamp}] ${level.toUpperCase()} ${event}${kvSuffix}\n`;
120
+ safeAppend(sessionLogPath, sessionLine);
121
+
122
+ // 3. events.ndjson — full event as JSON
123
+ safeAppend(eventsPath, `${JSON.stringify(logEvent)}\n`);
124
+
125
+ // 4. errors.log — stack traces with context (error level only)
126
+ if (level === "error" && error) {
127
+ const errorBlock = buildErrorBlock(logEvent, error);
128
+ safeAppend(errorsPath, errorBlock);
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Emit a tool event to the tools.ndjson file (and also to events.ndjson).
134
+ */
135
+ function emitTool(event: string, data: Record<string, unknown>): void {
136
+ const safeData = maybeRedactObject(data);
137
+
138
+ const logEvent: LogEvent = {
139
+ timestamp: new Date().toISOString(),
140
+ level: "info",
141
+ event,
142
+ agentName,
143
+ data: safeData,
144
+ };
145
+
146
+ // Console output
147
+ printToConsole(logEvent, verbose);
148
+
149
+ // events.ndjson
150
+ safeAppend(eventsPath, `${JSON.stringify(logEvent)}\n`);
151
+
152
+ // tools.ndjson
153
+ safeAppend(toolsPath, `${JSON.stringify(logEvent)}\n`);
154
+
155
+ // session.log
156
+ const kvPairs = formatKeyValues(safeData);
157
+ const kvSuffix = kvPairs.length > 0 ? ` ${kvPairs}` : "";
158
+ const sessionLine = `[${logEvent.timestamp}] INFO ${event}${kvSuffix}\n`;
159
+ safeAppend(sessionLogPath, sessionLine);
160
+ }
161
+
162
+ return {
163
+ info(event: string, data?: Record<string, unknown>): void {
164
+ emit("info", event, data ?? {});
165
+ },
166
+
167
+ warn(event: string, data?: Record<string, unknown>): void {
168
+ emit("warn", event, data ?? {});
169
+ },
170
+
171
+ error(event: string, err: Error, data?: Record<string, unknown>): void {
172
+ const errorData: Record<string, unknown> = {
173
+ ...data,
174
+ errorMessage: maybeRedact(err.message),
175
+ errorName: err.name,
176
+ };
177
+ emit("error", event, errorData, err);
178
+ },
179
+
180
+ debug(event: string, data?: Record<string, unknown>): void {
181
+ emit("debug", event, data ?? {});
182
+ },
183
+
184
+ toolStart(toolName: string, args: Record<string, unknown>): void {
185
+ emitTool("tool.start", { toolName, args });
186
+ },
187
+
188
+ toolEnd(toolName: string, durationMs: number, result?: string): void {
189
+ const data: Record<string, unknown> = { toolName, durationMs };
190
+ if (result !== undefined) {
191
+ data.result = maybeRedact(result);
192
+ }
193
+ emitTool("tool.end", data);
194
+ },
195
+
196
+ close(): void {
197
+ closed = true;
198
+ },
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Format a data record as space-separated key=value pairs for session.log.
204
+ */
205
+ function formatKeyValues(data: Record<string, unknown>): string {
206
+ const entries = Object.entries(data);
207
+ if (entries.length === 0) {
208
+ return "";
209
+ }
210
+
211
+ return entries
212
+ .map(([key, value]) => {
213
+ if (value === undefined || value === null) {
214
+ return `${key}=null`;
215
+ }
216
+ if (typeof value === "string") {
217
+ return value.includes(" ") ? `${key}="${value}"` : `${key}=${value}`;
218
+ }
219
+ if (typeof value === "object") {
220
+ return `${key}=${JSON.stringify(value)}`;
221
+ }
222
+ return `${key}=${String(value)}`;
223
+ })
224
+ .join(" ");
225
+ }
226
+
227
+ /**
228
+ * Build a multi-line error block for errors.log.
229
+ */
230
+ function buildErrorBlock(event: LogEvent, error: Error): string {
231
+ const separator = "=".repeat(72);
232
+ const lines: string[] = [
233
+ separator,
234
+ `Timestamp: ${event.timestamp}`,
235
+ `Event: ${event.event}`,
236
+ `Agent: ${event.agentName ?? "unknown"}`,
237
+ ];
238
+
239
+ // Include data fields
240
+ const dataEntries = Object.entries(event.data);
241
+ if (dataEntries.length > 0) {
242
+ lines.push(`Data: ${JSON.stringify(event.data)}`);
243
+ }
244
+
245
+ lines.push("");
246
+ lines.push(`Error: ${error.name}: ${error.message}`);
247
+
248
+ if (error.stack) {
249
+ lines.push("");
250
+ lines.push("Stack Trace:");
251
+ lines.push(error.stack);
252
+ }
253
+
254
+ if (error.cause instanceof Error) {
255
+ lines.push("");
256
+ lines.push(`Caused by: ${error.cause.name}: ${error.cause.message}`);
257
+ if (error.cause.stack) {
258
+ lines.push(error.cause.stack);
259
+ }
260
+ }
261
+
262
+ lines.push(separator);
263
+ lines.push("");
264
+
265
+ return lines.join("\n");
266
+ }
@@ -0,0 +1,259 @@
1
+ import { afterEach, describe, expect, spyOn, test } from "bun:test";
2
+ import type { LogEvent } from "../types.ts";
3
+ import { formatLogLine, printToConsole } from "./reporter.ts";
4
+
5
+ // Helper to build a LogEvent with sensible defaults
6
+ function makeEvent(overrides: Partial<LogEvent> = {}): LogEvent {
7
+ return {
8
+ timestamp: "2026-02-13T14:30:00.123Z",
9
+ level: "info",
10
+ event: "test.event",
11
+ agentName: "test-agent",
12
+ data: {},
13
+ ...overrides,
14
+ };
15
+ }
16
+
17
+ describe("formatLogLine", () => {
18
+ test("uses DBG label for debug level", () => {
19
+ const result = formatLogLine(makeEvent({ level: "debug" }));
20
+ expect(result).toContain("DBG");
21
+ });
22
+
23
+ test("uses INF label for info level", () => {
24
+ const result = formatLogLine(makeEvent({ level: "info" }));
25
+ expect(result).toContain("INF");
26
+ });
27
+
28
+ test("uses WRN label for warn level", () => {
29
+ const result = formatLogLine(makeEvent({ level: "warn" }));
30
+ expect(result).toContain("WRN");
31
+ });
32
+
33
+ test("uses ERR label for error level", () => {
34
+ const result = formatLogLine(makeEvent({ level: "error" }));
35
+ expect(result).toContain("ERR");
36
+ });
37
+
38
+ test("includes agent name and separator when present", () => {
39
+ const result = formatLogLine(makeEvent({ agentName: "scout-1" }));
40
+ expect(result).toContain("scout-1");
41
+ expect(result).toContain(" | ");
42
+ });
43
+
44
+ test("omits agent name and separator when null", () => {
45
+ const result = formatLogLine(makeEvent({ agentName: null }));
46
+ expect(result).not.toContain(" | ");
47
+ });
48
+
49
+ test("includes event name in output", () => {
50
+ const result = formatLogLine(makeEvent({ event: "agent.started" }));
51
+ expect(result).toContain("agent.started");
52
+ });
53
+
54
+ test("formats string data values as key=value", () => {
55
+ const result = formatLogLine(makeEvent({ data: { status: "ok" } }));
56
+ expect(result).toContain("status=ok");
57
+ });
58
+
59
+ test("formats number data values as key=value", () => {
60
+ const result = formatLogLine(makeEvent({ data: { duration: 5000 } }));
61
+ expect(result).toContain("duration=5000");
62
+ });
63
+
64
+ test("formats object data values as JSON", () => {
65
+ const result = formatLogLine(makeEvent({ data: { config: { enabled: true, timeout: 5000 } } }));
66
+ expect(result).toContain('config={"enabled":true,"timeout":5000}');
67
+ });
68
+
69
+ test("formats null data values as key=null", () => {
70
+ const result = formatLogLine(makeEvent({ data: { value: null } }));
71
+ expect(result).toContain("value=null");
72
+ });
73
+
74
+ test("formats undefined data values as key=null", () => {
75
+ const result = formatLogLine(makeEvent({ data: { value: undefined } }));
76
+ expect(result).toContain("value=null");
77
+ });
78
+
79
+ test("quotes string data values containing spaces", () => {
80
+ const result = formatLogLine(makeEvent({ data: { message: "hello world", status: "ok" } }));
81
+ expect(result).toContain('message="hello world"');
82
+ expect(result).toContain("status=ok");
83
+ });
84
+
85
+ test("handles multiple data key=value pairs", () => {
86
+ const result = formatLogLine(makeEvent({ data: { taskId: "task-123", duration: 5000 } }));
87
+ expect(result).toContain("taskId=task-123");
88
+ expect(result).toContain("duration=5000");
89
+ });
90
+
91
+ test("produces no data suffix for empty data object", () => {
92
+ const result = formatLogLine(makeEvent({ data: {} }));
93
+ // The event name should be at the end with no trailing key=value content
94
+ expect(result).toContain("test.event");
95
+ // No equals sign means no key=value pairs present
96
+ expect(result).not.toMatch(/\w+=\S/);
97
+ });
98
+
99
+ test("extracts HH:MM:SS time from ISO timestamp", () => {
100
+ const result = formatLogLine(makeEvent({ timestamp: "2026-02-13T14:30:00.123Z" }));
101
+ expect(result).toContain("[14:30:00]");
102
+ });
103
+
104
+ test("falls back to raw timestamp when no T separator", () => {
105
+ const result = formatLogLine(makeEvent({ timestamp: "invalid-timestamp" }));
106
+ expect(result).toContain("[invalid-timestamp]");
107
+ });
108
+
109
+ test("contains ANSI escape codes in output", () => {
110
+ const result = formatLogLine(makeEvent());
111
+ // \x1b[ is the ANSI escape sequence prefix
112
+ expect(result).toContain("\x1b[");
113
+ // Reset sequence should appear at least once
114
+ expect(result).toContain("\x1b[0m");
115
+ });
116
+
117
+ test("uses different ANSI color codes for different levels", () => {
118
+ const debugResult = formatLogLine(makeEvent({ level: "debug" }));
119
+ const infoResult = formatLogLine(makeEvent({ level: "info" }));
120
+ const warnResult = formatLogLine(makeEvent({ level: "warn" }));
121
+ const errorResult = formatLogLine(makeEvent({ level: "error" }));
122
+
123
+ // Each level uses a distinct color: gray(90), blue(34), yellow(33), red(31)
124
+ expect(debugResult).toContain("\x1b[90m");
125
+ expect(infoResult).toContain("\x1b[34m");
126
+ expect(warnResult).toContain("\x1b[33m");
127
+ expect(errorResult).toContain("\x1b[31m");
128
+ });
129
+
130
+ test("formats boolean data values via String()", () => {
131
+ const result = formatLogLine(makeEvent({ data: { enabled: true } }));
132
+ expect(result).toContain("enabled=true");
133
+ });
134
+ });
135
+
136
+ describe("printToConsole", () => {
137
+ let logSpy: ReturnType<typeof spyOn>;
138
+ let errorSpy: ReturnType<typeof spyOn>;
139
+
140
+ afterEach(() => {
141
+ logSpy?.mockRestore();
142
+ errorSpy?.mockRestore();
143
+ });
144
+
145
+ test("sends info events to console.log", () => {
146
+ logSpy = spyOn(console, "log").mockImplementation(() => {});
147
+ errorSpy = spyOn(console, "error").mockImplementation(() => {});
148
+ // Clear any calls captured during spy setup (bun's test reporter
149
+ // may flush output through console.log between spy creation and here)
150
+ logSpy.mockClear();
151
+ errorSpy.mockClear();
152
+
153
+ printToConsole(makeEvent({ level: "info" }), true);
154
+
155
+ expect(logSpy).toHaveBeenCalledTimes(1);
156
+ expect(errorSpy).toHaveBeenCalledTimes(0);
157
+ });
158
+
159
+ test("sends warn events to console.log", () => {
160
+ logSpy = spyOn(console, "log").mockImplementation(() => {});
161
+ errorSpy = spyOn(console, "error").mockImplementation(() => {});
162
+ logSpy.mockClear();
163
+ errorSpy.mockClear();
164
+
165
+ printToConsole(makeEvent({ level: "warn" }), false);
166
+
167
+ expect(logSpy).toHaveBeenCalledTimes(1);
168
+ expect(errorSpy).toHaveBeenCalledTimes(0);
169
+ });
170
+
171
+ test("sends error events to console.error", () => {
172
+ logSpy = spyOn(console, "log").mockImplementation(() => {});
173
+ errorSpy = spyOn(console, "error").mockImplementation(() => {});
174
+ logSpy.mockClear();
175
+ errorSpy.mockClear();
176
+
177
+ printToConsole(makeEvent({ level: "error" }), false);
178
+
179
+ expect(logSpy).toHaveBeenCalledTimes(0);
180
+ expect(errorSpy).toHaveBeenCalledTimes(1);
181
+ });
182
+
183
+ test("suppresses debug events when verbose is false", () => {
184
+ logSpy = spyOn(console, "log").mockImplementation(() => {});
185
+ errorSpy = spyOn(console, "error").mockImplementation(() => {});
186
+ logSpy.mockClear();
187
+ errorSpy.mockClear();
188
+
189
+ printToConsole(makeEvent({ level: "debug" }), false);
190
+
191
+ expect(logSpy).toHaveBeenCalledTimes(0);
192
+ expect(errorSpy).toHaveBeenCalledTimes(0);
193
+ });
194
+
195
+ test("shows debug events when verbose is true", () => {
196
+ logSpy = spyOn(console, "log").mockImplementation(() => {});
197
+ errorSpy = spyOn(console, "error").mockImplementation(() => {});
198
+ logSpy.mockClear();
199
+ errorSpy.mockClear();
200
+
201
+ printToConsole(makeEvent({ level: "debug" }), true);
202
+
203
+ expect(logSpy).toHaveBeenCalledTimes(1);
204
+ expect(errorSpy).toHaveBeenCalledTimes(0);
205
+ });
206
+
207
+ test("passes formatted line to console method", () => {
208
+ logSpy = spyOn(console, "log").mockImplementation(() => {});
209
+ errorSpy = spyOn(console, "error").mockImplementation(() => {});
210
+
211
+ const event = makeEvent({ level: "info", event: "my.custom.event" });
212
+ printToConsole(event, true);
213
+
214
+ const calledWith = logSpy.mock.calls[0]?.[0] as string;
215
+ expect(calledWith).toContain("my.custom.event");
216
+ expect(calledWith).toContain("INF");
217
+ });
218
+
219
+ test("error event output contains the formatted line", () => {
220
+ logSpy = spyOn(console, "log").mockImplementation(() => {});
221
+ errorSpy = spyOn(console, "error").mockImplementation(() => {});
222
+
223
+ const event = makeEvent({ level: "error", event: "fatal.crash" });
224
+ printToConsole(event, false);
225
+
226
+ const calledWith = errorSpy.mock.calls[0]?.[0] as string;
227
+ expect(calledWith).toContain("fatal.crash");
228
+ expect(calledWith).toContain("ERR");
229
+ });
230
+
231
+ test("suppresses non-error output when quiet mode is enabled", () => {
232
+ const { setQuiet } = require("./color.ts") as { setQuiet: (enabled: boolean) => void };
233
+
234
+ logSpy = spyOn(console, "log").mockImplementation(() => {});
235
+ errorSpy = spyOn(console, "error").mockImplementation(() => {});
236
+ logSpy.mockClear();
237
+ errorSpy.mockClear();
238
+
239
+ // Enable quiet mode
240
+ setQuiet(true);
241
+
242
+ // info, warn, debug should all be suppressed
243
+ printToConsole(makeEvent({ level: "info" }), true);
244
+ printToConsole(makeEvent({ level: "warn" }), true);
245
+ printToConsole(makeEvent({ level: "debug" }), true);
246
+
247
+ expect(logSpy).toHaveBeenCalledTimes(0);
248
+ expect(errorSpy).toHaveBeenCalledTimes(0);
249
+
250
+ // errors should still be output
251
+ printToConsole(makeEvent({ level: "error" }), true);
252
+
253
+ expect(logSpy).toHaveBeenCalledTimes(0);
254
+ expect(errorSpy).toHaveBeenCalledTimes(1);
255
+
256
+ // Restore quiet mode
257
+ setQuiet(false);
258
+ });
259
+ });
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Console reporter with ANSI colors for human-readable log output.
3
+ *
4
+ * Formats LogEvent objects into colored terminal output.
5
+ * Uses ANSI escape codes directly (no external dependencies).
6
+ */
7
+
8
+ import type { LogEvent } from "../types.ts";
9
+ import { color, isQuiet } from "./color.ts";
10
+
11
+ const LEVEL_COLORS: Record<LogEvent["level"], string> = {
12
+ debug: color.gray,
13
+ info: color.blue,
14
+ warn: color.yellow,
15
+ error: color.red,
16
+ };
17
+
18
+ const LEVEL_LABELS: Record<LogEvent["level"], string> = {
19
+ debug: "DBG",
20
+ info: "INF",
21
+ warn: "WRN",
22
+ error: "ERR",
23
+ };
24
+
25
+ /**
26
+ * Format a LogEvent into a single human-readable line with ANSI colors.
27
+ *
28
+ * Format: `[HH:MM:SS] LVL agent | event key=value key=value`
29
+ */
30
+ export function formatLogLine(event: LogEvent): string {
31
+ const levelColor = LEVEL_COLORS[event.level];
32
+ const label = LEVEL_LABELS[event.level];
33
+
34
+ // Extract just the time portion for compact display
35
+ const time = extractTime(event.timestamp);
36
+
37
+ // Build the agent prefix
38
+ const agentPart = event.agentName ? `${color.dim}${event.agentName}${color.reset} | ` : "";
39
+
40
+ // Build key=value pairs from data
41
+ const dataPart = formatData(event.data);
42
+ const dataSuffix = dataPart.length > 0 ? ` ${color.dim}${dataPart}${color.reset}` : "";
43
+
44
+ return `${color.dim}[${time}]${color.reset} ${levelColor}${color.bold}${label}${color.reset} ${agentPart}${event.event}${dataSuffix}`;
45
+ }
46
+
47
+ /**
48
+ * Print a LogEvent to the console, respecting verbose mode and quiet mode.
49
+ *
50
+ * When verbose is false, debug-level events are suppressed.
51
+ * When quiet mode is active, non-error events are suppressed.
52
+ * Errors go to stderr; everything else goes to stdout.
53
+ */
54
+ export function printToConsole(event: LogEvent, verbose: boolean): void {
55
+ if (isQuiet() && event.level !== "error") {
56
+ return;
57
+ }
58
+ if (!verbose && event.level === "debug") {
59
+ return;
60
+ }
61
+
62
+ const line = formatLogLine(event);
63
+
64
+ if (event.level === "error") {
65
+ console.error(line);
66
+ } else {
67
+ console.log(line);
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Extract the HH:MM:SS portion from an ISO timestamp.
73
+ * Falls back to the raw timestamp if parsing fails.
74
+ */
75
+ function extractTime(timestamp: string): string {
76
+ // ISO 8601: "2024-01-15T14:30:00.123Z" -> "14:30:00"
77
+ const match = /T(\d{2}:\d{2}:\d{2})/.exec(timestamp);
78
+ if (match?.[1]) {
79
+ return match[1];
80
+ }
81
+ return timestamp;
82
+ }
83
+
84
+ /**
85
+ * Format a data record as space-separated key=value pairs.
86
+ * Handles nested objects by JSON-stringifying them.
87
+ */
88
+ function formatData(data: Record<string, unknown>): string {
89
+ const entries = Object.entries(data);
90
+ if (entries.length === 0) {
91
+ return "";
92
+ }
93
+
94
+ return entries
95
+ .map(([key, value]) => {
96
+ if (value === undefined || value === null) {
97
+ return `${key}=null`;
98
+ }
99
+ if (typeof value === "string") {
100
+ // Quote strings that contain spaces
101
+ return value.includes(" ") ? `${key}="${value}"` : `${key}=${value}`;
102
+ }
103
+ if (typeof value === "object") {
104
+ return `${key}=${JSON.stringify(value)}`;
105
+ }
106
+ return `${key}=${String(value)}`;
107
+ })
108
+ .join(" ");
109
+ }