@kodrunhq/opencode-autopilot 1.15.2 → 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.
- 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/health/checks.ts +29 -4
- package/src/index.ts +103 -11
- 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/types.ts +66 -0
- package/src/memory/capture.ts +221 -25
- package/src/memory/database.ts +74 -12
- package/src/memory/index.ts +17 -1
- package/src/memory/project-key.ts +6 -0
- package/src/memory/repository.ts +833 -42
- package/src/memory/retrieval.ts +83 -169
- package/src/memory/schemas.ts +39 -7
- package/src/memory/types.ts +4 -0
- package/src/observability/event-handlers.ts +28 -17
- package/src/observability/event-store.ts +29 -1
- package/src/observability/forensic-log.ts +159 -0
- package/src/observability/forensic-schemas.ts +69 -0
- package/src/observability/forensic-types.ts +10 -0
- package/src/observability/index.ts +21 -27
- package/src/observability/log-reader.ts +142 -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/fallback/event-handler.ts +47 -3
- package/src/orchestrator/handlers/architect.ts +2 -1
- package/src/orchestrator/handlers/build.ts +55 -97
- package/src/orchestrator/handlers/retrospective.ts +2 -1
- package/src/orchestrator/handlers/types.ts +0 -1
- package/src/orchestrator/lesson-memory.ts +29 -9
- package/src/orchestrator/orchestration-logger.ts +37 -23
- package/src/orchestrator/phase.ts +8 -4
- 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 +29 -9
- package/src/tools/doctor.ts +26 -2
- package/src/tools/forensics.ts +7 -12
- package/src/tools/logs.ts +6 -5
- package/src/tools/memory-preferences.ts +157 -0
- package/src/tools/memory-status.ts +17 -96
- package/src/tools/orchestrate.ts +97 -81
- package/src/tools/pipeline-report.ts +3 -2
- package/src/tools/quick.ts +2 -2
- package/src/tools/review.ts +39 -6
- package/src/tools/session-stats.ts +3 -2
- package/src/utils/paths.ts +20 -1
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { getMemoryDb } from "../memory/database";
|
|
3
|
+
import {
|
|
4
|
+
gitFingerprintInputSchema,
|
|
5
|
+
projectGitFingerprintSchema,
|
|
6
|
+
projectPathRecordSchema,
|
|
7
|
+
projectRecordSchema,
|
|
8
|
+
} from "./schemas";
|
|
9
|
+
import type {
|
|
10
|
+
GitFingerprintInput,
|
|
11
|
+
ProjectGitFingerprint,
|
|
12
|
+
ProjectPathRecord,
|
|
13
|
+
ProjectRecord,
|
|
14
|
+
} from "./types";
|
|
15
|
+
|
|
16
|
+
interface ProjectRow {
|
|
17
|
+
readonly id: string;
|
|
18
|
+
readonly path: string;
|
|
19
|
+
readonly name: string;
|
|
20
|
+
readonly first_seen_at: string | null;
|
|
21
|
+
readonly last_updated: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ProjectPathRow {
|
|
25
|
+
readonly project_id: string;
|
|
26
|
+
readonly path: string;
|
|
27
|
+
readonly first_seen_at: string;
|
|
28
|
+
readonly last_updated: string;
|
|
29
|
+
readonly is_current: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ProjectFingerprintRow {
|
|
33
|
+
readonly project_id: string;
|
|
34
|
+
readonly normalized_remote_url: string;
|
|
35
|
+
readonly default_branch: string | null;
|
|
36
|
+
readonly first_seen_at: string;
|
|
37
|
+
readonly last_updated: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function resolveDb(db?: Database): Database {
|
|
41
|
+
return db ?? getMemoryDb();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function withWriteTransaction<T>(db: Database, callback: () => T): T {
|
|
45
|
+
const row = db.query("PRAGMA transaction_state").get() as { transaction_state?: string } | null;
|
|
46
|
+
const isAlreadyInTransaction = row?.transaction_state === "TRANSACTION";
|
|
47
|
+
if (isAlreadyInTransaction) {
|
|
48
|
+
return callback();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
db.run("BEGIN IMMEDIATE");
|
|
52
|
+
try {
|
|
53
|
+
const result = callback();
|
|
54
|
+
db.run("COMMIT");
|
|
55
|
+
return result;
|
|
56
|
+
} catch (error: unknown) {
|
|
57
|
+
try {
|
|
58
|
+
db.run("ROLLBACK");
|
|
59
|
+
} catch {
|
|
60
|
+
// Ignore rollback failures so the original error wins.
|
|
61
|
+
}
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function rowToProject(row: ProjectRow): ProjectRecord {
|
|
67
|
+
return projectRecordSchema.parse({
|
|
68
|
+
id: row.id,
|
|
69
|
+
path: row.path,
|
|
70
|
+
name: row.name,
|
|
71
|
+
firstSeenAt: row.first_seen_at ?? row.last_updated,
|
|
72
|
+
lastUpdated: row.last_updated,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function rowToProjectPath(row: ProjectPathRow): ProjectPathRecord {
|
|
77
|
+
return projectPathRecordSchema.parse({
|
|
78
|
+
projectId: row.project_id,
|
|
79
|
+
path: row.path,
|
|
80
|
+
firstSeenAt: row.first_seen_at,
|
|
81
|
+
lastUpdated: row.last_updated,
|
|
82
|
+
isCurrent: row.is_current === 1,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function rowToProjectFingerprint(row: ProjectFingerprintRow): ProjectGitFingerprint {
|
|
87
|
+
return projectGitFingerprintSchema.parse({
|
|
88
|
+
projectId: row.project_id,
|
|
89
|
+
normalizedRemoteUrl: row.normalized_remote_url,
|
|
90
|
+
defaultBranch: row.default_branch,
|
|
91
|
+
firstSeenAt: row.first_seen_at,
|
|
92
|
+
lastUpdated: row.last_updated,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function upsertProjectRecord(project: ProjectRecord, db?: Database): ProjectRecord {
|
|
97
|
+
const validated = projectRecordSchema.parse(project);
|
|
98
|
+
const d = resolveDb(db);
|
|
99
|
+
const firstSeenAt = validated.firstSeenAt ?? validated.lastUpdated;
|
|
100
|
+
|
|
101
|
+
withWriteTransaction(d, () => {
|
|
102
|
+
d.run(
|
|
103
|
+
`INSERT INTO projects (id, path, name, first_seen_at, last_updated)
|
|
104
|
+
VALUES (?, ?, ?, ?, ?)
|
|
105
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
106
|
+
path = excluded.path,
|
|
107
|
+
name = excluded.name,
|
|
108
|
+
first_seen_at = COALESCE(projects.first_seen_at, excluded.first_seen_at),
|
|
109
|
+
last_updated = excluded.last_updated`,
|
|
110
|
+
[validated.id, validated.path, validated.name, firstSeenAt, validated.lastUpdated],
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
d.run("UPDATE project_paths SET is_current = 0, last_updated = ? WHERE project_id = ?", [
|
|
114
|
+
validated.lastUpdated,
|
|
115
|
+
validated.id,
|
|
116
|
+
]);
|
|
117
|
+
d.run(
|
|
118
|
+
`INSERT INTO project_paths (project_id, path, first_seen_at, last_updated, is_current)
|
|
119
|
+
VALUES (?, ?, ?, ?, 1)
|
|
120
|
+
ON CONFLICT(project_id, path) DO UPDATE SET
|
|
121
|
+
last_updated = excluded.last_updated,
|
|
122
|
+
is_current = 1`,
|
|
123
|
+
[validated.id, validated.path, firstSeenAt, validated.lastUpdated],
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return projectRecordSchema.parse({
|
|
128
|
+
...validated,
|
|
129
|
+
firstSeenAt,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function getProjectById(projectId: string, db?: Database): ProjectRecord | null {
|
|
134
|
+
const d = resolveDb(db);
|
|
135
|
+
const row = d.query("SELECT * FROM projects WHERE id = ?").get(projectId) as ProjectRow | null;
|
|
136
|
+
return row ? rowToProject(row) : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function getProjectByCurrentPath(path: string, db?: Database): ProjectRecord | null {
|
|
140
|
+
const d = resolveDb(db);
|
|
141
|
+
const row = d.query("SELECT * FROM projects WHERE path = ?").get(path) as ProjectRow | null;
|
|
142
|
+
return row ? rowToProject(row) : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function getProjectByAnyPath(path: string, db?: Database): ProjectRecord | null {
|
|
146
|
+
const current = getProjectByCurrentPath(path, db);
|
|
147
|
+
if (current !== null) {
|
|
148
|
+
return current;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const d = resolveDb(db);
|
|
152
|
+
const row = d
|
|
153
|
+
.query(
|
|
154
|
+
`SELECT p.*
|
|
155
|
+
FROM project_paths pp
|
|
156
|
+
JOIN projects p ON p.id = pp.project_id
|
|
157
|
+
WHERE pp.path = ?
|
|
158
|
+
ORDER BY pp.is_current DESC, pp.last_updated DESC
|
|
159
|
+
LIMIT 1`,
|
|
160
|
+
)
|
|
161
|
+
.get(path) as ProjectRow | null;
|
|
162
|
+
return row ? rowToProject(row) : null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function listProjectPaths(projectId: string, db?: Database): readonly ProjectPathRecord[] {
|
|
166
|
+
const d = resolveDb(db);
|
|
167
|
+
const rows = d
|
|
168
|
+
.query(
|
|
169
|
+
`SELECT *
|
|
170
|
+
FROM project_paths
|
|
171
|
+
WHERE project_id = ?
|
|
172
|
+
ORDER BY is_current DESC, last_updated DESC, path ASC`,
|
|
173
|
+
)
|
|
174
|
+
.all(projectId) as ProjectPathRow[];
|
|
175
|
+
return Object.freeze(rows.map(rowToProjectPath));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function setProjectCurrentPath(
|
|
179
|
+
projectId: string,
|
|
180
|
+
path: string,
|
|
181
|
+
name: string,
|
|
182
|
+
seenAt: string,
|
|
183
|
+
db?: Database,
|
|
184
|
+
): ProjectRecord {
|
|
185
|
+
const d = resolveDb(db);
|
|
186
|
+
const existing = getProjectById(projectId, d);
|
|
187
|
+
if (existing === null) {
|
|
188
|
+
throw new Error(`Unknown project id: ${projectId}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const next = projectRecordSchema.parse({
|
|
192
|
+
id: existing.id,
|
|
193
|
+
path,
|
|
194
|
+
name,
|
|
195
|
+
firstSeenAt: existing.firstSeenAt,
|
|
196
|
+
lastUpdated: seenAt,
|
|
197
|
+
});
|
|
198
|
+
return upsertProjectRecord(next, d);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function upsertProjectGitFingerprint(
|
|
202
|
+
projectId: string,
|
|
203
|
+
fingerprint: GitFingerprintInput,
|
|
204
|
+
seenAt: string,
|
|
205
|
+
db?: Database,
|
|
206
|
+
): ProjectGitFingerprint {
|
|
207
|
+
const validated = gitFingerprintInputSchema.parse(fingerprint);
|
|
208
|
+
const d = resolveDb(db);
|
|
209
|
+
|
|
210
|
+
d.run(
|
|
211
|
+
`INSERT INTO project_git_fingerprints (
|
|
212
|
+
project_id,
|
|
213
|
+
normalized_remote_url,
|
|
214
|
+
default_branch,
|
|
215
|
+
first_seen_at,
|
|
216
|
+
last_updated
|
|
217
|
+
) VALUES (?, ?, ?, ?, ?)
|
|
218
|
+
ON CONFLICT(project_id, normalized_remote_url) DO UPDATE SET
|
|
219
|
+
default_branch = excluded.default_branch,
|
|
220
|
+
last_updated = excluded.last_updated`,
|
|
221
|
+
[projectId, validated.normalizedRemoteUrl, validated.defaultBranch, seenAt, seenAt],
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
return projectGitFingerprintSchema.parse({
|
|
225
|
+
projectId,
|
|
226
|
+
normalizedRemoteUrl: validated.normalizedRemoteUrl,
|
|
227
|
+
defaultBranch: validated.defaultBranch,
|
|
228
|
+
firstSeenAt: seenAt,
|
|
229
|
+
lastUpdated: seenAt,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function getProjectsByGitFingerprint(
|
|
234
|
+
normalizedRemoteUrl: string,
|
|
235
|
+
db?: Database,
|
|
236
|
+
): readonly ProjectRecord[] {
|
|
237
|
+
const d = resolveDb(db);
|
|
238
|
+
const rows = d
|
|
239
|
+
.query(
|
|
240
|
+
`SELECT DISTINCT p.*
|
|
241
|
+
FROM project_git_fingerprints pgf
|
|
242
|
+
JOIN projects p ON p.id = pgf.project_id
|
|
243
|
+
WHERE pgf.normalized_remote_url = ?
|
|
244
|
+
ORDER BY p.last_updated DESC, p.id DESC`,
|
|
245
|
+
)
|
|
246
|
+
.all(normalizedRemoteUrl) as ProjectRow[];
|
|
247
|
+
return Object.freeze(rows.map(rowToProject));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function listProjectGitFingerprints(
|
|
251
|
+
projectId: string,
|
|
252
|
+
db?: Database,
|
|
253
|
+
): readonly ProjectGitFingerprint[] {
|
|
254
|
+
const d = resolveDb(db);
|
|
255
|
+
const rows = d
|
|
256
|
+
.query(
|
|
257
|
+
`SELECT *
|
|
258
|
+
FROM project_git_fingerprints
|
|
259
|
+
WHERE project_id = ?
|
|
260
|
+
ORDER BY last_updated DESC, normalized_remote_url ASC`,
|
|
261
|
+
)
|
|
262
|
+
.all(projectId) as ProjectFingerprintRow[];
|
|
263
|
+
return Object.freeze(rows.map(rowToProjectFingerprint));
|
|
264
|
+
}
|
|
@@ -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,9 @@
|
|
|
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";
|
|
13
15
|
import { ensureDir, isEnoentError } from "../utils/fs-helpers";
|
|
16
|
+
import { getProjectArtifactDir } from "../utils/paths";
|
|
14
17
|
import { reviewMemorySchema } from "./schemas";
|
|
15
18
|
import type { ReviewMemory } from "./types";
|
|
16
19
|
|
|
@@ -20,6 +23,7 @@ const MEMORY_FILE = "review-memory.json";
|
|
|
20
23
|
const MAX_FINDINGS = 100;
|
|
21
24
|
const MAX_FALSE_POSITIVES = 50;
|
|
22
25
|
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
26
|
+
let legacyReviewMemoryMirrorWarned = false;
|
|
23
27
|
|
|
24
28
|
/**
|
|
25
29
|
* Create a valid empty memory object.
|
|
@@ -42,12 +46,19 @@ export function createEmptyMemory(): ReviewMemory {
|
|
|
42
46
|
* Prunes on load to cap storage.
|
|
43
47
|
*/
|
|
44
48
|
export async function loadReviewMemory(projectRoot: string): Promise<ReviewMemory | null> {
|
|
49
|
+
const kernelMemory = loadReviewMemoryFromKernel(getProjectArtifactDir(projectRoot));
|
|
50
|
+
if (kernelMemory !== null) {
|
|
51
|
+
return pruneMemory(kernelMemory);
|
|
52
|
+
}
|
|
53
|
+
|
|
45
54
|
const memoryPath = join(projectRoot, ".opencode-autopilot", MEMORY_FILE);
|
|
46
55
|
try {
|
|
47
56
|
const raw = await readFile(memoryPath, "utf-8");
|
|
48
57
|
const parsed = JSON.parse(raw);
|
|
49
58
|
const validated = reviewMemorySchema.parse(parsed);
|
|
50
|
-
|
|
59
|
+
const pruned = pruneMemory(validated);
|
|
60
|
+
saveReviewMemoryToKernel(getProjectArtifactDir(projectRoot), pruned);
|
|
61
|
+
return pruned;
|
|
51
62
|
} catch (error: unknown) {
|
|
52
63
|
if (isEnoentError(error)) {
|
|
53
64
|
return null;
|
|
@@ -70,12 +81,21 @@ export async function loadReviewMemory(projectRoot: string): Promise<ReviewMemor
|
|
|
70
81
|
*/
|
|
71
82
|
export async function saveReviewMemory(memory: ReviewMemory, projectRoot: string): Promise<void> {
|
|
72
83
|
const validated = reviewMemorySchema.parse(memory);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
84
|
+
saveReviewMemoryToKernel(getProjectArtifactDir(projectRoot), validated);
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const dir = join(projectRoot, ".opencode-autopilot");
|
|
88
|
+
await ensureDir(dir);
|
|
89
|
+
const memoryPath = join(dir, MEMORY_FILE);
|
|
90
|
+
const tmpPath = `${memoryPath}.tmp.${Date.now()}`;
|
|
91
|
+
await writeFile(tmpPath, JSON.stringify(validated, null, 2), "utf-8");
|
|
92
|
+
await rename(tmpPath, memoryPath);
|
|
93
|
+
} catch (error: unknown) {
|
|
94
|
+
if (!legacyReviewMemoryMirrorWarned) {
|
|
95
|
+
legacyReviewMemoryMirrorWarned = true;
|
|
96
|
+
console.warn("[opencode-autopilot] review-memory.json mirror write failed:", error);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
79
99
|
}
|
|
80
100
|
|
|
81
101
|
/**
|