@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,813 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import { access, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { LogEvent } from "../types.ts";
6
+ import { createLogger } from "./logger.ts";
7
+
8
+ describe("createLogger", () => {
9
+ let tempDir: string;
10
+ let logDir: string;
11
+
12
+ beforeEach(async () => {
13
+ tempDir = await mkdtemp(join(tmpdir(), "overstory-logger-test-"));
14
+ logDir = join(tempDir, "logs");
15
+ });
16
+
17
+ afterEach(async () => {
18
+ await rm(tempDir, { recursive: true, force: true });
19
+ });
20
+
21
+ async function readLogFile(filename: string): Promise<string> {
22
+ return readFile(join(logDir, filename), "utf-8");
23
+ }
24
+
25
+ async function readJsonLines(filename: string): Promise<LogEvent[]> {
26
+ const content = await readLogFile(filename);
27
+ return content
28
+ .trim()
29
+ .split("\n")
30
+ .filter((line) => line.length > 0)
31
+ .map((line) => JSON.parse(line) as LogEvent);
32
+ }
33
+
34
+ async function fileExists(filePath: string): Promise<boolean> {
35
+ try {
36
+ await access(filePath);
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ describe("lazy directory creation", () => {
44
+ test("does not create log directory until first write", async () => {
45
+ createLogger({ logDir, agentName: "test-agent" });
46
+
47
+ // Directory should NOT exist yet -- no write has been issued
48
+ const exists = await fileExists(logDir);
49
+ expect(exists).toBe(false);
50
+ });
51
+
52
+ test("creates log directory on first write", async () => {
53
+ const logger = createLogger({ logDir, agentName: "test-agent" });
54
+
55
+ logger.info("test.event");
56
+
57
+ // Give time for async mkdir + append to complete
58
+ await Bun.sleep(50);
59
+
60
+ const files = await readdir(logDir);
61
+ expect(files.length).toBeGreaterThan(0);
62
+
63
+ logger.close();
64
+ });
65
+ });
66
+
67
+ describe("info", () => {
68
+ test("writes to session.log and events.ndjson", async () => {
69
+ const logger = createLogger({ logDir, agentName: "test-agent" });
70
+
71
+ logger.info("test.event", { key: "value" });
72
+
73
+ await Bun.sleep(50);
74
+
75
+ const sessionLog = await readLogFile("session.log");
76
+ expect(sessionLog).toContain("INFO test.event");
77
+ expect(sessionLog).toContain("key=value");
78
+
79
+ const events = await readJsonLines("events.ndjson");
80
+ expect(events).toHaveLength(1);
81
+ expect(events[0]?.level).toBe("info");
82
+ expect(events[0]?.event).toBe("test.event");
83
+ expect(events[0]?.data.key).toBe("value");
84
+
85
+ logger.close();
86
+ });
87
+
88
+ test("does not write to errors.log", async () => {
89
+ const logger = createLogger({ logDir, agentName: "test-agent" });
90
+
91
+ logger.info("happy.path");
92
+
93
+ await Bun.sleep(50);
94
+
95
+ const exists = await fileExists(join(logDir, "errors.log"));
96
+ expect(exists).toBe(false);
97
+
98
+ logger.close();
99
+ });
100
+
101
+ test("works with empty data", async () => {
102
+ const logger = createLogger({ logDir, agentName: "test-agent" });
103
+
104
+ logger.info("simple.event");
105
+
106
+ await Bun.sleep(50);
107
+
108
+ const sessionLog = await readLogFile("session.log");
109
+ expect(sessionLog).toContain("INFO simple.event");
110
+
111
+ const events = await readJsonLines("events.ndjson");
112
+ expect(events[0]?.data).toEqual({});
113
+
114
+ logger.close();
115
+ });
116
+ });
117
+
118
+ describe("warn", () => {
119
+ test("writes to session.log and events.ndjson", async () => {
120
+ const logger = createLogger({ logDir, agentName: "test-agent" });
121
+
122
+ logger.warn("rate.limit", { remaining: 10 });
123
+
124
+ await Bun.sleep(50);
125
+
126
+ const sessionLog = await readLogFile("session.log");
127
+ expect(sessionLog).toContain("WARN rate.limit");
128
+ expect(sessionLog).toContain("remaining=10");
129
+
130
+ const events = await readJsonLines("events.ndjson");
131
+ expect(events[0]?.level).toBe("warn");
132
+ expect(events[0]?.event).toBe("rate.limit");
133
+
134
+ logger.close();
135
+ });
136
+
137
+ test("does not write to errors.log", async () => {
138
+ const logger = createLogger({ logDir, agentName: "test-agent" });
139
+
140
+ logger.warn("just.a.warning");
141
+
142
+ await Bun.sleep(50);
143
+
144
+ const exists = await fileExists(join(logDir, "errors.log"));
145
+ expect(exists).toBe(false);
146
+
147
+ logger.close();
148
+ });
149
+ });
150
+
151
+ describe("error", () => {
152
+ test("writes to session.log, events.ndjson, and errors.log", async () => {
153
+ const logger = createLogger({ logDir, agentName: "test-agent" });
154
+
155
+ const error = new Error("Something went wrong");
156
+ logger.error("request.failed", error, { statusCode: 500 });
157
+
158
+ await Bun.sleep(50);
159
+
160
+ const sessionLog = await readLogFile("session.log");
161
+ expect(sessionLog).toContain("ERROR request.failed");
162
+
163
+ const events = await readJsonLines("events.ndjson");
164
+ expect(events[0]?.level).toBe("error");
165
+ expect(events[0]?.event).toBe("request.failed");
166
+ expect(events[0]?.data.errorMessage).toBe("Something went wrong");
167
+ expect(events[0]?.data.errorName).toBe("Error");
168
+ expect(events[0]?.data.statusCode).toBe(500);
169
+
170
+ const errorsLog = await readLogFile("errors.log");
171
+ expect(errorsLog).toContain("Error: Something went wrong");
172
+ expect(errorsLog).toContain("request.failed");
173
+ expect(errorsLog).toContain("Stack Trace:");
174
+
175
+ logger.close();
176
+ });
177
+
178
+ test("includes error cause if present", async () => {
179
+ const logger = createLogger({ logDir, agentName: "test-agent" });
180
+
181
+ const cause = new Error("Root cause");
182
+ const error = new Error("Wrapper error", { cause });
183
+ logger.error("nested.error", error);
184
+
185
+ await Bun.sleep(50);
186
+
187
+ const errorsLog = await readLogFile("errors.log");
188
+ expect(errorsLog).toContain("Caused by: Error: Root cause");
189
+
190
+ logger.close();
191
+ });
192
+ });
193
+
194
+ describe("debug", () => {
195
+ test("writes to session.log and events.ndjson", async () => {
196
+ const logger = createLogger({ logDir, agentName: "test-agent" });
197
+
198
+ logger.debug("config.detail", { verbose: true });
199
+
200
+ await Bun.sleep(50);
201
+
202
+ const sessionLog = await readLogFile("session.log");
203
+ expect(sessionLog).toContain("DEBUG config.detail");
204
+
205
+ const events = await readJsonLines("events.ndjson");
206
+ expect(events[0]?.level).toBe("debug");
207
+
208
+ logger.close();
209
+ });
210
+
211
+ test("does not write to errors.log", async () => {
212
+ const logger = createLogger({ logDir, agentName: "test-agent" });
213
+
214
+ logger.debug("trace.detail");
215
+
216
+ await Bun.sleep(50);
217
+
218
+ const exists = await fileExists(join(logDir, "errors.log"));
219
+ expect(exists).toBe(false);
220
+
221
+ logger.close();
222
+ });
223
+ });
224
+
225
+ describe("toolStart and toolEnd", () => {
226
+ test("writes to session.log, events.ndjson, and tools.ndjson", async () => {
227
+ const logger = createLogger({ logDir, agentName: "test-agent" });
228
+
229
+ logger.toolStart("Read", { path: "/path/to/file" });
230
+ logger.toolEnd("Read", 150, "file contents");
231
+
232
+ await Bun.sleep(50);
233
+
234
+ const sessionLog = await readLogFile("session.log");
235
+ expect(sessionLog).toContain("tool.start");
236
+ expect(sessionLog).toContain("tool.end");
237
+
238
+ const events = await readJsonLines("events.ndjson");
239
+ expect(events).toHaveLength(2);
240
+ // Order not guaranteed due to async writes
241
+ const eventNames = events.map((e) => e.event);
242
+ expect(eventNames).toContain("tool.start");
243
+ expect(eventNames).toContain("tool.end");
244
+
245
+ const tools = await readJsonLines("tools.ndjson");
246
+ expect(tools).toHaveLength(2);
247
+ // Order not guaranteed due to async writes
248
+ const toolStart = tools.find((t) => t.event === "tool.start");
249
+ const toolEnd = tools.find((t) => t.event === "tool.end");
250
+ expect(toolStart).toBeDefined();
251
+ expect(toolStart?.data.toolName).toBe("Read");
252
+ expect(toolEnd).toBeDefined();
253
+ expect(toolEnd?.data.toolName).toBe("Read");
254
+ expect(toolEnd?.data.durationMs).toBe(150);
255
+ expect(toolEnd?.data.result).toBe("file contents");
256
+
257
+ logger.close();
258
+ });
259
+
260
+ test("toolStart includes args in tool event data", async () => {
261
+ const logger = createLogger({ logDir, agentName: "test-agent" });
262
+
263
+ logger.toolStart("Bash", { command: "ls -la", cwd: "/tmp" });
264
+
265
+ await Bun.sleep(50);
266
+
267
+ const tools = await readJsonLines("tools.ndjson");
268
+ const startEvent = tools.find((t) => t.event === "tool.start");
269
+ expect(startEvent?.data.args).toEqual({ command: "ls -la", cwd: "/tmp" });
270
+
271
+ logger.close();
272
+ });
273
+
274
+ test("toolEnd works without result", async () => {
275
+ const logger = createLogger({ logDir, agentName: "test-agent" });
276
+
277
+ logger.toolEnd("Write", 50);
278
+
279
+ await Bun.sleep(50);
280
+
281
+ const tools = await readJsonLines("tools.ndjson");
282
+ expect(tools[0]?.data.result).toBeUndefined();
283
+
284
+ logger.close();
285
+ });
286
+
287
+ test("does not write to errors.log", async () => {
288
+ const logger = createLogger({ logDir, agentName: "test-agent" });
289
+
290
+ logger.toolStart("Bash", { command: "echo test" });
291
+ logger.toolEnd("Bash", 10);
292
+
293
+ await Bun.sleep(50);
294
+
295
+ const exists = await fileExists(join(logDir, "errors.log"));
296
+ expect(exists).toBe(false);
297
+
298
+ logger.close();
299
+ });
300
+ });
301
+
302
+ describe("redaction", () => {
303
+ test("redacts secrets when redactSecrets is true (default)", async () => {
304
+ const logger = createLogger({ logDir, agentName: "test-agent" });
305
+
306
+ logger.info("api.call", { apiKey: "sk-ant-secret123" });
307
+
308
+ await Bun.sleep(50);
309
+
310
+ const events = await readJsonLines("events.ndjson");
311
+ expect(events[0]?.data.apiKey).toBe("[REDACTED]");
312
+
313
+ logger.close();
314
+ });
315
+
316
+ test("redacts secrets in session.log", async () => {
317
+ const logger = createLogger({ logDir, agentName: "test-agent" });
318
+
319
+ logger.info("api.call", { token: "ghp_abc123xyz" });
320
+
321
+ await Bun.sleep(50);
322
+
323
+ const sessionLog = await readLogFile("session.log");
324
+ expect(sessionLog).toContain("[REDACTED]");
325
+ expect(sessionLog).not.toContain("ghp_abc123xyz");
326
+
327
+ logger.close();
328
+ });
329
+
330
+ test("redacts secrets in error messages", async () => {
331
+ const logger = createLogger({ logDir, agentName: "test-agent" });
332
+
333
+ const error = new Error("Failed with key sk-ant-secret123");
334
+ logger.error("error.occurred", error);
335
+
336
+ await Bun.sleep(50);
337
+
338
+ const events = await readJsonLines("events.ndjson");
339
+ expect(events[0]?.data.errorMessage).toBe("Failed with key [REDACTED]");
340
+
341
+ logger.close();
342
+ });
343
+
344
+ test("redacts secrets in tool results", async () => {
345
+ const logger = createLogger({ logDir, agentName: "test-agent" });
346
+
347
+ logger.toolEnd("Bash", 100, "export ANTHROPIC_API_KEY=sk-ant-secret");
348
+
349
+ await Bun.sleep(50);
350
+
351
+ const tools = await readJsonLines("tools.ndjson");
352
+ expect(tools[0]?.data.result).toBe("export [REDACTED]");
353
+
354
+ logger.close();
355
+ });
356
+
357
+ test("does not redact secrets when redactSecrets is false", async () => {
358
+ const logger = createLogger({
359
+ logDir,
360
+ agentName: "test-agent",
361
+ redactSecrets: false,
362
+ });
363
+
364
+ logger.info("api.call", { apiKey: "sk-ant-secret123" });
365
+
366
+ await Bun.sleep(50);
367
+
368
+ const events = await readJsonLines("events.ndjson");
369
+ expect(events[0]?.data.apiKey).toBe("sk-ant-secret123");
370
+
371
+ logger.close();
372
+ });
373
+
374
+ test("does not redact when redactSecrets is false for error messages", async () => {
375
+ const logger = createLogger({
376
+ logDir,
377
+ agentName: "test-agent",
378
+ redactSecrets: false,
379
+ });
380
+
381
+ const error = new Error("Key is sk-ant-secret123");
382
+ logger.error("err", error);
383
+
384
+ await Bun.sleep(50);
385
+
386
+ const events = await readJsonLines("events.ndjson");
387
+ expect(events[0]?.data.errorMessage).toBe("Key is sk-ant-secret123");
388
+
389
+ logger.close();
390
+ });
391
+
392
+ test("does not redact when redactSecrets is false for tool results", async () => {
393
+ const logger = createLogger({
394
+ logDir,
395
+ agentName: "test-agent",
396
+ redactSecrets: false,
397
+ });
398
+
399
+ logger.toolEnd("Bash", 10, "Bearer my-token-value");
400
+
401
+ await Bun.sleep(50);
402
+
403
+ const tools = await readJsonLines("tools.ndjson");
404
+ expect(tools[0]?.data.result).toBe("Bearer my-token-value");
405
+
406
+ logger.close();
407
+ });
408
+ });
409
+
410
+ describe("verbose mode", () => {
411
+ let consoleLogSpy: ReturnType<typeof mock>;
412
+
413
+ beforeEach(() => {
414
+ consoleLogSpy = mock(() => {});
415
+ console.log = consoleLogSpy;
416
+ });
417
+
418
+ afterEach(() => {
419
+ consoleLogSpy.mockRestore();
420
+ });
421
+
422
+ test("suppresses debug console output when verbose is false", () => {
423
+ const logger = createLogger({
424
+ logDir,
425
+ agentName: "test-agent",
426
+ verbose: false,
427
+ });
428
+
429
+ logger.debug("debug.event");
430
+
431
+ // Console should not be called
432
+ expect(consoleLogSpy).toHaveBeenCalledTimes(0);
433
+
434
+ logger.close();
435
+ });
436
+
437
+ test("prints debug console output when verbose is true", () => {
438
+ const logger = createLogger({
439
+ logDir,
440
+ agentName: "test-agent",
441
+ verbose: true,
442
+ });
443
+
444
+ logger.debug("debug.event");
445
+
446
+ // Console should be called
447
+ expect(consoleLogSpy).toHaveBeenCalledTimes(1);
448
+
449
+ logger.close();
450
+ });
451
+
452
+ test("always prints non-debug events regardless of verbose", () => {
453
+ const logger = createLogger({
454
+ logDir,
455
+ agentName: "test-agent",
456
+ verbose: false,
457
+ });
458
+
459
+ logger.info("info.event");
460
+
461
+ // Console should be called even with verbose=false
462
+ expect(consoleLogSpy).toHaveBeenCalledTimes(1);
463
+
464
+ logger.close();
465
+ });
466
+ });
467
+
468
+ describe("close", () => {
469
+ test("prevents further writes after close", async () => {
470
+ const logger = createLogger({ logDir, agentName: "test-agent" });
471
+
472
+ logger.info("before.close");
473
+ await Bun.sleep(50);
474
+
475
+ logger.close();
476
+
477
+ logger.info("after.close");
478
+ await Bun.sleep(50);
479
+
480
+ const events = await readJsonLines("events.ndjson");
481
+ // Should only have the event before close
482
+ expect(events).toHaveLength(1);
483
+ expect(events[0]?.event).toBe("before.close");
484
+ });
485
+
486
+ test("prevents tool writes after close", async () => {
487
+ const logger = createLogger({ logDir, agentName: "test-agent" });
488
+
489
+ logger.toolStart("Read", { path: "/tmp/a" });
490
+ await Bun.sleep(50);
491
+
492
+ logger.close();
493
+
494
+ logger.toolStart("Read", { path: "/tmp/b" });
495
+ logger.toolEnd("Read", 100);
496
+ await Bun.sleep(50);
497
+
498
+ const tools = await readJsonLines("tools.ndjson");
499
+ expect(tools).toHaveLength(1);
500
+ expect(tools[0]?.event).toBe("tool.start");
501
+ });
502
+
503
+ test("prevents error writes after close", async () => {
504
+ const logger = createLogger({ logDir, agentName: "test-agent" });
505
+
506
+ logger.error("first.error", new Error("before"));
507
+ await Bun.sleep(50);
508
+
509
+ logger.close();
510
+
511
+ logger.error("second.error", new Error("after"));
512
+ await Bun.sleep(50);
513
+
514
+ const events = await readJsonLines("events.ndjson");
515
+ expect(events).toHaveLength(1);
516
+ expect(events[0]?.event).toBe("first.error");
517
+ });
518
+ });
519
+
520
+ describe("agentName", () => {
521
+ test("includes agentName in all logged events", async () => {
522
+ const logger = createLogger({ logDir, agentName: "scout-1" });
523
+
524
+ logger.info("test.event");
525
+
526
+ await Bun.sleep(50);
527
+
528
+ const events = await readJsonLines("events.ndjson");
529
+ expect(events[0]?.agentName).toBe("scout-1");
530
+
531
+ logger.close();
532
+ });
533
+
534
+ test("includes agentName in tool events", async () => {
535
+ const logger = createLogger({ logDir, agentName: "builder-3" });
536
+
537
+ logger.toolStart("Write", { path: "/tmp/out" });
538
+
539
+ await Bun.sleep(50);
540
+
541
+ const tools = await readJsonLines("tools.ndjson");
542
+ expect(tools[0]?.agentName).toBe("builder-3");
543
+
544
+ logger.close();
545
+ });
546
+
547
+ test("includes agentName in errors.log", async () => {
548
+ const logger = createLogger({ logDir, agentName: "merger-2" });
549
+
550
+ logger.error("merge.failed", new Error("conflict"));
551
+
552
+ await Bun.sleep(50);
553
+
554
+ const errorsLog = await readLogFile("errors.log");
555
+ expect(errorsLog).toContain("Agent: merger-2");
556
+
557
+ logger.close();
558
+ });
559
+ });
560
+
561
+ describe("error isolation", () => {
562
+ test("continues logging after write errors (no crash)", async () => {
563
+ // Create logger with an invalid path to trigger write errors
564
+ const invalidLogDir = join(tempDir, "nonexistent", "very", "deep", "path");
565
+ const logger = createLogger({ logDir: invalidLogDir, agentName: "test-agent" });
566
+
567
+ // These should not throw even if writes fail
568
+ expect(() => logger.info("test1")).not.toThrow();
569
+ expect(() => logger.error("test2", new Error("test"))).not.toThrow();
570
+ expect(() => logger.debug("test3")).not.toThrow();
571
+ expect(() => logger.toolStart("Bash", { cmd: "ls" })).not.toThrow();
572
+ expect(() => logger.toolEnd("Bash", 10)).not.toThrow();
573
+
574
+ logger.close();
575
+ });
576
+ });
577
+
578
+ describe("session.log format", () => {
579
+ test("uses [ISO_TIMESTAMP] LEVEL event key=value format", async () => {
580
+ const logger = createLogger({ logDir, agentName: "test-agent" });
581
+
582
+ logger.info("task.completed", { taskId: "task-123", duration: 5000 });
583
+
584
+ await Bun.sleep(50);
585
+
586
+ const sessionLog = await readLogFile("session.log");
587
+ // Format: [TIMESTAMP] LEVEL EVENT key=value key=value\n
588
+ expect(sessionLog).toMatch(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\]/);
589
+ expect(sessionLog).toContain("INFO task.completed");
590
+ expect(sessionLog).toContain("taskId=task-123");
591
+ expect(sessionLog).toContain("duration=5000");
592
+
593
+ logger.close();
594
+ });
595
+
596
+ test("quotes string values containing spaces", async () => {
597
+ const logger = createLogger({ logDir, agentName: "test-agent" });
598
+
599
+ logger.info("deploy.start", { env: "production west" });
600
+
601
+ await Bun.sleep(50);
602
+
603
+ const sessionLog = await readLogFile("session.log");
604
+ expect(sessionLog).toContain('env="production west"');
605
+
606
+ logger.close();
607
+ });
608
+
609
+ test("renders null and undefined values as key=null", async () => {
610
+ const logger = createLogger({ logDir, agentName: "test-agent" });
611
+
612
+ logger.info("check.result", { passed: null, detail: undefined });
613
+
614
+ await Bun.sleep(50);
615
+
616
+ const sessionLog = await readLogFile("session.log");
617
+ expect(sessionLog).toContain("passed=null");
618
+ expect(sessionLog).toContain("detail=null");
619
+
620
+ logger.close();
621
+ });
622
+
623
+ test("JSON stringifies object values", async () => {
624
+ const logger = createLogger({ logDir, agentName: "test-agent" });
625
+
626
+ logger.info("config.loaded", { options: { retries: 3, timeout: 1000 } });
627
+
628
+ await Bun.sleep(50);
629
+
630
+ const sessionLog = await readLogFile("session.log");
631
+ expect(sessionLog).toContain('options={"retries":3,"timeout":1000}');
632
+
633
+ logger.close();
634
+ });
635
+
636
+ test("renders simple string values without quotes", async () => {
637
+ const logger = createLogger({ logDir, agentName: "test-agent" });
638
+
639
+ logger.info("agent.started", { name: "scout-1" });
640
+
641
+ await Bun.sleep(50);
642
+
643
+ const sessionLog = await readLogFile("session.log");
644
+ expect(sessionLog).toContain("name=scout-1");
645
+ // Should NOT be quoted since there are no spaces
646
+ expect(sessionLog).not.toContain('name="scout-1"');
647
+
648
+ logger.close();
649
+ });
650
+
651
+ test("renders boolean values", async () => {
652
+ const logger = createLogger({ logDir, agentName: "test-agent" });
653
+
654
+ logger.info("feature.flag", { enabled: true, deprecated: false });
655
+
656
+ await Bun.sleep(50);
657
+
658
+ const sessionLog = await readLogFile("session.log");
659
+ expect(sessionLog).toContain("enabled=true");
660
+ expect(sessionLog).toContain("deprecated=false");
661
+
662
+ logger.close();
663
+ });
664
+
665
+ test("renders event without key=value suffix when data is empty", async () => {
666
+ const logger = createLogger({ logDir, agentName: "test-agent" });
667
+
668
+ logger.info("heartbeat");
669
+
670
+ await Bun.sleep(50);
671
+
672
+ const sessionLog = await readLogFile("session.log");
673
+ // Should end with just the event name and newline, no trailing space
674
+ expect(sessionLog).toMatch(/INFO heartbeat\n$/);
675
+
676
+ logger.close();
677
+ });
678
+ });
679
+
680
+ describe("events.ndjson format", () => {
681
+ test("each line is a valid JSON LogEvent with all required fields", async () => {
682
+ const logger = createLogger({ logDir, agentName: "test-agent" });
683
+
684
+ logger.info("check.fields", { someKey: 42 });
685
+
686
+ await Bun.sleep(50);
687
+
688
+ const events = await readJsonLines("events.ndjson");
689
+ const event = events[0];
690
+ expect(event).toBeDefined();
691
+
692
+ // Verify all LogEvent fields are present
693
+ expect(event?.timestamp).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/);
694
+ expect(event?.level).toBe("info");
695
+ expect(event?.event).toBe("check.fields");
696
+ expect(event?.agentName).toBe("test-agent");
697
+ expect(event?.data).toEqual({ someKey: 42 });
698
+
699
+ logger.close();
700
+ });
701
+
702
+ test("uses valid NDJSON format with one JSON object per line", async () => {
703
+ const logger = createLogger({ logDir, agentName: "test-agent" });
704
+
705
+ logger.info("event1");
706
+ logger.info("event2");
707
+ logger.info("event3");
708
+
709
+ await Bun.sleep(50);
710
+
711
+ const content = await readLogFile("events.ndjson");
712
+ const lines = content.trim().split("\n");
713
+
714
+ expect(lines).toHaveLength(3);
715
+
716
+ // Each line should be valid JSON
717
+ for (const line of lines) {
718
+ expect(() => JSON.parse(line)).not.toThrow();
719
+ }
720
+
721
+ logger.close();
722
+ });
723
+
724
+ test("tool events also appear in events.ndjson", async () => {
725
+ const logger = createLogger({ logDir, agentName: "test-agent" });
726
+
727
+ logger.toolStart("Bash", { command: "ls" });
728
+
729
+ await Bun.sleep(50);
730
+
731
+ const events = await readJsonLines("events.ndjson");
732
+ expect(events).toHaveLength(1);
733
+ expect(events[0]?.event).toBe("tool.start");
734
+
735
+ logger.close();
736
+ });
737
+ });
738
+
739
+ describe("errors.log format", () => {
740
+ test("includes separator lines, timestamp, event, agent, error, and stack", async () => {
741
+ const logger = createLogger({ logDir, agentName: "test-agent" });
742
+
743
+ const error = new Error("Test error");
744
+ logger.error("test.error", error);
745
+
746
+ await Bun.sleep(50);
747
+
748
+ const errorsLog = await readLogFile("errors.log");
749
+
750
+ // Separator is 72 '=' characters
751
+ expect(errorsLog).toContain("=".repeat(72));
752
+ expect(errorsLog).toContain("Timestamp:");
753
+ expect(errorsLog).toContain("Event: test.error");
754
+ expect(errorsLog).toContain("Agent: test-agent");
755
+ expect(errorsLog).toContain("Error: Error: Test error");
756
+ expect(errorsLog).toContain("Stack Trace:");
757
+
758
+ logger.close();
759
+ });
760
+
761
+ test("includes Data field when data is provided", async () => {
762
+ const logger = createLogger({ logDir, agentName: "test-agent" });
763
+
764
+ logger.error("db.error", new Error("connection refused"), { host: "localhost" });
765
+
766
+ await Bun.sleep(50);
767
+
768
+ const errorsLog = await readLogFile("errors.log");
769
+ expect(errorsLog).toContain("Data:");
770
+ expect(errorsLog).toContain('"host"');
771
+
772
+ logger.close();
773
+ });
774
+
775
+ test("includes cause chain when error.cause exists", async () => {
776
+ const logger = createLogger({ logDir, agentName: "test-agent" });
777
+
778
+ const rootCause = new TypeError("null reference");
779
+ const mid = new Error("query failed", { cause: rootCause });
780
+ logger.error("deep.error", mid);
781
+
782
+ await Bun.sleep(50);
783
+
784
+ const errorsLog = await readLogFile("errors.log");
785
+ expect(errorsLog).toContain("Caused by: TypeError: null reference");
786
+
787
+ logger.close();
788
+ });
789
+ });
790
+
791
+ describe("multiple events", () => {
792
+ test("logs multiple events across all levels", async () => {
793
+ const logger = createLogger({ logDir, agentName: "test-agent" });
794
+
795
+ logger.info("event1");
796
+ logger.warn("event2");
797
+ logger.error("event3", new Error("test"));
798
+
799
+ await Bun.sleep(50);
800
+
801
+ const events = await readJsonLines("events.ndjson");
802
+ expect(events).toHaveLength(3);
803
+
804
+ // Check that all events are present (order not guaranteed due to async writes)
805
+ const eventNames = events.map((e) => e.event);
806
+ expect(eventNames).toContain("event1");
807
+ expect(eventNames).toContain("event2");
808
+ expect(eventNames).toContain("event3");
809
+
810
+ logger.close();
811
+ });
812
+ });
813
+ });