@kodrunhq/opencode-autopilot 1.15.2 → 1.17.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 (93) hide show
  1. package/bin/cli.ts +5 -0
  2. package/bin/inspect.ts +337 -0
  3. package/package.json +1 -1
  4. package/src/agents/autopilot.ts +7 -15
  5. package/src/config/index.ts +29 -0
  6. package/src/config/migrations.ts +196 -0
  7. package/src/config/v7.ts +45 -0
  8. package/src/config.ts +3 -3
  9. package/src/health/checks.ts +126 -4
  10. package/src/health/types.ts +1 -1
  11. package/src/index.ts +128 -13
  12. package/src/inspect/formatters.ts +225 -0
  13. package/src/inspect/repository.ts +882 -0
  14. package/src/kernel/database.ts +45 -0
  15. package/src/kernel/migrations.ts +62 -0
  16. package/src/kernel/repository.ts +571 -0
  17. package/src/kernel/schema.ts +122 -0
  18. package/src/kernel/transaction.ts +48 -0
  19. package/src/kernel/types.ts +65 -0
  20. package/src/logging/domains.ts +39 -0
  21. package/src/logging/forensic-writer.ts +177 -0
  22. package/src/logging/index.ts +4 -0
  23. package/src/logging/logger.ts +44 -0
  24. package/src/logging/performance.ts +59 -0
  25. package/src/logging/rotation.ts +261 -0
  26. package/src/logging/types.ts +33 -0
  27. package/src/memory/capture-utils.ts +149 -0
  28. package/src/memory/capture.ts +82 -67
  29. package/src/memory/database.ts +74 -12
  30. package/src/memory/decay.ts +11 -2
  31. package/src/memory/index.ts +17 -1
  32. package/src/memory/injector.ts +4 -1
  33. package/src/memory/lessons.ts +85 -0
  34. package/src/memory/observations.ts +177 -0
  35. package/src/memory/preferences.ts +718 -0
  36. package/src/memory/project-key.ts +6 -0
  37. package/src/memory/projects.ts +83 -0
  38. package/src/memory/repository.ts +52 -216
  39. package/src/memory/retrieval.ts +88 -170
  40. package/src/memory/schemas.ts +39 -7
  41. package/src/memory/types.ts +4 -0
  42. package/src/observability/context-display.ts +8 -0
  43. package/src/observability/event-handlers.ts +69 -20
  44. package/src/observability/event-store.ts +29 -1
  45. package/src/observability/forensic-log.ts +167 -0
  46. package/src/observability/forensic-schemas.ts +77 -0
  47. package/src/observability/forensic-types.ts +10 -0
  48. package/src/observability/index.ts +21 -27
  49. package/src/observability/log-reader.ts +161 -111
  50. package/src/observability/log-writer.ts +41 -83
  51. package/src/observability/retention.ts +2 -2
  52. package/src/observability/session-logger.ts +36 -57
  53. package/src/observability/summary-generator.ts +31 -19
  54. package/src/observability/types.ts +12 -24
  55. package/src/orchestrator/contracts/invariants.ts +14 -0
  56. package/src/orchestrator/contracts/legacy-result-adapter.ts +8 -20
  57. package/src/orchestrator/error-context.ts +24 -0
  58. package/src/orchestrator/fallback/event-handler.ts +47 -3
  59. package/src/orchestrator/handlers/architect.ts +2 -1
  60. package/src/orchestrator/handlers/build-utils.ts +118 -0
  61. package/src/orchestrator/handlers/build.ts +42 -219
  62. package/src/orchestrator/handlers/retrospective.ts +2 -2
  63. package/src/orchestrator/handlers/types.ts +0 -1
  64. package/src/orchestrator/lesson-memory.ts +36 -11
  65. package/src/orchestrator/orchestration-logger.ts +53 -24
  66. package/src/orchestrator/phase.ts +8 -4
  67. package/src/orchestrator/progress.ts +63 -0
  68. package/src/orchestrator/state.ts +79 -17
  69. package/src/projects/database.ts +47 -0
  70. package/src/projects/repository.ts +264 -0
  71. package/src/projects/resolve.ts +301 -0
  72. package/src/projects/schemas.ts +30 -0
  73. package/src/projects/types.ts +12 -0
  74. package/src/review/memory.ts +39 -11
  75. package/src/review/parse-findings.ts +116 -0
  76. package/src/review/pipeline.ts +3 -107
  77. package/src/review/selection.ts +38 -4
  78. package/src/scoring/time-provider.ts +23 -0
  79. package/src/tools/doctor.ts +28 -4
  80. package/src/tools/forensics.ts +7 -12
  81. package/src/tools/logs.ts +38 -11
  82. package/src/tools/memory-preferences.ts +157 -0
  83. package/src/tools/memory-status.ts +17 -96
  84. package/src/tools/orchestrate.ts +108 -90
  85. package/src/tools/pipeline-report.ts +3 -2
  86. package/src/tools/quick.ts +2 -2
  87. package/src/tools/replay.ts +42 -0
  88. package/src/tools/review.ts +46 -7
  89. package/src/tools/session-stats.ts +3 -2
  90. package/src/tools/summary.ts +43 -0
  91. package/src/utils/paths.ts +20 -1
  92. package/src/utils/random.ts +33 -0
  93. package/src/ux/session-summary.ts +56 -0
