@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,546 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: overstory logs [--agent <name>] [--level <level>] [--since <time>] [--until <time>] [--limit <n>] [--follow] [--json]
|
|
3
|
+
*
|
|
4
|
+
* Queries NDJSON log files from .overstory/logs/{agent-name}/{session-timestamp}/events.ndjson
|
|
5
|
+
* and presents a unified timeline view.
|
|
6
|
+
*
|
|
7
|
+
* Unlike trace/errors/replay which query events.db (SQLite), this command reads raw NDJSON files
|
|
8
|
+
* on disk — the source of truth written by each agent logger.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readdir, stat } from "node:fs/promises";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { loadConfig } from "../config.ts";
|
|
14
|
+
import { ValidationError } from "../errors.ts";
|
|
15
|
+
import { color } from "../logging/color.ts";
|
|
16
|
+
import type { LogEvent } from "../types.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse a named flag value from args.
|
|
20
|
+
*/
|
|
21
|
+
function getFlag(args: string[], flag: string): string | undefined {
|
|
22
|
+
const idx = args.indexOf(flag);
|
|
23
|
+
if (idx === -1 || idx + 1 >= args.length) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
return args[idx + 1];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function hasFlag(args: string[], flag: string): boolean {
|
|
30
|
+
return args.includes(flag);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse relative time formats like "1h", "30m", "2d", "10s" into a Date object.
|
|
35
|
+
* Falls back to parsing as ISO 8601 if not in relative format.
|
|
36
|
+
*/
|
|
37
|
+
function parseRelativeTime(timeStr: string): Date {
|
|
38
|
+
const relativeMatch = /^(\d+)(s|m|h|d)$/.exec(timeStr);
|
|
39
|
+
if (relativeMatch) {
|
|
40
|
+
const value = Number.parseInt(relativeMatch[1] ?? "0", 10);
|
|
41
|
+
const unit = relativeMatch[2];
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
let offsetMs = 0;
|
|
44
|
+
|
|
45
|
+
switch (unit) {
|
|
46
|
+
case "s":
|
|
47
|
+
offsetMs = value * 1000;
|
|
48
|
+
break;
|
|
49
|
+
case "m":
|
|
50
|
+
offsetMs = value * 60 * 1000;
|
|
51
|
+
break;
|
|
52
|
+
case "h":
|
|
53
|
+
offsetMs = value * 60 * 60 * 1000;
|
|
54
|
+
break;
|
|
55
|
+
case "d":
|
|
56
|
+
offsetMs = value * 24 * 60 * 60 * 1000;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return new Date(now - offsetMs);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Not a relative format, treat as ISO 8601
|
|
64
|
+
return new Date(timeStr);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Format the date portion of an ISO timestamp.
|
|
69
|
+
* Returns "YYYY-MM-DD".
|
|
70
|
+
*/
|
|
71
|
+
function formatDate(timestamp: string): string {
|
|
72
|
+
const match = /^(\d{4}-\d{2}-\d{2})/.exec(timestamp);
|
|
73
|
+
if (match?.[1]) {
|
|
74
|
+
return match[1];
|
|
75
|
+
}
|
|
76
|
+
return "";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Format an absolute time from an ISO timestamp.
|
|
81
|
+
* Returns "HH:MM:SS" portion.
|
|
82
|
+
*/
|
|
83
|
+
function formatAbsoluteTime(timestamp: string): string {
|
|
84
|
+
const match = /T(\d{2}:\d{2}:\d{2})/.exec(timestamp);
|
|
85
|
+
if (match?.[1]) {
|
|
86
|
+
return match[1];
|
|
87
|
+
}
|
|
88
|
+
return timestamp;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build a detail string for a log event based on its data.
|
|
93
|
+
*/
|
|
94
|
+
function buildLogDetail(event: LogEvent): string {
|
|
95
|
+
const parts: string[] = [];
|
|
96
|
+
|
|
97
|
+
for (const [key, value] of Object.entries(event.data)) {
|
|
98
|
+
if (value !== null && value !== undefined) {
|
|
99
|
+
const strValue = typeof value === "string" ? value : JSON.stringify(value);
|
|
100
|
+
// Truncate long values
|
|
101
|
+
const truncated = strValue.length > 80 ? `${strValue.slice(0, 77)}...` : strValue;
|
|
102
|
+
parts.push(`${key}=${truncated}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return parts.join(" ");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Discover all events.ndjson files in the logs directory.
|
|
111
|
+
* Returns array of { agentName, sessionTimestamp, path }.
|
|
112
|
+
*/
|
|
113
|
+
async function discoverLogFiles(
|
|
114
|
+
logsDir: string,
|
|
115
|
+
agentFilter?: string,
|
|
116
|
+
): Promise<
|
|
117
|
+
Array<{
|
|
118
|
+
agentName: string;
|
|
119
|
+
sessionTimestamp: string;
|
|
120
|
+
path: string;
|
|
121
|
+
}>
|
|
122
|
+
> {
|
|
123
|
+
const discovered: Array<{
|
|
124
|
+
agentName: string;
|
|
125
|
+
sessionTimestamp: string;
|
|
126
|
+
path: string;
|
|
127
|
+
}> = [];
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const agentDirs = await readdir(logsDir);
|
|
131
|
+
|
|
132
|
+
for (const agentName of agentDirs) {
|
|
133
|
+
if (agentFilter !== undefined && agentName !== agentFilter) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const agentDir = join(logsDir, agentName);
|
|
138
|
+
let agentStat: Awaited<ReturnType<typeof stat>>;
|
|
139
|
+
try {
|
|
140
|
+
agentStat = await stat(agentDir);
|
|
141
|
+
} catch {
|
|
142
|
+
continue; // Not a directory or doesn't exist
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!agentStat.isDirectory()) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const sessionDirs = await readdir(agentDir);
|
|
150
|
+
|
|
151
|
+
for (const sessionTimestamp of sessionDirs) {
|
|
152
|
+
const eventsPath = join(agentDir, sessionTimestamp, "events.ndjson");
|
|
153
|
+
let eventsStat: Awaited<ReturnType<typeof stat>>;
|
|
154
|
+
try {
|
|
155
|
+
eventsStat = await stat(eventsPath);
|
|
156
|
+
} catch {
|
|
157
|
+
continue; // File doesn't exist
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (eventsStat.isFile()) {
|
|
161
|
+
discovered.push({
|
|
162
|
+
agentName,
|
|
163
|
+
sessionTimestamp,
|
|
164
|
+
path: eventsPath,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
// Logs directory doesn't exist or can't be read
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Sort by session timestamp (chronological)
|
|
175
|
+
discovered.sort((a, b) => a.sessionTimestamp.localeCompare(b.sessionTimestamp));
|
|
176
|
+
|
|
177
|
+
return discovered;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Parse a single NDJSON file and return log events.
|
|
182
|
+
* Silently skips invalid lines.
|
|
183
|
+
*/
|
|
184
|
+
async function parseLogFile(path: string): Promise<LogEvent[]> {
|
|
185
|
+
const events: LogEvent[] = [];
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const file = Bun.file(path);
|
|
189
|
+
const text = await file.text();
|
|
190
|
+
const lines = text.split("\n");
|
|
191
|
+
|
|
192
|
+
for (const line of lines) {
|
|
193
|
+
if (line.trim() === "") {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const parsed: unknown = JSON.parse(line);
|
|
199
|
+
// Validate that it has required LogEvent fields
|
|
200
|
+
if (
|
|
201
|
+
typeof parsed === "object" &&
|
|
202
|
+
parsed !== null &&
|
|
203
|
+
"timestamp" in parsed &&
|
|
204
|
+
"event" in parsed
|
|
205
|
+
) {
|
|
206
|
+
events.push(parsed as LogEvent);
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
// Invalid JSON line, skip silently
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
// File can't be read, return empty array
|
|
214
|
+
return [];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return events;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Apply filters to log events.
|
|
222
|
+
*/
|
|
223
|
+
function filterEvents(
|
|
224
|
+
events: LogEvent[],
|
|
225
|
+
filters: {
|
|
226
|
+
level?: string;
|
|
227
|
+
since?: Date;
|
|
228
|
+
until?: Date;
|
|
229
|
+
},
|
|
230
|
+
): LogEvent[] {
|
|
231
|
+
return events.filter((event) => {
|
|
232
|
+
if (filters.level !== undefined && event.level !== filters.level) {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const eventTime = new Date(event.timestamp).getTime();
|
|
237
|
+
|
|
238
|
+
if (filters.since !== undefined && eventTime < filters.since.getTime()) {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (filters.until !== undefined && eventTime > filters.until.getTime()) {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return true;
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Print log events with ANSI colors and date separators.
|
|
252
|
+
*/
|
|
253
|
+
function printLogs(events: LogEvent[]): void {
|
|
254
|
+
const w = process.stdout.write.bind(process.stdout);
|
|
255
|
+
|
|
256
|
+
w(`${color.bold}Logs${color.reset}\n`);
|
|
257
|
+
w(`${"=".repeat(70)}\n`);
|
|
258
|
+
|
|
259
|
+
if (events.length === 0) {
|
|
260
|
+
w(`${color.dim}No log files found.${color.reset}\n`);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
w(`${color.dim}${events.length} ${events.length === 1 ? "entry" : "entries"}${color.reset}\n\n`);
|
|
265
|
+
|
|
266
|
+
let lastDate = "";
|
|
267
|
+
|
|
268
|
+
for (const event of events) {
|
|
269
|
+
// Print date separator when the date changes
|
|
270
|
+
const date = formatDate(event.timestamp);
|
|
271
|
+
if (date && date !== lastDate) {
|
|
272
|
+
if (lastDate !== "") {
|
|
273
|
+
w("\n");
|
|
274
|
+
}
|
|
275
|
+
w(`${color.dim}--- ${date} ---${color.reset}\n`);
|
|
276
|
+
lastDate = date;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const time = formatAbsoluteTime(event.timestamp);
|
|
280
|
+
|
|
281
|
+
// Format level display
|
|
282
|
+
let levelStr: string;
|
|
283
|
+
let levelColorCode: string;
|
|
284
|
+
switch (event.level) {
|
|
285
|
+
case "debug":
|
|
286
|
+
levelStr = "DBG";
|
|
287
|
+
levelColorCode = color.gray;
|
|
288
|
+
break;
|
|
289
|
+
case "info":
|
|
290
|
+
levelStr = "INF";
|
|
291
|
+
levelColorCode = color.blue;
|
|
292
|
+
break;
|
|
293
|
+
case "warn":
|
|
294
|
+
levelStr = "WRN";
|
|
295
|
+
levelColorCode = color.yellow;
|
|
296
|
+
break;
|
|
297
|
+
case "error":
|
|
298
|
+
levelStr = "ERR";
|
|
299
|
+
levelColorCode = color.red;
|
|
300
|
+
break;
|
|
301
|
+
default:
|
|
302
|
+
levelStr = String(event.level).slice(0, 3).toUpperCase();
|
|
303
|
+
levelColorCode = color.gray;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const agentLabel = event.agentName ? `[${event.agentName}]` : "[unknown]";
|
|
307
|
+
const detail = buildLogDetail(event);
|
|
308
|
+
const detailSuffix = detail ? ` ${color.dim}${detail}${color.reset}` : "";
|
|
309
|
+
|
|
310
|
+
w(
|
|
311
|
+
`${time} ${levelColorCode}${levelStr}${color.reset} ` +
|
|
312
|
+
`${event.event} ${color.dim}${agentLabel}${color.reset}${detailSuffix}\n`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Follow mode: tail logs in real time.
|
|
319
|
+
*/
|
|
320
|
+
async function followLogs(
|
|
321
|
+
logsDir: string,
|
|
322
|
+
filters: {
|
|
323
|
+
agent?: string;
|
|
324
|
+
level?: string;
|
|
325
|
+
},
|
|
326
|
+
): Promise<void> {
|
|
327
|
+
const w = process.stdout.write.bind(process.stdout);
|
|
328
|
+
|
|
329
|
+
w(`${color.bold}Following logs (Ctrl+C to stop)${color.reset}\n\n`);
|
|
330
|
+
|
|
331
|
+
// Track file positions for tailing
|
|
332
|
+
const filePositions = new Map<string, number>();
|
|
333
|
+
|
|
334
|
+
while (true) {
|
|
335
|
+
const discovered = await discoverLogFiles(logsDir, filters.agent);
|
|
336
|
+
|
|
337
|
+
for (const { path } of discovered) {
|
|
338
|
+
const file = Bun.file(path);
|
|
339
|
+
let fileSize: number;
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
const fileStat = await stat(path);
|
|
343
|
+
fileSize = fileStat.size;
|
|
344
|
+
} catch {
|
|
345
|
+
continue; // File disappeared
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const lastPosition = filePositions.get(path) ?? 0;
|
|
349
|
+
|
|
350
|
+
if (fileSize > lastPosition) {
|
|
351
|
+
// New data available
|
|
352
|
+
try {
|
|
353
|
+
const fullText = await file.text();
|
|
354
|
+
const newText = fullText.slice(lastPosition);
|
|
355
|
+
const lines = newText.split("\n");
|
|
356
|
+
|
|
357
|
+
for (const line of lines) {
|
|
358
|
+
if (line.trim() === "") {
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
const parsed: unknown = JSON.parse(line);
|
|
364
|
+
if (
|
|
365
|
+
typeof parsed === "object" &&
|
|
366
|
+
parsed !== null &&
|
|
367
|
+
"timestamp" in parsed &&
|
|
368
|
+
"event" in parsed
|
|
369
|
+
) {
|
|
370
|
+
const event = parsed as LogEvent;
|
|
371
|
+
|
|
372
|
+
// Apply level filter
|
|
373
|
+
if (filters.level !== undefined && event.level !== filters.level) {
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Print immediately
|
|
378
|
+
const time = formatAbsoluteTime(event.timestamp);
|
|
379
|
+
|
|
380
|
+
let levelStr: string;
|
|
381
|
+
let levelColorCode: string;
|
|
382
|
+
switch (event.level) {
|
|
383
|
+
case "debug":
|
|
384
|
+
levelStr = "DBG";
|
|
385
|
+
levelColorCode = color.gray;
|
|
386
|
+
break;
|
|
387
|
+
case "info":
|
|
388
|
+
levelStr = "INF";
|
|
389
|
+
levelColorCode = color.blue;
|
|
390
|
+
break;
|
|
391
|
+
case "warn":
|
|
392
|
+
levelStr = "WRN";
|
|
393
|
+
levelColorCode = color.yellow;
|
|
394
|
+
break;
|
|
395
|
+
case "error":
|
|
396
|
+
levelStr = "ERR";
|
|
397
|
+
levelColorCode = color.red;
|
|
398
|
+
break;
|
|
399
|
+
default:
|
|
400
|
+
levelStr = String(event.level).slice(0, 3).toUpperCase();
|
|
401
|
+
levelColorCode = color.gray;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const agentLabel = event.agentName ? `[${event.agentName}]` : "[unknown]";
|
|
405
|
+
const detail = buildLogDetail(event);
|
|
406
|
+
const detailSuffix = detail ? ` ${color.dim}${detail}${color.reset}` : "";
|
|
407
|
+
|
|
408
|
+
w(
|
|
409
|
+
`${time} ${levelColorCode}${levelStr}${color.reset} ` +
|
|
410
|
+
`${event.event} ${color.dim}${agentLabel}${color.reset}${detailSuffix}\n`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
} catch {
|
|
414
|
+
// Invalid JSON line, skip
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
filePositions.set(path, fileSize);
|
|
419
|
+
} catch {
|
|
420
|
+
// File read error, skip
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Sleep for 1 second before next poll
|
|
426
|
+
await Bun.sleep(1000);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const LOGS_HELP = `overstory logs -- Query NDJSON log files from .overstory/logs
|
|
431
|
+
|
|
432
|
+
Usage: overstory logs [options]
|
|
433
|
+
|
|
434
|
+
Options:
|
|
435
|
+
--agent <name> Filter logs by agent name
|
|
436
|
+
--level <level> Filter by log level: debug, info, warn, error
|
|
437
|
+
--since <time> Start time filter (ISO 8601 or relative: 1h, 30m, 2d, 10s)
|
|
438
|
+
--until <time> End time filter (ISO 8601)
|
|
439
|
+
--limit <n> Max entries to show (default: 100, returns most recent)
|
|
440
|
+
--follow Tail logs in real time (poll every 1s, Ctrl+C to stop)
|
|
441
|
+
--json Output as JSON array of LogEvent objects
|
|
442
|
+
--help, -h Show this help`;
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Entry point for `overstory logs` command.
|
|
446
|
+
*/
|
|
447
|
+
export async function logsCommand(args: string[]): Promise<void> {
|
|
448
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
449
|
+
process.stdout.write(`${LOGS_HELP}\n`);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const json = hasFlag(args, "--json");
|
|
454
|
+
const follow = hasFlag(args, "--follow");
|
|
455
|
+
const agentName = getFlag(args, "--agent");
|
|
456
|
+
const level = getFlag(args, "--level");
|
|
457
|
+
const sinceStr = getFlag(args, "--since");
|
|
458
|
+
const untilStr = getFlag(args, "--until");
|
|
459
|
+
const limitStr = getFlag(args, "--limit");
|
|
460
|
+
const limit = limitStr ? Number.parseInt(limitStr, 10) : 100;
|
|
461
|
+
|
|
462
|
+
if (Number.isNaN(limit) || limit < 1) {
|
|
463
|
+
throw new ValidationError("--limit must be a positive integer", {
|
|
464
|
+
field: "limit",
|
|
465
|
+
value: limitStr,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Validate level if provided
|
|
470
|
+
if (level !== undefined && !["debug", "info", "warn", "error"].includes(level)) {
|
|
471
|
+
throw new ValidationError("--level must be one of: debug, info, warn, error", {
|
|
472
|
+
field: "level",
|
|
473
|
+
value: level,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Parse time filters
|
|
478
|
+
let since: Date | undefined;
|
|
479
|
+
let until: Date | undefined;
|
|
480
|
+
|
|
481
|
+
if (sinceStr !== undefined) {
|
|
482
|
+
since = parseRelativeTime(sinceStr);
|
|
483
|
+
if (Number.isNaN(since.getTime())) {
|
|
484
|
+
throw new ValidationError("--since must be a valid ISO 8601 timestamp or relative time", {
|
|
485
|
+
field: "since",
|
|
486
|
+
value: sinceStr,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (untilStr !== undefined) {
|
|
492
|
+
until = new Date(untilStr);
|
|
493
|
+
if (Number.isNaN(until.getTime())) {
|
|
494
|
+
throw new ValidationError("--until must be a valid ISO 8601 timestamp", {
|
|
495
|
+
field: "until",
|
|
496
|
+
value: untilStr,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const cwd = process.cwd();
|
|
502
|
+
const config = await loadConfig(cwd);
|
|
503
|
+
const logsDir = join(config.project.root, ".overstory", "logs");
|
|
504
|
+
|
|
505
|
+
// Follow mode: tail logs in real time
|
|
506
|
+
if (follow) {
|
|
507
|
+
await followLogs(logsDir, { agent: agentName, level });
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Discovery phase: find all events.ndjson files
|
|
512
|
+
const discovered = await discoverLogFiles(logsDir, agentName);
|
|
513
|
+
|
|
514
|
+
if (discovered.length === 0) {
|
|
515
|
+
if (json) {
|
|
516
|
+
process.stdout.write("[]\n");
|
|
517
|
+
} else {
|
|
518
|
+
process.stdout.write("No log files found.\n");
|
|
519
|
+
}
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Parsing phase: read and parse all files
|
|
524
|
+
const allEvents: LogEvent[] = [];
|
|
525
|
+
|
|
526
|
+
for (const { path } of discovered) {
|
|
527
|
+
const events = await parseLogFile(path);
|
|
528
|
+
allEvents.push(...events);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Apply filters
|
|
532
|
+
const filtered = filterEvents(allEvents, { level, since, until });
|
|
533
|
+
|
|
534
|
+
// Sort by timestamp chronologically
|
|
535
|
+
filtered.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
536
|
+
|
|
537
|
+
// Apply limit: take the LAST N entries (most recent)
|
|
538
|
+
const limited = filtered.slice(-limit);
|
|
539
|
+
|
|
540
|
+
if (json) {
|
|
541
|
+
process.stdout.write(`${JSON.stringify(limited)}\n`);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
printLogs(limited);
|
|
546
|
+
}
|