@os-eco/overstory-cli 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +381 -0
  3. package/agents/builder.md +137 -0
  4. package/agents/coordinator.md +263 -0
  5. package/agents/lead.md +301 -0
  6. package/agents/merger.md +160 -0
  7. package/agents/monitor.md +214 -0
  8. package/agents/reviewer.md +140 -0
  9. package/agents/scout.md +119 -0
  10. package/agents/supervisor.md +423 -0
  11. package/package.json +47 -0
  12. package/src/agents/checkpoint.test.ts +88 -0
  13. package/src/agents/checkpoint.ts +101 -0
  14. package/src/agents/hooks-deployer.test.ts +2040 -0
  15. package/src/agents/hooks-deployer.ts +607 -0
  16. package/src/agents/identity.test.ts +603 -0
  17. package/src/agents/identity.ts +384 -0
  18. package/src/agents/lifecycle.test.ts +196 -0
  19. package/src/agents/lifecycle.ts +183 -0
  20. package/src/agents/manifest.test.ts +746 -0
  21. package/src/agents/manifest.ts +354 -0
  22. package/src/agents/overlay.test.ts +676 -0
  23. package/src/agents/overlay.ts +308 -0
  24. package/src/beads/client.test.ts +217 -0
  25. package/src/beads/client.ts +202 -0
  26. package/src/beads/molecules.test.ts +338 -0
  27. package/src/beads/molecules.ts +198 -0
  28. package/src/commands/agents.test.ts +322 -0
  29. package/src/commands/agents.ts +287 -0
  30. package/src/commands/clean.test.ts +670 -0
  31. package/src/commands/clean.ts +618 -0
  32. package/src/commands/completions.test.ts +342 -0
  33. package/src/commands/completions.ts +887 -0
  34. package/src/commands/coordinator.test.ts +1530 -0
  35. package/src/commands/coordinator.ts +733 -0
  36. package/src/commands/costs.test.ts +1119 -0
  37. package/src/commands/costs.ts +564 -0
  38. package/src/commands/dashboard.test.ts +308 -0
  39. package/src/commands/dashboard.ts +838 -0
  40. package/src/commands/doctor.test.ts +294 -0
  41. package/src/commands/doctor.ts +213 -0
  42. package/src/commands/errors.test.ts +647 -0
  43. package/src/commands/errors.ts +248 -0
  44. package/src/commands/feed.test.ts +578 -0
  45. package/src/commands/feed.ts +361 -0
  46. package/src/commands/group.test.ts +262 -0
  47. package/src/commands/group.ts +511 -0
  48. package/src/commands/hooks.test.ts +458 -0
  49. package/src/commands/hooks.ts +253 -0
  50. package/src/commands/init.test.ts +347 -0
  51. package/src/commands/init.ts +650 -0
  52. package/src/commands/inspect.test.ts +670 -0
  53. package/src/commands/inspect.ts +431 -0
  54. package/src/commands/log.test.ts +1454 -0
  55. package/src/commands/log.ts +724 -0
  56. package/src/commands/logs.test.ts +379 -0
  57. package/src/commands/logs.ts +546 -0
  58. package/src/commands/mail.test.ts +1270 -0
  59. package/src/commands/mail.ts +771 -0
  60. package/src/commands/merge.test.ts +670 -0
  61. package/src/commands/merge.ts +355 -0
  62. package/src/commands/metrics.test.ts +444 -0
  63. package/src/commands/metrics.ts +143 -0
  64. package/src/commands/monitor.test.ts +191 -0
  65. package/src/commands/monitor.ts +390 -0
  66. package/src/commands/nudge.test.ts +230 -0
  67. package/src/commands/nudge.ts +372 -0
  68. package/src/commands/prime.test.ts +470 -0
  69. package/src/commands/prime.ts +381 -0
  70. package/src/commands/replay.test.ts +741 -0
  71. package/src/commands/replay.ts +360 -0
  72. package/src/commands/run.test.ts +431 -0
  73. package/src/commands/run.ts +351 -0
  74. package/src/commands/sling.test.ts +657 -0
  75. package/src/commands/sling.ts +661 -0
  76. package/src/commands/spec.test.ts +203 -0
  77. package/src/commands/spec.ts +168 -0
  78. package/src/commands/status.test.ts +430 -0
  79. package/src/commands/status.ts +398 -0
  80. package/src/commands/stop.test.ts +420 -0
  81. package/src/commands/stop.ts +151 -0
  82. package/src/commands/supervisor.test.ts +187 -0
  83. package/src/commands/supervisor.ts +535 -0
  84. package/src/commands/trace.test.ts +745 -0
  85. package/src/commands/trace.ts +325 -0
  86. package/src/commands/watch.test.ts +145 -0
  87. package/src/commands/watch.ts +247 -0
  88. package/src/commands/worktree.test.ts +786 -0
  89. package/src/commands/worktree.ts +311 -0
  90. package/src/config.test.ts +822 -0
  91. package/src/config.ts +829 -0
  92. package/src/doctor/agents.test.ts +454 -0
  93. package/src/doctor/agents.ts +396 -0
  94. package/src/doctor/config-check.test.ts +190 -0
  95. package/src/doctor/config-check.ts +183 -0
  96. package/src/doctor/consistency.test.ts +651 -0
  97. package/src/doctor/consistency.ts +294 -0
  98. package/src/doctor/databases.test.ts +290 -0
  99. package/src/doctor/databases.ts +218 -0
  100. package/src/doctor/dependencies.test.ts +184 -0
  101. package/src/doctor/dependencies.ts +175 -0
  102. package/src/doctor/logs.test.ts +251 -0
  103. package/src/doctor/logs.ts +295 -0
  104. package/src/doctor/merge-queue.test.ts +216 -0
  105. package/src/doctor/merge-queue.ts +144 -0
  106. package/src/doctor/structure.test.ts +291 -0
  107. package/src/doctor/structure.ts +198 -0
  108. package/src/doctor/types.ts +37 -0
  109. package/src/doctor/version.test.ts +136 -0
  110. package/src/doctor/version.ts +129 -0
  111. package/src/e2e/init-sling-lifecycle.test.ts +277 -0
  112. package/src/errors.ts +217 -0
  113. package/src/events/store.test.ts +660 -0
  114. package/src/events/store.ts +369 -0
  115. package/src/events/tool-filter.test.ts +330 -0
  116. package/src/events/tool-filter.ts +126 -0
  117. package/src/index.ts +316 -0
  118. package/src/insights/analyzer.test.ts +466 -0
  119. package/src/insights/analyzer.ts +203 -0
  120. package/src/logging/color.test.ts +142 -0
  121. package/src/logging/color.ts +71 -0
  122. package/src/logging/logger.test.ts +813 -0
  123. package/src/logging/logger.ts +266 -0
  124. package/src/logging/reporter.test.ts +259 -0
  125. package/src/logging/reporter.ts +109 -0
  126. package/src/logging/sanitizer.test.ts +190 -0
  127. package/src/logging/sanitizer.ts +57 -0
  128. package/src/mail/broadcast.test.ts +203 -0
  129. package/src/mail/broadcast.ts +92 -0
  130. package/src/mail/client.test.ts +773 -0
  131. package/src/mail/client.ts +223 -0
  132. package/src/mail/store.test.ts +705 -0
  133. package/src/mail/store.ts +387 -0
  134. package/src/merge/queue.test.ts +359 -0
  135. package/src/merge/queue.ts +231 -0
  136. package/src/merge/resolver.test.ts +1345 -0
  137. package/src/merge/resolver.ts +645 -0
  138. package/src/metrics/store.test.ts +667 -0
  139. package/src/metrics/store.ts +445 -0
  140. package/src/metrics/summary.test.ts +398 -0
  141. package/src/metrics/summary.ts +178 -0
  142. package/src/metrics/transcript.test.ts +356 -0
  143. package/src/metrics/transcript.ts +175 -0
  144. package/src/mulch/client.test.ts +671 -0
  145. package/src/mulch/client.ts +332 -0
  146. package/src/sessions/compat.test.ts +280 -0
  147. package/src/sessions/compat.ts +104 -0
  148. package/src/sessions/store.test.ts +873 -0
  149. package/src/sessions/store.ts +494 -0
  150. package/src/test-helpers.test.ts +124 -0
  151. package/src/test-helpers.ts +126 -0
  152. package/src/tracker/beads.ts +56 -0
  153. package/src/tracker/factory.test.ts +80 -0
  154. package/src/tracker/factory.ts +64 -0
  155. package/src/tracker/seeds.ts +182 -0
  156. package/src/tracker/types.ts +52 -0
  157. package/src/types.ts +724 -0
  158. package/src/watchdog/daemon.test.ts +1975 -0
  159. package/src/watchdog/daemon.ts +671 -0
  160. package/src/watchdog/health.test.ts +431 -0
  161. package/src/watchdog/health.ts +264 -0
  162. package/src/watchdog/triage.test.ts +164 -0
  163. package/src/watchdog/triage.ts +179 -0
  164. package/src/worktree/manager.test.ts +439 -0
  165. package/src/worktree/manager.ts +198 -0
  166. package/src/worktree/tmux.test.ts +1009 -0
  167. package/src/worktree/tmux.ts +509 -0
  168. package/templates/CLAUDE.md.tmpl +89 -0
  169. package/templates/hooks.json.tmpl +105 -0
  170. package/templates/overlay.md.tmpl +81 -0
@@ -0,0 +1,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
+ }