@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.
- package/bin/cli.ts +5 -0
- package/bin/inspect.ts +337 -0
- package/package.json +1 -1
- package/src/agents/autopilot.ts +7 -15
- package/src/config/index.ts +29 -0
- package/src/config/migrations.ts +196 -0
- package/src/config/v7.ts +45 -0
- package/src/config.ts +3 -3
- package/src/health/checks.ts +126 -4
- package/src/health/types.ts +1 -1
- package/src/index.ts +128 -13
- package/src/inspect/formatters.ts +225 -0
- package/src/inspect/repository.ts +882 -0
- package/src/kernel/database.ts +45 -0
- package/src/kernel/migrations.ts +62 -0
- package/src/kernel/repository.ts +571 -0
- package/src/kernel/schema.ts +122 -0
- package/src/kernel/transaction.ts +48 -0
- package/src/kernel/types.ts +65 -0
- package/src/logging/domains.ts +39 -0
- package/src/logging/forensic-writer.ts +177 -0
- package/src/logging/index.ts +4 -0
- package/src/logging/logger.ts +44 -0
- package/src/logging/performance.ts +59 -0
- package/src/logging/rotation.ts +261 -0
- package/src/logging/types.ts +33 -0
- package/src/memory/capture-utils.ts +149 -0
- package/src/memory/capture.ts +82 -67
- package/src/memory/database.ts +74 -12
- package/src/memory/decay.ts +11 -2
- package/src/memory/index.ts +17 -1
- package/src/memory/injector.ts +4 -1
- package/src/memory/lessons.ts +85 -0
- package/src/memory/observations.ts +177 -0
- package/src/memory/preferences.ts +718 -0
- package/src/memory/project-key.ts +6 -0
- package/src/memory/projects.ts +83 -0
- package/src/memory/repository.ts +52 -216
- package/src/memory/retrieval.ts +88 -170
- package/src/memory/schemas.ts +39 -7
- package/src/memory/types.ts +4 -0
- package/src/observability/context-display.ts +8 -0
- package/src/observability/event-handlers.ts +69 -20
- package/src/observability/event-store.ts +29 -1
- package/src/observability/forensic-log.ts +167 -0
- package/src/observability/forensic-schemas.ts +77 -0
- package/src/observability/forensic-types.ts +10 -0
- package/src/observability/index.ts +21 -27
- package/src/observability/log-reader.ts +161 -111
- package/src/observability/log-writer.ts +41 -83
- package/src/observability/retention.ts +2 -2
- package/src/observability/session-logger.ts +36 -57
- package/src/observability/summary-generator.ts +31 -19
- package/src/observability/types.ts +12 -24
- package/src/orchestrator/contracts/invariants.ts +14 -0
- package/src/orchestrator/contracts/legacy-result-adapter.ts +8 -20
- package/src/orchestrator/error-context.ts +24 -0
- package/src/orchestrator/fallback/event-handler.ts +47 -3
- package/src/orchestrator/handlers/architect.ts +2 -1
- package/src/orchestrator/handlers/build-utils.ts +118 -0
- package/src/orchestrator/handlers/build.ts +42 -219
- package/src/orchestrator/handlers/retrospective.ts +2 -2
- package/src/orchestrator/handlers/types.ts +0 -1
- package/src/orchestrator/lesson-memory.ts +36 -11
- package/src/orchestrator/orchestration-logger.ts +53 -24
- package/src/orchestrator/phase.ts +8 -4
- package/src/orchestrator/progress.ts +63 -0
- package/src/orchestrator/state.ts +79 -17
- package/src/projects/database.ts +47 -0
- package/src/projects/repository.ts +264 -0
- package/src/projects/resolve.ts +301 -0
- package/src/projects/schemas.ts +30 -0
- package/src/projects/types.ts +12 -0
- package/src/review/memory.ts +39 -11
- package/src/review/parse-findings.ts +116 -0
- package/src/review/pipeline.ts +3 -107
- package/src/review/selection.ts +38 -4
- package/src/scoring/time-provider.ts +23 -0
- package/src/tools/doctor.ts +28 -4
- package/src/tools/forensics.ts +7 -12
- package/src/tools/logs.ts +38 -11
- package/src/tools/memory-preferences.ts +157 -0
- package/src/tools/memory-status.ts +17 -96
- package/src/tools/orchestrate.ts +108 -90
- package/src/tools/pipeline-report.ts +3 -2
- package/src/tools/quick.ts +2 -2
- package/src/tools/replay.ts +42 -0
- package/src/tools/review.ts +46 -7
- package/src/tools/session-stats.ts +3 -2
- package/src/tools/summary.ts +43 -0
- package/src/utils/paths.ts +20 -1
- package/src/utils/random.ts +33 -0
- 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>;
|
package/src/review/memory.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Per-project review memory persistence.
|
|
3
3
|
*
|
|
4
|
-
* Stores recent findings and false positives
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
+
}
|
package/src/review/pipeline.ts
CHANGED
|
@@ -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";
|