@kodrunhq/opencode-autopilot 1.15.1 → 1.16.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 (64) hide show
  1. package/README.md +14 -0
  2. package/bin/cli.ts +5 -0
  3. package/bin/inspect.ts +337 -0
  4. package/package.json +1 -1
  5. package/src/agents/autopilot.ts +7 -15
  6. package/src/agents/index.ts +54 -21
  7. package/src/health/checks.ts +108 -4
  8. package/src/health/runner.ts +3 -0
  9. package/src/index.ts +105 -12
  10. package/src/inspect/formatters.ts +225 -0
  11. package/src/inspect/repository.ts +882 -0
  12. package/src/kernel/database.ts +45 -0
  13. package/src/kernel/migrations.ts +62 -0
  14. package/src/kernel/repository.ts +571 -0
  15. package/src/kernel/schema.ts +122 -0
  16. package/src/kernel/types.ts +66 -0
  17. package/src/memory/capture.ts +221 -25
  18. package/src/memory/database.ts +74 -12
  19. package/src/memory/index.ts +17 -1
  20. package/src/memory/project-key.ts +6 -0
  21. package/src/memory/repository.ts +833 -42
  22. package/src/memory/retrieval.ts +83 -169
  23. package/src/memory/schemas.ts +39 -7
  24. package/src/memory/types.ts +4 -0
  25. package/src/observability/event-handlers.ts +28 -17
  26. package/src/observability/event-store.ts +29 -1
  27. package/src/observability/forensic-log.ts +159 -0
  28. package/src/observability/forensic-schemas.ts +69 -0
  29. package/src/observability/forensic-types.ts +10 -0
  30. package/src/observability/index.ts +21 -27
  31. package/src/observability/log-reader.ts +142 -111
  32. package/src/observability/log-writer.ts +41 -83
  33. package/src/observability/retention.ts +2 -2
  34. package/src/observability/session-logger.ts +36 -57
  35. package/src/observability/summary-generator.ts +31 -19
  36. package/src/observability/types.ts +12 -24
  37. package/src/orchestrator/contracts/invariants.ts +14 -0
  38. package/src/orchestrator/contracts/legacy-result-adapter.ts +8 -20
  39. package/src/orchestrator/fallback/event-handler.ts +47 -3
  40. package/src/orchestrator/handlers/architect.ts +2 -1
  41. package/src/orchestrator/handlers/build.ts +55 -97
  42. package/src/orchestrator/handlers/retrospective.ts +2 -1
  43. package/src/orchestrator/handlers/types.ts +0 -1
  44. package/src/orchestrator/lesson-memory.ts +29 -9
  45. package/src/orchestrator/orchestration-logger.ts +37 -23
  46. package/src/orchestrator/phase.ts +8 -4
  47. package/src/orchestrator/state.ts +79 -17
  48. package/src/projects/database.ts +47 -0
  49. package/src/projects/repository.ts +264 -0
  50. package/src/projects/resolve.ts +301 -0
  51. package/src/projects/schemas.ts +30 -0
  52. package/src/projects/types.ts +12 -0
  53. package/src/review/memory.ts +29 -9
  54. package/src/tools/doctor.ts +40 -5
  55. package/src/tools/forensics.ts +7 -12
  56. package/src/tools/logs.ts +6 -5
  57. package/src/tools/memory-preferences.ts +157 -0
  58. package/src/tools/memory-status.ts +17 -96
  59. package/src/tools/orchestrate.ts +97 -81
  60. package/src/tools/pipeline-report.ts +3 -2
  61. package/src/tools/quick.ts +2 -2
  62. package/src/tools/review.ts +39 -6
  63. package/src/tools/session-stats.ts +3 -2
  64. package/src/utils/paths.ts +20 -1
