@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,301 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { execFile as execFileCb, execFileSync } from "node:child_process";
3
+ import { randomUUID } from "node:crypto";
4
+ import { basename } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import {
7
+ getProjectByAnyPath,
8
+ getProjectByCurrentPath,
9
+ getProjectsByGitFingerprint,
10
+ setProjectCurrentPath,
11
+ upsertProjectGitFingerprint,
12
+ upsertProjectRecord,
13
+ } from "./repository";
14
+ import { projectRecordSchema } from "./schemas";
15
+ import type { GitFingerprintInput, ProjectRecord } from "./types";
16
+
17
+ const execFile = promisify(execFileCb);
18
+
19
+ export interface ProjectResolverDeps {
20
+ readonly db?: Database;
21
+ readonly now?: () => string;
22
+ readonly createProjectId?: () => string;
23
+ readonly readGitFingerprint?: (projectRoot: string) => Promise<GitFingerprintInput | null>;
24
+ }
25
+
26
+ export function normalizeGitRemoteUrl(remoteUrl: string): string | null {
27
+ const trimmed = remoteUrl.trim();
28
+ if (trimmed.length === 0) {
29
+ return null;
30
+ }
31
+
32
+ const scpMatch = trimmed.match(/^([^@\s]+)@([^:\s]+):(.+)$/);
33
+ if (scpMatch) {
34
+ const [, _user, host, path] = scpMatch;
35
+ const normalizedPath = path.replace(/\.git$/i, "").replace(/^\/+/, "");
36
+ return `${host.toLowerCase()}/${normalizedPath}`;
37
+ }
38
+
39
+ try {
40
+ const parsed = new URL(trimmed);
41
+ const normalizedPath = parsed.pathname.replace(/\.git$/i, "").replace(/^\/+/, "");
42
+ if (normalizedPath.length === 0) {
43
+ return null;
44
+ }
45
+ return `${parsed.host.toLowerCase()}/${normalizedPath}`;
46
+ } catch {
47
+ const normalized = trimmed.replace(/\.git$/i, "").replace(/^\/+/, "");
48
+ return normalized.length > 0 ? normalized : null;
49
+ }
50
+ }
51
+
52
+ async function readGitCommand(
53
+ projectRoot: string,
54
+ args: readonly string[],
55
+ ): Promise<string | null> {
56
+ try {
57
+ const { stdout } = await execFile("git", args, {
58
+ cwd: projectRoot,
59
+ timeout: 5000,
60
+ });
61
+ const trimmed = stdout.trim();
62
+ return trimmed.length > 0 ? trimmed : null;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ function readGitCommandSync(projectRoot: string, args: readonly string[]): string | null {
69
+ try {
70
+ const stdout = execFileSync("git", args, {
71
+ cwd: projectRoot,
72
+ timeout: 5000,
73
+ encoding: "utf-8",
74
+ });
75
+ const trimmed = stdout.trim();
76
+ return trimmed.length > 0 ? trimmed : null;
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ export async function readProjectGitFingerprint(
83
+ projectRoot: string,
84
+ ): Promise<GitFingerprintInput | null> {
85
+ const remoteUrl = await readGitCommand(projectRoot, ["config", "--get", "remote.origin.url"]);
86
+ if (remoteUrl === null) {
87
+ return null;
88
+ }
89
+
90
+ const normalizedRemoteUrl = normalizeGitRemoteUrl(remoteUrl);
91
+ if (normalizedRemoteUrl === null) {
92
+ return null;
93
+ }
94
+
95
+ const remoteHead = await readGitCommand(projectRoot, [
96
+ "symbolic-ref",
97
+ "--short",
98
+ "refs/remotes/origin/HEAD",
99
+ ]);
100
+ const defaultBranch =
101
+ remoteHead === null ? null : remoteHead.split("/").slice(1).join("/") || null;
102
+
103
+ return {
104
+ normalizedRemoteUrl,
105
+ defaultBranch,
106
+ };
107
+ }
108
+
109
+ export function readProjectGitFingerprintSync(projectRoot: string): GitFingerprintInput | null {
110
+ const remoteUrl = readGitCommandSync(projectRoot, ["config", "--get", "remote.origin.url"]);
111
+ if (remoteUrl === null) {
112
+ return null;
113
+ }
114
+
115
+ const normalizedRemoteUrl = normalizeGitRemoteUrl(remoteUrl);
116
+ if (normalizedRemoteUrl === null) {
117
+ return null;
118
+ }
119
+
120
+ const remoteHead = readGitCommandSync(projectRoot, [
121
+ "symbolic-ref",
122
+ "--short",
123
+ "refs/remotes/origin/HEAD",
124
+ ]);
125
+ const defaultBranch =
126
+ remoteHead === null ? null : remoteHead.split("/").slice(1).join("/") || null;
127
+
128
+ return {
129
+ normalizedRemoteUrl,
130
+ defaultBranch,
131
+ };
132
+ }
133
+
134
+ export interface SyncProjectResolverDeps {
135
+ readonly db?: Database;
136
+ readonly now?: () => string;
137
+ readonly createProjectId?: () => string;
138
+ readonly readGitFingerprint?: (projectRoot: string) => GitFingerprintInput | null;
139
+ readonly allowCreate?: boolean;
140
+ }
141
+
142
+ export async function resolveProjectIdentity(
143
+ projectRoot: string,
144
+ deps: ProjectResolverDeps = {},
145
+ ): Promise<ProjectRecord> {
146
+ const now = deps.now ?? (() => new Date().toISOString());
147
+ const createProjectId = deps.createProjectId ?? (() => randomUUID());
148
+ const readGitFingerprint = deps.readGitFingerprint ?? readProjectGitFingerprint;
149
+ const seenAt = now();
150
+ const projectName = basename(projectRoot);
151
+
152
+ const current = getProjectByCurrentPath(projectRoot, deps.db);
153
+ if (current !== null) {
154
+ const updated = upsertProjectRecord(
155
+ {
156
+ ...current,
157
+ name: projectName,
158
+ lastUpdated: seenAt,
159
+ },
160
+ deps.db,
161
+ );
162
+ const fingerprint = await readGitFingerprint(projectRoot);
163
+ if (fingerprint !== null) {
164
+ upsertProjectGitFingerprint(updated.id, fingerprint, seenAt, deps.db);
165
+ }
166
+ return updated;
167
+ }
168
+
169
+ const historical = getProjectByAnyPath(projectRoot, deps.db);
170
+ if (historical !== null) {
171
+ const updated = setProjectCurrentPath(historical.id, projectRoot, projectName, seenAt, deps.db);
172
+ const fingerprint = await readGitFingerprint(projectRoot);
173
+ if (fingerprint !== null) {
174
+ upsertProjectGitFingerprint(updated.id, fingerprint, seenAt, deps.db);
175
+ }
176
+ return updated;
177
+ }
178
+
179
+ const fingerprint = await readGitFingerprint(projectRoot);
180
+ if (fingerprint !== null) {
181
+ const matches = getProjectsByGitFingerprint(fingerprint.normalizedRemoteUrl, deps.db);
182
+ if (matches.length === 1) {
183
+ const updated = setProjectCurrentPath(
184
+ matches[0].id,
185
+ projectRoot,
186
+ projectName,
187
+ seenAt,
188
+ deps.db,
189
+ );
190
+ upsertProjectGitFingerprint(updated.id, fingerprint, seenAt, deps.db);
191
+ return updated;
192
+ }
193
+ }
194
+
195
+ const created = upsertProjectRecord(
196
+ {
197
+ id: createProjectId(),
198
+ path: projectRoot,
199
+ name: projectName,
200
+ firstSeenAt: seenAt,
201
+ lastUpdated: seenAt,
202
+ },
203
+ deps.db,
204
+ );
205
+ if (fingerprint !== null) {
206
+ upsertProjectGitFingerprint(created.id, fingerprint, seenAt, deps.db);
207
+ }
208
+ return created;
209
+ }
210
+
211
+ export function resolveProjectIdentitySync(
212
+ projectRoot: string,
213
+ deps: SyncProjectResolverDeps = {},
214
+ ): ProjectRecord {
215
+ const now = deps.now ?? (() => new Date().toISOString());
216
+ const createProjectId = deps.createProjectId ?? (() => randomUUID());
217
+ const readGitFingerprint = deps.readGitFingerprint ?? readProjectGitFingerprintSync;
218
+ const allowCreate = deps.allowCreate ?? true;
219
+ const seenAt = now();
220
+ const projectName = basename(projectRoot);
221
+
222
+ const current = getProjectByCurrentPath(projectRoot, deps.db);
223
+ if (current !== null) {
224
+ if (!allowCreate) {
225
+ return current;
226
+ }
227
+
228
+ const updated = upsertProjectRecord(
229
+ {
230
+ ...current,
231
+ name: projectName,
232
+ lastUpdated: seenAt,
233
+ },
234
+ deps.db,
235
+ );
236
+ const fingerprint = readGitFingerprint(projectRoot);
237
+ if (fingerprint !== null) {
238
+ upsertProjectGitFingerprint(updated.id, fingerprint, seenAt, deps.db);
239
+ }
240
+ return updated;
241
+ }
242
+
243
+ const historical = getProjectByAnyPath(projectRoot, deps.db);
244
+ if (historical !== null) {
245
+ if (!allowCreate) {
246
+ return historical;
247
+ }
248
+
249
+ const updated = setProjectCurrentPath(historical.id, projectRoot, projectName, seenAt, deps.db);
250
+ const fingerprint = readGitFingerprint(projectRoot);
251
+ if (fingerprint !== null) {
252
+ upsertProjectGitFingerprint(updated.id, fingerprint, seenAt, deps.db);
253
+ }
254
+ return updated;
255
+ }
256
+
257
+ const fingerprint = readGitFingerprint(projectRoot);
258
+ if (fingerprint !== null) {
259
+ const matches = getProjectsByGitFingerprint(fingerprint.normalizedRemoteUrl, deps.db);
260
+ if (matches.length === 1) {
261
+ if (!allowCreate) {
262
+ return matches[0];
263
+ }
264
+
265
+ const updated = setProjectCurrentPath(
266
+ matches[0].id,
267
+ projectRoot,
268
+ projectName,
269
+ seenAt,
270
+ deps.db,
271
+ );
272
+ upsertProjectGitFingerprint(updated.id, fingerprint, seenAt, deps.db);
273
+ return updated;
274
+ }
275
+ }
276
+
277
+ if (!allowCreate) {
278
+ return projectRecordSchema.parse({
279
+ id: `project:${projectRoot}`,
280
+ path: projectRoot,
281
+ name: projectName,
282
+ firstSeenAt: seenAt,
283
+ lastUpdated: seenAt,
284
+ });
285
+ }
286
+
287
+ const created = upsertProjectRecord(
288
+ {
289
+ id: createProjectId(),
290
+ path: projectRoot,
291
+ name: projectName,
292
+ firstSeenAt: seenAt,
293
+ lastUpdated: seenAt,
294
+ },
295
+ deps.db,
296
+ );
297
+ if (fingerprint !== null) {
298
+ upsertProjectGitFingerprint(created.id, fingerprint, seenAt, deps.db);
299
+ }
300
+ return created;
301
+ }
@@ -0,0 +1,30 @@
1
+ import { z } from "zod";
2
+
3
+ export const projectRecordSchema = z.object({
4
+ id: z.string().min(1).max(128),
5
+ path: z.string().min(1).max(4096),
6
+ name: z.string().min(1).max(256),
7
+ firstSeenAt: z.string().min(1).max(128).optional(),
8
+ lastUpdated: z.string().min(1).max(128),
9
+ });
10
+
11
+ export const projectPathRecordSchema = z.object({
12
+ projectId: z.string().min(1).max(128),
13
+ path: z.string().min(1).max(4096),
14
+ firstSeenAt: z.string().min(1).max(128),
15
+ lastUpdated: z.string().min(1).max(128),
16
+ isCurrent: z.boolean(),
17
+ });
18
+
19
+ export const projectGitFingerprintSchema = z.object({
20
+ projectId: z.string().min(1).max(128),
21
+ normalizedRemoteUrl: z.string().min(1).max(2048),
22
+ defaultBranch: z.string().min(1).max(256).nullable(),
23
+ firstSeenAt: z.string().min(1).max(128),
24
+ lastUpdated: z.string().min(1).max(128),
25
+ });
26
+
27
+ export const gitFingerprintInputSchema = z.object({
28
+ normalizedRemoteUrl: z.string().min(1).max(2048),
29
+ defaultBranch: z.string().min(1).max(256).nullable().default(null),
30
+ });
@@ -0,0 +1,12 @@
1
+ import type { z } from "zod";
2
+ import type {
3
+ gitFingerprintInputSchema,
4
+ projectGitFingerprintSchema,
5
+ projectPathRecordSchema,
6
+ projectRecordSchema,
7
+ } from "./schemas";
8
+
9
+ export type ProjectRecord = z.infer<typeof projectRecordSchema>;
10
+ export type ProjectPathRecord = z.infer<typeof projectPathRecordSchema>;
11
+ export type ProjectGitFingerprint = z.infer<typeof projectGitFingerprintSchema>;
12
+ export type GitFingerprintInput = z.infer<typeof gitFingerprintInputSchema>;
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Per-project review memory persistence.
3
3
  *
4
- * Stores recent findings and false positives at
5
- * {projectRoot}/.opencode-autopilot/review-memory.json
4
+ * Stores recent findings and false positives in the project kernel,
5
+ * with {projectRoot}/.opencode-autopilot/review-memory.json kept as a
6
+ * compatibility mirror/export during the Phase 4 migration window.
6
7
  *
7
8
  * Memory is pruned on load to cap storage and remove stale entries.
8
9
  * All writes are atomic (tmp file + rename) to prevent corruption.
@@ -10,7 +11,10 @@
10
11
 
11
12
  import { readFile, rename, writeFile } from "node:fs/promises";
12
13
  import { join } from "node:path";
14
+ import { loadReviewMemoryFromKernel, saveReviewMemoryToKernel } from "../kernel/repository";
15
+ import { getLogger } from "../logging/domains";
13
16
  import { ensureDir, isEnoentError } from "../utils/fs-helpers";
17
+ import { getProjectArtifactDir } from "../utils/paths";
14
18
  import { reviewMemorySchema } from "./schemas";
15
19
  import type { ReviewMemory } from "./types";
16
20
 
@@ -20,6 +24,7 @@ const MEMORY_FILE = "review-memory.json";
20
24
  const MAX_FINDINGS = 100;
21
25
  const MAX_FALSE_POSITIVES = 50;
22
26
  const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
27
+ let legacyReviewMemoryMirrorWarned = false;
23
28
 
24
29
  /**
25
30
  * Create a valid empty memory object.
@@ -42,12 +47,19 @@ export function createEmptyMemory(): ReviewMemory {
42
47
  * Prunes on load to cap storage.
43
48
  */
44
49
  export async function loadReviewMemory(projectRoot: string): Promise<ReviewMemory | null> {
50
+ const kernelMemory = loadReviewMemoryFromKernel(getProjectArtifactDir(projectRoot));
51
+ if (kernelMemory !== null) {
52
+ return pruneMemory(kernelMemory);
53
+ }
54
+
45
55
  const memoryPath = join(projectRoot, ".opencode-autopilot", MEMORY_FILE);
46
56
  try {
47
57
  const raw = await readFile(memoryPath, "utf-8");
48
58
  const parsed = JSON.parse(raw);
49
59
  const validated = reviewMemorySchema.parse(parsed);
50
- return pruneMemory(validated);
60
+ const pruned = pruneMemory(validated);
61
+ saveReviewMemoryToKernel(getProjectArtifactDir(projectRoot), pruned);
62
+ return pruned;
51
63
  } catch (error: unknown) {
52
64
  if (isEnoentError(error)) {
53
65
  return null;
@@ -70,12 +82,23 @@ export async function loadReviewMemory(projectRoot: string): Promise<ReviewMemor
70
82
  */
71
83
  export async function saveReviewMemory(memory: ReviewMemory, projectRoot: string): Promise<void> {
72
84
  const validated = reviewMemorySchema.parse(memory);
73
- const dir = join(projectRoot, ".opencode-autopilot");
74
- await ensureDir(dir);
75
- const memoryPath = join(dir, MEMORY_FILE);
76
- const tmpPath = `${memoryPath}.tmp.${Date.now()}`;
77
- await writeFile(tmpPath, JSON.stringify(validated, null, 2), "utf-8");
78
- await rename(tmpPath, memoryPath);
85
+ saveReviewMemoryToKernel(getProjectArtifactDir(projectRoot), validated);
86
+
87
+ try {
88
+ const dir = join(projectRoot, ".opencode-autopilot");
89
+ await ensureDir(dir);
90
+ const memoryPath = join(dir, MEMORY_FILE);
91
+ const tmpPath = `${memoryPath}.tmp.${Date.now()}`;
92
+ await writeFile(tmpPath, JSON.stringify(validated, null, 2), "utf-8");
93
+ await rename(tmpPath, memoryPath);
94
+ } catch (error: unknown) {
95
+ if (!legacyReviewMemoryMirrorWarned) {
96
+ legacyReviewMemoryMirrorWarned = true;
97
+ getLogger("review", "memory").warn("review-memory.json mirror write failed", {
98
+ error: String(error),
99
+ });
100
+ }
101
+ }
79
102
  }
80
103
 
81
104
  /**
@@ -86,8 +109,13 @@ export async function saveReviewMemory(memory: ReviewMemory, projectRoot: string
86
109
  * - falsePositives: cap at 50 (keep newest by markedAt date)
87
110
  * - falsePositives: remove entries older than 30 days
88
111
  */
89
- export function pruneMemory(memory: ReviewMemory): ReviewMemory {
90
- const now = Date.now();
112
+ import { systemTimeProvider, type TimeProvider } from "../scoring/time-provider";
113
+
114
+ export function pruneMemory(
115
+ memory: ReviewMemory,
116
+ timeProvider: TimeProvider = systemTimeProvider,
117
+ ): ReviewMemory {
118
+ const now = timeProvider.now();
91
119
 
92
120
  // Prune false positives older than 30 days first, then cap
93
121
  const freshFalsePositives = memory.falsePositives.filter(
@@ -0,0 +1,116 @@
1
+ import { reviewFindingSchema, reviewFindingsEnvelopeSchema } from "./schemas";
2
+ import type { ReviewFinding, ReviewFindingsEnvelope } from "./types";
3
+
4
+ export function parseTypedFindingsEnvelope(raw: string): ReviewFindingsEnvelope | null {
5
+ try {
6
+ const parsed = JSON.parse(raw);
7
+ return reviewFindingsEnvelopeSchema.parse(parsed);
8
+ } catch {
9
+ return null;
10
+ }
11
+ }
12
+
13
+ export function parseAgentFindings(raw: string, agentName: string): readonly ReviewFinding[] {
14
+ const findings: ReviewFinding[] = [];
15
+
16
+ const jsonStr = extractJson(raw);
17
+ if (jsonStr === null) return Object.freeze(findings);
18
+
19
+ try {
20
+ const cleanJson = sanitizeMalformedJson(jsonStr);
21
+ const parsed = JSON.parse(cleanJson);
22
+ const items = Array.isArray(parsed) ? parsed : parsed?.findings;
23
+
24
+ if (!Array.isArray(items)) return Object.freeze(findings);
25
+
26
+ for (const item of items) {
27
+ if (typeof item !== "object" || item === null) continue;
28
+
29
+ const problem = item.problem || item.description || item.issue || "No description provided";
30
+ const normalized = {
31
+ ...item,
32
+ agent: item.agent || agentName,
33
+ severity: normalizeSeverity(item.severity),
34
+ domain: item.domain || "general",
35
+ title:
36
+ item.title || item.name || (problem ? String(problem).slice(0, 50) : "Untitled finding"),
37
+ file: item.file || item.path || item.filename || "unknown",
38
+ source: item.source || "phase1",
39
+ evidence: item.evidence || item.snippet || item.context || "No evidence provided",
40
+ problem: problem,
41
+ fix: item.fix || item.recommendation || item.solution || "No fix provided",
42
+ };
43
+
44
+ const result = reviewFindingSchema.safeParse(normalized);
45
+ if (result.success) {
46
+ findings.push(result.data);
47
+ }
48
+ }
49
+ } catch {
50
+ // JSON parse failed completely
51
+ }
52
+
53
+ return Object.freeze(findings);
54
+ }
55
+
56
+ function normalizeSeverity(sev: unknown): string {
57
+ if (typeof sev !== "string") return "LOW";
58
+ const upper = sev.toUpperCase();
59
+ if (["CRITICAL", "HIGH", "MEDIUM", "LOW"].includes(upper)) return upper;
60
+ return "LOW";
61
+ }
62
+
63
+ function sanitizeMalformedJson(json: string): string {
64
+ let clean = json;
65
+ // Remove trailing commas in objects and arrays
66
+ clean = clean.replace(/,\s*([}\]])/g, "$1");
67
+ // Replace unescaped newlines in strings (basic attempt)
68
+ // This is risky with regex but catches common LLM markdown mistakes
69
+ return clean;
70
+ }
71
+
72
+ export function extractJson(raw: string): string | null {
73
+ const codeBlockMatch = raw.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
74
+ if (codeBlockMatch) {
75
+ return codeBlockMatch[1].trim();
76
+ }
77
+
78
+ const objectStart = raw.indexOf("{");
79
+ const arrayStart = raw.indexOf("[");
80
+
81
+ if (objectStart === -1 && arrayStart === -1) return null;
82
+
83
+ const start =
84
+ objectStart === -1
85
+ ? arrayStart
86
+ : arrayStart === -1
87
+ ? objectStart
88
+ : Math.min(objectStart, arrayStart);
89
+
90
+ let depth = 0;
91
+ let inString = false;
92
+ let escaped = false;
93
+ for (let i = start; i < raw.length; i++) {
94
+ const ch = raw[i];
95
+ if (escaped) {
96
+ escaped = false;
97
+ continue;
98
+ }
99
+ if (ch === "\\" && inString) {
100
+ escaped = true;
101
+ continue;
102
+ }
103
+ if (ch === '"') {
104
+ inString = !inString;
105
+ continue;
106
+ }
107
+ if (inString) continue;
108
+ if (ch === "{" || ch === "[") depth++;
109
+ if (ch === "}" || ch === "]") depth--;
110
+ if (depth === 0) {
111
+ return raw.slice(start, i + 1);
112
+ }
113
+ }
114
+
115
+ return null;
116
+ }
@@ -18,8 +18,8 @@ import { buildCrossVerificationPrompts, condenseFinding } from "./cross-verifica
18
18
  const STAGE3_NAMES: ReadonlySet<string> = new Set(STAGE3_AGENTS.map((a) => a.name));
19
19
 
20
20
  import { buildFixInstructions, determineFixableFindings } from "./fix-cycle";
21
+ import { parseAgentFindings, parseTypedFindingsEnvelope } from "./parse-findings";
21
22
  import { buildReport } from "./report";
22
- import { reviewFindingSchema, reviewFindingsEnvelopeSchema } from "./schemas";
23
23
  import type { ReviewFinding, ReviewFindingsEnvelope, ReviewReport, ReviewState } from "./types";
24
24
 
25
25
  export type { ReviewState };
@@ -38,112 +38,6 @@ export interface ReviewStageResult {
38
38
  readonly parseMode?: "typed" | "legacy";
39
39
  }
40
40
 
41
- function parseTypedFindingsEnvelope(raw: string): ReviewFindingsEnvelope | null {
42
- try {
43
- const parsed = JSON.parse(raw);
44
- return reviewFindingsEnvelopeSchema.parse(parsed);
45
- } catch {
46
- return null;
47
- }
48
- }
49
-
50
- /**
51
- * Parse findings from raw LLM output (which may contain markdown, prose, code blocks).
52
- *
53
- * Handles:
54
- * - {"findings": [...]} wrapper
55
- * - Raw array [{...}, ...]
56
- * - JSON embedded in markdown code blocks
57
- * - Prose with embedded JSON
58
- *
59
- * Sets agent field to agentName if missing from individual findings.
60
- * Validates each finding through reviewFindingSchema, discards invalid ones.
61
- */
62
- export function parseAgentFindings(raw: string, agentName: string): readonly ReviewFinding[] {
63
- const findings: ReviewFinding[] = [];
64
-
65
- // Try to extract JSON from the raw text
66
- const jsonStr = extractJson(raw);
67
- if (jsonStr === null) return Object.freeze(findings);
68
-
69
- try {
70
- const parsed = JSON.parse(jsonStr);
71
- const items = Array.isArray(parsed) ? parsed : parsed?.findings;
72
-
73
- if (!Array.isArray(items)) return Object.freeze(findings);
74
-
75
- for (const item of items) {
76
- // Set agent field if missing
77
- const withAgent = item.agent ? item : { ...item, agent: agentName };
78
- const result = reviewFindingSchema.safeParse(withAgent);
79
- if (result.success) {
80
- findings.push(result.data);
81
- }
82
- }
83
- } catch {
84
- // JSON parse failed -- return empty
85
- }
86
-
87
- return Object.freeze(findings);
88
- }
89
-
90
- /**
91
- * Extract the first JSON object or array from raw text.
92
- * Looks for:
93
- * 1. JSON inside markdown code blocks
94
- * 2. {"findings": ...} pattern
95
- * 3. Raw array [{...}]
96
- */
97
- function extractJson(raw: string): string | null {
98
- // Try markdown code block extraction first
99
- const codeBlockMatch = raw.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
100
- if (codeBlockMatch) {
101
- return codeBlockMatch[1].trim();
102
- }
103
-
104
- // Try to find {"findings": ...} or [{...}]
105
- const objectStart = raw.indexOf("{");
106
- const arrayStart = raw.indexOf("[");
107
-
108
- if (objectStart === -1 && arrayStart === -1) return null;
109
-
110
- // Pick whichever comes first
111
- const start =
112
- objectStart === -1
113
- ? arrayStart
114
- : arrayStart === -1
115
- ? objectStart
116
- : Math.min(objectStart, arrayStart);
117
-
118
- // Find matching close bracket (string-literal-aware depth tracking)
119
- let depth = 0;
120
- let inString = false;
121
- let escaped = false;
122
- for (let i = start; i < raw.length; i++) {
123
- const ch = raw[i];
124
- if (escaped) {
125
- escaped = false;
126
- continue;
127
- }
128
- if (ch === "\\" && inString) {
129
- escaped = true;
130
- continue;
131
- }
132
- if (ch === '"') {
133
- inString = !inString;
134
- continue;
135
- }
136
- if (inString) continue;
137
- if (ch === "{" || ch === "[") depth++;
138
- if (ch === "}" || ch === "]") depth--;
139
- if (depth === 0) {
140
- return raw.slice(start, i + 1);
141
- }
142
- }
143
-
144
- return null;
145
- }
146
-
147
41
  /**
148
42
  * Advance the pipeline from the current stage to the next.
149
43
  *
@@ -155,6 +49,8 @@ export function advancePipeline(
155
49
  findingsJson: string,
156
50
  currentState: ReviewState,
157
51
  agentName = "unknown",
52
+ _runId?: string,
53
+ _seed?: string,
158
54
  ): ReviewStageResult {
159
55
  const typedEnvelope = parseTypedFindingsEnvelope(findingsJson);
160
56
  const parseMode = typedEnvelope ? "typed" : "legacy";