@@ -0,0 +1,167 @@
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
+ mkdirSync(artifactDir, { recursive: true });
44
+
45
+ try {
46
+ appendForensicEventsToKernel(artifactDir, [event]);
47
+ } catch (kernelError) {
48
+ if (!forensicWriteWarned) {
49
+ forensicWriteWarned = true;
50
+ console.warn("[opencode-autopilot] forensic log write failed:", kernelError);
51
+ }
52
+ }
53
+
54
+ try {
55
+ const logPath = join(artifactDir, FORENSIC_LOG_FILE);
56
+ appendFileSync(logPath, `${JSON.stringify(event)}\n`, "utf-8");
57
+ } catch (mirrorError) {
58
+ if (!forensicMirrorWarned) {
59
+ forensicMirrorWarned = true;
60
+ console.warn("[opencode-autopilot] forensic JSONL mirror write failed:", mirrorError);
61
+ }
62
+ }
63
+ }
64
+
65
+ export function getForensicLogPath(projectRoot: string): string {
66
+ return join(getProjectArtifactDir(projectRoot), FORENSIC_LOG_FILE);
67
+ }
68
+
69
+ export function createForensicEvent(input: ForensicEventInput): ForensicEvent {
70
+ return forensicEventSchema.parse({
71
+ schemaVersion: 1,
72
+ timestamp: input.timestamp ?? new Date().toISOString(),
73
+ projectRoot: input.projectRoot,
74
+ domain: input.domain,
75
+ runId: input.runId ?? null,
76
+ sessionId: input.sessionId ?? null,
77
+ parentSessionId: input.parentSessionId ?? null,
78
+ phase: input.phase ?? null,
79
+ dispatchId: input.dispatchId ?? null,
80
+ taskId: input.taskId ?? null,
81
+ agent: input.agent ?? null,
82
+ type: input.type,
83
+ code: input.code ?? null,
84
+ message: redactMessage(input.message),
85
+ payload: input.payload ?? {},
86
+ });
87
+ }
88
+
89
+ export function appendForensicEvent(projectRoot: string, event: ForensicEventInput): void {
90
+ try {
91
+ const validated = createForensicEvent(event);
92
+ const artifactDir = getProjectArtifactDir(projectRoot);
93
+ appendValidatedForensicEvent(artifactDir, validated);
94
+ } catch (error) {
95
+ if (!forensicWriteWarned) {
96
+ forensicWriteWarned = true;
97
+ console.warn("[opencode-autopilot] forensic log write failed:", error);
98
+ }
99
+ }
100
+ }
101
+
102
+ export function appendForensicEventForArtifactDir(
103
+ artifactDir: string,
104
+ event: Omit<ForensicEventInput, "projectRoot">,
105
+ ): void {
106
+ try {
107
+ const validated = createForensicEvent({
108
+ ...event,
109
+ projectRoot: toProjectRootFromArtifactDir(artifactDir),
110
+ });
111
+ appendValidatedForensicEvent(artifactDir, validated);
112
+ } catch (error) {
113
+ if (!forensicWriteWarned) {
114
+ forensicWriteWarned = true;
115
+ console.warn("[opencode-autopilot] forensic log write failed:", error);
116
+ }
117
+ }
118
+ }
119
+
120
+ export async function readForensicEvents(projectRoot: string): Promise<readonly ForensicEvent[]> {
121
+ const kernelEvents = loadForensicEventsFromKernel(getProjectArtifactDir(projectRoot));
122
+ if (kernelEvents.length > 0) {
123
+ return kernelEvents;
124
+ }
125
+
126
+ try {
127
+ const raw = await readFile(getForensicLogPath(projectRoot), "utf-8");
128
+ const events: ForensicEvent[] = [];
129
+ for (const line of raw.split("\n")) {
130
+ const trimmed = line.trim();
131
+ if (!trimmed) {
132
+ continue;
133
+ }
134
+ try {
135
+ events.push(forensicEventSchema.parse(JSON.parse(trimmed)));
136
+ } catch {
137
+ // Ignore malformed forensic lines so one bad append does not hide the rest.
138
+ }
139
+ }
140
+ return Object.freeze(events);
141
+ } catch (error: unknown) {
142
+ if (isEnoentError(error)) {
143
+ return Object.freeze([]);
144
+ }
145
+ throw error;
146
+ }
147
+ }
148
+
149
+ export async function listProjectsWithForensicLogs(logsRoot?: string): Promise<readonly string[]> {
150
+ const root = logsRoot;
151
+ if (!root) {
152
+ return Object.freeze([]);
153
+ }
154
+
155
+ try {
156
+ const entries = await readdir(root, { withFileTypes: true });
157
+ const projects = entries
158
+ .filter((entry) => entry.isDirectory())
159
+ .map((entry) => join(root, entry.name));
160
+ return Object.freeze(projects);
161
+ } catch (error: unknown) {
162
+ if (isEnoentError(error)) {
163
+ return Object.freeze([]);
164
+ }
165
+ throw error;
166
+ }
167
+ }
@@ -0,0 +1,77 @@
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
+ "info",
23
+ "debug",
24
+ ]);
25
+
26
+ export const forensicEventDomainSchema = z.enum([
27
+ "session",
28
+ "orchestrator",
29
+ "contract",
30
+ "system",
31
+ "review",
32
+ ]);
33
+
34
+ export type JsonValue =
35
+ | null
36
+ | boolean
37
+ | number
38
+ | string
39
+ | readonly JsonValue[]
40
+ | { readonly [key: string]: JsonValue };
41
+
42
+ export const jsonValueSchema: z.ZodType<JsonValue> = z.lazy(() =>
43
+ z.union([
44
+ z.null(),
45
+ z.boolean(),
46
+ z.number(),
47
+ z.string(),
48
+ z.array(jsonValueSchema),
49
+ z.record(z.string(), jsonValueSchema),
50
+ ]),
51
+ );
52
+
53
+ export const forensicEventSchema = z.object({
54
+ schemaVersion: z.literal(1),
55
+ timestamp: z.string().max(128),
56
+ projectRoot: z.string().max(4096),
57
+ domain: forensicEventDomainSchema,
58
+ runId: z.string().max(128).nullable().default(null),
59
+ sessionId: z.string().max(256).nullable().default(null),
60
+ parentSessionId: z.string().max(256).nullable().default(null),
61
+ phase: z.string().max(128).nullable().default(null),
62
+ dispatchId: z.string().max(128).nullable().default(null),
63
+ taskId: z.number().int().positive().nullable().default(null),
64
+ agent: z.string().max(128).nullable().default(null),
65
+ type: forensicEventTypeSchema,
66
+ code: z.string().max(128).nullable().default(null),
67
+ message: z.string().max(4096).nullable().default(null),
68
+ payload: z.record(z.string(), jsonValueSchema).default({}),
69
+ });
70
+
71
+ export const forensicEventDefaults = forensicEventSchema.parse({
72
+ schemaVersion: 1,
73
+ timestamp: new Date(0).toISOString(),
74
+ projectRoot: "/unknown",
75
+ domain: "orchestrator",
76
+ type: "error",
77
+ });
@@ -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,169 @@ 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;
42
+ readonly subsystem?: string;
43
+ /** Matches against event.type for semantic severity (e.g. "error", "warning"),
44
+ * or against event.payload.severity / event.payload.level for explicit severity fields. */
45
+ readonly severity?: string;
39
46
  }