@@ -0,0 +1,159 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { readdir, readFile } from "node:fs/promises";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { appendForensicEventsToKernel, loadForensicEventsFromKernel } from "../kernel/repository";
5
+ import { isEnoentError } from "../utils/fs-helpers";
6
+ import { getProjectArtifactDir } from "../utils/paths";
7
+ import { forensicEventSchema } from "./forensic-schemas";
8
+ import type { ForensicEvent, ForensicEventDomain, ForensicEventType } from "./forensic-types";
9
+
10
+ const FORENSIC_LOG_FILE = "orchestration.jsonl";
11
+ let forensicWriteWarned = false;
12
+ let forensicMirrorWarned = false;
13
+
14
+ interface ForensicEventInput {
15
+ readonly timestamp?: string;
16
+ readonly projectRoot: string;
17
+ readonly domain: ForensicEventDomain;
18
+ readonly runId?: string | null;
19
+ readonly sessionId?: string | null;
20
+ readonly parentSessionId?: string | null;
21
+ readonly phase?: string | null;
22
+ readonly dispatchId?: string | null;
23
+ readonly taskId?: number | null;
24
+ readonly agent?: string | null;
25
+ readonly type: ForensicEventType;
26
+ readonly code?: string | null;
27
+ readonly message?: string | null;
28
+ readonly payload?: Record<string, string | number | boolean | null | readonly unknown[] | object>;
29
+ }
30
+
31
+ function redactMessage(message: string | null | undefined): string | null {
32
+ if (message == null) {
33
+ return null;
34
+ }
35
+ return message.replace(/[/\\][^\s"']+/g, "[PATH]");
36
+ }
37
+
38
+ function toProjectRootFromArtifactDir(artifactDir: string): string {
39
+ return basename(artifactDir) === ".opencode-autopilot" ? dirname(artifactDir) : artifactDir;
40
+ }
41
+
42
+ function appendValidatedForensicEvent(artifactDir: string, event: ForensicEvent): void {
43
+ appendForensicEventsToKernel(artifactDir, [event]);
44
+
45
+ try {
46
+ mkdirSync(artifactDir, { recursive: true });
47
+ const logPath = join(artifactDir, FORENSIC_LOG_FILE);
48
+ appendFileSync(logPath, `${JSON.stringify(event)}\n`, "utf-8");
49
+ } catch (mirrorError) {
50
+ if (!forensicMirrorWarned) {
51
+ forensicMirrorWarned = true;
52
+ console.warn("[opencode-autopilot] forensic JSONL mirror write failed:", mirrorError);
53
+ }
54
+ }
55
+ }
56
+
57
+ export function getForensicLogPath(projectRoot: string): string {
58
+ return join(getProjectArtifactDir(projectRoot), FORENSIC_LOG_FILE);
59
+ }
60
+
61
+ export function createForensicEvent(input: ForensicEventInput): ForensicEvent {
62
+ return forensicEventSchema.parse({
63
+ schemaVersion: 1,
64
+ timestamp: input.timestamp ?? new Date().toISOString(),
65
+ projectRoot: input.projectRoot,
66
+ domain: input.domain,
67
+ runId: input.runId ?? null,
68
+ sessionId: input.sessionId ?? null,
69
+ parentSessionId: input.parentSessionId ?? null,
70
+ phase: input.phase ?? null,
71
+ dispatchId: input.dispatchId ?? null,
72
+ taskId: input.taskId ?? null,
73
+ agent: input.agent ?? null,
74
+ type: input.type,
75
+ code: input.code ?? null,
76
+ message: redactMessage(input.message),
77
+ payload: input.payload ?? {},
78
+ });
79
+ }
80
+
81
+ export function appendForensicEvent(projectRoot: string, event: ForensicEventInput): void {
82
+ try {
83
+ const validated = createForensicEvent(event);
84
+ const artifactDir = getProjectArtifactDir(projectRoot);
85
+ appendValidatedForensicEvent(artifactDir, validated);
86
+ } catch (error) {
87
+ if (!forensicWriteWarned) {
88
+ forensicWriteWarned = true;
89
+ console.warn("[opencode-autopilot] forensic log write failed:", error);
90
+ }
91
+ }
92
+ }
93
+
94
+ export function appendForensicEventForArtifactDir(
95
+ artifactDir: string,
96
+ event: Omit<ForensicEventInput, "projectRoot">,
97
+ ): void {
98
+ try {
99
+ const validated = createForensicEvent({
100
+ ...event,
101
+ projectRoot: toProjectRootFromArtifactDir(artifactDir),
102
+ });
103
+ appendValidatedForensicEvent(artifactDir, validated);
104
+ } catch (error) {
105
+ if (!forensicWriteWarned) {
106
+ forensicWriteWarned = true;
107
+ console.warn("[opencode-autopilot] forensic log write failed:", error);
108
+ }
109
+ }
110
+ }
111
+
112
+ export async function readForensicEvents(projectRoot: string): Promise<readonly ForensicEvent[]> {
113
+ const kernelEvents = loadForensicEventsFromKernel(getProjectArtifactDir(projectRoot));
114
+ if (kernelEvents.length > 0) {
115
+ return kernelEvents;
116
+ }
117
+
118
+ try {
119
+ const raw = await readFile(getForensicLogPath(projectRoot), "utf-8");
120
+ const events: ForensicEvent[] = [];
121
+ for (const line of raw.split("\n")) {
122
+ const trimmed = line.trim();
123
+ if (!trimmed) {
124
+ continue;
125
+ }
126
+ try {
127
+ events.push(forensicEventSchema.parse(JSON.parse(trimmed)));
128
+ } catch {
129
+ // Ignore malformed forensic lines so one bad append does not hide the rest.
130
+ }
131
+ }
132
+ return Object.freeze(events);
133
+ } catch (error: unknown) {
134
+ if (isEnoentError(error)) {
135
+ return Object.freeze([]);
136
+ }
137
+ throw error;
138
+ }
139
+ }
140
+
141
+ export async function listProjectsWithForensicLogs(logsRoot?: string): Promise<readonly string[]> {
142
+ const root = logsRoot;
143
+ if (!root) {
144
+ return Object.freeze([]);
145
+ }
146
+
147
+ try {
148
+ const entries = await readdir(root, { withFileTypes: true });
149
+ const projects = entries
150
+ .filter((entry) => entry.isDirectory())
151
+ .map((entry) => join(root, entry.name));
152
+ return Object.freeze(projects);
153
+ } catch (error: unknown) {
154
+ if (isEnoentError(error)) {
155
+ return Object.freeze([]);
156
+ }
157
+ throw error;
158
+ }
159
+ }
@@ -0,0 +1,69 @@
1
+ import { z } from "zod";
2
+
3
+ export const forensicEventTypeSchema = z.enum([
4
+ "run_started",
5
+ "dispatch",
6
+ "dispatch_multi",
7
+ "result_applied",
8
+ "phase_transition",
9
+ "complete",
10
+ "decision",
11
+ "error",
12
+ "loop_detected",
13
+ "failure_recorded",
14
+ "warning",
15
+ "session_start",
16
+ "session_end",
17
+ "fallback",
18
+ "model_switch",
19
+ "context_warning",
20
+ "tool_complete",
21
+ "compacted",
22
+ ]);
23
+
24
+ export const forensicEventDomainSchema = z.enum(["session", "orchestrator", "contract"]);
25
+
26
+ export type JsonValue =
27
+ | null
28
+ | boolean
29
+ | number
30
+ | string
31
+ | readonly JsonValue[]
32
+ | { readonly [key: string]: JsonValue };
33
+
34
+ export const jsonValueSchema: z.ZodType<JsonValue> = z.lazy(() =>
35
+ z.union([
36
+ z.null(),
37
+ z.boolean(),
38
+ z.number(),
39
+ z.string(),
40
+ z.array(jsonValueSchema),
41
+ z.record(z.string(), jsonValueSchema),
42
+ ]),
43
+ );
44
+
45
+ export const forensicEventSchema = z.object({
46
+ schemaVersion: z.literal(1),
47
+ timestamp: z.string().max(128),
48
+ projectRoot: z.string().max(4096),
49
+ domain: forensicEventDomainSchema,
50
+ runId: z.string().max(128).nullable().default(null),
51
+ sessionId: z.string().max(256).nullable().default(null),
52
+ parentSessionId: z.string().max(256).nullable().default(null),
53
+ phase: z.string().max(128).nullable().default(null),
54
+ dispatchId: z.string().max(128).nullable().default(null),
55
+ taskId: z.number().int().positive().nullable().default(null),
56
+ agent: z.string().max(128).nullable().default(null),
57
+ type: forensicEventTypeSchema,
58
+ code: z.string().max(128).nullable().default(null),
59
+ message: z.string().max(4096).nullable().default(null),
60
+ payload: z.record(z.string(), jsonValueSchema).default({}),
61
+ });
62
+
63
+ export const forensicEventDefaults = forensicEventSchema.parse({
64
+ schemaVersion: 1,
65
+ timestamp: new Date(0).toISOString(),
66
+ projectRoot: "/unknown",
67
+ domain: "orchestrator",
68
+ type: "error",
69
+ });
@@ -0,0 +1,10 @@
1
+ import type { z } from "zod";
2
+ import type {
3
+ forensicEventDomainSchema,
4
+ forensicEventSchema,
5
+ forensicEventTypeSchema,
6
+ } from "./forensic-schemas";
7
+
8
+ export type ForensicEventDomain = z.infer<typeof forensicEventDomainSchema>;
9
+ export type ForensicEventType = z.infer<typeof forensicEventTypeSchema>;
10
+ export type ForensicEvent = z.infer<typeof forensicEventSchema>;
@@ -2,14 +2,25 @@
2
2
  * Observability module barrel export.
3
3
  *
4
4
  * Re-exports all public APIs from the session observability system:
5
- * - Schemas and types for structured events
6
- * - Session logger for event capture (JSONL append)
7
- * - Log writer for complete session persistence (atomic JSON)
8
- * - Log reader for querying persisted sessions
9
- * - Summary generator for human-readable markdown reports
10
- * - Retention for time-based log pruning
5
+ * - Schemas and types for structured forensic events
6
+ * - Forensic log writer/reader for durable project-local evidence
7
+ * - Summary generator for human-readable reports
8
+ * - Retention for time-based pruning
11
9
  */
12
10
 
11
+ export {
12
+ appendForensicEvent,
13
+ appendForensicEventForArtifactDir,
14
+ createForensicEvent,
15
+ getForensicLogPath,
16
+ readForensicEvents,
17
+ } from "./forensic-log";
18
+ export {
19
+ forensicEventDefaults,
20
+ forensicEventDomainSchema,
21
+ forensicEventSchema,
22
+ forensicEventTypeSchema,
23
+ } from "./forensic-schemas";
13
24
  export type { EventSearchFilters, SessionLogEntry } from "./log-reader";
14
25
  export {
15
26
  listSessionLogs,
@@ -17,20 +28,8 @@ export {
17
28
  readSessionLog,
18
29
  searchEvents,
19
30
  } from "./log-reader";
20
- export { convertToSessionLog, getLogsDir, writeSessionLog } from "./log-writer";
31
+ export { getLogsDir, writeSessionLog } from "./log-writer";
21
32
  export { pruneOldLogs } from "./retention";
22
- export {
23
- baseEventSchema,
24
- decisionEventSchema,
25
- errorEventSchema,
26
- fallbackEventSchema,
27
- loggingConfigSchema,
28
- loggingDefaults,
29
- modelSwitchEventSchema,
30
- sessionDecisionSchema,
31
- sessionEventSchema,
32
- sessionLogSchema,
33
- } from "./schemas";
34
33
  export { getLogsDir as getEventLogsDir, getSessionLog, logEvent } from "./session-logger";
35
34
 
36
35
  export {
@@ -40,14 +39,9 @@ export {
40
39
  generateSessionSummary,
41
40
  } from "./summary-generator";
42
41
  export type {
43
- BaseEvent,
44
- DecisionEvent,
45
- ErrorEvent,
46
- FallbackEvent,
47
- LoggingConfig,
48
- ModelSwitchEvent,
42
+ ForensicEvent,
43
+ ForensicEventDomain,
44
+ ForensicEventType,
49
45
  SessionDecision,
50
- SessionEvent,
51
- SessionEventType,
52
46
  SessionLog,
53
47
  } from "./types";
@@ -1,27 +1,32 @@
1
- /**
2
- * Session log reading, listing, searching, and filtering.
3
- *
4
- * Provides query capabilities over persisted session logs:
5
- * - Read a specific session by ID
6
- * - List all sessions sorted by startedAt (newest first)
7
- * - Read the most recent session
8
- * - Search/filter events within a session by type and time range
9
- *
10
- * All functions handle missing directories gracefully (D-16).
11
- */
12
-
13
- import { readdir, readFile } from "node:fs/promises";
1
+ import { readdir } from "node:fs/promises";
14
2
  import { join } from "node:path";
15
3
  import { isEnoentError } from "../utils/fs-helpers";
16
- import { getLogsDir } from "./log-writer";
17
- import { sessionLogSchema } from "./schemas";
18
- import type { SessionEvent, SessionLog } from "./types";
4
+ import { getProjectRootFromArtifactDir } from "../utils/paths";
5
+ import { readForensicEvents } from "./forensic-log";
6
+ import type { ForensicEvent } from "./forensic-types";
7
+
8
+ export interface SessionDecision {
9
+ readonly timestamp: string | null;
10
+ readonly phase: string;
11
+ readonly agent: string;
12
+ readonly decision: string;
13
+ readonly rationale: string;
14
+ }
15
+
16
+ export interface SessionLog {
17
+ readonly schemaVersion: 1;
18
+ readonly sessionId: string;
19
+ readonly projectRoot: string;
20
+ readonly startedAt: string;
21
+ readonly endedAt: string | null;
22
+ readonly events: readonly ForensicEvent[];
23
+ readonly decisions: readonly SessionDecision[];
24
+ readonly errorSummary: Readonly<Record<string, number>>;
25
+ }
19
26
 
20
- /**
21
- * Summary entry for session listing (lightweight, no full event data).
22
- */
23
27
  export interface SessionLogEntry {
24
28
  readonly sessionId: string;
29
+ readonly projectRoot: string;
25
30
  readonly startedAt: string;
26
31
  readonly endedAt: string | null;
27
32
  readonly eventCount: number;
@@ -29,124 +34,150 @@ export interface SessionLogEntry {
29
34
  readonly errorCount: number;
30
35
  }
31
36
 
32
- /**
33
- * Filters for searching events within a session log.
34
- */
35
37
  export interface EventSearchFilters {
36
38
  readonly type?: string;
37
39
  readonly after?: string;
38
40
  readonly before?: string;
41
+ readonly domain?: string;
39
42
  }
40
43
 
41
- /**
42
- * Reads and parses a specific session log by ID.
43
- *
44
- * Returns null on: non-existent file, malformed JSON, invalid schema.
45
- * Never throws for expected failure modes.
46
- */
47
- export async function readSessionLog(
48
- sessionId: string,
49
- logsDir?: string,
50
- ): Promise<SessionLog | null> {
51
- const dir = logsDir ?? getLogsDir();
52
- const logPath = join(dir, `${sessionId}.json`);
44
+ function isSessionForProject(event: Readonly<ForensicEvent>, sessionId: string): boolean {
45
+ return event.domain === "session" && event.sessionId === sessionId;
46
+ }
53
47
 
54
- try {
55
- const content = await readFile(logPath, "utf-8");
56
- const parsed = JSON.parse(content);
57
- const result = sessionLogSchema.safeParse(parsed);
58
- return result.success ? result.data : null;
59
- } catch (error: unknown) {
60
- if (isEnoentError(error)) return null;
61
- // Recover from malformed JSON
62
- if (error instanceof SyntaxError) return null;
63
- throw error;
48
+ function buildErrorSummary(events: readonly ForensicEvent[]): Readonly<Record<string, number>> {
49
+ const summary: Record<string, number> = {};
50
+ for (const event of events) {
51
+ if (event.type !== "error") {
52
+ continue;
53
+ }
54
+ const code =
55
+ event.code ?? (typeof event.payload.errorType === "string" ? event.payload.errorType : null);
56
+ const key = code ?? "unknown";
57
+ summary[key] = (summary[key] ?? 0) + 1;
64
58
  }
59
+ return Object.freeze(summary);
65
60
  }
66
61
 
67
- /**
68
- * Lists all session logs sorted by startedAt descending (newest first).
69
- *
70
- * Returns a lightweight SessionLogEntry for each log (no full event data).
71
- * Skips non-JSON files and malformed logs.
72
- * Returns empty array for missing or empty directories.
73
- */
74
- export async function listSessionLogs(logsDir?: string): Promise<readonly SessionLogEntry[]> {
75
- const dir = logsDir ?? getLogsDir();
76
-
77
- let files: string[];
78
- try {
79
- files = await readdir(dir);
80
- } catch (error: unknown) {
81
- if (isEnoentError(error)) return [];
82
- throw error;
83
- }
62
+ function buildDecisions(events: readonly ForensicEvent[]): readonly SessionDecision[] {
63
+ return Object.freeze(
64
+ events
65
+ .filter((event) => event.type === "decision")
66
+ .map((event) => ({
67
+ timestamp: event.timestamp,
68
+ phase: event.phase ?? "UNKNOWN",
69
+ agent: event.agent ?? "unknown",
70
+ decision:
71
+ typeof event.payload.decision === "string"
72
+ ? event.payload.decision
73
+ : (event.message ?? "decision recorded"),
74
+ rationale:
75
+ typeof event.payload.rationale === "string"
76
+ ? event.payload.rationale
77
+ : (event.message ?? ""),
78
+ })),
79
+ );
80
+ }
84
81
 
85
- const jsonFiles = files.filter((f) => f.endsWith(".json"));
86
- const entries: SessionLogEntry[] = [];
87
-
88
- for (const file of jsonFiles) {
89
- try {
90
- const content = await readFile(join(dir, file), "utf-8");
91
- const parsed = JSON.parse(content);
92
- const result = sessionLogSchema.safeParse(parsed);
93
-
94
- if (result.success) {
95
- const log = result.data;
96
- const errorCount = log.events.filter((e) => e.type === "error").length;
97
-
98
- entries.push({
99
- sessionId: log.sessionId,
100
- startedAt: log.startedAt,
101
- endedAt: log.endedAt,
102
- eventCount: log.events.length,
103
- decisionCount: log.decisions.length,
104
- errorCount,
105
- });
106
- }
107
- } catch (innerError: unknown) {
108
- // SyntaxError = malformed JSON, expected and skipped.
109
- // I/O errors (permissions, etc.) are unexpected — log them.
110
- if (!(innerError instanceof SyntaxError) && !isEnoentError(innerError)) {
111
- console.error("[opencode-autopilot] Unexpected error reading log file:", file, innerError);
112
- }
113
- }
82
+ function buildSessionLog(
83
+ projectRoot: string,
84
+ sessionId: string,
85
+ events: readonly ForensicEvent[],
86
+ ): SessionLog | null {
87
+ if (events.length === 0) {
88
+ return null;
114
89
  }
115
90
 
116
- // Sort by startedAt descending (newest first)
117
- entries.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
91
+ const sessionEvents = events
92
+ .filter((event) => event.sessionId === sessionId)
93
+ .sort((a, b) => a.timestamp.localeCompare(b.timestamp));
94
+ if (sessionEvents.length === 0) {
95
+ return null;
96
+ }
118
97
 
119
- return entries;
98
+ const started = sessionEvents.find((event) => event.type === "session_start");
99
+ const ended = [...sessionEvents].reverse().find((event) => event.type === "session_end");
100
+
101
+ return {
102
+ schemaVersion: 1,
103
+ sessionId,
104
+ projectRoot,
105
+ startedAt: started?.timestamp ?? sessionEvents[0].timestamp,
106
+ endedAt: ended?.timestamp ?? null,
107
+ events: Object.freeze(sessionEvents),
108
+ decisions: buildDecisions(sessionEvents),
109
+ errorSummary: buildErrorSummary(sessionEvents),
110
+ };
120
111
  }
121
112
 
122
- /**
123
- * Returns the most recent session log (by startedAt).
124
- *
125
- * Returns null when no valid logs exist.
126
- */
127
- export async function readLatestSessionLog(logsDir?: string): Promise<SessionLog | null> {
128
- const entries = await listSessionLogs(logsDir);
113
+ export async function readSessionLog(
114
+ sessionId: string,
115
+ artifactDirOrProjectRoot: string,
116
+ ): Promise<SessionLog | null> {
117
+ const projectRoot = getProjectRootFromArtifactDir(artifactDirOrProjectRoot);
118
+ const events = await readForensicEvents(projectRoot);
119
+ return buildSessionLog(projectRoot, sessionId, events);
120
+ }
129
121
 
130
- if (entries.length === 0) return null;
122
+ export async function listSessionLogs(
123
+ artifactDirOrProjectRoot: string,
124
+ ): Promise<readonly SessionLogEntry[]> {
125
+ const projectRoot = getProjectRootFromArtifactDir(artifactDirOrProjectRoot);
126
+ const events = await readForensicEvents(projectRoot);
127
+ const sessionIds = [
128
+ ...new Set(events.map((event) => event.sessionId).filter((value): value is string => !!value)),
129
+ ];
130
+
131
+ const entries = sessionIds
132
+ .map((sessionId) => buildSessionLog(projectRoot, sessionId, events))
133
+ .filter((entry): entry is SessionLog => entry !== null)
134
+ .map((log) => ({
135
+ sessionId: log.sessionId,
136
+ projectRoot: log.projectRoot,
137
+ startedAt: log.startedAt,
138
+ endedAt: log.endedAt,
139
+ eventCount: log.events.length,
140
+ decisionCount: log.decisions.length,
141
+ errorCount: Object.values(log.errorSummary).reduce((sum, count) => sum + count, 0),
142
+ }))
143
+ .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
144
+
145
+ return Object.freeze(entries);
146
+ }
131
147
 
132
- // Entries are already sorted newest-first
133
- return readSessionLog(entries[0].sessionId, logsDir);
148
+ export async function readLatestSessionLog(
149
+ artifactDirOrProjectRoot: string,
150
+ ): Promise<SessionLog | null> {
151
+ const entries = await listSessionLogs(artifactDirOrProjectRoot);
152
+ if (entries.length === 0) {
153
+ return null;
154
+ }
155
+ return readSessionLog(entries[0].sessionId, artifactDirOrProjectRoot);
134
156
  }
135
157
 
136
- /**
137
- * Filters events by type and/or time range.
138
- *
139
- * Pure function (no I/O, no side effects).
140
- * Accepts a readonly array of events and returns a filtered copy.
141
- */
142
158
  export function searchEvents(
143
- events: readonly SessionEvent[],
159
+ events: readonly ForensicEvent[],
144
160
  filters: EventSearchFilters,
145
- ): readonly SessionEvent[] {
161
+ ): readonly ForensicEvent[] {
146
162
  return events.filter((event) => {
147
163
  if (filters.type && event.type !== filters.type) return false;
164
+ if (filters.domain && event.domain !== filters.domain) return false;
148
165
  if (filters.after && event.timestamp <= filters.after) return false;
149
166
  if (filters.before && event.timestamp >= filters.before) return false;
150
167
  return true;
151
168
  });
152
169
  }
170
+
171
+ export async function listProjectRootsWithLogs(rootDir: string): Promise<readonly string[]> {
172
+ try {
173
+ const entries = await readdir(rootDir, { withFileTypes: true });
174
+ return Object.freeze(
175
+ entries.filter((entry) => entry.isDirectory()).map((entry) => join(rootDir, entry.name)),
176
+ );
177
+ } catch (error: unknown) {
178
+ if (isEnoentError(error)) {
179
+ return Object.freeze([]);
180
+ }
181
+ throw error;
182
+ }
183
+ }