@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,724 @@
1
+ /**
2
+ * CLI command: overstory log <event> --agent <name> [--stdin]
3
+ *
4
+ * Called by Pre/PostToolUse and Stop hooks.
5
+ * Events: tool-start, tool-end, session-end.
6
+ * Writes to .overstory/logs/{agent-name}/{session-timestamp}/.
7
+ *
8
+ * When --stdin is passed, reads one line of JSON from stdin containing the full
9
+ * hook payload (tool_name, tool_input, transcript_path, session_id, etc.)
10
+ * and writes structured events to the EventStore for observability.
11
+ */
12
+
13
+ import { join } from "node:path";
14
+ import { updateIdentity } from "../agents/identity.ts";
15
+ import { loadConfig } from "../config.ts";
16
+ import { ValidationError } from "../errors.ts";
17
+ import { createEventStore } from "../events/store.ts";
18
+ import { filterToolArgs } from "../events/tool-filter.ts";
19
+ import { analyzeSessionInsights } from "../insights/analyzer.ts";
20
+ import { createLogger } from "../logging/logger.ts";
21
+ import { createMailClient } from "../mail/client.ts";
22
+ import { createMailStore } from "../mail/store.ts";
23
+ import { createMetricsStore } from "../metrics/store.ts";
24
+ import { estimateCost, parseTranscriptUsage } from "../metrics/transcript.ts";
25
+ import { createMulchClient, type MulchClient } from "../mulch/client.ts";
26
+ import { openSessionStore } from "../sessions/compat.ts";
27
+ import { createRunStore } from "../sessions/store.ts";
28
+ import type { AgentSession } from "../types.ts";
29
+
30
+ /**
31
+ * Parse a named flag value from args.
32
+ */
33
+ function getFlag(args: string[], flag: string): string | undefined {
34
+ const idx = args.indexOf(flag);
35
+ if (idx === -1 || idx + 1 >= args.length) {
36
+ return undefined;
37
+ }
38
+ return args[idx + 1];
39
+ }
40
+
41
+ /**
42
+ * Get or create a session timestamp directory for the agent.
43
+ * Uses a file-based marker to track the current session directory.
44
+ */
45
+ async function getSessionDir(logsBase: string, agentName: string): Promise<string> {
46
+ const agentLogsDir = join(logsBase, agentName);
47
+ const markerPath = join(agentLogsDir, ".current-session");
48
+
49
+ const markerFile = Bun.file(markerPath);
50
+ if (await markerFile.exists()) {
51
+ const sessionDir = (await markerFile.text()).trim();
52
+ if (sessionDir.length > 0) {
53
+ return sessionDir;
54
+ }
55
+ }
56
+
57
+ // Create a new session directory
58
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
59
+ const sessionDir = join(agentLogsDir, timestamp);
60
+ const { mkdir } = await import("node:fs/promises");
61
+ await mkdir(sessionDir, { recursive: true });
62
+ await Bun.write(markerPath, sessionDir);
63
+ return sessionDir;
64
+ }
65
+
66
+ /**
67
+ * Update the lastActivity timestamp for an agent in the SessionStore.
68
+ * Non-fatal: silently ignores errors to avoid breaking hook execution.
69
+ */
70
+ function updateLastActivity(projectRoot: string, agentName: string): void {
71
+ try {
72
+ const overstoryDir = join(projectRoot, ".overstory");
73
+ const { store } = openSessionStore(overstoryDir);
74
+ try {
75
+ const session = store.getByName(agentName);
76
+ if (session) {
77
+ store.updateLastActivity(agentName);
78
+ if (session.state === "booting" || session.state === "zombie") {
79
+ store.updateState(agentName, "working");
80
+ }
81
+ }
82
+ } finally {
83
+ store.close();
84
+ }
85
+ } catch {
86
+ // Non-fatal: don't break logging if session update fails
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Agent capabilities that run as persistent interactive sessions.
92
+ * The Stop hook fires every turn for these agents (not just at session end),
93
+ * so they must NOT auto-transition to 'completed' on session-end events.
94
+ */
95
+ const PERSISTENT_CAPABILITIES = new Set(["coordinator", "monitor"]);
96
+
97
+ /**
98
+ * Transition agent state to 'completed' in the SessionStore.
99
+ * Called when session-end event fires.
100
+ *
101
+ * Skips the transition for persistent agent types (coordinator, monitor)
102
+ * whose Stop hook fires every turn, not just at true session end.
103
+ *
104
+ * Non-fatal: silently ignores errors to avoid breaking hook execution.
105
+ */
106
+ function transitionToCompleted(projectRoot: string, agentName: string): void {
107
+ try {
108
+ const overstoryDir = join(projectRoot, ".overstory");
109
+ const { store } = openSessionStore(overstoryDir);
110
+ try {
111
+ const session = store.getByName(agentName);
112
+ if (session && PERSISTENT_CAPABILITIES.has(session.capability)) {
113
+ // Persistent agents: only update activity, don't mark completed
114
+ store.updateLastActivity(agentName);
115
+ return;
116
+ }
117
+ store.updateState(agentName, "completed");
118
+ store.updateLastActivity(agentName);
119
+ } finally {
120
+ store.close();
121
+ }
122
+ } catch {
123
+ // Non-fatal: don't break logging if session update fails
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Look up an agent's session record.
129
+ * Returns null if not found.
130
+ */
131
+ function getAgentSession(projectRoot: string, agentName: string): AgentSession | null {
132
+ try {
133
+ const overstoryDir = join(projectRoot, ".overstory");
134
+ const { store } = openSessionStore(overstoryDir);
135
+ try {
136
+ return store.getByName(agentName);
137
+ } finally {
138
+ store.close();
139
+ }
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Read one line of JSON from stdin. Returns parsed object or null on failure.
147
+ * Used when --stdin flag is present to receive hook payload from Claude Code.
148
+ *
149
+ * Reads ALL chunks from stdin to handle large payloads that exceed a single buffer.
150
+ */
151
+ async function readStdinJson(): Promise<Record<string, unknown> | null> {
152
+ try {
153
+ const reader = Bun.stdin.stream().getReader();
154
+ const chunks: Uint8Array[] = [];
155
+ while (true) {
156
+ const { value, done } = await reader.read();
157
+ if (done) break;
158
+ if (value) chunks.push(value);
159
+ }
160
+ reader.releaseLock();
161
+ if (chunks.length === 0) return null;
162
+ // Concatenate all chunks
163
+ const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
164
+ const combined = new Uint8Array(totalLength);
165
+ let offset = 0;
166
+ for (const chunk of chunks) {
167
+ combined.set(chunk, offset);
168
+ offset += chunk.length;
169
+ }
170
+ const text = new TextDecoder().decode(combined).trim();
171
+ if (text.length === 0) return null;
172
+ return JSON.parse(text) as Record<string, unknown>;
173
+ } catch {
174
+ return null;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Resolve the path to a Claude Code transcript JSONL file.
180
+ * Tries direct construction first, then searches all project directories.
181
+ * Caches the found path for faster subsequent lookups.
182
+ */
183
+ async function resolveTranscriptPath(
184
+ projectRoot: string,
185
+ sessionId: string,
186
+ logsBase: string,
187
+ agentName: string,
188
+ ): Promise<string | null> {
189
+ // Check cached path first
190
+ const cachePath = join(logsBase, agentName, ".transcript-path");
191
+ const cacheFile = Bun.file(cachePath);
192
+ if (await cacheFile.exists()) {
193
+ const cached = (await cacheFile.text()).trim();
194
+ if (cached.length > 0 && (await Bun.file(cached).exists())) {
195
+ return cached;
196
+ }
197
+ }
198
+
199
+ const homeDir = process.env.HOME ?? "";
200
+ const claudeProjectsDir = join(homeDir, ".claude", "projects");
201
+
202
+ // Try direct construction from project root
203
+ const projectKey = projectRoot.replace(/\//g, "-");
204
+ const directPath = join(claudeProjectsDir, projectKey, `${sessionId}.jsonl`);
205
+ if (await Bun.file(directPath).exists()) {
206
+ await Bun.write(cachePath, directPath);
207
+ return directPath;
208
+ }
209
+
210
+ // Search all project directories for the session file
211
+ const { readdir } = await import("node:fs/promises");
212
+ try {
213
+ const projects = await readdir(claudeProjectsDir);
214
+ for (const project of projects) {
215
+ const candidate = join(claudeProjectsDir, project, `${sessionId}.jsonl`);
216
+ if (await Bun.file(candidate).exists()) {
217
+ await Bun.write(cachePath, candidate);
218
+ return candidate;
219
+ }
220
+ }
221
+ } catch {
222
+ // Claude projects dir may not exist
223
+ }
224
+
225
+ return null;
226
+ }
227
+
228
+ /**
229
+ * Auto-record expertise from mulch learn results.
230
+ * Called during session-end for non-persistent agents.
231
+ * Records a reference entry for each suggested domain at the canonical root,
232
+ * then sends a slim notification mail to the parent agent.
233
+ *
234
+ * @returns List of successfully recorded domains
235
+ */
236
+ export async function autoRecordExpertise(params: {
237
+ mulchClient: MulchClient;
238
+ agentName: string;
239
+ capability: string;
240
+ beadId: string | null;
241
+ mailDbPath: string;
242
+ parentAgent: string | null;
243
+ projectRoot: string;
244
+ sessionStartedAt: string;
245
+ }): Promise<string[]> {
246
+ const learnResult = await params.mulchClient.learn({ since: "HEAD~1" });
247
+ if (learnResult.suggestedDomains.length === 0) {
248
+ return [];
249
+ }
250
+
251
+ const recordedDomains: string[] = [];
252
+ const filesList = learnResult.changedFiles.join(", ");
253
+
254
+ for (const domain of learnResult.suggestedDomains) {
255
+ try {
256
+ await params.mulchClient.record(domain, {
257
+ type: "reference",
258
+ description: `${params.capability} agent ${params.agentName} completed work in this domain. Files: ${filesList}`,
259
+ tags: ["auto-session-end", params.capability],
260
+ evidenceBead: params.beadId ?? undefined,
261
+ });
262
+ recordedDomains.push(domain);
263
+ } catch {
264
+ // Non-fatal per domain: skip failed records
265
+ }
266
+ }
267
+
268
+ // Analyze session events for deeper insights (tool usage, file edits, errors)
269
+ let insightSummary = "";
270
+ try {
271
+ const eventsDbPath = join(params.projectRoot, ".overstory", "events.db");
272
+ const eventStore = createEventStore(eventsDbPath);
273
+
274
+ const events = eventStore.getByAgent(params.agentName, {
275
+ since: params.sessionStartedAt,
276
+ });
277
+ const toolStats = eventStore.getToolStats({
278
+ agentName: params.agentName,
279
+ since: params.sessionStartedAt,
280
+ });
281
+
282
+ eventStore.close();
283
+
284
+ const analysis = analyzeSessionInsights({
285
+ events,
286
+ toolStats,
287
+ agentName: params.agentName,
288
+ capability: params.capability,
289
+ domains: learnResult.suggestedDomains,
290
+ });
291
+
292
+ // Record each insight to mulch
293
+ for (const insight of analysis.insights) {
294
+ try {
295
+ await params.mulchClient.record(insight.domain, {
296
+ type: insight.type,
297
+ description: insight.description,
298
+ tags: insight.tags,
299
+ evidenceBead: params.beadId ?? undefined,
300
+ });
301
+ if (!recordedDomains.includes(insight.domain)) {
302
+ recordedDomains.push(insight.domain);
303
+ }
304
+ } catch {
305
+ // Non-fatal per insight: skip failed records
306
+ }
307
+ }
308
+
309
+ // Build insight summary for mail
310
+ if (analysis.insights.length > 0) {
311
+ const insightTypes = new Map<string, number>();
312
+ for (const insight of analysis.insights) {
313
+ const count = insightTypes.get(insight.type) ?? 0;
314
+ insightTypes.set(insight.type, count + 1);
315
+ }
316
+ const typeCounts = Array.from(insightTypes.entries())
317
+ .map(([type, count]) => `${count} ${type}`)
318
+ .join(", ");
319
+ insightSummary = `\n\nAuto-insights: ${typeCounts} (${analysis.toolProfile.totalToolCalls} tool calls, ${analysis.fileProfile.totalEdits} edits)`;
320
+ }
321
+ } catch {
322
+ // Non-fatal: insight analysis should not break session-end handling
323
+ }
324
+
325
+ if (recordedDomains.length > 0) {
326
+ const mailStore = createMailStore(params.mailDbPath);
327
+ const mailClient = createMailClient(mailStore);
328
+ const recipient = params.parentAgent ?? "orchestrator";
329
+ const domainsList = recordedDomains.join(", ");
330
+ mailClient.send({
331
+ from: params.agentName,
332
+ to: recipient,
333
+ subject: `mulch: auto-recorded insights in ${domainsList}`,
334
+ body: `Session completed. Auto-recorded expertise in: ${domainsList}.\n\nChanged files: ${filesList}${insightSummary}`,
335
+ type: "status",
336
+ priority: "low",
337
+ });
338
+ mailClient.close();
339
+ }
340
+
341
+ return recordedDomains;
342
+ }
343
+
344
+ /**
345
+ * Entry point for `overstory log <event> --agent <name>`.
346
+ */
347
+ const LOG_HELP = `overstory log — Log a hook event
348
+
349
+ Usage: overstory log <event> --agent <name> [--stdin]
350
+
351
+ Arguments:
352
+ <event> Event type: tool-start, tool-end, session-end
353
+
354
+ Options:
355
+ --agent <name> Agent name (required)
356
+ --tool-name <name> Tool name (for tool-start/tool-end events, legacy)
357
+ --transcript <path> Path to Claude Code transcript JSONL (for session-end, legacy)
358
+ --stdin Read hook payload JSON from stdin (preferred)
359
+ --help, -h Show this help`;
360
+
361
+ export async function logCommand(args: string[]): Promise<void> {
362
+ if (args.includes("--help") || args.includes("-h")) {
363
+ process.stdout.write(`${LOG_HELP}\n`);
364
+ return;
365
+ }
366
+
367
+ const event = args.find((a) => !a.startsWith("--"));
368
+ const agentName = getFlag(args, "--agent");
369
+ const useStdin = args.includes("--stdin");
370
+ const toolNameFlag = getFlag(args, "--tool-name") ?? "unknown";
371
+ const transcriptPathFlag = getFlag(args, "--transcript");
372
+
373
+ if (!event) {
374
+ throw new ValidationError("Event is required: overstory log <event> --agent <name>", {
375
+ field: "event",
376
+ });
377
+ }
378
+
379
+ const validEvents = ["tool-start", "tool-end", "session-end"];
380
+ if (!validEvents.includes(event)) {
381
+ throw new ValidationError(`Invalid event "${event}". Valid: ${validEvents.join(", ")}`, {
382
+ field: "event",
383
+ value: event,
384
+ });
385
+ }
386
+
387
+ if (!agentName) {
388
+ throw new ValidationError("--agent is required for log command", {
389
+ field: "agent",
390
+ });
391
+ }
392
+
393
+ // Read stdin payload if --stdin flag is set
394
+ let stdinPayload: Record<string, unknown> | null = null;
395
+ if (useStdin) {
396
+ stdinPayload = await readStdinJson();
397
+ }
398
+
399
+ // Extract fields from stdin payload (preferred) or fall back to flags
400
+ const toolName =
401
+ typeof stdinPayload?.tool_name === "string" ? stdinPayload.tool_name : toolNameFlag;
402
+ const toolInput =
403
+ stdinPayload?.tool_input !== undefined &&
404
+ stdinPayload?.tool_input !== null &&
405
+ typeof stdinPayload.tool_input === "object"
406
+ ? (stdinPayload.tool_input as Record<string, unknown>)
407
+ : null;
408
+ const sessionId = typeof stdinPayload?.session_id === "string" ? stdinPayload.session_id : null;
409
+ const transcriptPath =
410
+ typeof stdinPayload?.transcript_path === "string"
411
+ ? stdinPayload.transcript_path
412
+ : transcriptPathFlag;
413
+
414
+ const cwd = process.cwd();
415
+ const config = await loadConfig(cwd);
416
+ const logsBase = join(config.project.root, ".overstory", "logs");
417
+ const sessionDir = await getSessionDir(logsBase, agentName);
418
+
419
+ const logger = createLogger({
420
+ logDir: sessionDir,
421
+ agentName,
422
+ verbose: config.logging.verbose,
423
+ redactSecrets: config.logging.redactSecrets,
424
+ });
425
+
426
+ switch (event) {
427
+ case "tool-start": {
428
+ // Backward compatibility: always write to per-agent log files
429
+ logger.toolStart(toolName, toolInput ?? {});
430
+ updateLastActivity(config.project.root, agentName);
431
+
432
+ // When --stdin is used, also write to EventStore for structured observability
433
+ if (useStdin) {
434
+ try {
435
+ const eventsDbPath = join(config.project.root, ".overstory", "events.db");
436
+ const eventStore = createEventStore(eventsDbPath);
437
+ const filtered = toolInput
438
+ ? filterToolArgs(toolName, toolInput)
439
+ : { args: {}, summary: toolName };
440
+ eventStore.insert({
441
+ runId: null,
442
+ agentName,
443
+ sessionId,
444
+ eventType: "tool_start",
445
+ toolName,
446
+ toolArgs: JSON.stringify(filtered.args),
447
+ toolDurationMs: null,
448
+ level: "info",
449
+ data: JSON.stringify({ summary: filtered.summary }),
450
+ });
451
+ eventStore.close();
452
+ } catch {
453
+ // Non-fatal: EventStore write should not break hook execution
454
+ }
455
+ }
456
+ break;
457
+ }
458
+ case "tool-end": {
459
+ // Backward compatibility: always write to per-agent log files
460
+ logger.toolEnd(toolName, 0);
461
+ updateLastActivity(config.project.root, agentName);
462
+
463
+ // When --stdin is used, write to EventStore and correlate with tool-start
464
+ if (useStdin) {
465
+ try {
466
+ const eventsDbPath = join(config.project.root, ".overstory", "events.db");
467
+ const eventStore = createEventStore(eventsDbPath);
468
+ const filtered = toolInput
469
+ ? filterToolArgs(toolName, toolInput)
470
+ : { args: {}, summary: toolName };
471
+ eventStore.insert({
472
+ runId: null,
473
+ agentName,
474
+ sessionId,
475
+ eventType: "tool_end",
476
+ toolName,
477
+ toolArgs: JSON.stringify(filtered.args),
478
+ toolDurationMs: null,
479
+ level: "info",
480
+ data: JSON.stringify({ summary: filtered.summary }),
481
+ });
482
+ const correlation = eventStore.correlateToolEnd(agentName, toolName);
483
+ if (correlation) {
484
+ logger.toolEnd(toolName, correlation.durationMs);
485
+ }
486
+ eventStore.close();
487
+ } catch {
488
+ // Non-fatal: EventStore write should not break hook execution
489
+ }
490
+
491
+ // Throttled token snapshot recording
492
+ if (sessionId) {
493
+ try {
494
+ // Throttle check
495
+ const snapshotMarkerPath = join(logsBase, agentName, ".last-snapshot");
496
+ const SNAPSHOT_INTERVAL_MS = 30_000;
497
+ const snapshotMarkerFile = Bun.file(snapshotMarkerPath);
498
+ let shouldSnapshot = true;
499
+
500
+ if (await snapshotMarkerFile.exists()) {
501
+ const lastTs = Number.parseInt(await snapshotMarkerFile.text(), 10);
502
+ if (!Number.isNaN(lastTs) && Date.now() - lastTs < SNAPSHOT_INTERVAL_MS) {
503
+ shouldSnapshot = false;
504
+ }
505
+ }
506
+
507
+ if (shouldSnapshot) {
508
+ const transcriptPath = await resolveTranscriptPath(
509
+ config.project.root,
510
+ sessionId,
511
+ logsBase,
512
+ agentName,
513
+ );
514
+ if (transcriptPath) {
515
+ const usage = await parseTranscriptUsage(transcriptPath);
516
+ const cost = estimateCost(usage);
517
+ const metricsDbPath = join(config.project.root, ".overstory", "metrics.db");
518
+ const metricsStore = createMetricsStore(metricsDbPath);
519
+ metricsStore.recordSnapshot({
520
+ agentName,
521
+ inputTokens: usage.inputTokens,
522
+ outputTokens: usage.outputTokens,
523
+ cacheReadTokens: usage.cacheReadTokens,
524
+ cacheCreationTokens: usage.cacheCreationTokens,
525
+ estimatedCostUsd: cost,
526
+ modelUsed: usage.modelUsed,
527
+ createdAt: new Date().toISOString(),
528
+ });
529
+ metricsStore.close();
530
+ await Bun.write(snapshotMarkerPath, String(Date.now()));
531
+ }
532
+ }
533
+ } catch {
534
+ // Non-fatal: snapshot recording should not break tool-end handling
535
+ }
536
+ }
537
+ }
538
+ break;
539
+ }
540
+ case "session-end":
541
+ logger.info("session.end", { agentName });
542
+ // Transition agent state to completed
543
+ transitionToCompleted(config.project.root, agentName);
544
+ // Look up agent session for identity update and metrics recording
545
+ {
546
+ const agentSession = getAgentSession(config.project.root, agentName);
547
+ const beadId = agentSession?.beadId ?? null;
548
+
549
+ // Update agent identity with completed session
550
+ const identityBaseDir = join(config.project.root, ".overstory", "agents");
551
+ try {
552
+ await updateIdentity(identityBaseDir, agentName, {
553
+ sessionsCompleted: 1,
554
+ completedTask: beadId ? { beadId, summary: `Completed task ${beadId}` } : undefined,
555
+ });
556
+ } catch {
557
+ // Non-fatal: identity may not exist for this agent
558
+ }
559
+
560
+ // Auto-nudge coordinator when a lead completes so it wakes up
561
+ // to process merge_ready / worker_done messages without waiting
562
+ // for user input (see decision mx-728f8d).
563
+ if (agentSession?.capability === "lead") {
564
+ try {
565
+ const nudgesDir = join(config.project.root, ".overstory", "pending-nudges");
566
+ const { mkdir } = await import("node:fs/promises");
567
+ await mkdir(nudgesDir, { recursive: true });
568
+ const markerPath = join(nudgesDir, "coordinator.json");
569
+ const marker = {
570
+ from: agentName,
571
+ reason: "lead_completed",
572
+ subject: `Lead ${agentName} completed — check mail for merge_ready/worker_done`,
573
+ messageId: `auto-nudge-${agentName}-${Date.now()}`,
574
+ createdAt: new Date().toISOString(),
575
+ };
576
+ await Bun.write(markerPath, `${JSON.stringify(marker, null, "\t")}\n`);
577
+ } catch {
578
+ // Non-fatal: nudge failure should not break session-end
579
+ }
580
+ }
581
+
582
+ // Record session metrics (with optional token data from transcript)
583
+ if (agentSession) {
584
+ // Auto-complete the current run when the coordinator exits.
585
+ // This handles the case where the user closes the tmux window
586
+ // without running `overstory coordinator stop`.
587
+ if (agentSession.capability === "coordinator") {
588
+ try {
589
+ const currentRunPath = join(config.project.root, ".overstory", "current-run.txt");
590
+ const currentRunFile = Bun.file(currentRunPath);
591
+ if (await currentRunFile.exists()) {
592
+ const runId = (await currentRunFile.text()).trim();
593
+ if (runId.length > 0) {
594
+ const runStore = createRunStore(
595
+ join(config.project.root, ".overstory", "sessions.db"),
596
+ );
597
+ try {
598
+ runStore.completeRun(runId, "completed");
599
+ } finally {
600
+ runStore.close();
601
+ }
602
+ const { unlink: unlinkFile } = await import("node:fs/promises");
603
+ try {
604
+ await unlinkFile(currentRunPath);
605
+ } catch {
606
+ // File may already be gone
607
+ }
608
+ }
609
+ }
610
+ } catch {
611
+ // Non-fatal: run completion should not break session-end handling
612
+ }
613
+ }
614
+
615
+ try {
616
+ const metricsDbPath = join(config.project.root, ".overstory", "metrics.db");
617
+ const metricsStore = createMetricsStore(metricsDbPath);
618
+ const now = new Date().toISOString();
619
+ const durationMs = new Date(now).getTime() - new Date(agentSession.startedAt).getTime();
620
+
621
+ // Parse token usage from transcript if path provided
622
+ let inputTokens = 0;
623
+ let outputTokens = 0;
624
+ let cacheReadTokens = 0;
625
+ let cacheCreationTokens = 0;
626
+ let estimatedCostUsd: number | null = null;
627
+ let modelUsed: string | null = null;
628
+
629
+ if (transcriptPath) {
630
+ try {
631
+ const usage = await parseTranscriptUsage(transcriptPath);
632
+ inputTokens = usage.inputTokens;
633
+ outputTokens = usage.outputTokens;
634
+ cacheReadTokens = usage.cacheReadTokens;
635
+ cacheCreationTokens = usage.cacheCreationTokens;
636
+ modelUsed = usage.modelUsed;
637
+ estimatedCostUsd = estimateCost(usage);
638
+ } catch {
639
+ // Non-fatal: transcript parsing should not break metrics
640
+ }
641
+ }
642
+
643
+ metricsStore.recordSession({
644
+ agentName,
645
+ beadId: agentSession.beadId,
646
+ capability: agentSession.capability,
647
+ startedAt: agentSession.startedAt,
648
+ completedAt: now,
649
+ durationMs,
650
+ exitCode: null,
651
+ mergeResult: null,
652
+ parentAgent: agentSession.parentAgent,
653
+ inputTokens,
654
+ outputTokens,
655
+ cacheReadTokens,
656
+ cacheCreationTokens,
657
+ estimatedCostUsd,
658
+ modelUsed,
659
+ runId: agentSession.runId,
660
+ });
661
+ metricsStore.close();
662
+ } catch {
663
+ // Non-fatal: metrics recording should not break session-end handling
664
+ }
665
+
666
+ // Auto-record expertise via mulch learn + record (post-session).
667
+ // Skip persistent agents whose Stop hook fires every turn.
668
+ if (!PERSISTENT_CAPABILITIES.has(agentSession.capability)) {
669
+ try {
670
+ const mulchClient = createMulchClient(config.project.root);
671
+ const mailDbPath = join(config.project.root, ".overstory", "mail.db");
672
+ await autoRecordExpertise({
673
+ mulchClient,
674
+ agentName,
675
+ capability: agentSession.capability,
676
+ beadId,
677
+ mailDbPath,
678
+ parentAgent: agentSession.parentAgent,
679
+ projectRoot: config.project.root,
680
+ sessionStartedAt: agentSession.startedAt,
681
+ });
682
+ } catch {
683
+ // Non-fatal: mulch learn/record should not break session-end handling
684
+ }
685
+ }
686
+ }
687
+
688
+ // Write session-end event to EventStore when --stdin is used
689
+ if (useStdin) {
690
+ try {
691
+ const eventsDbPath = join(config.project.root, ".overstory", "events.db");
692
+ const eventStore = createEventStore(eventsDbPath);
693
+ eventStore.insert({
694
+ runId: null,
695
+ agentName,
696
+ sessionId,
697
+ eventType: "session_end",
698
+ toolName: null,
699
+ toolArgs: null,
700
+ toolDurationMs: null,
701
+ level: "info",
702
+ data: transcriptPath ? JSON.stringify({ transcriptPath }) : null,
703
+ });
704
+ eventStore.close();
705
+ } catch {
706
+ // Non-fatal: EventStore write should not break session-end
707
+ }
708
+ }
709
+ }
710
+ // Clear the current session marker
711
+ {
712
+ const markerPath = join(logsBase, agentName, ".current-session");
713
+ try {
714
+ const { unlink } = await import("node:fs/promises");
715
+ await unlink(markerPath);
716
+ } catch {
717
+ // Marker may not exist
718
+ }
719
+ }
720
+ break;
721
+ }
722
+
723
+ logger.close();
724
+ }