@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,466 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { StoredEvent, ToolStats } from "../types.ts";
3
+ import { analyzeSessionInsights, inferDomain } from "./analyzer.ts";
4
+
5
+ describe("inferDomain", () => {
6
+ test("maps src/mail/ to messaging", () => {
7
+ expect(inferDomain("src/mail/store.ts")).toBe("messaging");
8
+ });
9
+
10
+ test("maps src/commands/ to cli", () => {
11
+ expect(inferDomain("src/commands/log.ts")).toBe("cli");
12
+ });
13
+
14
+ test("maps src/agents/ to agents", () => {
15
+ expect(inferDomain("src/agents/manifest.ts")).toBe("agents");
16
+ });
17
+
18
+ test("maps agents/ to agents", () => {
19
+ expect(inferDomain("agents/builder.md")).toBe("agents");
20
+ });
21
+
22
+ test("maps src/events/ to cli", () => {
23
+ expect(inferDomain("src/events/store.ts")).toBe("cli");
24
+ });
25
+
26
+ test("maps src/logging/ to cli", () => {
27
+ expect(inferDomain("src/logging/logger.ts")).toBe("cli");
28
+ });
29
+
30
+ test("maps src/metrics/ to cli", () => {
31
+ expect(inferDomain("src/metrics/store.ts")).toBe("cli");
32
+ });
33
+
34
+ test("maps src/merge/ to architecture", () => {
35
+ expect(inferDomain("src/merge/resolver.ts")).toBe("architecture");
36
+ });
37
+
38
+ test("maps src/worktree/ to architecture", () => {
39
+ expect(inferDomain("src/worktree/manager.ts")).toBe("architecture");
40
+ });
41
+
42
+ test("maps *.test.ts to typescript", () => {
43
+ expect(inferDomain("src/config.test.ts")).toBe("typescript");
44
+ });
45
+
46
+ test("maps other src/ files to typescript", () => {
47
+ expect(inferDomain("src/config.ts")).toBe("typescript");
48
+ });
49
+
50
+ test("returns null for unrecognized paths", () => {
51
+ expect(inferDomain("README.md")).toBe(null);
52
+ });
53
+ });
54
+
55
+ describe("analyzeSessionInsights", () => {
56
+ test("returns empty insights for empty events", () => {
57
+ const result = analyzeSessionInsights({
58
+ events: [],
59
+ toolStats: [],
60
+ agentName: "test-agent",
61
+ capability: "builder",
62
+ domains: ["typescript"],
63
+ });
64
+
65
+ expect(result.insights).toEqual([]);
66
+ expect(result.toolProfile.topTools).toEqual([]);
67
+ expect(result.toolProfile.totalToolCalls).toBe(0);
68
+ expect(result.toolProfile.errorCount).toBe(0);
69
+ expect(result.fileProfile.hotFiles).toEqual([]);
70
+ expect(result.fileProfile.totalEdits).toBe(0);
71
+ });
72
+
73
+ test("builds correct tool profile from tool stats", () => {
74
+ const toolStats: ToolStats[] = [
75
+ { toolName: "Read", count: 15, avgDurationMs: 50, maxDurationMs: 100 },
76
+ { toolName: "Edit", count: 8, avgDurationMs: 120, maxDurationMs: 200 },
77
+ { toolName: "Bash", count: 3, avgDurationMs: 500, maxDurationMs: 1000 },
78
+ ];
79
+
80
+ const result = analyzeSessionInsights({
81
+ events: [],
82
+ toolStats,
83
+ agentName: "test-agent",
84
+ capability: "builder",
85
+ domains: ["typescript"],
86
+ });
87
+
88
+ expect(result.toolProfile.totalToolCalls).toBe(26);
89
+ expect(result.toolProfile.topTools).toHaveLength(3);
90
+ expect(result.toolProfile.topTools[0]).toEqual({
91
+ name: "Read",
92
+ count: 15,
93
+ avgMs: 50,
94
+ });
95
+ expect(result.toolProfile.topTools[1]).toEqual({
96
+ name: "Edit",
97
+ count: 8,
98
+ avgMs: 120,
99
+ });
100
+ });
101
+
102
+ test("detects hot files from edit events", () => {
103
+ const events: StoredEvent[] = [
104
+ {
105
+ id: 1,
106
+ runId: "run-1",
107
+ agentName: "test-agent",
108
+ sessionId: "session-1",
109
+ eventType: "tool_start",
110
+ toolName: "Edit",
111
+ toolArgs: JSON.stringify({ file_path: "src/config.ts" }),
112
+ toolDurationMs: null,
113
+ level: "info",
114
+ data: null,
115
+ createdAt: "2024-01-01T10:00:00.000Z",
116
+ },
117
+ {
118
+ id: 2,
119
+ runId: "run-1",
120
+ agentName: "test-agent",
121
+ sessionId: "session-1",
122
+ eventType: "tool_start",
123
+ toolName: "Edit",
124
+ toolArgs: JSON.stringify({ file_path: "src/config.ts" }),
125
+ toolDurationMs: null,
126
+ level: "info",
127
+ data: null,
128
+ createdAt: "2024-01-01T10:01:00.000Z",
129
+ },
130
+ {
131
+ id: 3,
132
+ runId: "run-1",
133
+ agentName: "test-agent",
134
+ sessionId: "session-1",
135
+ eventType: "tool_start",
136
+ toolName: "Edit",
137
+ toolArgs: JSON.stringify({ file_path: "src/config.ts" }),
138
+ toolDurationMs: null,
139
+ level: "info",
140
+ data: null,
141
+ createdAt: "2024-01-01T10:02:00.000Z",
142
+ },
143
+ {
144
+ id: 4,
145
+ runId: "run-1",
146
+ agentName: "test-agent",
147
+ sessionId: "session-1",
148
+ eventType: "tool_start",
149
+ toolName: "Write",
150
+ toolArgs: JSON.stringify({ file_path: "src/new-file.ts" }),
151
+ toolDurationMs: null,
152
+ level: "info",
153
+ data: null,
154
+ createdAt: "2024-01-01T10:03:00.000Z",
155
+ },
156
+ ];
157
+
158
+ const result = analyzeSessionInsights({
159
+ events,
160
+ toolStats: [],
161
+ agentName: "test-agent",
162
+ capability: "builder",
163
+ domains: ["typescript"],
164
+ });
165
+
166
+ expect(result.fileProfile.totalEdits).toBe(4);
167
+ expect(result.fileProfile.hotFiles).toHaveLength(1);
168
+ expect(result.fileProfile.hotFiles[0]).toEqual({
169
+ path: "src/config.ts",
170
+ editCount: 3,
171
+ });
172
+ });
173
+
174
+ test("generates error pattern insight when errors are present", () => {
175
+ const events: StoredEvent[] = [
176
+ {
177
+ id: 1,
178
+ runId: "run-1",
179
+ agentName: "test-agent",
180
+ sessionId: "session-1",
181
+ eventType: "tool_start",
182
+ toolName: "Bash",
183
+ toolArgs: JSON.stringify({ command: "bun test" }),
184
+ toolDurationMs: null,
185
+ level: "error",
186
+ data: "Test failed",
187
+ createdAt: "2024-01-01T10:00:00.000Z",
188
+ },
189
+ {
190
+ id: 2,
191
+ runId: "run-1",
192
+ agentName: "test-agent",
193
+ sessionId: "session-1",
194
+ eventType: "tool_start",
195
+ toolName: "Edit",
196
+ toolArgs: JSON.stringify({ file_path: "src/test.ts" }),
197
+ toolDurationMs: null,
198
+ level: "error",
199
+ data: "File not found",
200
+ createdAt: "2024-01-01T10:01:00.000Z",
201
+ },
202
+ ];
203
+
204
+ const result = analyzeSessionInsights({
205
+ events,
206
+ toolStats: [],
207
+ agentName: "test-agent",
208
+ capability: "builder",
209
+ domains: ["typescript"],
210
+ });
211
+
212
+ expect(result.toolProfile.errorCount).toBe(2);
213
+ const errorInsight = result.insights.find((i) => i.type === "failure");
214
+ expect(errorInsight).toBeDefined();
215
+ expect(errorInsight?.description).toContain("2 error(s)");
216
+ expect(errorInsight?.description).toContain("Bash");
217
+ expect(errorInsight?.description).toContain("Edit");
218
+ expect(errorInsight?.tags).toContain("error-pattern");
219
+ });
220
+
221
+ test("generates tool workflow pattern insight for sessions with 10+ tool calls", () => {
222
+ const toolStats: ToolStats[] = [
223
+ { toolName: "Read", count: 12, avgDurationMs: 50, maxDurationMs: 100 },
224
+ { toolName: "Grep", count: 5, avgDurationMs: 80, maxDurationMs: 150 },
225
+ { toolName: "Edit", count: 3, avgDurationMs: 120, maxDurationMs: 200 },
226
+ ];
227
+
228
+ const result = analyzeSessionInsights({
229
+ events: [],
230
+ toolStats,
231
+ agentName: "test-agent",
232
+ capability: "scout",
233
+ domains: ["architecture"],
234
+ });
235
+
236
+ const workflowInsight = result.insights.find((i) => i.tags.includes("tool-profile"));
237
+ expect(workflowInsight).toBeDefined();
238
+ expect(workflowInsight?.type).toBe("pattern");
239
+ expect(workflowInsight?.domain).toBe("architecture");
240
+ expect(workflowInsight?.description).toContain("Read (12)");
241
+ expect(workflowInsight?.description).toContain("read-heavy workflow");
242
+ expect(workflowInsight?.tags).toContain("scout");
243
+ });
244
+
245
+ test("generates hot file insights with inferred domains", () => {
246
+ const events: StoredEvent[] = [
247
+ // 4 edits to src/mail/store.ts → messaging domain
248
+ ...Array.from({ length: 4 }, (_, i) => ({
249
+ id: i + 1,
250
+ runId: "run-1",
251
+ agentName: "test-agent",
252
+ sessionId: "session-1",
253
+ eventType: "tool_start" as const,
254
+ toolName: "Edit",
255
+ toolArgs: JSON.stringify({ file_path: "src/mail/store.ts" }),
256
+ toolDurationMs: null,
257
+ level: "info" as const,
258
+ data: null,
259
+ createdAt: `2024-01-01T10:0${i}:00.000Z`,
260
+ })),
261
+ // 3 edits to src/commands/log.ts → cli domain
262
+ ...Array.from({ length: 3 }, (_, i) => ({
263
+ id: i + 5,
264
+ runId: "run-1",
265
+ agentName: "test-agent",
266
+ sessionId: "session-1",
267
+ eventType: "tool_start" as const,
268
+ toolName: "Edit",
269
+ toolArgs: JSON.stringify({ file_path: "src/commands/log.ts" }),
270
+ toolDurationMs: null,
271
+ level: "info" as const,
272
+ data: null,
273
+ createdAt: `2024-01-01T10:0${i + 4}:00.000Z`,
274
+ })),
275
+ ];
276
+
277
+ const result = analyzeSessionInsights({
278
+ events,
279
+ toolStats: [],
280
+ agentName: "test-agent",
281
+ capability: "builder",
282
+ domains: ["typescript"],
283
+ });
284
+
285
+ const hotFileInsights = result.insights.filter((i) => i.tags.includes("hot-file"));
286
+ expect(hotFileInsights).toHaveLength(2);
287
+
288
+ const mailInsight = hotFileInsights.find((i) => i.description.includes("src/mail/store.ts"));
289
+ expect(mailInsight?.domain).toBe("messaging");
290
+ expect(mailInsight?.description).toContain("4 edits");
291
+
292
+ const cliInsight = hotFileInsights.find((i) => i.description.includes("src/commands/log.ts"));
293
+ expect(cliInsight?.domain).toBe("cli");
294
+ expect(cliInsight?.description).toContain("3 edits");
295
+ });
296
+
297
+ test("limits hot files to top 3", () => {
298
+ const events: StoredEvent[] = [
299
+ // 5 files with 3+ edits, should only return top 3
300
+ ...Array.from({ length: 5 }, (_, i) => ({
301
+ id: i + 1,
302
+ runId: "run-1",
303
+ agentName: "test-agent",
304
+ sessionId: "session-1",
305
+ eventType: "tool_start" as const,
306
+ toolName: "Edit",
307
+ toolArgs: JSON.stringify({ file_path: `file${i}.ts` }),
308
+ toolDurationMs: null,
309
+ level: "info" as const,
310
+ data: null,
311
+ createdAt: "2024-01-01T10:00:00.000Z",
312
+ })),
313
+ ...Array.from({ length: 5 }, (_, i) => ({
314
+ id: i + 6,
315
+ runId: "run-1",
316
+ agentName: "test-agent",
317
+ sessionId: "session-1",
318
+ eventType: "tool_start" as const,
319
+ toolName: "Edit",
320
+ toolArgs: JSON.stringify({ file_path: `file${i}.ts` }),
321
+ toolDurationMs: null,
322
+ level: "info" as const,
323
+ data: null,
324
+ createdAt: "2024-01-01T10:01:00.000Z",
325
+ })),
326
+ ...Array.from({ length: 5 }, (_, i) => ({
327
+ id: i + 11,
328
+ runId: "run-1",
329
+ agentName: "test-agent",
330
+ sessionId: "session-1",
331
+ eventType: "tool_start" as const,
332
+ toolName: "Edit",
333
+ toolArgs: JSON.stringify({ file_path: `file${i}.ts` }),
334
+ toolDurationMs: null,
335
+ level: "info" as const,
336
+ data: null,
337
+ createdAt: "2024-01-01T10:02:00.000Z",
338
+ })),
339
+ // Extra edits to file0 and file1 to make them top 2
340
+ ...Array.from({ length: 2 }, (_, i) => ({
341
+ id: i + 16,
342
+ runId: "run-1",
343
+ agentName: "test-agent",
344
+ sessionId: "session-1",
345
+ eventType: "tool_start" as const,
346
+ toolName: "Edit",
347
+ toolArgs: JSON.stringify({ file_path: "file0.ts" }),
348
+ toolDurationMs: null,
349
+ level: "info" as const,
350
+ data: null,
351
+ createdAt: "2024-01-01T10:03:00.000Z",
352
+ })),
353
+ {
354
+ id: 18,
355
+ runId: "run-1",
356
+ agentName: "test-agent",
357
+ sessionId: "session-1",
358
+ eventType: "tool_start" as const,
359
+ toolName: "Edit",
360
+ toolArgs: JSON.stringify({ file_path: "file1.ts" }),
361
+ toolDurationMs: null,
362
+ level: "info" as const,
363
+ data: null,
364
+ createdAt: "2024-01-01T10:04:00.000Z",
365
+ },
366
+ ];
367
+
368
+ const result = analyzeSessionInsights({
369
+ events,
370
+ toolStats: [],
371
+ agentName: "test-agent",
372
+ capability: "builder",
373
+ domains: ["typescript"],
374
+ });
375
+
376
+ const hotFileInsights = result.insights.filter((i) => i.tags.includes("hot-file"));
377
+ expect(hotFileInsights).toHaveLength(3);
378
+ expect(hotFileInsights[0]?.description).toContain("file0.ts");
379
+ expect(hotFileInsights[0]?.description).toContain("5 edits");
380
+ expect(hotFileInsights[1]?.description).toContain("file1.ts");
381
+ expect(hotFileInsights[1]?.description).toContain("4 edits");
382
+ });
383
+
384
+ test("handles malformed tool args gracefully", () => {
385
+ const events: StoredEvent[] = [
386
+ {
387
+ id: 1,
388
+ runId: "run-1",
389
+ agentName: "test-agent",
390
+ sessionId: "session-1",
391
+ eventType: "tool_start",
392
+ toolName: "Edit",
393
+ toolArgs: "not-valid-json",
394
+ toolDurationMs: null,
395
+ level: "info",
396
+ data: null,
397
+ createdAt: "2024-01-01T10:00:00.000Z",
398
+ },
399
+ ];
400
+
401
+ const result = analyzeSessionInsights({
402
+ events,
403
+ toolStats: [],
404
+ agentName: "test-agent",
405
+ capability: "builder",
406
+ domains: ["typescript"],
407
+ });
408
+
409
+ expect(result.fileProfile.totalEdits).toBe(0);
410
+ expect(result.fileProfile.hotFiles).toEqual([]);
411
+ });
412
+
413
+ test("classifies workflow types correctly", () => {
414
+ // Test write-heavy
415
+ const writeHeavyStats: ToolStats[] = [
416
+ { toolName: "Edit", count: 12, avgDurationMs: 120, maxDurationMs: 200 },
417
+ { toolName: "Read", count: 3, avgDurationMs: 50, maxDurationMs: 100 },
418
+ ];
419
+
420
+ const writeResult = analyzeSessionInsights({
421
+ events: [],
422
+ toolStats: writeHeavyStats,
423
+ agentName: "test-agent",
424
+ capability: "builder",
425
+ domains: ["typescript"],
426
+ });
427
+
428
+ const writeInsight = writeResult.insights.find((i) => i.tags.includes("tool-profile"));
429
+ expect(writeInsight?.description).toContain("write-heavy workflow");
430
+
431
+ // Test bash-heavy
432
+ const bashHeavyStats: ToolStats[] = [
433
+ { toolName: "Bash", count: 12, avgDurationMs: 500, maxDurationMs: 1000 },
434
+ { toolName: "Read", count: 3, avgDurationMs: 50, maxDurationMs: 100 },
435
+ ];
436
+
437
+ const bashResult = analyzeSessionInsights({
438
+ events: [],
439
+ toolStats: bashHeavyStats,
440
+ agentName: "test-agent",
441
+ capability: "builder",
442
+ domains: ["typescript"],
443
+ });
444
+
445
+ const bashInsight = bashResult.insights.find((i) => i.tags.includes("tool-profile"));
446
+ expect(bashInsight?.description).toContain("bash-heavy workflow");
447
+
448
+ // Test balanced
449
+ const balancedStats: ToolStats[] = [
450
+ { toolName: "Read", count: 5, avgDurationMs: 50, maxDurationMs: 100 },
451
+ { toolName: "Edit", count: 5, avgDurationMs: 120, maxDurationMs: 200 },
452
+ { toolName: "Bash", count: 5, avgDurationMs: 500, maxDurationMs: 1000 },
453
+ ];
454
+
455
+ const balancedResult = analyzeSessionInsights({
456
+ events: [],
457
+ toolStats: balancedStats,
458
+ agentName: "test-agent",
459
+ capability: "builder",
460
+ domains: ["typescript"],
461
+ });
462
+
463
+ const balancedInsight = balancedResult.insights.find((i) => i.tags.includes("tool-profile"));
464
+ expect(balancedInsight?.description).toContain("balanced workflow");
465
+ });
466
+ });
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Session insight analyzer.
3
+ *
4
+ * Analyzes EventStore data from a completed session to extract structured
5
+ * patterns about tool usage, file edits, and errors. Produces SessionInsight
6
+ * objects suitable for recording to mulch.
7
+ */
8
+
9
+ import type {
10
+ FileProfile,
11
+ InsightAnalysis,
12
+ SessionInsight,
13
+ StoredEvent,
14
+ ToolProfile,
15
+ ToolStats,
16
+ } from "../types.ts";
17
+
18
+ /**
19
+ * Infer mulch domain from a file path.
20
+ *
21
+ * Maps file paths to domain names based on directory structure.
22
+ * Returns null if no clear mapping exists.
23
+ */
24
+ export function inferDomain(filePath: string): string | null {
25
+ if (filePath.includes("src/mail/")) {
26
+ return "messaging";
27
+ }
28
+ if (filePath.includes("src/commands/")) {
29
+ return "cli";
30
+ }
31
+ if (filePath.includes("src/agents/") || filePath.includes("agents/")) {
32
+ return "agents";
33
+ }
34
+ if (
35
+ filePath.includes("src/events/") ||
36
+ filePath.includes("src/logging/") ||
37
+ filePath.includes("src/metrics/")
38
+ ) {
39
+ return "cli";
40
+ }
41
+ if (filePath.includes("src/merge/") || filePath.includes("src/worktree/")) {
42
+ return "architecture";
43
+ }
44
+ if (filePath.endsWith(".test.ts")) {
45
+ return "typescript";
46
+ }
47
+ if (filePath.includes("src/")) {
48
+ return "typescript";
49
+ }
50
+ return null;
51
+ }
52
+
53
+ /**
54
+ * Analyze session data to extract structured insights.
55
+ *
56
+ * Processes EventStore events and tool stats to identify patterns in:
57
+ * - Tool usage (workflow approach)
58
+ * - File edit frequency (complexity signals)
59
+ * - Error patterns
60
+ *
61
+ * Returns an InsightAnalysis with insights, toolProfile, and fileProfile.
62
+ */
63
+ export function analyzeSessionInsights(params: {
64
+ events: StoredEvent[];
65
+ toolStats: ToolStats[];
66
+ agentName: string;
67
+ capability: string;
68
+ domains: string[];
69
+ }): InsightAnalysis {
70
+ const insights: SessionInsight[] = [];
71
+ const fallbackDomain = params.domains[0] ?? "agents";
72
+
73
+ // Build tool profile
74
+ const topTools = params.toolStats
75
+ .sort((a, b) => b.count - a.count)
76
+ .slice(0, 5)
77
+ .map((stat) => ({
78
+ name: stat.toolName,
79
+ count: stat.count,
80
+ avgMs: Math.round(stat.avgDurationMs),
81
+ }));
82
+
83
+ const totalToolCalls = params.toolStats.reduce((sum, stat) => sum + stat.count, 0);
84
+ const errorCount = params.events.filter((e) => e.level === "error").length;
85
+
86
+ const toolProfile: ToolProfile = {
87
+ topTools,
88
+ totalToolCalls,
89
+ errorCount,
90
+ };
91
+
92
+ // Build file profile
93
+ const fileEditCounts = new Map<string, number>();
94
+ for (const event of params.events) {
95
+ if (
96
+ event.eventType === "tool_start" &&
97
+ (event.toolName === "Edit" || event.toolName === "Write") &&
98
+ event.toolArgs !== null
99
+ ) {
100
+ try {
101
+ const args = JSON.parse(event.toolArgs) as { file_path?: string };
102
+ if (args.file_path !== undefined) {
103
+ const currentCount = fileEditCounts.get(args.file_path) ?? 0;
104
+ fileEditCounts.set(args.file_path, currentCount + 1);
105
+ }
106
+ } catch {
107
+ // Skip malformed tool args
108
+ }
109
+ }
110
+ }
111
+
112
+ const hotFiles = Array.from(fileEditCounts.entries())
113
+ .filter(([_, count]) => count >= 3)
114
+ .map(([path, count]) => ({ path, editCount: count }))
115
+ .sort((a, b) => b.editCount - a.editCount)
116
+ .slice(0, 3); // Limit to top 3 hot files
117
+
118
+ const totalEdits = Array.from(fileEditCounts.values()).reduce((sum, count) => sum + count, 0);
119
+
120
+ const fileProfile: FileProfile = {
121
+ hotFiles,
122
+ totalEdits,
123
+ };
124
+
125
+ // Generate insights
126
+
127
+ // 1. Tool workflow pattern (if totalToolCalls >= 10)
128
+ if (totalToolCalls >= 10) {
129
+ const readTools = ["Read", "Grep", "Glob"];
130
+ const writeTools = ["Edit", "Write"];
131
+ const bashTools = ["Bash"];
132
+
133
+ const readCount = params.toolStats
134
+ .filter((s) => readTools.includes(s.toolName))
135
+ .reduce((sum, s) => sum + s.count, 0);
136
+ const writeCount = params.toolStats
137
+ .filter((s) => writeTools.includes(s.toolName))
138
+ .reduce((sum, s) => sum + s.count, 0);
139
+ const bashCount = params.toolStats
140
+ .filter((s) => bashTools.includes(s.toolName))
141
+ .reduce((sum, s) => sum + s.count, 0);
142
+
143
+ const readPct = readCount / totalToolCalls;
144
+ const writePct = writeCount / totalToolCalls;
145
+ const bashPct = bashCount / totalToolCalls;
146
+
147
+ let workflowType: string;
148
+ if (readPct > 0.5) {
149
+ workflowType = "read-heavy";
150
+ } else if (writePct > 0.5) {
151
+ workflowType = "write-heavy";
152
+ } else if (bashPct > 0.5) {
153
+ workflowType = "bash-heavy";
154
+ } else {
155
+ workflowType = "balanced";
156
+ }
157
+
158
+ const topToolsDesc = topTools
159
+ .slice(0, 3)
160
+ .map((t) => `${t.name} (${t.count})`)
161
+ .join(", ");
162
+
163
+ insights.push({
164
+ type: "pattern",
165
+ domain: fallbackDomain,
166
+ description: `Session tool profile: ${topToolsDesc} — ${workflowType} workflow`,
167
+ tags: ["auto-insight", "tool-profile", params.capability],
168
+ });
169
+ }
170
+
171
+ // 2. Hot files pattern (for files with 3+ edits)
172
+ for (const hotFile of hotFiles) {
173
+ const domain = inferDomain(hotFile.path) ?? fallbackDomain;
174
+ insights.push({
175
+ type: "pattern",
176
+ domain,
177
+ description: `File ${hotFile.path} required ${hotFile.editCount} edits during session — high iteration suggests complexity`,
178
+ tags: ["auto-insight", "hot-file", params.capability],
179
+ });
180
+ }
181
+
182
+ // 3. Error pattern (if errorCount > 0)
183
+ if (errorCount > 0) {
184
+ const errorEvents = params.events.filter((e) => e.level === "error");
185
+ const errorTools = Array.from(
186
+ new Set(errorEvents.map((e) => e.toolName).filter((name): name is string => name !== null)),
187
+ );
188
+ const errorToolsList = errorTools.length > 0 ? errorTools.join(", ") : "unknown";
189
+
190
+ insights.push({
191
+ type: "failure",
192
+ domain: fallbackDomain,
193
+ description: `Session encountered ${errorCount} error(s). Error tools: ${errorToolsList}`,
194
+ tags: ["auto-insight", "error-pattern", params.capability],
195
+ });
196
+ }
197
+
198
+ return {
199
+ insights,
200
+ toolProfile,
201
+ fileProfile,
202
+ };
203
+ }