@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.
- package/LICENSE +21 -0
- package/README.md +381 -0
- package/agents/builder.md +137 -0
- package/agents/coordinator.md +263 -0
- package/agents/lead.md +301 -0
- package/agents/merger.md +160 -0
- package/agents/monitor.md +214 -0
- package/agents/reviewer.md +140 -0
- package/agents/scout.md +119 -0
- package/agents/supervisor.md +423 -0
- package/package.json +47 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +101 -0
- package/src/agents/hooks-deployer.test.ts +2040 -0
- package/src/agents/hooks-deployer.ts +607 -0
- package/src/agents/identity.test.ts +603 -0
- package/src/agents/identity.ts +384 -0
- package/src/agents/lifecycle.test.ts +196 -0
- package/src/agents/lifecycle.ts +183 -0
- package/src/agents/manifest.test.ts +746 -0
- package/src/agents/manifest.ts +354 -0
- package/src/agents/overlay.test.ts +676 -0
- package/src/agents/overlay.ts +308 -0
- package/src/beads/client.test.ts +217 -0
- package/src/beads/client.ts +202 -0
- package/src/beads/molecules.test.ts +338 -0
- package/src/beads/molecules.ts +198 -0
- package/src/commands/agents.test.ts +322 -0
- package/src/commands/agents.ts +287 -0
- package/src/commands/clean.test.ts +670 -0
- package/src/commands/clean.ts +618 -0
- package/src/commands/completions.test.ts +342 -0
- package/src/commands/completions.ts +887 -0
- package/src/commands/coordinator.test.ts +1530 -0
- package/src/commands/coordinator.ts +733 -0
- package/src/commands/costs.test.ts +1119 -0
- package/src/commands/costs.ts +564 -0
- package/src/commands/dashboard.test.ts +308 -0
- package/src/commands/dashboard.ts +838 -0
- package/src/commands/doctor.test.ts +294 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/errors.test.ts +647 -0
- package/src/commands/errors.ts +248 -0
- package/src/commands/feed.test.ts +578 -0
- package/src/commands/feed.ts +361 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +511 -0
- package/src/commands/hooks.test.ts +458 -0
- package/src/commands/hooks.ts +253 -0
- package/src/commands/init.test.ts +347 -0
- package/src/commands/init.ts +650 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +431 -0
- package/src/commands/log.test.ts +1454 -0
- package/src/commands/log.ts +724 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +546 -0
- package/src/commands/mail.test.ts +1270 -0
- package/src/commands/mail.ts +771 -0
- package/src/commands/merge.test.ts +670 -0
- package/src/commands/merge.ts +355 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +143 -0
- package/src/commands/monitor.test.ts +191 -0
- package/src/commands/monitor.ts +390 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +372 -0
- package/src/commands/prime.test.ts +470 -0
- package/src/commands/prime.ts +381 -0
- package/src/commands/replay.test.ts +741 -0
- package/src/commands/replay.ts +360 -0
- package/src/commands/run.test.ts +431 -0
- package/src/commands/run.ts +351 -0
- package/src/commands/sling.test.ts +657 -0
- package/src/commands/sling.ts +661 -0
- package/src/commands/spec.test.ts +203 -0
- package/src/commands/spec.ts +168 -0
- package/src/commands/status.test.ts +430 -0
- package/src/commands/status.ts +398 -0
- package/src/commands/stop.test.ts +420 -0
- package/src/commands/stop.ts +151 -0
- package/src/commands/supervisor.test.ts +187 -0
- package/src/commands/supervisor.ts +535 -0
- package/src/commands/trace.test.ts +745 -0
- package/src/commands/trace.ts +325 -0
- package/src/commands/watch.test.ts +145 -0
- package/src/commands/watch.ts +247 -0
- package/src/commands/worktree.test.ts +786 -0
- package/src/commands/worktree.ts +311 -0
- package/src/config.test.ts +822 -0
- package/src/config.ts +829 -0
- package/src/doctor/agents.test.ts +454 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +190 -0
- package/src/doctor/config-check.ts +183 -0
- package/src/doctor/consistency.test.ts +651 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +290 -0
- package/src/doctor/databases.ts +218 -0
- package/src/doctor/dependencies.test.ts +184 -0
- package/src/doctor/dependencies.ts +175 -0
- package/src/doctor/logs.test.ts +251 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +216 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +291 -0
- package/src/doctor/structure.ts +198 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +136 -0
- package/src/doctor/version.ts +129 -0
- package/src/e2e/init-sling-lifecycle.test.ts +277 -0
- package/src/errors.ts +217 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +369 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/index.ts +316 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +142 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +813 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +259 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +773 -0
- package/src/mail/client.ts +223 -0
- package/src/mail/store.test.ts +705 -0
- package/src/mail/store.ts +387 -0
- package/src/merge/queue.test.ts +359 -0
- package/src/merge/queue.ts +231 -0
- package/src/merge/resolver.test.ts +1345 -0
- package/src/merge/resolver.ts +645 -0
- package/src/metrics/store.test.ts +667 -0
- package/src/metrics/store.ts +445 -0
- package/src/metrics/summary.test.ts +398 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +356 -0
- package/src/metrics/transcript.ts +175 -0
- package/src/mulch/client.test.ts +671 -0
- package/src/mulch/client.ts +332 -0
- package/src/sessions/compat.test.ts +280 -0
- package/src/sessions/compat.ts +104 -0
- package/src/sessions/store.test.ts +873 -0
- package/src/sessions/store.ts +494 -0
- package/src/test-helpers.test.ts +124 -0
- package/src/test-helpers.ts +126 -0
- package/src/tracker/beads.ts +56 -0
- package/src/tracker/factory.test.ts +80 -0
- package/src/tracker/factory.ts +64 -0
- package/src/tracker/seeds.ts +182 -0
- package/src/tracker/types.ts +52 -0
- package/src/types.ts +724 -0
- package/src/watchdog/daemon.test.ts +1975 -0
- package/src/watchdog/daemon.ts +671 -0
- package/src/watchdog/health.test.ts +431 -0
- package/src/watchdog/health.ts +264 -0
- package/src/watchdog/triage.test.ts +164 -0
- package/src/watchdog/triage.ts +179 -0
- package/src/worktree/manager.test.ts +439 -0
- package/src/worktree/manager.ts +198 -0
- package/src/worktree/tmux.test.ts +1009 -0
- package/src/worktree/tmux.ts +509 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +105 -0
- 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
|
+
}
|