@katyella/legio 0.1.0

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