@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.
- package/LICENSE +21 -0
- package/README.md +381 -0
- package/agents/builder.md +137 -0
- package/agents/coordinator.md +263 -0
- package/agents/lead.md +301 -0
- package/agents/merger.md +160 -0
- package/agents/monitor.md +214 -0
- package/agents/reviewer.md +140 -0
- package/agents/scout.md +119 -0
- package/agents/supervisor.md +423 -0
- package/package.json +47 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +101 -0
- package/src/agents/hooks-deployer.test.ts +2040 -0
- package/src/agents/hooks-deployer.ts +607 -0
- package/src/agents/identity.test.ts +603 -0
- package/src/agents/identity.ts +384 -0
- package/src/agents/lifecycle.test.ts +196 -0
- package/src/agents/lifecycle.ts +183 -0
- package/src/agents/manifest.test.ts +746 -0
- package/src/agents/manifest.ts +354 -0
- package/src/agents/overlay.test.ts +676 -0
- package/src/agents/overlay.ts +308 -0
- package/src/beads/client.test.ts +217 -0
- package/src/beads/client.ts +202 -0
- package/src/beads/molecules.test.ts +338 -0
- package/src/beads/molecules.ts +198 -0
- package/src/commands/agents.test.ts +322 -0
- package/src/commands/agents.ts +287 -0
- package/src/commands/clean.test.ts +670 -0
- package/src/commands/clean.ts +618 -0
- package/src/commands/completions.test.ts +342 -0
- package/src/commands/completions.ts +887 -0
- package/src/commands/coordinator.test.ts +1530 -0
- package/src/commands/coordinator.ts +733 -0
- package/src/commands/costs.test.ts +1119 -0
- package/src/commands/costs.ts +564 -0
- package/src/commands/dashboard.test.ts +308 -0
- package/src/commands/dashboard.ts +838 -0
- package/src/commands/doctor.test.ts +294 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/errors.test.ts +647 -0
- package/src/commands/errors.ts +248 -0
- package/src/commands/feed.test.ts +578 -0
- package/src/commands/feed.ts +361 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +511 -0
- package/src/commands/hooks.test.ts +458 -0
- package/src/commands/hooks.ts +253 -0
- package/src/commands/init.test.ts +347 -0
- package/src/commands/init.ts +650 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +431 -0
- package/src/commands/log.test.ts +1454 -0
- package/src/commands/log.ts +724 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +546 -0
- package/src/commands/mail.test.ts +1270 -0
- package/src/commands/mail.ts +771 -0
- package/src/commands/merge.test.ts +670 -0
- package/src/commands/merge.ts +355 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +143 -0
- package/src/commands/monitor.test.ts +191 -0
- package/src/commands/monitor.ts +390 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +372 -0
- package/src/commands/prime.test.ts +470 -0
- package/src/commands/prime.ts +381 -0
- package/src/commands/replay.test.ts +741 -0
- package/src/commands/replay.ts +360 -0
- package/src/commands/run.test.ts +431 -0
- package/src/commands/run.ts +351 -0
- package/src/commands/sling.test.ts +657 -0
- package/src/commands/sling.ts +661 -0
- package/src/commands/spec.test.ts +203 -0
- package/src/commands/spec.ts +168 -0
- package/src/commands/status.test.ts +430 -0
- package/src/commands/status.ts +398 -0
- package/src/commands/stop.test.ts +420 -0
- package/src/commands/stop.ts +151 -0
- package/src/commands/supervisor.test.ts +187 -0
- package/src/commands/supervisor.ts +535 -0
- package/src/commands/trace.test.ts +745 -0
- package/src/commands/trace.ts +325 -0
- package/src/commands/watch.test.ts +145 -0
- package/src/commands/watch.ts +247 -0
- package/src/commands/worktree.test.ts +786 -0
- package/src/commands/worktree.ts +311 -0
- package/src/config.test.ts +822 -0
- package/src/config.ts +829 -0
- package/src/doctor/agents.test.ts +454 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +190 -0
- package/src/doctor/config-check.ts +183 -0
- package/src/doctor/consistency.test.ts +651 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +290 -0
- package/src/doctor/databases.ts +218 -0
- package/src/doctor/dependencies.test.ts +184 -0
- package/src/doctor/dependencies.ts +175 -0
- package/src/doctor/logs.test.ts +251 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +216 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +291 -0
- package/src/doctor/structure.ts +198 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +136 -0
- package/src/doctor/version.ts +129 -0
- package/src/e2e/init-sling-lifecycle.test.ts +277 -0
- package/src/errors.ts +217 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +369 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/index.ts +316 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +142 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +813 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +259 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +773 -0
- package/src/mail/client.ts +223 -0
- package/src/mail/store.test.ts +705 -0
- package/src/mail/store.ts +387 -0
- package/src/merge/queue.test.ts +359 -0
- package/src/merge/queue.ts +231 -0
- package/src/merge/resolver.test.ts +1345 -0
- package/src/merge/resolver.ts +645 -0
- package/src/metrics/store.test.ts +667 -0
- package/src/metrics/store.ts +445 -0
- package/src/metrics/summary.test.ts +398 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +356 -0
- package/src/metrics/transcript.ts +175 -0
- package/src/mulch/client.test.ts +671 -0
- package/src/mulch/client.ts +332 -0
- package/src/sessions/compat.test.ts +280 -0
- package/src/sessions/compat.ts +104 -0
- package/src/sessions/store.test.ts +873 -0
- package/src/sessions/store.ts +494 -0
- package/src/test-helpers.test.ts +124 -0
- package/src/test-helpers.ts +126 -0
- package/src/tracker/beads.ts +56 -0
- package/src/tracker/factory.test.ts +80 -0
- package/src/tracker/factory.ts +64 -0
- package/src/tracker/seeds.ts +182 -0
- package/src/tracker/types.ts +52 -0
- package/src/types.ts +724 -0
- package/src/watchdog/daemon.test.ts +1975 -0
- package/src/watchdog/daemon.ts +671 -0
- package/src/watchdog/health.test.ts +431 -0
- package/src/watchdog/health.ts +264 -0
- package/src/watchdog/triage.test.ts +164 -0
- package/src/watchdog/triage.ts +179 -0
- package/src/worktree/manager.test.ts +439 -0
- package/src/worktree/manager.ts +198 -0
- package/src/worktree/tmux.test.ts +1009 -0
- package/src/worktree/tmux.ts +509 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +105 -0
- 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
|
+
}
|