40
47
 
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`);
48
+ function _isSessionForProject(event: Readonly<ForensicEvent>, sessionId: string): boolean {
49
+ return event.domain === "session" && event.sessionId === sessionId;
50
+ }
53
51
 
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;
52
+ function buildErrorSummary(events: readonly ForensicEvent[]): Readonly<Record<string, number>> {
53
+ const summary: Record<string, number> = {};
54
+ for (const event of events) {
55
+ if (event.type !== "error") {
56
+ continue;
57
+ }
58
+ const code =
59
+ event.code ?? (typeof event.payload.errorType === "string" ? event.payload.errorType : null);
60
+ const key = code ?? "unknown";
61
+ summary[key] = (summary[key] ?? 0) + 1;
64
62
  }
63
+ return Object.freeze(summary);
65
64
  }
66
65
 
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
- }
66
+ function buildDecisions(events: readonly ForensicEvent[]): readonly SessionDecision[] {
67
+ return Object.freeze(
68
+ events
69
+ .filter((event) => event.type === "decision")
70
+ .map((event) => ({
71
+ timestamp: event.timestamp,
72
+ phase: event.phase ?? "UNKNOWN",
73
+ agent: event.agent ?? "unknown",
74
+ decision:
75
+ typeof event.payload.decision === "string"
76
+ ? event.payload.decision
77
+ : (event.message ?? "decision recorded"),
78
+ rationale:
79
+ typeof event.payload.rationale === "string"
80
+ ? event.payload.rationale
81
+ : (event.message ?? ""),
82
+ })),
83
+ );
84
+ }
84
85
 
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
- }
86
+ function buildSessionLog(
87
+ projectRoot: string,
88
+ sessionId: string,
89
+ events: readonly ForensicEvent[],
90
+ ): SessionLog | null {
91
+ if (events.length === 0) {
92
+ return null;
114
93
  }
115
94
 
116
- // Sort by startedAt descending (newest first)
117
- entries.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
95
+ const sessionEvents = events
96
+ .filter((event) => event.sessionId === sessionId)
97
+ .sort((a, b) => a.timestamp.localeCompare(b.timestamp));
98
+ if (sessionEvents.length === 0) {
99
+ return null;
100
+ }
118
101
 
119
- return entries;
102
+ const started = sessionEvents.find((event) => event.type === "session_start");
103
+ const ended = [...sessionEvents].reverse().find((event) => event.type === "session_end");
104
+
105
+ return {
106
+ schemaVersion: 1,
107
+ sessionId,
108
+ projectRoot,
109
+ startedAt: started?.timestamp ?? sessionEvents[0].timestamp,
110
+ endedAt: ended?.timestamp ?? null,
111
+ events: Object.freeze(sessionEvents),
112
+ decisions: buildDecisions(sessionEvents),
113
+ errorSummary: buildErrorSummary(sessionEvents),
114
+ };
120
115
  }
121
116
 
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);
117
+ export async function readSessionLog(
118
+ sessionId: string,
119
+ artifactDirOrProjectRoot: string,
120
+ ): Promise<SessionLog | null> {
121
+ const projectRoot = getProjectRootFromArtifactDir(artifactDirOrProjectRoot);
122
+ const events = await readForensicEvents(projectRoot);
123
+ return buildSessionLog(projectRoot, sessionId, events);
124
+ }
129
125
 
130
- if (entries.length === 0) return null;
126
+ export async function listSessionLogs(
127
+ artifactDirOrProjectRoot: string,
128
+ ): Promise<readonly SessionLogEntry[]> {
129
+ const projectRoot = getProjectRootFromArtifactDir(artifactDirOrProjectRoot);
130
+ const events = await readForensicEvents(projectRoot);
131
+ const sessionIds = [
132
+ ...new Set(events.map((event) => event.sessionId).filter((value): value is string => !!value)),
133
+ ];
134
+
135
+ const entries = sessionIds
136
+ .map((sessionId) => buildSessionLog(projectRoot, sessionId, events))
137
+ .filter((entry): entry is SessionLog => entry !== null)
138
+ .map((log) => ({
139
+ sessionId: log.sessionId,
140
+ projectRoot: log.projectRoot,
141
+ startedAt: log.startedAt,
142
+ endedAt: log.endedAt,
143
+ eventCount: log.events.length,
144
+ decisionCount: log.decisions.length,
145
+ errorCount: Object.values(log.errorSummary).reduce((sum, count) => sum + count, 0),
146
+ }))
147
+ .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
148
+
149
+ return Object.freeze(entries);
150
+ }
131
151
 
132
- // Entries are already sorted newest-first
133
- return readSessionLog(entries[0].sessionId, logsDir);
152
+ export async function readLatestSessionLog(
153
+ artifactDirOrProjectRoot: string,
154
+ ): Promise<SessionLog | null> {
155
+ const entries = await listSessionLogs(artifactDirOrProjectRoot);
156
+ if (entries.length === 0) {
157
+ return null;
158
+ }
159
+ return readSessionLog(entries[0].sessionId, artifactDirOrProjectRoot);
134
160
  }
135
161
 
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
162
  export function searchEvents(
143
- events: readonly SessionEvent[],
163
+ events: readonly ForensicEvent[],
144
164
  filters: EventSearchFilters,
145
- ): readonly SessionEvent[] {
165
+ ): readonly ForensicEvent[] {
146
166
  return events.filter((event) => {
147
167
  if (filters.type && event.type !== filters.type) return false;
168
+ if (filters.domain && event.domain !== filters.domain) return false;
148
169
  if (filters.after && event.timestamp <= filters.after) return false;
149
170
  if (filters.before && event.timestamp >= filters.before) return false;
171
+ if (filters.subsystem) {
172
+ const subsystem = event.payload.subsystem;
173
+ if (typeof subsystem !== "string" || subsystem !== filters.subsystem) return false;
174
+ }
175
+ if (filters.severity) {
176
+ const payloadSeverity =
177
+ typeof event.payload.severity === "string"
178
+ ? event.payload.severity
179
+ : typeof event.payload.level === "string"
180
+ ? event.payload.level
181
+ : null;
182
+ const matchesSemantic = event.type === filters.severity;
183
+ const matchesPayload = payloadSeverity === filters.severity;
184
+ if (!matchesSemantic && !matchesPayload) return false;
185
+ }
150
186
  return true;
151
187
  });
152
188
  }
189
+
190
+ export async function listProjectRootsWithLogs(rootDir: string): Promise<readonly string[]> {
191
+ try {
192
+ const entries = await readdir(rootDir, { withFileTypes: true });
193
+ return Object.freeze(
194
+ entries.filter((entry) => entry.isDirectory()).map((entry) => join(rootDir, entry.name)),
195
+ );
196
+ } catch (error: unknown) {
197
+ if (isEnoentError(error)) {
198
+ return Object.freeze([]);
199
+ }
200
+ throw error;
201
+ }
202
+ }