@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,1454 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, readdir, rm, stat } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { ValidationError } from "../errors.ts";
6
+ import { createEventStore } from "../events/store.ts";
7
+ import { createMailClient } from "../mail/client.ts";
8
+ import { createMailStore } from "../mail/store.ts";
9
+ import { createMetricsStore } from "../metrics/store.ts";
10
+ import type { MulchClient } from "../mulch/client.ts";
11
+ import { createRunStore, createSessionStore } from "../sessions/store.ts";
12
+ import type { AgentSession, MulchLearnResult, StoredEvent } from "../types.ts";
13
+ import { autoRecordExpertise, logCommand } from "./log.ts";
14
+
15
+ /**
16
+ * Tests for `overstory log` command.
17
+ *
18
+ * Uses real filesystem (temp dirs) and real bun:sqlite to test logging behavior.
19
+ * Captures process.stdout.write to verify help text output.
20
+ */
21
+
22
+ describe("logCommand", () => {
23
+ let chunks: string[];
24
+ let originalWrite: typeof process.stdout.write;
25
+ let tempDir: string;
26
+ let originalCwd: string;
27
+
28
+ beforeEach(async () => {
29
+ // Spy on stdout
30
+ chunks = [];
31
+ originalWrite = process.stdout.write;
32
+ process.stdout.write = ((chunk: string) => {
33
+ chunks.push(chunk);
34
+ return true;
35
+ }) as typeof process.stdout.write;
36
+
37
+ // Create temp dir with .overstory/config.yaml structure
38
+ tempDir = await mkdtemp(join(tmpdir(), "log-test-"));
39
+ const overstoryDir = join(tempDir, ".overstory");
40
+ await Bun.write(
41
+ join(overstoryDir, "config.yaml"),
42
+ `project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
43
+ );
44
+
45
+ // Change to temp dir so loadConfig() works
46
+ originalCwd = process.cwd();
47
+ process.chdir(tempDir);
48
+ });
49
+
50
+ afterEach(async () => {
51
+ process.stdout.write = originalWrite;
52
+ process.chdir(originalCwd);
53
+ await rm(tempDir, { recursive: true, force: true });
54
+ });
55
+
56
+ function output(): string {
57
+ return chunks.join("");
58
+ }
59
+
60
+ /**
61
+ * Fake MulchClient for testing autoRecordExpertise.
62
+ * Only learn() and record() are implemented — other methods are stubs.
63
+ * Justified: we are testing orchestration logic, not the mulch CLI itself.
64
+ */
65
+ function createFakeMulchClient(
66
+ learnResult: MulchLearnResult,
67
+ opts?: { recordShouldFail?: boolean },
68
+ ): {
69
+ client: MulchClient;
70
+ recordCalls: Array<{ domain: string; options: Record<string, unknown> }>;
71
+ } {
72
+ const recordCalls: Array<{ domain: string; options: Record<string, unknown> }> = [];
73
+ const client = {
74
+ async learn() {
75
+ return learnResult;
76
+ },
77
+ async record(domain: string, options: Record<string, unknown>) {
78
+ if (opts?.recordShouldFail) {
79
+ throw new Error("mulch record failed");
80
+ }
81
+ recordCalls.push({ domain, options });
82
+ },
83
+ } as unknown as MulchClient;
84
+ return { client, recordCalls };
85
+ }
86
+
87
+ test("--help flag shows help text", async () => {
88
+ await logCommand(["--help"]);
89
+ const out = output();
90
+
91
+ expect(out).toContain("overstory log");
92
+ expect(out).toContain("tool-start");
93
+ expect(out).toContain("tool-end");
94
+ expect(out).toContain("session-end");
95
+ expect(out).toContain("--agent");
96
+ });
97
+
98
+ test("-h flag shows help text", async () => {
99
+ await logCommand(["-h"]);
100
+ const out = output();
101
+
102
+ expect(out).toContain("overstory log");
103
+ expect(out).toContain("tool-start");
104
+ expect(out).toContain("tool-end");
105
+ expect(out).toContain("session-end");
106
+ expect(out).toContain("--agent");
107
+ });
108
+
109
+ test("missing event with only flags throws ValidationError", async () => {
110
+ // The code finds first non-flag arg. Passing only flags should trigger "Event is required"
111
+ // Note: the implementation checks for undefined event
112
+ await expect(async () => {
113
+ await logCommand([]);
114
+ }).toThrow(ValidationError);
115
+
116
+ await expect(async () => {
117
+ await logCommand([]);
118
+ }).toThrow("Event is required");
119
+ });
120
+
121
+ test("invalid event name throws ValidationError", async () => {
122
+ expect(async () => {
123
+ await logCommand(["invalid-event", "--agent", "test-agent"]);
124
+ }).toThrow(ValidationError);
125
+
126
+ expect(async () => {
127
+ await logCommand(["invalid-event", "--agent", "test-agent"]);
128
+ }).toThrow("Invalid event");
129
+ });
130
+
131
+ test("missing --agent flag throws ValidationError", async () => {
132
+ expect(async () => {
133
+ await logCommand(["tool-start"]);
134
+ }).toThrow(ValidationError);
135
+
136
+ expect(async () => {
137
+ await logCommand(["tool-start"]);
138
+ }).toThrow("--agent is required");
139
+ });
140
+
141
+ test("tool-start creates log directory structure", async () => {
142
+ await logCommand(["tool-start", "--agent", "test-builder", "--tool-name", "Read"]);
143
+
144
+ const logsDir = join(tempDir, ".overstory", "logs", "test-builder");
145
+ const contents = await readdir(logsDir);
146
+
147
+ // Should have at least .current-session marker and a session directory
148
+ expect(contents).toContain(".current-session");
149
+ expect(contents.length).toBeGreaterThanOrEqual(2);
150
+ });
151
+
152
+ test("tool-start creates session directory and .current-session marker", async () => {
153
+ await logCommand(["tool-start", "--agent", "test-scout", "--tool-name", "Grep"]);
154
+
155
+ const logsDir = join(tempDir, ".overstory", "logs", "test-scout");
156
+ const markerPath = join(logsDir, ".current-session");
157
+ const markerFile = Bun.file(markerPath);
158
+
159
+ expect(await markerFile.exists()).toBe(true);
160
+
161
+ const sessionDir = (await markerFile.text()).trim();
162
+ expect(sessionDir).toBeTruthy();
163
+ expect(sessionDir).toContain(logsDir);
164
+
165
+ // Session directory should exist
166
+ const dirStat = await stat(sessionDir);
167
+ expect(dirStat.isDirectory()).toBe(true);
168
+ });
169
+
170
+ test("tool-start creates log files in session directory", async () => {
171
+ await logCommand(["tool-start", "--agent", "test-builder", "--tool-name", "Write"]);
172
+
173
+ // Wait for async file writes to complete
174
+ await new Promise((resolve) => setTimeout(resolve, 50));
175
+
176
+ const logsDir = join(tempDir, ".overstory", "logs", "test-builder");
177
+ const markerPath = join(logsDir, ".current-session");
178
+ const sessionDir = (await Bun.file(markerPath).text()).trim();
179
+
180
+ // Check for events.ndjson file
181
+ const eventsFile = Bun.file(join(sessionDir, "events.ndjson"));
182
+ expect(await eventsFile.exists()).toBe(true);
183
+ });
184
+
185
+ test("tool-end uses the same session directory as tool-start", async () => {
186
+ await logCommand(["tool-start", "--agent", "test-agent", "--tool-name", "Edit"]);
187
+
188
+ const logsDir = join(tempDir, ".overstory", "logs", "test-agent");
189
+ const markerPath = join(logsDir, ".current-session");
190
+ const sessionDirAfterStart = (await Bun.file(markerPath).text()).trim();
191
+
192
+ await logCommand(["tool-end", "--agent", "test-agent", "--tool-name", "Edit"]);
193
+
194
+ const sessionDirAfterEnd = (await Bun.file(markerPath).text()).trim();
195
+ expect(sessionDirAfterEnd).toBe(sessionDirAfterStart);
196
+ });
197
+
198
+ test("tool-end writes to the same session directory", async () => {
199
+ await logCommand(["tool-start", "--agent", "test-worker", "--tool-name", "Bash"]);
200
+ await logCommand(["tool-end", "--agent", "test-worker", "--tool-name", "Bash"]);
201
+
202
+ // Wait for async file writes to complete
203
+ await new Promise((resolve) => setTimeout(resolve, 50));
204
+
205
+ const logsDir = join(tempDir, ".overstory", "logs", "test-worker");
206
+ const markerPath = join(logsDir, ".current-session");
207
+ const sessionDir = (await Bun.file(markerPath).text()).trim();
208
+
209
+ // Events file should contain both tool-start and tool-end events
210
+ const eventsFile = Bun.file(join(sessionDir, "events.ndjson"));
211
+ const eventsContent = await eventsFile.text();
212
+
213
+ expect(eventsContent).toContain("tool.start");
214
+ expect(eventsContent).toContain("tool.end");
215
+ });
216
+
217
+ test("session-end transitions agent state to completed in sessions.db", async () => {
218
+ // Create sessions.db with a test agent
219
+ const dbPath = join(tempDir, ".overstory", "sessions.db");
220
+ const session: AgentSession = {
221
+ id: "session-001",
222
+ agentName: "test-agent",
223
+ capability: "builder",
224
+ worktreePath: "/tmp/test",
225
+ branchName: "test-branch",
226
+ beadId: "bead-001",
227
+ tmuxSession: "test-tmux",
228
+ state: "working",
229
+ pid: 12345,
230
+ parentAgent: null,
231
+ depth: 0,
232
+ runId: null,
233
+ startedAt: new Date().toISOString(),
234
+ lastActivity: new Date().toISOString(),
235
+ escalationLevel: 0,
236
+ stalledSince: null,
237
+ };
238
+ const store = createSessionStore(dbPath);
239
+ store.upsert(session);
240
+ store.close();
241
+
242
+ await logCommand(["session-end", "--agent", "test-agent"]);
243
+
244
+ // Read sessions.db and verify state changed to completed
245
+ const readStore = createSessionStore(dbPath);
246
+ const updatedSession = readStore.getByName("test-agent");
247
+ readStore.close();
248
+
249
+ expect(updatedSession).toBeDefined();
250
+ expect(updatedSession?.state).toBe("completed");
251
+ });
252
+
253
+ test("session-end clears the .current-session marker", async () => {
254
+ // First create a session with tool-start
255
+ await logCommand(["tool-start", "--agent", "test-cleanup", "--tool-name", "Read"]);
256
+
257
+ const logsDir = join(tempDir, ".overstory", "logs", "test-cleanup");
258
+ const markerPath = join(logsDir, ".current-session");
259
+
260
+ // Verify marker exists before session-end
261
+ let markerFile = Bun.file(markerPath);
262
+ expect(await markerFile.exists()).toBe(true);
263
+
264
+ // Now end the session
265
+ await logCommand(["session-end", "--agent", "test-cleanup"]);
266
+
267
+ // Marker should be removed - need to create a new Bun.file reference
268
+ markerFile = Bun.file(markerPath);
269
+ expect(await markerFile.exists()).toBe(false);
270
+ });
271
+
272
+ test("session-end records metrics when agent session exists in sessions.db", async () => {
273
+ // Create sessions.db with a test agent
274
+ const sessionsDbPath = join(tempDir, ".overstory", "sessions.db");
275
+ const session: AgentSession = {
276
+ id: "session-002",
277
+ agentName: "metrics-agent",
278
+ capability: "scout",
279
+ worktreePath: "/tmp/metrics",
280
+ branchName: "metrics-branch",
281
+ beadId: "bead-002",
282
+ tmuxSession: "metrics-tmux",
283
+ state: "working",
284
+ pid: 54321,
285
+ parentAgent: "parent-agent",
286
+ depth: 1,
287
+ runId: null,
288
+ startedAt: new Date(Date.now() - 60_000).toISOString(), // 1 minute ago
289
+ lastActivity: new Date().toISOString(),
290
+ escalationLevel: 0,
291
+ stalledSince: null,
292
+ };
293
+ const sessStore = createSessionStore(sessionsDbPath);
294
+ sessStore.upsert(session);
295
+ sessStore.close();
296
+
297
+ await logCommand(["session-end", "--agent", "metrics-agent"]);
298
+
299
+ // Verify metrics.db was created and has the session record
300
+ const metricsDbPath = join(tempDir, ".overstory", "metrics.db");
301
+ const metricsStore = createMetricsStore(metricsDbPath);
302
+ const metrics = metricsStore.getRecentSessions(1);
303
+ metricsStore.close();
304
+
305
+ expect(metrics).toHaveLength(1);
306
+ expect(metrics[0]?.agentName).toBe("metrics-agent");
307
+ expect(metrics[0]?.beadId).toBe("bead-002");
308
+ expect(metrics[0]?.capability).toBe("scout");
309
+ expect(metrics[0]?.parentAgent).toBe("parent-agent");
310
+ });
311
+
312
+ test("session-end does NOT transition coordinator to completed (persistent agent)", async () => {
313
+ // Create sessions.db with a coordinator agent
314
+ const dbPath = join(tempDir, ".overstory", "sessions.db");
315
+ const session: AgentSession = {
316
+ id: "session-coord",
317
+ agentName: "coordinator",
318
+ capability: "coordinator",
319
+ worktreePath: tempDir,
320
+ branchName: "main",
321
+ beadId: "",
322
+ tmuxSession: "overstory-coordinator",
323
+ state: "working",
324
+ pid: 11111,
325
+ parentAgent: null,
326
+ depth: 0,
327
+ runId: null,
328
+ startedAt: new Date().toISOString(),
329
+ lastActivity: new Date(Date.now() - 60_000).toISOString(),
330
+ escalationLevel: 0,
331
+ stalledSince: null,
332
+ };
333
+ const store = createSessionStore(dbPath);
334
+ store.upsert(session);
335
+ store.close();
336
+
337
+ await logCommand(["session-end", "--agent", "coordinator"]);
338
+
339
+ // Coordinator should remain 'working', not transition to 'completed'
340
+ const readStore = createSessionStore(dbPath);
341
+ const updatedSession = readStore.getByName("coordinator");
342
+ readStore.close();
343
+
344
+ expect(updatedSession).toBeDefined();
345
+ expect(updatedSession?.state).toBe("working");
346
+ // But lastActivity should be updated
347
+ expect(new Date(updatedSession?.lastActivity ?? "").getTime()).toBeGreaterThan(
348
+ new Date(session.lastActivity).getTime(),
349
+ );
350
+ });
351
+
352
+ test("session-end does NOT transition monitor to completed (persistent agent)", async () => {
353
+ const dbPath = join(tempDir, ".overstory", "sessions.db");
354
+ const session: AgentSession = {
355
+ id: "session-mon",
356
+ agentName: "monitor",
357
+ capability: "monitor",
358
+ worktreePath: tempDir,
359
+ branchName: "main",
360
+ beadId: "",
361
+ tmuxSession: "overstory-monitor",
362
+ state: "working",
363
+ pid: 22222,
364
+ parentAgent: null,
365
+ depth: 0,
366
+ runId: null,
367
+ startedAt: new Date().toISOString(),
368
+ lastActivity: new Date(Date.now() - 60_000).toISOString(),
369
+ escalationLevel: 0,
370
+ stalledSince: null,
371
+ };
372
+ const store = createSessionStore(dbPath);
373
+ store.upsert(session);
374
+ store.close();
375
+
376
+ await logCommand(["session-end", "--agent", "monitor"]);
377
+
378
+ const readStore = createSessionStore(dbPath);
379
+ const updatedSession = readStore.getByName("monitor");
380
+ readStore.close();
381
+
382
+ expect(updatedSession).toBeDefined();
383
+ expect(updatedSession?.state).toBe("working");
384
+ });
385
+
386
+ describe("session-end coordinator run completion", () => {
387
+ test("session-end auto-completes the active run for coordinator agent", async () => {
388
+ // Create sessions.db with coordinator and a run
389
+ const dbPath = join(tempDir, ".overstory", "sessions.db");
390
+ const sessionStoreLocal = createSessionStore(dbPath);
391
+ sessionStoreLocal.upsert({
392
+ id: "session-coord-run",
393
+ agentName: "coordinator",
394
+ capability: "coordinator",
395
+ worktreePath: tempDir,
396
+ branchName: "main",
397
+ beadId: "",
398
+ tmuxSession: "overstory-coordinator",
399
+ state: "working",
400
+ pid: 11111,
401
+ parentAgent: null,
402
+ depth: 0,
403
+ runId: "run-test-001",
404
+ startedAt: new Date().toISOString(),
405
+ lastActivity: new Date().toISOString(),
406
+ escalationLevel: 0,
407
+ stalledSince: null,
408
+ });
409
+ sessionStoreLocal.close();
410
+
411
+ // Create the run
412
+ const runStore = createRunStore(dbPath);
413
+ runStore.createRun({
414
+ id: "run-test-001",
415
+ startedAt: new Date().toISOString(),
416
+ coordinatorSessionId: "session-coord-run",
417
+ status: "active",
418
+ });
419
+ runStore.close();
420
+
421
+ // Write current-run.txt
422
+ const currentRunPath = join(tempDir, ".overstory", "current-run.txt");
423
+ await Bun.write(currentRunPath, "run-test-001");
424
+
425
+ // Verify current-run.txt exists before test
426
+ expect(await Bun.file(currentRunPath).exists()).toBe(true);
427
+
428
+ // Call session-end
429
+ await logCommand(["session-end", "--agent", "coordinator"]);
430
+
431
+ // Verify: run status is "completed" in RunStore
432
+ const runStoreRead = createRunStore(dbPath);
433
+ const run = runStoreRead.getRun("run-test-001");
434
+ runStoreRead.close();
435
+
436
+ expect(run).toBeDefined();
437
+ expect(run?.status).toBe("completed");
438
+ expect(run?.completedAt).toBeTruthy();
439
+
440
+ // Verify: current-run.txt is deleted (create fresh file reference)
441
+ expect(await Bun.file(currentRunPath).exists()).toBe(false);
442
+ });
443
+
444
+ test("session-end does not fail when no active run for coordinator", async () => {
445
+ // Create a coordinator session but no current-run.txt
446
+ const dbPath = join(tempDir, ".overstory", "sessions.db");
447
+ const sessionStoreLocal = createSessionStore(dbPath);
448
+ sessionStoreLocal.upsert({
449
+ id: "session-coord-no-run",
450
+ agentName: "coordinator-no-run",
451
+ capability: "coordinator",
452
+ worktreePath: tempDir,
453
+ branchName: "main",
454
+ beadId: "",
455
+ tmuxSession: "overstory-coordinator-no-run",
456
+ state: "working",
457
+ pid: 11112,
458
+ parentAgent: null,
459
+ depth: 0,
460
+ runId: null,
461
+ startedAt: new Date().toISOString(),
462
+ lastActivity: new Date().toISOString(),
463
+ escalationLevel: 0,
464
+ stalledSince: null,
465
+ });
466
+ sessionStoreLocal.close();
467
+
468
+ // Call session-end (should not throw)
469
+ await expect(async () => {
470
+ await logCommand(["session-end", "--agent", "coordinator-no-run"]);
471
+ }).not.toThrow();
472
+ });
473
+
474
+ test("session-end does not complete run for non-coordinator agents", async () => {
475
+ // Create a builder session, create a run, write current-run.txt
476
+ const dbPath = join(tempDir, ".overstory", "sessions.db");
477
+ const sessionStoreLocal = createSessionStore(dbPath);
478
+ sessionStoreLocal.upsert({
479
+ id: "session-builder-run",
480
+ agentName: "test-builder",
481
+ capability: "builder",
482
+ worktreePath: tempDir,
483
+ branchName: "builder-branch",
484
+ beadId: "bead-builder-001",
485
+ tmuxSession: "overstory-builder",
486
+ state: "working",
487
+ pid: 11113,
488
+ parentAgent: null,
489
+ depth: 2,
490
+ runId: "run-test-002",
491
+ startedAt: new Date().toISOString(),
492
+ lastActivity: new Date().toISOString(),
493
+ escalationLevel: 0,
494
+ stalledSince: null,
495
+ });
496
+ sessionStoreLocal.close();
497
+
498
+ // Create the run
499
+ const runStore = createRunStore(dbPath);
500
+ runStore.createRun({
501
+ id: "run-test-002",
502
+ startedAt: new Date().toISOString(),
503
+ coordinatorSessionId: "session-coord-run",
504
+ status: "active",
505
+ });
506
+ runStore.close();
507
+
508
+ // Write current-run.txt
509
+ await Bun.write(join(tempDir, ".overstory", "current-run.txt"), "run-test-002");
510
+
511
+ // Call session-end for builder
512
+ await logCommand(["session-end", "--agent", "test-builder"]);
513
+
514
+ // Verify: run status remains "active"
515
+ const runStoreRead = createRunStore(dbPath);
516
+ const run = runStoreRead.getRun("run-test-002");
517
+ runStoreRead.close();
518
+
519
+ expect(run).toBeDefined();
520
+ expect(run?.status).toBe("active");
521
+ expect(run?.completedAt).toBeNull();
522
+
523
+ // Verify: current-run.txt still exists
524
+ const currentRunFile = Bun.file(join(tempDir, ".overstory", "current-run.txt"));
525
+ expect(await currentRunFile.exists()).toBe(true);
526
+ });
527
+
528
+ test("session-end handles already-completed run gracefully", async () => {
529
+ // Create a coordinator session, create a run that is already completed
530
+ const dbPath = join(tempDir, ".overstory", "sessions.db");
531
+ const sessionStoreLocal = createSessionStore(dbPath);
532
+ sessionStoreLocal.upsert({
533
+ id: "session-coord-completed",
534
+ agentName: "coordinator-completed",
535
+ capability: "coordinator",
536
+ worktreePath: tempDir,
537
+ branchName: "main",
538
+ beadId: "",
539
+ tmuxSession: "overstory-coordinator-completed",
540
+ state: "working",
541
+ pid: 11114,
542
+ parentAgent: null,
543
+ depth: 0,
544
+ runId: "run-test-003",
545
+ startedAt: new Date().toISOString(),
546
+ lastActivity: new Date().toISOString(),
547
+ escalationLevel: 0,
548
+ stalledSince: null,
549
+ });
550
+ sessionStoreLocal.close();
551
+
552
+ // Create the run already completed
553
+ const runStore = createRunStore(dbPath);
554
+ runStore.createRun({
555
+ id: "run-test-003",
556
+ startedAt: new Date().toISOString(),
557
+ coordinatorSessionId: "session-coord-completed",
558
+ status: "active",
559
+ });
560
+ // Complete it immediately
561
+ runStore.completeRun("run-test-003", "completed");
562
+ runStore.close();
563
+
564
+ // Write current-run.txt
565
+ await Bun.write(join(tempDir, ".overstory", "current-run.txt"), "run-test-003");
566
+
567
+ // Call session-end (should not throw — completeRun is idempotent)
568
+ await expect(async () => {
569
+ await logCommand(["session-end", "--agent", "coordinator-completed"]);
570
+ }).not.toThrow();
571
+
572
+ // Verify: run is still completed
573
+ const runStoreRead = createRunStore(dbPath);
574
+ const run = runStoreRead.getRun("run-test-003");
575
+ runStoreRead.close();
576
+
577
+ expect(run).toBeDefined();
578
+ expect(run?.status).toBe("completed");
579
+ });
580
+ });
581
+
582
+ test("session-end writes pending-nudge marker for coordinator when lead completes", async () => {
583
+ // Create sessions.db with a lead agent
584
+ const dbPath = join(tempDir, ".overstory", "sessions.db");
585
+ const session: AgentSession = {
586
+ id: "session-lead",
587
+ agentName: "lead-alpha",
588
+ capability: "lead",
589
+ worktreePath: tempDir,
590
+ branchName: "lead-alpha-branch",
591
+ beadId: "bead-lead-001",
592
+ tmuxSession: "overstory-lead-alpha",
593
+ state: "working",
594
+ pid: 33333,
595
+ parentAgent: null,
596
+ depth: 0,
597
+ runId: null,
598
+ startedAt: new Date().toISOString(),
599
+ lastActivity: new Date().toISOString(),
600
+ escalationLevel: 0,
601
+ stalledSince: null,
602
+ };
603
+ const store = createSessionStore(dbPath);
604
+ store.upsert(session);
605
+ store.close();
606
+
607
+ await logCommand(["session-end", "--agent", "lead-alpha"]);
608
+
609
+ // Verify the pending-nudge marker was written for the coordinator
610
+ const markerPath = join(tempDir, ".overstory", "pending-nudges", "coordinator.json");
611
+ const markerFile = Bun.file(markerPath);
612
+ expect(await markerFile.exists()).toBe(true);
613
+
614
+ const marker = JSON.parse(await markerFile.text());
615
+ expect(marker.from).toBe("lead-alpha");
616
+ expect(marker.reason).toBe("lead_completed");
617
+ expect(marker.subject).toContain("lead-alpha");
618
+ expect(marker.messageId).toContain("auto-nudge-lead-alpha-");
619
+ expect(marker.createdAt).toBeDefined();
620
+ });
621
+
622
+ test("session-end does NOT write pending-nudge marker for non-lead agents", async () => {
623
+ // Create sessions.db with a builder agent (not a lead)
624
+ const dbPath = join(tempDir, ".overstory", "sessions.db");
625
+ const session: AgentSession = {
626
+ id: "session-builder",
627
+ agentName: "builder-beta",
628
+ capability: "builder",
629
+ worktreePath: tempDir,
630
+ branchName: "builder-beta-branch",
631
+ beadId: "bead-builder-001",
632
+ tmuxSession: "overstory-builder-beta",
633
+ state: "working",
634
+ pid: 44444,
635
+ parentAgent: null,
636
+ depth: 0,
637
+ runId: null,
638
+ startedAt: new Date().toISOString(),
639
+ lastActivity: new Date().toISOString(),
640
+ escalationLevel: 0,
641
+ stalledSince: null,
642
+ };
643
+ const store = createSessionStore(dbPath);
644
+ store.upsert(session);
645
+ store.close();
646
+
647
+ await logCommand(["session-end", "--agent", "builder-beta"]);
648
+
649
+ // Verify no pending-nudge marker was written
650
+ const markerPath = join(tempDir, ".overstory", "pending-nudges", "coordinator.json");
651
+ const markerFile = Bun.file(markerPath);
652
+ expect(await markerFile.exists()).toBe(false);
653
+ });
654
+
655
+ test("session-end does not crash when sessions.db does not exist", async () => {
656
+ // No sessions.db file exists
657
+ // session-end should complete without throwing
658
+ await expect(
659
+ logCommand(["session-end", "--agent", "nonexistent-agent"]),
660
+ ).resolves.toBeUndefined();
661
+ });
662
+
663
+ test("tool-start updates lastActivity timestamp in sessions.db", async () => {
664
+ // Create sessions.db with a test agent
665
+ const dbPath = join(tempDir, ".overstory", "sessions.db");
666
+ const oldTimestamp = new Date(Date.now() - 120_000).toISOString(); // 2 minutes ago
667
+ const session: AgentSession = {
668
+ id: "session-003",
669
+ agentName: "activity-agent",
670
+ capability: "builder",
671
+ worktreePath: "/tmp/activity",
672
+ branchName: "activity-branch",
673
+ beadId: "bead-003",
674
+ tmuxSession: "activity-tmux",
675
+ state: "working",
676
+ pid: 99999,
677
+ parentAgent: null,
678
+ depth: 0,
679
+ runId: null,
680
+ startedAt: oldTimestamp,
681
+ lastActivity: oldTimestamp,
682
+ escalationLevel: 0,
683
+ stalledSince: null,
684
+ };
685
+ const store = createSessionStore(dbPath);
686
+ store.upsert(session);
687
+ store.close();
688
+
689
+ await logCommand(["tool-start", "--agent", "activity-agent", "--tool-name", "Glob"]);
690
+
691
+ // Read sessions.db and verify lastActivity was updated
692
+ const readStore = createSessionStore(dbPath);
693
+ const updatedSession = readStore.getByName("activity-agent");
694
+ readStore.close();
695
+
696
+ expect(updatedSession).toBeDefined();
697
+ expect(updatedSession?.lastActivity).not.toBe(oldTimestamp);
698
+ expect(new Date(updatedSession?.lastActivity ?? "").getTime()).toBeGreaterThan(
699
+ new Date(oldTimestamp).getTime(),
700
+ );
701
+ });
702
+
703
+ test("tool-start transitions state from booting to working", async () => {
704
+ // Create sessions.db with agent in 'booting' state
705
+ const dbPath = join(tempDir, ".overstory", "sessions.db");
706
+ const session: AgentSession = {
707
+ id: "session-004",
708
+ agentName: "booting-agent",
709
+ capability: "builder",
710
+ worktreePath: "/tmp/booting",
711
+ branchName: "booting-branch",
712
+ beadId: "bead-004",
713
+ tmuxSession: "booting-tmux",
714
+ state: "booting",
715
+ pid: 11111,
716
+ parentAgent: null,
717
+ depth: 0,
718
+ runId: null,
719
+ startedAt: new Date().toISOString(),
720
+ lastActivity: new Date().toISOString(),
721
+ escalationLevel: 0,
722
+ stalledSince: null,
723
+ };
724
+ const store = createSessionStore(dbPath);
725
+ store.upsert(session);
726
+ store.close();
727
+
728
+ await logCommand(["tool-start", "--agent", "booting-agent", "--tool-name", "Read"]);
729
+
730
+ // Read sessions.db and verify state changed to working
731
+ const readStore = createSessionStore(dbPath);
732
+ const updatedSession = readStore.getByName("booting-agent");
733
+ readStore.close();
734
+
735
+ expect(updatedSession).toBeDefined();
736
+ expect(updatedSession?.state).toBe("working");
737
+ });
738
+
739
+ test("tool-start defaults to unknown when --tool-name not provided", async () => {
740
+ // Should not throw when --tool-name is missing
741
+ await expect(
742
+ logCommand(["tool-start", "--agent", "default-tool-agent"]),
743
+ ).resolves.toBeUndefined();
744
+
745
+ // Verify log was created
746
+ const logsDir = join(tempDir, ".overstory", "logs", "default-tool-agent");
747
+ const markerPath = join(logsDir, ".current-session");
748
+ const markerFile = Bun.file(markerPath);
749
+
750
+ expect(await markerFile.exists()).toBe(true);
751
+
752
+ // Wait for async file writes to complete (logger uses fire-and-forget appendFile)
753
+ await new Promise((resolve) => setTimeout(resolve, 50));
754
+
755
+ const sessionDir = (await markerFile.text()).trim();
756
+ const eventsFile = Bun.file(join(sessionDir, "events.ndjson"));
757
+ const eventsContent = await eventsFile.text();
758
+
759
+ // Should contain "unknown" as the tool name
760
+ expect(eventsContent).toContain("unknown");
761
+ });
762
+
763
+ test("tool-end defaults to unknown when --tool-name not provided", async () => {
764
+ await logCommand(["tool-start", "--agent", "default-end-agent"]);
765
+
766
+ // tool-end without --tool-name should not throw
767
+ await expect(logCommand(["tool-end", "--agent", "default-end-agent"])).resolves.toBeUndefined();
768
+
769
+ // Wait for async file writes to complete
770
+ await new Promise((resolve) => setTimeout(resolve, 50));
771
+
772
+ const logsDir = join(tempDir, ".overstory", "logs", "default-end-agent");
773
+ const markerPath = join(logsDir, ".current-session");
774
+ const sessionDir = (await Bun.file(markerPath).text()).trim();
775
+ const eventsFile = Bun.file(join(sessionDir, "events.ndjson"));
776
+ const eventsContent = await eventsFile.text();
777
+
778
+ expect(eventsContent).toContain("unknown");
779
+ });
780
+
781
+ test("--help includes --stdin option in output", async () => {
782
+ await logCommand(["--help"]);
783
+ const out = output();
784
+
785
+ expect(out).toContain("--stdin");
786
+ });
787
+
788
+ test("session-end does not crash when mulch learn/record fails", async () => {
789
+ // Create sessions.db with a builder agent (non-persistent)
790
+ const dbPath = join(tempDir, ".overstory", "sessions.db");
791
+ const session: AgentSession = {
792
+ id: "session-mulch-fail",
793
+ agentName: "mulch-fail-agent",
794
+ capability: "builder",
795
+ worktreePath: tempDir,
796
+ branchName: "mulch-fail-branch",
797
+ beadId: "bead-mulch-001",
798
+ tmuxSession: "overstory-mulch-fail",
799
+ state: "working",
800
+ pid: 55555,
801
+ parentAgent: "parent-agent",
802
+ depth: 1,
803
+ runId: null,
804
+ startedAt: new Date().toISOString(),
805
+ lastActivity: new Date().toISOString(),
806
+ escalationLevel: 0,
807
+ stalledSince: null,
808
+ };
809
+ const store = createSessionStore(dbPath);
810
+ store.upsert(session);
811
+ store.close();
812
+
813
+ // session-end should complete without throwing even if mulch learn/record fails
814
+ await expect(
815
+ logCommand(["session-end", "--agent", "mulch-fail-agent"]),
816
+ ).resolves.toBeUndefined();
817
+
818
+ // Verify state transitioned to completed
819
+ const readStore = createSessionStore(dbPath);
820
+ const updatedSession = readStore.getByName("mulch-fail-agent");
821
+ readStore.close();
822
+
823
+ expect(updatedSession).toBeDefined();
824
+ expect(updatedSession?.state).toBe("completed");
825
+ });
826
+
827
+ test("session-end skips mulch auto-record for coordinator (persistent agent)", async () => {
828
+ // Create sessions.db with a coordinator agent
829
+ const dbPath = join(tempDir, ".overstory", "sessions.db");
830
+ const session: AgentSession = {
831
+ id: "session-coord-mulch",
832
+ agentName: "coordinator-mulch",
833
+ capability: "coordinator",
834
+ worktreePath: tempDir,
835
+ branchName: "main",
836
+ beadId: "",
837
+ tmuxSession: "overstory-coordinator-mulch",
838
+ state: "working",
839
+ pid: 66666,
840
+ parentAgent: null,
841
+ depth: 0,
842
+ runId: null,
843
+ startedAt: new Date().toISOString(),
844
+ lastActivity: new Date().toISOString(),
845
+ escalationLevel: 0,
846
+ stalledSince: null,
847
+ };
848
+ const store = createSessionStore(dbPath);
849
+ store.upsert(session);
850
+ store.close();
851
+
852
+ await logCommand(["session-end", "--agent", "coordinator-mulch"]);
853
+
854
+ // Verify no mail.db was created (mulch auto-record was skipped)
855
+ const mailDbPath = join(tempDir, ".overstory", "mail.db");
856
+ const mailDbFile = Bun.file(mailDbPath);
857
+ expect(await mailDbFile.exists()).toBe(false);
858
+
859
+ // Coordinator should remain working (persistent agent)
860
+ const readStore = createSessionStore(dbPath);
861
+ const updatedSession = readStore.getByName("coordinator-mulch");
862
+ readStore.close();
863
+
864
+ expect(updatedSession).toBeDefined();
865
+ expect(updatedSession?.state).toBe("working");
866
+ });
867
+
868
+ test("autoRecordExpertise calls record for each suggested domain", async () => {
869
+ const learnResult: MulchLearnResult = {
870
+ success: true,
871
+ command: "mulch learn",
872
+ changedFiles: ["src/foo.ts", "src/bar.ts"],
873
+ suggestedDomains: ["typescript", "cli"],
874
+ unmatchedFiles: [],
875
+ };
876
+ const { client, recordCalls } = createFakeMulchClient(learnResult);
877
+ const mailDbPath = join(tempDir, ".overstory", "auto-record-mail.db");
878
+
879
+ const result = await autoRecordExpertise({
880
+ mulchClient: client,
881
+ agentName: "test-builder",
882
+ capability: "builder",
883
+ beadId: "bead-123",
884
+ mailDbPath,
885
+ parentAgent: "parent-lead",
886
+ projectRoot: tempDir,
887
+ sessionStartedAt: new Date().toISOString(),
888
+ });
889
+
890
+ expect(result).toEqual(["typescript", "cli"]);
891
+ expect(recordCalls).toHaveLength(2);
892
+ expect(recordCalls[0]?.domain).toBe("typescript");
893
+ expect(recordCalls[0]?.options).toMatchObject({
894
+ type: "reference",
895
+ tags: ["auto-session-end", "builder"],
896
+ evidenceBead: "bead-123",
897
+ });
898
+ expect(recordCalls[1]?.domain).toBe("cli");
899
+ });
900
+
901
+ test("autoRecordExpertise sends mail with auto-recorded subject", async () => {
902
+ const learnResult: MulchLearnResult = {
903
+ success: true,
904
+ command: "mulch learn",
905
+ changedFiles: ["src/foo.ts"],
906
+ suggestedDomains: ["typescript"],
907
+ unmatchedFiles: [],
908
+ };
909
+ const { client } = createFakeMulchClient(learnResult);
910
+ const mailDbPath = join(tempDir, ".overstory", "auto-record-mail2.db");
911
+
912
+ await autoRecordExpertise({
913
+ mulchClient: client,
914
+ agentName: "test-builder",
915
+ capability: "builder",
916
+ beadId: "bead-456",
917
+ mailDbPath,
918
+ parentAgent: "parent-lead",
919
+ projectRoot: tempDir,
920
+ sessionStartedAt: new Date().toISOString(),
921
+ });
922
+
923
+ const mailStore = createMailStore(mailDbPath);
924
+ const mailClient = createMailClient(mailStore);
925
+ const messages = mailClient.list({ to: "parent-lead" });
926
+ mailClient.close();
927
+
928
+ expect(messages).toHaveLength(1);
929
+ expect(messages[0]?.subject).toBe("mulch: auto-recorded insights in typescript");
930
+ expect(messages[0]?.body).toContain("Auto-recorded expertise in: typescript");
931
+ });
932
+
933
+ test("autoRecordExpertise continues when individual record calls fail", async () => {
934
+ const learnResult: MulchLearnResult = {
935
+ success: true,
936
+ command: "mulch learn",
937
+ changedFiles: ["src/foo.ts"],
938
+ suggestedDomains: ["typescript", "cli"],
939
+ unmatchedFiles: [],
940
+ };
941
+ const { client } = createFakeMulchClient(learnResult, { recordShouldFail: true });
942
+ const mailDbPath = join(tempDir, ".overstory", "auto-record-fail.db");
943
+
944
+ const result = await autoRecordExpertise({
945
+ mulchClient: client,
946
+ agentName: "test-builder",
947
+ capability: "builder",
948
+ beadId: null,
949
+ mailDbPath,
950
+ parentAgent: null,
951
+ projectRoot: tempDir,
952
+ sessionStartedAt: new Date().toISOString(),
953
+ });
954
+
955
+ // All records failed, so no domains recorded and no mail sent
956
+ expect(result).toEqual([]);
957
+ const mailFile = Bun.file(mailDbPath);
958
+ expect(await mailFile.exists()).toBe(false);
959
+ });
960
+
961
+ test("autoRecordExpertise returns empty when no domains suggested", async () => {
962
+ const learnResult: MulchLearnResult = {
963
+ success: true,
964
+ command: "mulch learn",
965
+ changedFiles: ["src/foo.ts"],
966
+ suggestedDomains: [],
967
+ unmatchedFiles: [],
968
+ };
969
+ const { client, recordCalls } = createFakeMulchClient(learnResult);
970
+ const mailDbPath = join(tempDir, ".overstory", "auto-record-empty.db");
971
+
972
+ const result = await autoRecordExpertise({
973
+ mulchClient: client,
974
+ agentName: "test-builder",
975
+ capability: "builder",
976
+ beadId: null,
977
+ mailDbPath,
978
+ parentAgent: null,
979
+ projectRoot: tempDir,
980
+ sessionStartedAt: new Date().toISOString(),
981
+ });
982
+
983
+ expect(result).toEqual([]);
984
+ expect(recordCalls).toHaveLength(0);
985
+ });
986
+
987
+ test("autoRecordExpertise records pattern insights when EventStore has tool data", async () => {
988
+ const learnResult: MulchLearnResult = {
989
+ success: true,
990
+ command: "mulch learn",
991
+ changedFiles: ["src/mail/store.ts"],
992
+ suggestedDomains: ["messaging"],
993
+ unmatchedFiles: [],
994
+ };
995
+ const { client, recordCalls } = createFakeMulchClient(learnResult);
996
+ const mailDbPath = join(tempDir, ".overstory", "insight-analysis-mail.db");
997
+
998
+ // Create EventStore with test data
999
+ const eventsDbPath = join(tempDir, ".overstory", "events.db");
1000
+ const eventStore = createEventStore(eventsDbPath);
1001
+
1002
+ const sessionStartedAt = new Date(Date.now() - 60_000).toISOString(); // 1 minute ago
1003
+
1004
+ // Insert tool events: 15 tool calls total (10+ triggers workflow insight)
1005
+ // Read-heavy: 12 Read, 3 Edit → should classify as read-heavy
1006
+ for (let i = 0; i < 12; i++) {
1007
+ eventStore.insert({
1008
+ runId: null,
1009
+ agentName: "insight-agent",
1010
+ sessionId: "sess-insight",
1011
+ eventType: "tool_start",
1012
+ toolName: "Read",
1013
+ toolArgs: JSON.stringify({ file_path: `/src/file${i}.ts` }),
1014
+ toolDurationMs: null,
1015
+ level: "info",
1016
+ data: JSON.stringify({ summary: `read: /src/file${i}.ts` }),
1017
+ });
1018
+ }
1019
+
1020
+ // Add 4 edits to same file → hot file
1021
+ for (let i = 0; i < 4; i++) {
1022
+ eventStore.insert({
1023
+ runId: null,
1024
+ agentName: "insight-agent",
1025
+ sessionId: "sess-insight",
1026
+ eventType: "tool_start",
1027
+ toolName: "Edit",
1028
+ toolArgs: JSON.stringify({ file_path: "src/mail/store.ts" }),
1029
+ toolDurationMs: null,
1030
+ level: "info",
1031
+ data: JSON.stringify({ summary: "edit: src/mail/store.ts" }),
1032
+ });
1033
+ }
1034
+
1035
+ // Add 1 error event → error pattern
1036
+ eventStore.insert({
1037
+ runId: null,
1038
+ agentName: "insight-agent",
1039
+ sessionId: "sess-insight",
1040
+ eventType: "tool_start",
1041
+ toolName: "Bash",
1042
+ toolArgs: JSON.stringify({ command: "bun test" }),
1043
+ toolDurationMs: null,
1044
+ level: "error",
1045
+ data: "Test failed",
1046
+ });
1047
+
1048
+ eventStore.close();
1049
+
1050
+ // Run autoRecordExpertise
1051
+ const result = await autoRecordExpertise({
1052
+ mulchClient: client,
1053
+ agentName: "insight-agent",
1054
+ capability: "builder",
1055
+ beadId: "bead-insight",
1056
+ mailDbPath,
1057
+ parentAgent: "parent-agent",
1058
+ projectRoot: tempDir,
1059
+ sessionStartedAt,
1060
+ });
1061
+
1062
+ // Verify reference + insights were recorded
1063
+ expect(recordCalls.length).toBeGreaterThanOrEqual(2); // At least reference + 1 insight
1064
+
1065
+ // Verify reference entry
1066
+ const referenceCall = recordCalls.find((c) => c.options.type === "reference");
1067
+ expect(referenceCall).toBeDefined();
1068
+ expect(referenceCall?.domain).toBe("messaging");
1069
+
1070
+ // Verify pattern insights
1071
+ const patternCalls = recordCalls.filter((c) => c.options.type === "pattern");
1072
+ expect(patternCalls.length).toBeGreaterThanOrEqual(2);
1073
+
1074
+ // Verify workflow insight
1075
+ const workflowInsight = patternCalls.find((c) => {
1076
+ const desc = c.options.description;
1077
+ return typeof desc === "string" && desc.includes("read-heavy workflow");
1078
+ });
1079
+ expect(workflowInsight).toBeDefined();
1080
+
1081
+ // Verify hot file insight
1082
+ const hotFileInsight = patternCalls.find((c) => {
1083
+ const desc = c.options.description;
1084
+ return (
1085
+ typeof desc === "string" && desc.includes("src/mail/store.ts") && desc.includes("4 edits")
1086
+ );
1087
+ });
1088
+ expect(hotFileInsight).toBeDefined();
1089
+ expect(hotFileInsight?.domain).toBe("messaging"); // Inferred from src/mail/
1090
+
1091
+ // Verify failure insight
1092
+ const failureCall = recordCalls.find((c) => c.options.type === "failure");
1093
+ expect(failureCall).toBeDefined();
1094
+
1095
+ // Verify recorded domains includes unique domains from insights
1096
+ expect(result).toContain("messaging");
1097
+ });
1098
+
1099
+ test("autoRecordExpertise includes insight summary in notification mail", async () => {
1100
+ const learnResult: MulchLearnResult = {
1101
+ success: true,
1102
+ command: "mulch learn",
1103
+ changedFiles: ["src/config.ts"],
1104
+ suggestedDomains: ["typescript"],
1105
+ unmatchedFiles: [],
1106
+ };
1107
+ const { client } = createFakeMulchClient(learnResult);
1108
+ const mailDbPath = join(tempDir, ".overstory", "insight-mail-summary.db");
1109
+
1110
+ // Create EventStore with 10+ tool calls to trigger workflow insight
1111
+ const eventsDbPath = join(tempDir, ".overstory", "events.db");
1112
+ const eventStore = createEventStore(eventsDbPath);
1113
+ const sessionStartedAt = new Date(Date.now() - 60_000).toISOString();
1114
+
1115
+ for (let i = 0; i < 10; i++) {
1116
+ eventStore.insert({
1117
+ runId: null,
1118
+ agentName: "mail-insight-agent",
1119
+ sessionId: "sess-mail",
1120
+ eventType: "tool_start",
1121
+ toolName: "Read",
1122
+ toolArgs: JSON.stringify({ file_path: `/src/file${i}.ts` }),
1123
+ toolDurationMs: null,
1124
+ level: "info",
1125
+ data: JSON.stringify({ summary: `read: /src/file${i}.ts` }),
1126
+ });
1127
+ }
1128
+
1129
+ eventStore.close();
1130
+
1131
+ await autoRecordExpertise({
1132
+ mulchClient: client,
1133
+ agentName: "mail-insight-agent",
1134
+ capability: "scout",
1135
+ beadId: "bead-mail",
1136
+ mailDbPath,
1137
+ parentAgent: "parent-agent",
1138
+ projectRoot: tempDir,
1139
+ sessionStartedAt,
1140
+ });
1141
+
1142
+ // Verify mail was sent with insight summary
1143
+ const mailStore = createMailStore(mailDbPath);
1144
+ const mailClient = createMailClient(mailStore);
1145
+ const messages = mailClient.list({ to: "parent-agent" });
1146
+ mailClient.close();
1147
+
1148
+ expect(messages).toHaveLength(1);
1149
+ const mail = messages[0];
1150
+ expect(mail?.body).toContain("Auto-insights:");
1151
+ expect(mail?.body).toContain("10 tool calls");
1152
+ expect(mail?.body).toContain("pattern"); // At least 1 pattern insight
1153
+ });
1154
+ });
1155
+
1156
+ /**
1157
+ * Tests for `overstory log` with --stdin flag.
1158
+ *
1159
+ * Uses Bun.spawn to invoke the log command as a subprocess with piped stdin,
1160
+ * because Bun.stdin.stream() cannot be injected in-process.
1161
+ * Real filesystem + real SQLite for EventStore verification.
1162
+ */
1163
+ describe("logCommand --stdin integration", () => {
1164
+ let tempDir: string;
1165
+
1166
+ beforeEach(async () => {
1167
+ tempDir = await mkdtemp(join(tmpdir(), "log-stdin-test-"));
1168
+ const overstoryDir = join(tempDir, ".overstory");
1169
+ await Bun.write(
1170
+ join(overstoryDir, "config.yaml"),
1171
+ `project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
1172
+ );
1173
+ });
1174
+
1175
+ afterEach(async () => {
1176
+ await rm(tempDir, { recursive: true, force: true });
1177
+ });
1178
+
1179
+ /**
1180
+ * Helper: run `overstory log` as a subprocess with stdin piped.
1181
+ * Uses bun to run the CLI entry point directly.
1182
+ */
1183
+ async function runLogWithStdin(
1184
+ event: string,
1185
+ agentName: string,
1186
+ stdinJson: Record<string, unknown>,
1187
+ ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
1188
+ // Inline script that calls logCommand with --stdin and reads from stdin
1189
+ const scriptPath = join(tempDir, "_run-log.ts");
1190
+ const scriptContent = `
1191
+ import { logCommand } from "${join(import.meta.dir, "log.ts").replace(/\\/g, "/")}";
1192
+ const args = process.argv.slice(2);
1193
+ try {
1194
+ await logCommand(args);
1195
+ } catch (e) {
1196
+ console.error(e instanceof Error ? e.message : String(e));
1197
+ process.exit(1);
1198
+ }
1199
+ `;
1200
+ await Bun.write(scriptPath, scriptContent);
1201
+
1202
+ const proc = Bun.spawn(["bun", "run", scriptPath, event, "--agent", agentName, "--stdin"], {
1203
+ cwd: tempDir,
1204
+ stdin: "pipe",
1205
+ stdout: "pipe",
1206
+ stderr: "pipe",
1207
+ });
1208
+
1209
+ // Write the JSON payload to stdin and close
1210
+ proc.stdin.write(JSON.stringify(stdinJson));
1211
+ proc.stdin.end();
1212
+
1213
+ const exitCode = await proc.exited;
1214
+ const stdout = await new Response(proc.stdout).text();
1215
+ const stderr = await new Response(proc.stderr).text();
1216
+
1217
+ return { exitCode, stdout, stderr };
1218
+ }
1219
+
1220
+ test("tool-start with --stdin writes to EventStore", async () => {
1221
+ const payload = {
1222
+ tool_name: "Read",
1223
+ tool_input: { file_path: "/src/index.ts" },
1224
+ session_id: "sess-test-001",
1225
+ };
1226
+
1227
+ const result = await runLogWithStdin("tool-start", "stdin-builder", payload);
1228
+ expect(result.exitCode).toBe(0);
1229
+
1230
+ // Verify EventStore has the event
1231
+ const eventsDbPath = join(tempDir, ".overstory", "events.db");
1232
+ const eventStore = createEventStore(eventsDbPath);
1233
+ const events = eventStore.getByAgent("stdin-builder");
1234
+ eventStore.close();
1235
+
1236
+ expect(events).toHaveLength(1);
1237
+ const event = events[0] as StoredEvent;
1238
+ expect(event.eventType).toBe("tool_start");
1239
+ expect(event.toolName).toBe("Read");
1240
+ expect(event.sessionId).toBe("sess-test-001");
1241
+ expect(event.agentName).toBe("stdin-builder");
1242
+
1243
+ // Verify filtered tool args were stored
1244
+ const toolArgs = JSON.parse(event.toolArgs ?? "{}");
1245
+ expect(toolArgs.file_path).toBe("/src/index.ts");
1246
+
1247
+ // Verify summary in data
1248
+ const data = JSON.parse(event.data ?? "{}");
1249
+ expect(data.summary).toBe("read: /src/index.ts");
1250
+ });
1251
+
1252
+ test("tool-end with --stdin writes to EventStore and correlates with tool-start", async () => {
1253
+ // First create a tool-start event
1254
+ const startPayload = {
1255
+ tool_name: "Bash",
1256
+ tool_input: { command: "bun test" },
1257
+ session_id: "sess-test-002",
1258
+ };
1259
+ const startResult = await runLogWithStdin("tool-start", "correlate-agent", startPayload);
1260
+ expect(startResult.exitCode).toBe(0);
1261
+
1262
+ // Small delay to ensure measurable duration
1263
+ await new Promise((resolve) => setTimeout(resolve, 10));
1264
+
1265
+ // Now send tool-end
1266
+ const endPayload = {
1267
+ tool_name: "Bash",
1268
+ tool_input: { command: "bun test" },
1269
+ session_id: "sess-test-002",
1270
+ };
1271
+ const endResult = await runLogWithStdin("tool-end", "correlate-agent", endPayload);
1272
+ expect(endResult.exitCode).toBe(0);
1273
+
1274
+ // Verify EventStore has both events
1275
+ const eventsDbPath = join(tempDir, ".overstory", "events.db");
1276
+ const eventStore = createEventStore(eventsDbPath);
1277
+ const events = eventStore.getByAgent("correlate-agent");
1278
+ eventStore.close();
1279
+
1280
+ expect(events).toHaveLength(2);
1281
+
1282
+ const startEvent = events.find((e) => e.eventType === "tool_start");
1283
+ const endEvent = events.find((e) => e.eventType === "tool_end");
1284
+ expect(startEvent).toBeDefined();
1285
+ expect(endEvent).toBeDefined();
1286
+
1287
+ // The start event should have tool_duration_ms set by correlateToolEnd()
1288
+ // (value may be affected by SQLite timestamp vs Date.now() timezone behavior,
1289
+ // so we only assert it was populated — not the exact value)
1290
+ expect(startEvent?.toolDurationMs).not.toBeNull();
1291
+ });
1292
+
1293
+ test("tool-start with --stdin filters large tool_input", async () => {
1294
+ const payload = {
1295
+ tool_name: "Write",
1296
+ tool_input: {
1297
+ file_path: "/src/new-file.ts",
1298
+ content: "x".repeat(50_000), // 50KB of content — should be dropped
1299
+ },
1300
+ session_id: "sess-test-003",
1301
+ };
1302
+
1303
+ const result = await runLogWithStdin("tool-start", "filter-agent", payload);
1304
+ expect(result.exitCode).toBe(0);
1305
+
1306
+ const eventsDbPath = join(tempDir, ".overstory", "events.db");
1307
+ const eventStore = createEventStore(eventsDbPath);
1308
+ const events = eventStore.getByAgent("filter-agent");
1309
+ eventStore.close();
1310
+
1311
+ expect(events).toHaveLength(1);
1312
+ const event = events[0] as StoredEvent;
1313
+
1314
+ // The Write filter keeps file_path but drops content
1315
+ const toolArgs = JSON.parse(event.toolArgs ?? "{}");
1316
+ expect(toolArgs.file_path).toBe("/src/new-file.ts");
1317
+ expect(toolArgs).not.toHaveProperty("content");
1318
+
1319
+ // Verify summary
1320
+ const data = JSON.parse(event.data ?? "{}");
1321
+ expect(data.summary).toBe("write: /src/new-file.ts");
1322
+ });
1323
+
1324
+ test("session-end with --stdin writes to EventStore with transcript_path", async () => {
1325
+ const payload = {
1326
+ session_id: "sess-test-004",
1327
+ transcript_path: "/tmp/transcript.jsonl",
1328
+ };
1329
+
1330
+ const result = await runLogWithStdin("session-end", "session-end-agent", payload);
1331
+ expect(result.exitCode).toBe(0);
1332
+
1333
+ const eventsDbPath = join(tempDir, ".overstory", "events.db");
1334
+ const eventStore = createEventStore(eventsDbPath);
1335
+ const events = eventStore.getByAgent("session-end-agent");
1336
+ eventStore.close();
1337
+
1338
+ expect(events).toHaveLength(1);
1339
+ const event = events[0] as StoredEvent;
1340
+ expect(event.eventType).toBe("session_end");
1341
+ expect(event.sessionId).toBe("sess-test-004");
1342
+
1343
+ // Verify transcript path stored in data
1344
+ const data = JSON.parse(event.data ?? "{}");
1345
+ expect(data.transcriptPath).toBe("/tmp/transcript.jsonl");
1346
+ });
1347
+
1348
+ test("tool-start with --stdin still writes to legacy log files", async () => {
1349
+ const payload = {
1350
+ tool_name: "Grep",
1351
+ tool_input: { pattern: "TODO", path: "/src" },
1352
+ session_id: "sess-test-005",
1353
+ };
1354
+
1355
+ const result = await runLogWithStdin("tool-start", "legacy-compat-agent", payload);
1356
+ expect(result.exitCode).toBe(0);
1357
+
1358
+ // Wait for async file writes to complete
1359
+ await new Promise((resolve) => setTimeout(resolve, 50));
1360
+
1361
+ // Verify legacy log files exist
1362
+ const logsDir = join(tempDir, ".overstory", "logs", "legacy-compat-agent");
1363
+ const markerPath = join(logsDir, ".current-session");
1364
+ const markerFile = Bun.file(markerPath);
1365
+ expect(await markerFile.exists()).toBe(true);
1366
+
1367
+ const sessionDir = (await markerFile.text()).trim();
1368
+ const eventsFile = Bun.file(join(sessionDir, "events.ndjson"));
1369
+ expect(await eventsFile.exists()).toBe(true);
1370
+
1371
+ const eventsContent = await eventsFile.text();
1372
+ expect(eventsContent).toContain("tool.start");
1373
+ expect(eventsContent).toContain("Grep");
1374
+ });
1375
+
1376
+ test("tool-start with --stdin handles empty stdin gracefully", async () => {
1377
+ // Send empty JSON object — should still work (falls back to "unknown" tool name)
1378
+ const scriptPath = join(tempDir, "_run-log-empty.ts");
1379
+ const scriptContent = `
1380
+ import { logCommand } from "${join(import.meta.dir, "log.ts").replace(/\\/g, "/")}";
1381
+ try {
1382
+ await logCommand(["tool-start", "--agent", "empty-stdin-agent", "--stdin"]);
1383
+ } catch (e) {
1384
+ console.error(e instanceof Error ? e.message : String(e));
1385
+ process.exit(1);
1386
+ }
1387
+ `;
1388
+ await Bun.write(scriptPath, scriptContent);
1389
+
1390
+ const proc = Bun.spawn(["bun", "run", scriptPath], {
1391
+ cwd: tempDir,
1392
+ stdin: "pipe",
1393
+ stdout: "pipe",
1394
+ stderr: "pipe",
1395
+ });
1396
+
1397
+ // Write empty string and close immediately
1398
+ proc.stdin.end();
1399
+
1400
+ const exitCode = await proc.exited;
1401
+ expect(exitCode).toBe(0);
1402
+ });
1403
+
1404
+ test("tool-start with --stdin and unknown tool name uses fallback filter", async () => {
1405
+ const payload = {
1406
+ tool_name: "SomeCustomTool",
1407
+ tool_input: { custom_key: "custom_value" },
1408
+ session_id: "sess-test-006",
1409
+ };
1410
+
1411
+ const result = await runLogWithStdin("tool-start", "custom-tool-agent", payload);
1412
+ expect(result.exitCode).toBe(0);
1413
+
1414
+ const eventsDbPath = join(tempDir, ".overstory", "events.db");
1415
+ const eventStore = createEventStore(eventsDbPath);
1416
+ const events = eventStore.getByAgent("custom-tool-agent");
1417
+ eventStore.close();
1418
+
1419
+ expect(events).toHaveLength(1);
1420
+ const event = events[0] as StoredEvent;
1421
+ expect(event.toolName).toBe("SomeCustomTool");
1422
+
1423
+ // Unknown tools get empty args from filterToolArgs
1424
+ const toolArgs = JSON.parse(event.toolArgs ?? "{}");
1425
+ expect(toolArgs).toEqual({});
1426
+
1427
+ const data = JSON.parse(event.data ?? "{}");
1428
+ expect(data.summary).toBe("SomeCustomTool");
1429
+ });
1430
+
1431
+ test("tool-end with --stdin handles large payloads (>64KB)", async () => {
1432
+ const payload = {
1433
+ tool_name: "Bash",
1434
+ tool_input: { command: "cat /some/file" },
1435
+ tool_result: "x".repeat(100_000), // 100KB payload
1436
+ session_id: "sess-large-payload",
1437
+ };
1438
+
1439
+ const result = await runLogWithStdin("tool-end", "large-payload-agent", payload);
1440
+ expect(result.exitCode).toBe(0);
1441
+
1442
+ // Verify EventStore received the event with correct tool name
1443
+ const eventsDbPath = join(tempDir, ".overstory", "events.db");
1444
+ const eventStore = createEventStore(eventsDbPath);
1445
+ const events = eventStore.getByAgent("large-payload-agent");
1446
+ eventStore.close();
1447
+
1448
+ expect(events).toHaveLength(1);
1449
+ const event = events[0] as StoredEvent;
1450
+ expect(event.eventType).toBe("tool_end");
1451
+ expect(event.toolName).toBe("Bash");
1452
+ // tool_result is not stored in EventStore (filtered out), but tool_name was parsed correctly
1453
+ });
1454
+ });