@evo-dev/core 0.0.1-alpha.2 → 0.0.1-alpha.20
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/assets/skills/coding/knowledge-distillation/SKILL.md +5 -3
- package/assets/team/agents/code-reviewer.md +48 -0
- package/assets/team/agents/docs-maintainer.md +51 -0
- package/assets/team/agents/implementation-engineer.md +51 -0
- package/assets/team/agents/product-scope-analyst.md +58 -0
- package/assets/team/agents/release-engineer.md +55 -0
- package/assets/team/agents/security-boundary-reviewer.md +50 -0
- package/assets/team/agents/solution-architect.md +51 -0
- package/assets/team/agents/verification-engineer.md +51 -0
- package/assets/team/team.md +102 -0
- package/dist/assets/index.js +5 -5
- package/dist/config/index.js +793 -241
- package/dist/index.js +20840 -12908
- package/dist/plugins/index.js +13 -13
- package/package.json +1 -1
- package/src/agents/index.ts +1 -265
- package/src/code-agent-traces/index.ts +11 -12
- package/src/config/index.ts +2 -0
- package/src/config/settings.ts +116 -7
- package/src/config/store.ts +1 -1
- package/src/daemon/index.ts +1 -41
- package/src/evolution/candidates/index.ts +730 -0
- package/src/evolution/control/index.ts +20 -0
- package/src/evolution/evidence/analysis.ts +533 -0
- package/src/evolution/evidence/index.ts +3 -0
- package/src/evolution/evidence/session-memory/analysis.ts +287 -0
- package/src/evolution/evidence/session-memory/constants.ts +9 -0
- package/src/evolution/evidence/session-memory/index.ts +9 -0
- package/src/evolution/evidence/session-memory/paths.ts +29 -0
- package/src/evolution/evidence/session-memory/policy.ts +39 -0
- package/src/evolution/evidence/session-memory/retention.ts +643 -0
- package/src/evolution/evidence/session-memory/segment.ts +216 -0
- package/src/evolution/evidence/session-memory/semantic-packet.ts +408 -0
- package/src/evolution/evidence/session-memory/sensitivity.ts +335 -0
- package/src/evolution/evidence/session-memory/state-machine.ts +249 -0
- package/src/evolution/evidence/session-memory/storage.ts +744 -0
- package/src/evolution/evidence/session-memory/types.ts +296 -0
- package/src/evolution/evidence/session-memory/updater.ts +199 -0
- package/src/evolution/formatters.ts +169 -0
- package/src/evolution/imports/apply.ts +435 -0
- package/src/evolution/imports/diff.ts +472 -0
- package/src/evolution/imports/index.ts +7 -0
- package/src/evolution/imports/materialize.ts +640 -0
- package/src/evolution/imports/paths.ts +129 -0
- package/src/evolution/imports/stage.ts +414 -0
- package/src/evolution/imports/storage.ts +952 -0
- package/src/evolution/imports/types.ts +226 -0
- package/src/evolution/index.ts +19 -2827
- package/src/evolution/knowledge/change-store.ts +558 -0
- package/src/evolution/knowledge/changes.ts +459 -0
- package/src/evolution/knowledge/freshness.ts +69 -0
- package/src/{knowledge → evolution/knowledge}/index.ts +1532 -206
- package/src/evolution/knowledge/review.ts +446 -0
- package/src/evolution/knowledge/support.ts +135 -0
- package/src/evolution/paths.ts +44 -0
- package/src/evolution/processor/distillation.ts +518 -0
- package/src/evolution/processor/index.ts +3 -0
- package/src/evolution/processor/process.ts +594 -0
- package/src/{learning → evolution/review}/index.ts +10 -14
- package/src/evolution/schema.ts +639 -0
- package/src/evolution/shared.ts +1053 -0
- package/src/evolution/triggers/classification.ts +102 -0
- package/src/evolution/triggers/index.ts +295 -0
- package/src/hooks/index.ts +281 -197
- package/src/index.ts +15 -4
- package/src/projects/index.ts +934 -0
- package/src/runtime-logs/index.ts +100 -13
- package/src/team/index.ts +582 -3
- package/src/utils/errors.ts +13 -0
- package/src/utils/fs.ts +40 -0
- package/src/utils/hash.ts +9 -0
- package/src/utils/ids.ts +12 -0
- package/src/utils/index.ts +7 -0
- package/src/utils/parsing.ts +11 -0
- package/src/utils/text.ts +18 -0
- package/src/utils/time.ts +5 -0
- package/src/workflow/index.ts +3 -21
- package/src/project/index.ts +0 -507
- package/src/task/index.ts +0 -840
|
@@ -0,0 +1,934 @@
|
|
|
1
|
+
import { lstat, readFile, readdir, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, parse } from "node:path";
|
|
3
|
+
import { resolveEvoDevPaths } from "../config/paths.ts";
|
|
4
|
+
import { resolvePathProjectLogKey, resolveProjectLogIdentity } from "../runtime-logs/index.ts";
|
|
5
|
+
import {
|
|
6
|
+
isFileExistsError,
|
|
7
|
+
isNotFoundError,
|
|
8
|
+
normalizeTimestamp,
|
|
9
|
+
sanitizeStorageId,
|
|
10
|
+
sha256Short,
|
|
11
|
+
writeJsonFile,
|
|
12
|
+
} from "../utils/index.ts";
|
|
13
|
+
|
|
14
|
+
export type ProjectWorkspaceKind = "git" | "directory";
|
|
15
|
+
export type ProjectDiscoveryTarget = "claude" | "codex";
|
|
16
|
+
|
|
17
|
+
export interface ResolvedProjectWorkspace {
|
|
18
|
+
projectKey: string;
|
|
19
|
+
workspaceKey: string;
|
|
20
|
+
displayName: string;
|
|
21
|
+
projectRoot: string;
|
|
22
|
+
workspaceRoot: string;
|
|
23
|
+
workspaceKind: ProjectWorkspaceKind;
|
|
24
|
+
gitCommonDir: string | null;
|
|
25
|
+
linkedWorktree: boolean;
|
|
26
|
+
cwd: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DiscoveredProjectRecordV1 {
|
|
30
|
+
schemaVersion: 1;
|
|
31
|
+
kind: "discovered-project";
|
|
32
|
+
projectKey: string;
|
|
33
|
+
displayName: string;
|
|
34
|
+
workspaceRoot: string;
|
|
35
|
+
lastCwd: string;
|
|
36
|
+
workspaceKind: ProjectWorkspaceKind;
|
|
37
|
+
sourceTargets: ProjectDiscoveryTarget[];
|
|
38
|
+
lastSessionKey: string | null;
|
|
39
|
+
firstSeenAt: string;
|
|
40
|
+
lastSeenAt: string;
|
|
41
|
+
localOnly: true;
|
|
42
|
+
sourceContentStored: false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface RegisteredProjectRecordV1 {
|
|
46
|
+
schemaVersion: 1;
|
|
47
|
+
kind: "registered-project";
|
|
48
|
+
projectKey: string;
|
|
49
|
+
displayName: string;
|
|
50
|
+
workspaceRoot: string;
|
|
51
|
+
workspaceKind: ProjectWorkspaceKind;
|
|
52
|
+
registeredAt: string;
|
|
53
|
+
lastSeenAt: string;
|
|
54
|
+
localOnly: true;
|
|
55
|
+
sourceContentStored: false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ProjectWorkspaceRecordV1 {
|
|
59
|
+
schemaVersion: 1;
|
|
60
|
+
kind: "project-workspace";
|
|
61
|
+
workspaceKey: string;
|
|
62
|
+
projectKey: string;
|
|
63
|
+
displayName: string;
|
|
64
|
+
workspaceRoot: string;
|
|
65
|
+
workspaceKind: ProjectWorkspaceKind;
|
|
66
|
+
gitCommonDir: string | null;
|
|
67
|
+
sourceTargets: ProjectDiscoveryTarget[];
|
|
68
|
+
firstSeenAt: string;
|
|
69
|
+
lastSeenAt: string;
|
|
70
|
+
localOnly: true;
|
|
71
|
+
sourceContentStored: false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ProjectWorkspaceSummary extends ProjectWorkspaceRecordV1 {
|
|
75
|
+
available: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ProjectAliasRecordV1 {
|
|
79
|
+
schemaVersion: 1;
|
|
80
|
+
kind: "project-alias";
|
|
81
|
+
aliasProjectKey: string;
|
|
82
|
+
canonicalProjectKey: string;
|
|
83
|
+
workspaceRoot: string;
|
|
84
|
+
createdAt: string;
|
|
85
|
+
updatedAt: string;
|
|
86
|
+
localOnly: true;
|
|
87
|
+
sourceContentStored: false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type ProjectAliasMap = Record<string, string>;
|
|
91
|
+
|
|
92
|
+
export class ProjectRegistrationError extends Error {
|
|
93
|
+
readonly code: "invalid" | "not-found" | "conflict";
|
|
94
|
+
|
|
95
|
+
constructor(code: "invalid" | "not-found" | "conflict", message: string) {
|
|
96
|
+
super(message);
|
|
97
|
+
this.name = "ProjectRegistrationError";
|
|
98
|
+
this.code = code;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function resolveProjectRegistryPaths(homeDir: string): {
|
|
103
|
+
discoveredDir: string;
|
|
104
|
+
registeredDir: string;
|
|
105
|
+
workspaceDir: string;
|
|
106
|
+
aliasDir: string;
|
|
107
|
+
discoveredPath: (projectKey: string) => string;
|
|
108
|
+
registeredPath: (projectKey: string) => string;
|
|
109
|
+
workspacePath: (workspaceKey: string) => string;
|
|
110
|
+
aliasPath: (aliasProjectKey: string) => string;
|
|
111
|
+
} {
|
|
112
|
+
const paths = resolveEvoDevPaths(homeDir);
|
|
113
|
+
const discoveredDir = join(paths.stateDir, "projects", "discovered");
|
|
114
|
+
const registeredDir = join(paths.rootDir, "projects");
|
|
115
|
+
const workspaceDir = join(paths.stateDir, "projects", "workspaces");
|
|
116
|
+
const aliasDir = join(paths.stateDir, "projects", "aliases");
|
|
117
|
+
return {
|
|
118
|
+
discoveredDir,
|
|
119
|
+
registeredDir,
|
|
120
|
+
workspaceDir,
|
|
121
|
+
aliasDir,
|
|
122
|
+
discoveredPath: (projectKey) => join(discoveredDir, `${projectKey}.json`),
|
|
123
|
+
registeredPath: (projectKey) => join(registeredDir, projectKey, "workspace.json"),
|
|
124
|
+
workspacePath: (workspaceKey) => join(workspaceDir, `${workspaceKey}.json`),
|
|
125
|
+
aliasPath: (aliasProjectKey) => join(aliasDir, `${aliasProjectKey}.json`),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function resolveProjectWorkspaceFromCwd(input: {
|
|
130
|
+
homeDir: string;
|
|
131
|
+
cwd: string;
|
|
132
|
+
}): Promise<ResolvedProjectWorkspace | null> {
|
|
133
|
+
if (!isAbsolute(input.cwd)) return null;
|
|
134
|
+
|
|
135
|
+
let cwd: string;
|
|
136
|
+
try {
|
|
137
|
+
const info = await stat(input.cwd);
|
|
138
|
+
if (!info.isDirectory()) return null;
|
|
139
|
+
cwd = await realpath(input.cwd);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (isNotFoundError(error)) return null;
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const gitRoot = await findNearestGitRoot(cwd);
|
|
146
|
+
const workspaceRoot = gitRoot ?? cwd;
|
|
147
|
+
const identity = resolveProjectLogIdentity(input.homeDir, workspaceRoot);
|
|
148
|
+
return {
|
|
149
|
+
projectKey: sanitizeStorageId(identity.projectKey, "project"),
|
|
150
|
+
workspaceKey: sanitizeStorageId(identity.workspaceKey, "workspace"),
|
|
151
|
+
displayName: basename(identity.projectRoot) || parse(identity.projectRoot).root,
|
|
152
|
+
projectRoot: identity.projectRoot,
|
|
153
|
+
workspaceRoot,
|
|
154
|
+
workspaceKind: gitRoot === null ? "directory" : "git",
|
|
155
|
+
gitCommonDir: identity.gitCommonDir,
|
|
156
|
+
linkedWorktree: identity.linkedWorktree,
|
|
157
|
+
cwd,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function recordDiscoveredProject(input: {
|
|
162
|
+
homeDir: string;
|
|
163
|
+
cwd: string;
|
|
164
|
+
target: ProjectDiscoveryTarget;
|
|
165
|
+
sessionKey?: string | null;
|
|
166
|
+
now?: Date | string;
|
|
167
|
+
}): Promise<{ record: DiscoveredProjectRecordV1; path: string } | null> {
|
|
168
|
+
const workspace = await resolveProjectWorkspaceFromCwd(input);
|
|
169
|
+
if (workspace === null) return null;
|
|
170
|
+
|
|
171
|
+
const now = normalizeTimestamp(input.now);
|
|
172
|
+
const paths = resolveProjectRegistryPaths(input.homeDir);
|
|
173
|
+
const path = paths.discoveredPath(workspace.projectKey);
|
|
174
|
+
const existing = await readDiscoveredProject(path);
|
|
175
|
+
await writeProjectWorkspaceRecord({
|
|
176
|
+
paths,
|
|
177
|
+
workspace,
|
|
178
|
+
target: input.target,
|
|
179
|
+
now,
|
|
180
|
+
});
|
|
181
|
+
if (workspace.workspaceKey !== workspace.projectKey) {
|
|
182
|
+
await writeProjectAliasRecord({
|
|
183
|
+
paths,
|
|
184
|
+
aliasProjectKey: workspace.workspaceKey,
|
|
185
|
+
canonicalProjectKey: workspace.projectKey,
|
|
186
|
+
workspaceRoot: workspace.workspaceRoot,
|
|
187
|
+
now,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
const record: DiscoveredProjectRecordV1 = {
|
|
191
|
+
schemaVersion: 1,
|
|
192
|
+
kind: "discovered-project",
|
|
193
|
+
projectKey: workspace.projectKey,
|
|
194
|
+
displayName: workspace.displayName,
|
|
195
|
+
workspaceRoot: workspace.projectRoot,
|
|
196
|
+
lastCwd: workspace.cwd,
|
|
197
|
+
workspaceKind: workspace.workspaceKind,
|
|
198
|
+
sourceTargets: mergeTargets(existing?.sourceTargets ?? [], [input.target]),
|
|
199
|
+
lastSessionKey: normalizeSessionKey(input.sessionKey ?? existing?.lastSessionKey ?? null),
|
|
200
|
+
firstSeenAt: existing?.firstSeenAt ?? now,
|
|
201
|
+
lastSeenAt: now,
|
|
202
|
+
localOnly: true,
|
|
203
|
+
sourceContentStored: false,
|
|
204
|
+
};
|
|
205
|
+
await writeJsonFile(path, record);
|
|
206
|
+
return { record, path };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function listDiscoveredProjects(input: {
|
|
210
|
+
homeDir: string;
|
|
211
|
+
}): Promise<DiscoveredProjectRecordV1[]> {
|
|
212
|
+
const paths = resolveProjectRegistryPaths(input.homeDir);
|
|
213
|
+
const records = new Map<string, DiscoveredProjectRecordV1>();
|
|
214
|
+
const aliases = await listProjectAliases(input);
|
|
215
|
+
|
|
216
|
+
for (const path of await listJsonFilePaths(paths.discoveredDir)) {
|
|
217
|
+
const record = await readDiscoveredProject(path);
|
|
218
|
+
if (record !== null) {
|
|
219
|
+
mergeDiscoveredRecord(
|
|
220
|
+
records,
|
|
221
|
+
await canonicalizeDiscoveredRecord(input.homeDir, record, aliases),
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
for (const binding of await listLegacyHookBindings(input.homeDir)) {
|
|
227
|
+
if (binding.cwd === null) continue;
|
|
228
|
+
const workspace = await resolveProjectWorkspaceFromCwd({
|
|
229
|
+
homeDir: input.homeDir,
|
|
230
|
+
cwd: binding.cwd,
|
|
231
|
+
});
|
|
232
|
+
if (workspace === null) continue;
|
|
233
|
+
mergeDiscoveredRecord(records, {
|
|
234
|
+
schemaVersion: 1,
|
|
235
|
+
kind: "discovered-project",
|
|
236
|
+
projectKey: workspace.projectKey,
|
|
237
|
+
displayName: workspace.displayName,
|
|
238
|
+
workspaceRoot: workspace.projectRoot,
|
|
239
|
+
lastCwd: workspace.cwd,
|
|
240
|
+
workspaceKind: workspace.workspaceKind,
|
|
241
|
+
sourceTargets: [binding.target],
|
|
242
|
+
lastSessionKey: normalizeSessionKey(binding.sessionKey),
|
|
243
|
+
firstSeenAt: binding.updatedAt,
|
|
244
|
+
lastSeenAt: binding.updatedAt,
|
|
245
|
+
localOnly: true,
|
|
246
|
+
sourceContentStored: false,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return [...records.values()].sort(
|
|
251
|
+
(left, right) =>
|
|
252
|
+
right.lastSeenAt.localeCompare(left.lastSeenAt) ||
|
|
253
|
+
left.projectKey.localeCompare(right.projectKey),
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function listRegisteredProjects(input: {
|
|
258
|
+
homeDir: string;
|
|
259
|
+
}): Promise<RegisteredProjectRecordV1[]> {
|
|
260
|
+
const { registeredDir } = resolveProjectRegistryPaths(input.homeDir);
|
|
261
|
+
const aliases = await listProjectAliases(input);
|
|
262
|
+
const records = new Map<string, RegisteredProjectRecordV1>();
|
|
263
|
+
for (const projectKey of await listDirectoryNames(registeredDir)) {
|
|
264
|
+
const record = await readRegisteredProject(join(registeredDir, projectKey, "workspace.json"));
|
|
265
|
+
if (record === null || record.projectKey !== projectKey) continue;
|
|
266
|
+
mergeRegisteredRecord(
|
|
267
|
+
records,
|
|
268
|
+
await canonicalizeRegisteredRecord(input.homeDir, record, aliases),
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
return [...records.values()].sort(
|
|
272
|
+
(left, right) =>
|
|
273
|
+
right.lastSeenAt.localeCompare(left.lastSeenAt) ||
|
|
274
|
+
left.projectKey.localeCompare(right.projectKey),
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function listProjectAliases(input: {
|
|
279
|
+
homeDir: string;
|
|
280
|
+
}): Promise<ProjectAliasMap> {
|
|
281
|
+
const paths = resolveProjectRegistryPaths(input.homeDir);
|
|
282
|
+
const aliases: ProjectAliasMap = {};
|
|
283
|
+
for (const path of await listJsonFilePaths(paths.aliasDir)) {
|
|
284
|
+
const record = await readProjectAlias(path);
|
|
285
|
+
if (record !== null) aliases[record.aliasProjectKey] = record.canonicalProjectKey;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
for (const path of await listJsonFilePaths(paths.discoveredDir)) {
|
|
289
|
+
const record = await readDiscoveredProject(path);
|
|
290
|
+
if (record !== null) {
|
|
291
|
+
await collectAvailableProjectAlias(input.homeDir, record, aliases);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
for (const projectKey of await listDirectoryNames(paths.registeredDir)) {
|
|
295
|
+
const record = await readRegisteredProject(
|
|
296
|
+
join(paths.registeredDir, projectKey, "workspace.json"),
|
|
297
|
+
);
|
|
298
|
+
if (record !== null) {
|
|
299
|
+
await collectAvailableProjectAlias(input.homeDir, record, aliases);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return normalizeProjectAliases(aliases);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function canonicalizeProjectKey(projectKey: string, aliases: ProjectAliasMap): string {
|
|
306
|
+
let current = projectKey;
|
|
307
|
+
const seen = new Set<string>();
|
|
308
|
+
while (!seen.has(current)) {
|
|
309
|
+
seen.add(current);
|
|
310
|
+
const next = aliases[current];
|
|
311
|
+
if (next === undefined || next === current) return current;
|
|
312
|
+
current = next;
|
|
313
|
+
}
|
|
314
|
+
return projectKey;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export async function listEquivalentProjectKeys(input: {
|
|
318
|
+
homeDir: string;
|
|
319
|
+
projectKey: string;
|
|
320
|
+
}): Promise<string[]> {
|
|
321
|
+
const aliases = await listProjectAliases({ homeDir: input.homeDir });
|
|
322
|
+
const canonical = canonicalizeProjectKey(input.projectKey, aliases);
|
|
323
|
+
return [
|
|
324
|
+
...new Set([
|
|
325
|
+
canonical,
|
|
326
|
+
input.projectKey,
|
|
327
|
+
...Object.keys(aliases).filter(
|
|
328
|
+
(alias) => canonicalizeProjectKey(alias, aliases) === canonical,
|
|
329
|
+
),
|
|
330
|
+
]),
|
|
331
|
+
].sort();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export async function listProjectWorkspaces(input: {
|
|
335
|
+
homeDir: string;
|
|
336
|
+
}): Promise<ProjectWorkspaceSummary[]> {
|
|
337
|
+
const paths = resolveProjectRegistryPaths(input.homeDir);
|
|
338
|
+
const aliases = await listProjectAliases(input);
|
|
339
|
+
const records = new Map<string, ProjectWorkspaceSummary>();
|
|
340
|
+
|
|
341
|
+
for (const path of await listJsonFilePaths(paths.workspaceDir)) {
|
|
342
|
+
const record = await readProjectWorkspace(path);
|
|
343
|
+
if (record !== null) {
|
|
344
|
+
mergeProjectWorkspaceSummary(
|
|
345
|
+
records,
|
|
346
|
+
await summarizeProjectWorkspace(input.homeDir, record, aliases),
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
for (const path of await listJsonFilePaths(paths.discoveredDir)) {
|
|
351
|
+
const record = await readDiscoveredProject(path);
|
|
352
|
+
if (record !== null) {
|
|
353
|
+
mergeProjectWorkspaceSummary(
|
|
354
|
+
records,
|
|
355
|
+
await summarizeLegacyWorkspace(input.homeDir, record, aliases),
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
for (const projectKey of await listDirectoryNames(paths.registeredDir)) {
|
|
360
|
+
const record = await readRegisteredProject(
|
|
361
|
+
join(paths.registeredDir, projectKey, "workspace.json"),
|
|
362
|
+
);
|
|
363
|
+
if (record !== null) {
|
|
364
|
+
mergeProjectWorkspaceSummary(
|
|
365
|
+
records,
|
|
366
|
+
await summarizeLegacyWorkspace(input.homeDir, record, aliases),
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return [...records.values()].sort(
|
|
372
|
+
(left, right) =>
|
|
373
|
+
left.projectKey.localeCompare(right.projectKey) ||
|
|
374
|
+
Number(right.available) - Number(left.available) ||
|
|
375
|
+
right.lastSeenAt.localeCompare(left.lastSeenAt) ||
|
|
376
|
+
left.workspaceKey.localeCompare(right.workspaceKey),
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export async function registerDiscoveredProject(input: {
|
|
381
|
+
homeDir: string;
|
|
382
|
+
projectKey: string;
|
|
383
|
+
now?: Date | string;
|
|
384
|
+
}): Promise<{ record: RegisteredProjectRecordV1; path: string; changed: boolean }> {
|
|
385
|
+
assertProjectKey(input.projectKey);
|
|
386
|
+
const paths = resolveProjectRegistryPaths(input.homeDir);
|
|
387
|
+
const path = paths.registeredPath(input.projectKey);
|
|
388
|
+
const existing = await readRegisteredProject(path);
|
|
389
|
+
if (existing !== null) return { record: existing, path, changed: false };
|
|
390
|
+
|
|
391
|
+
const discovered = (await listDiscoveredProjects({ homeDir: input.homeDir })).find(
|
|
392
|
+
(record) => record.projectKey === input.projectKey,
|
|
393
|
+
);
|
|
394
|
+
if (discovered === undefined) {
|
|
395
|
+
throw new ProjectRegistrationError(
|
|
396
|
+
"not-found",
|
|
397
|
+
"The project must be discovered by a local Code Agent session before it can be added.",
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const record: RegisteredProjectRecordV1 = {
|
|
402
|
+
schemaVersion: 1,
|
|
403
|
+
kind: "registered-project",
|
|
404
|
+
projectKey: discovered.projectKey,
|
|
405
|
+
displayName: discovered.displayName,
|
|
406
|
+
workspaceRoot: discovered.workspaceRoot,
|
|
407
|
+
workspaceKind: discovered.workspaceKind,
|
|
408
|
+
registeredAt: normalizeTimestamp(input.now),
|
|
409
|
+
lastSeenAt: discovered.lastSeenAt,
|
|
410
|
+
localOnly: true,
|
|
411
|
+
sourceContentStored: false,
|
|
412
|
+
};
|
|
413
|
+
try {
|
|
414
|
+
await writeJsonFile(path, record, { overwrite: false });
|
|
415
|
+
return { record, path, changed: true };
|
|
416
|
+
} catch (error) {
|
|
417
|
+
if (!isFileExistsError(error)) throw error;
|
|
418
|
+
const concurrent = await readRegisteredProject(path);
|
|
419
|
+
if (concurrent !== null) return { record: concurrent, path, changed: false };
|
|
420
|
+
throw new ProjectRegistrationError("conflict", "The project registration already exists.");
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function writeProjectWorkspaceRecord(input: {
|
|
425
|
+
paths: ReturnType<typeof resolveProjectRegistryPaths>;
|
|
426
|
+
workspace: ResolvedProjectWorkspace;
|
|
427
|
+
target: ProjectDiscoveryTarget;
|
|
428
|
+
now: string;
|
|
429
|
+
}): Promise<void> {
|
|
430
|
+
const path = input.paths.workspacePath(input.workspace.workspaceKey);
|
|
431
|
+
const existing = await readProjectWorkspace(path);
|
|
432
|
+
const record: ProjectWorkspaceRecordV1 = {
|
|
433
|
+
schemaVersion: 1,
|
|
434
|
+
kind: "project-workspace",
|
|
435
|
+
workspaceKey: input.workspace.workspaceKey,
|
|
436
|
+
projectKey: input.workspace.projectKey,
|
|
437
|
+
displayName:
|
|
438
|
+
basename(input.workspace.workspaceRoot) || parse(input.workspace.workspaceRoot).root,
|
|
439
|
+
workspaceRoot: input.workspace.workspaceRoot,
|
|
440
|
+
workspaceKind: input.workspace.workspaceKind,
|
|
441
|
+
gitCommonDir: input.workspace.gitCommonDir,
|
|
442
|
+
sourceTargets: mergeTargets(existing?.sourceTargets ?? [], [input.target]),
|
|
443
|
+
firstSeenAt: existing?.firstSeenAt ?? input.now,
|
|
444
|
+
lastSeenAt: input.now,
|
|
445
|
+
localOnly: true,
|
|
446
|
+
sourceContentStored: false,
|
|
447
|
+
};
|
|
448
|
+
await writeJsonFile(path, record);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function writeProjectAliasRecord(input: {
|
|
452
|
+
paths: ReturnType<typeof resolveProjectRegistryPaths>;
|
|
453
|
+
aliasProjectKey: string;
|
|
454
|
+
canonicalProjectKey: string;
|
|
455
|
+
workspaceRoot: string;
|
|
456
|
+
now: string;
|
|
457
|
+
}): Promise<void> {
|
|
458
|
+
const path = input.paths.aliasPath(input.aliasProjectKey);
|
|
459
|
+
const existing = await readProjectAlias(path);
|
|
460
|
+
const record: ProjectAliasRecordV1 = {
|
|
461
|
+
schemaVersion: 1,
|
|
462
|
+
kind: "project-alias",
|
|
463
|
+
aliasProjectKey: input.aliasProjectKey,
|
|
464
|
+
canonicalProjectKey: input.canonicalProjectKey,
|
|
465
|
+
workspaceRoot: input.workspaceRoot,
|
|
466
|
+
createdAt: existing?.createdAt ?? input.now,
|
|
467
|
+
updatedAt: input.now,
|
|
468
|
+
localOnly: true,
|
|
469
|
+
sourceContentStored: false,
|
|
470
|
+
};
|
|
471
|
+
await writeJsonFile(path, record);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function canonicalizeDiscoveredRecord(
|
|
475
|
+
homeDir: string,
|
|
476
|
+
record: DiscoveredProjectRecordV1,
|
|
477
|
+
aliases: ProjectAliasMap,
|
|
478
|
+
): Promise<DiscoveredProjectRecordV1> {
|
|
479
|
+
const workspace = await resolveProjectWorkspaceFromCwd({
|
|
480
|
+
homeDir,
|
|
481
|
+
cwd: record.workspaceRoot,
|
|
482
|
+
}).catch(() => null);
|
|
483
|
+
if (workspace === null) {
|
|
484
|
+
return {
|
|
485
|
+
...record,
|
|
486
|
+
projectKey: canonicalizeProjectKey(record.projectKey, aliases),
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
return {
|
|
490
|
+
...record,
|
|
491
|
+
projectKey: workspace.projectKey,
|
|
492
|
+
displayName: workspace.displayName,
|
|
493
|
+
workspaceRoot: workspace.projectRoot,
|
|
494
|
+
workspaceKind: workspace.workspaceKind,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function canonicalizeRegisteredRecord(
|
|
499
|
+
homeDir: string,
|
|
500
|
+
record: RegisteredProjectRecordV1,
|
|
501
|
+
aliases: ProjectAliasMap,
|
|
502
|
+
): Promise<RegisteredProjectRecordV1> {
|
|
503
|
+
const workspace = await resolveProjectWorkspaceFromCwd({
|
|
504
|
+
homeDir,
|
|
505
|
+
cwd: record.workspaceRoot,
|
|
506
|
+
}).catch(() => null);
|
|
507
|
+
if (workspace === null) {
|
|
508
|
+
return {
|
|
509
|
+
...record,
|
|
510
|
+
projectKey: canonicalizeProjectKey(record.projectKey, aliases),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
return {
|
|
514
|
+
...record,
|
|
515
|
+
projectKey: workspace.projectKey,
|
|
516
|
+
displayName: workspace.displayName,
|
|
517
|
+
workspaceRoot: workspace.projectRoot,
|
|
518
|
+
workspaceKind: workspace.workspaceKind,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function collectAvailableProjectAlias(
|
|
523
|
+
homeDir: string,
|
|
524
|
+
record: Pick<DiscoveredProjectRecordV1, "projectKey" | "workspaceRoot">,
|
|
525
|
+
aliases: ProjectAliasMap,
|
|
526
|
+
): Promise<void> {
|
|
527
|
+
const workspace = await resolveProjectWorkspaceFromCwd({
|
|
528
|
+
homeDir,
|
|
529
|
+
cwd: record.workspaceRoot,
|
|
530
|
+
}).catch(() => null);
|
|
531
|
+
if (workspace === null) return;
|
|
532
|
+
if (record.projectKey !== workspace.projectKey) {
|
|
533
|
+
aliases[record.projectKey] = workspace.projectKey;
|
|
534
|
+
}
|
|
535
|
+
if (workspace.workspaceKey !== workspace.projectKey) {
|
|
536
|
+
aliases[workspace.workspaceKey] = workspace.projectKey;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function normalizeProjectAliases(aliases: ProjectAliasMap): ProjectAliasMap {
|
|
541
|
+
return Object.fromEntries(
|
|
542
|
+
Object.keys(aliases)
|
|
543
|
+
.sort()
|
|
544
|
+
.flatMap((alias) => {
|
|
545
|
+
const canonical = canonicalizeProjectKey(alias, aliases);
|
|
546
|
+
return canonical === alias ? [] : [[alias, canonical]];
|
|
547
|
+
}),
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function summarizeProjectWorkspace(
|
|
552
|
+
homeDir: string,
|
|
553
|
+
record: ProjectWorkspaceRecordV1,
|
|
554
|
+
aliases: ProjectAliasMap,
|
|
555
|
+
): Promise<ProjectWorkspaceSummary> {
|
|
556
|
+
const workspace = await resolveProjectWorkspaceFromCwd({
|
|
557
|
+
homeDir,
|
|
558
|
+
cwd: record.workspaceRoot,
|
|
559
|
+
}).catch(() => null);
|
|
560
|
+
if (workspace === null) {
|
|
561
|
+
return {
|
|
562
|
+
...record,
|
|
563
|
+
projectKey: canonicalizeProjectKey(record.projectKey, aliases),
|
|
564
|
+
available: false,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
return {
|
|
568
|
+
...record,
|
|
569
|
+
workspaceKey: workspace.workspaceKey,
|
|
570
|
+
projectKey: workspace.projectKey,
|
|
571
|
+
displayName: basename(workspace.workspaceRoot) || parse(workspace.workspaceRoot).root,
|
|
572
|
+
workspaceRoot: workspace.workspaceRoot,
|
|
573
|
+
workspaceKind: workspace.workspaceKind,
|
|
574
|
+
gitCommonDir: workspace.gitCommonDir,
|
|
575
|
+
available: true,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async function summarizeLegacyWorkspace(
|
|
580
|
+
homeDir: string,
|
|
581
|
+
record: DiscoveredProjectRecordV1 | RegisteredProjectRecordV1,
|
|
582
|
+
aliases: ProjectAliasMap,
|
|
583
|
+
): Promise<ProjectWorkspaceSummary> {
|
|
584
|
+
const workspace = await resolveProjectWorkspaceFromCwd({
|
|
585
|
+
homeDir,
|
|
586
|
+
cwd: record.workspaceRoot,
|
|
587
|
+
}).catch(() => null);
|
|
588
|
+
const sourceTargets = record.kind === "discovered-project" ? record.sourceTargets : [];
|
|
589
|
+
const firstSeenAt =
|
|
590
|
+
record.kind === "discovered-project" ? record.firstSeenAt : record.registeredAt;
|
|
591
|
+
if (workspace === null) {
|
|
592
|
+
return {
|
|
593
|
+
schemaVersion: 1,
|
|
594
|
+
kind: "project-workspace",
|
|
595
|
+
workspaceKey: resolvePathProjectLogKey(homeDir, record.workspaceRoot),
|
|
596
|
+
projectKey: canonicalizeProjectKey(record.projectKey, aliases),
|
|
597
|
+
displayName: record.displayName,
|
|
598
|
+
workspaceRoot: record.workspaceRoot,
|
|
599
|
+
workspaceKind: record.workspaceKind,
|
|
600
|
+
gitCommonDir: null,
|
|
601
|
+
sourceTargets,
|
|
602
|
+
firstSeenAt,
|
|
603
|
+
lastSeenAt: record.lastSeenAt,
|
|
604
|
+
localOnly: true,
|
|
605
|
+
sourceContentStored: false,
|
|
606
|
+
available: false,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
return {
|
|
610
|
+
schemaVersion: 1,
|
|
611
|
+
kind: "project-workspace",
|
|
612
|
+
workspaceKey: workspace.workspaceKey,
|
|
613
|
+
projectKey: workspace.projectKey,
|
|
614
|
+
displayName: basename(workspace.workspaceRoot) || parse(workspace.workspaceRoot).root,
|
|
615
|
+
workspaceRoot: workspace.workspaceRoot,
|
|
616
|
+
workspaceKind: workspace.workspaceKind,
|
|
617
|
+
gitCommonDir: workspace.gitCommonDir,
|
|
618
|
+
sourceTargets,
|
|
619
|
+
firstSeenAt,
|
|
620
|
+
lastSeenAt: record.lastSeenAt,
|
|
621
|
+
localOnly: true,
|
|
622
|
+
sourceContentStored: false,
|
|
623
|
+
available: true,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function mergeProjectWorkspaceSummary(
|
|
628
|
+
records: Map<string, ProjectWorkspaceSummary>,
|
|
629
|
+
next: ProjectWorkspaceSummary,
|
|
630
|
+
): void {
|
|
631
|
+
const previous = records.get(next.workspaceKey);
|
|
632
|
+
if (previous === undefined) {
|
|
633
|
+
records.set(next.workspaceKey, next);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const newer = next.lastSeenAt >= previous.lastSeenAt ? next : previous;
|
|
637
|
+
records.set(next.workspaceKey, {
|
|
638
|
+
...newer,
|
|
639
|
+
projectKey: next.projectKey,
|
|
640
|
+
available: previous.available || next.available,
|
|
641
|
+
sourceTargets: mergeTargets(previous.sourceTargets, next.sourceTargets),
|
|
642
|
+
firstSeenAt: previous.firstSeenAt <= next.firstSeenAt ? previous.firstSeenAt : next.firstSeenAt,
|
|
643
|
+
lastSeenAt: previous.lastSeenAt >= next.lastSeenAt ? previous.lastSeenAt : next.lastSeenAt,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async function findNearestGitRoot(cwd: string): Promise<string | null> {
|
|
648
|
+
let current = cwd;
|
|
649
|
+
for (;;) {
|
|
650
|
+
try {
|
|
651
|
+
const marker = await lstat(join(current, ".git"));
|
|
652
|
+
if (marker.isDirectory() || marker.isFile()) return current;
|
|
653
|
+
} catch (error) {
|
|
654
|
+
if (!isNotFoundError(error)) throw error;
|
|
655
|
+
}
|
|
656
|
+
const parent = dirname(current);
|
|
657
|
+
if (parent === current) return null;
|
|
658
|
+
current = parent;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async function readDiscoveredProject(path: string): Promise<DiscoveredProjectRecordV1 | null> {
|
|
663
|
+
try {
|
|
664
|
+
return parseDiscoveredProject(JSON.parse(await readFile(path, "utf8")) as unknown);
|
|
665
|
+
} catch (error) {
|
|
666
|
+
if (isNotFoundError(error) || error instanceof ProjectRegistrationError) return null;
|
|
667
|
+
throw error;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
async function readRegisteredProject(path: string): Promise<RegisteredProjectRecordV1 | null> {
|
|
672
|
+
try {
|
|
673
|
+
return parseRegisteredProject(JSON.parse(await readFile(path, "utf8")) as unknown);
|
|
674
|
+
} catch (error) {
|
|
675
|
+
if (isNotFoundError(error) || error instanceof ProjectRegistrationError) return null;
|
|
676
|
+
throw error;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function readProjectWorkspace(path: string): Promise<ProjectWorkspaceRecordV1 | null> {
|
|
681
|
+
try {
|
|
682
|
+
return parseProjectWorkspace(JSON.parse(await readFile(path, "utf8")) as unknown);
|
|
683
|
+
} catch (error) {
|
|
684
|
+
if (isNotFoundError(error) || error instanceof ProjectRegistrationError) return null;
|
|
685
|
+
throw error;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function readProjectAlias(path: string): Promise<ProjectAliasRecordV1 | null> {
|
|
690
|
+
try {
|
|
691
|
+
return parseProjectAlias(JSON.parse(await readFile(path, "utf8")) as unknown);
|
|
692
|
+
} catch (error) {
|
|
693
|
+
if (isNotFoundError(error) || error instanceof ProjectRegistrationError) return null;
|
|
694
|
+
throw error;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function parseDiscoveredProject(value: unknown): DiscoveredProjectRecordV1 {
|
|
699
|
+
const record = requireRecord(value);
|
|
700
|
+
if (
|
|
701
|
+
record.schemaVersion !== 1 ||
|
|
702
|
+
record.kind !== "discovered-project" ||
|
|
703
|
+
!isProjectKey(record.projectKey) ||
|
|
704
|
+
!isNonEmptyString(record.displayName) ||
|
|
705
|
+
!isAbsoluteString(record.workspaceRoot) ||
|
|
706
|
+
!isAbsoluteString(record.lastCwd) ||
|
|
707
|
+
!isWorkspaceKind(record.workspaceKind) ||
|
|
708
|
+
!isDiscoveryTargetArray(record.sourceTargets) ||
|
|
709
|
+
!(record.lastSessionKey === null || isNonEmptyString(record.lastSessionKey)) ||
|
|
710
|
+
!isNonEmptyString(record.firstSeenAt) ||
|
|
711
|
+
!isNonEmptyString(record.lastSeenAt) ||
|
|
712
|
+
record.localOnly !== true ||
|
|
713
|
+
record.sourceContentStored !== false
|
|
714
|
+
) {
|
|
715
|
+
throw new ProjectRegistrationError("invalid", "Discovered project record is invalid.");
|
|
716
|
+
}
|
|
717
|
+
return record as unknown as DiscoveredProjectRecordV1;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function parseRegisteredProject(value: unknown): RegisteredProjectRecordV1 {
|
|
721
|
+
const record = requireRecord(value);
|
|
722
|
+
if (
|
|
723
|
+
record.schemaVersion !== 1 ||
|
|
724
|
+
record.kind !== "registered-project" ||
|
|
725
|
+
!isProjectKey(record.projectKey) ||
|
|
726
|
+
!isNonEmptyString(record.displayName) ||
|
|
727
|
+
!isAbsoluteString(record.workspaceRoot) ||
|
|
728
|
+
!isWorkspaceKind(record.workspaceKind) ||
|
|
729
|
+
!isNonEmptyString(record.registeredAt) ||
|
|
730
|
+
!isNonEmptyString(record.lastSeenAt) ||
|
|
731
|
+
record.localOnly !== true ||
|
|
732
|
+
record.sourceContentStored !== false
|
|
733
|
+
) {
|
|
734
|
+
throw new ProjectRegistrationError("invalid", "Registered project record is invalid.");
|
|
735
|
+
}
|
|
736
|
+
return record as unknown as RegisteredProjectRecordV1;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function parseProjectWorkspace(value: unknown): ProjectWorkspaceRecordV1 {
|
|
740
|
+
const record = requireRecord(value);
|
|
741
|
+
if (
|
|
742
|
+
record.schemaVersion !== 1 ||
|
|
743
|
+
record.kind !== "project-workspace" ||
|
|
744
|
+
!isProjectKey(record.workspaceKey) ||
|
|
745
|
+
!isProjectKey(record.projectKey) ||
|
|
746
|
+
!isNonEmptyString(record.displayName) ||
|
|
747
|
+
!isAbsoluteString(record.workspaceRoot) ||
|
|
748
|
+
!isWorkspaceKind(record.workspaceKind) ||
|
|
749
|
+
!(record.gitCommonDir === null || isAbsoluteString(record.gitCommonDir)) ||
|
|
750
|
+
!isDiscoveryTargetArray(record.sourceTargets) ||
|
|
751
|
+
!isNonEmptyString(record.firstSeenAt) ||
|
|
752
|
+
!isNonEmptyString(record.lastSeenAt) ||
|
|
753
|
+
record.localOnly !== true ||
|
|
754
|
+
record.sourceContentStored !== false
|
|
755
|
+
) {
|
|
756
|
+
throw new ProjectRegistrationError("invalid", "Project workspace record is invalid.");
|
|
757
|
+
}
|
|
758
|
+
return record as unknown as ProjectWorkspaceRecordV1;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function parseProjectAlias(value: unknown): ProjectAliasRecordV1 {
|
|
762
|
+
const record = requireRecord(value);
|
|
763
|
+
if (
|
|
764
|
+
record.schemaVersion !== 1 ||
|
|
765
|
+
record.kind !== "project-alias" ||
|
|
766
|
+
!isProjectKey(record.aliasProjectKey) ||
|
|
767
|
+
!isProjectKey(record.canonicalProjectKey) ||
|
|
768
|
+
record.aliasProjectKey === record.canonicalProjectKey ||
|
|
769
|
+
!isAbsoluteString(record.workspaceRoot) ||
|
|
770
|
+
!isNonEmptyString(record.createdAt) ||
|
|
771
|
+
!isNonEmptyString(record.updatedAt) ||
|
|
772
|
+
record.localOnly !== true ||
|
|
773
|
+
record.sourceContentStored !== false
|
|
774
|
+
) {
|
|
775
|
+
throw new ProjectRegistrationError("invalid", "Project alias record is invalid.");
|
|
776
|
+
}
|
|
777
|
+
return record as unknown as ProjectAliasRecordV1;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function mergeDiscoveredRecord(
|
|
781
|
+
records: Map<string, DiscoveredProjectRecordV1>,
|
|
782
|
+
next: DiscoveredProjectRecordV1,
|
|
783
|
+
): void {
|
|
784
|
+
const previous = records.get(next.projectKey);
|
|
785
|
+
if (previous === undefined) {
|
|
786
|
+
records.set(next.projectKey, next);
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
const newer = next.lastSeenAt >= previous.lastSeenAt ? next : previous;
|
|
790
|
+
records.set(next.projectKey, {
|
|
791
|
+
...newer,
|
|
792
|
+
sourceTargets: mergeTargets(previous.sourceTargets, next.sourceTargets),
|
|
793
|
+
firstSeenAt: previous.firstSeenAt <= next.firstSeenAt ? previous.firstSeenAt : next.firstSeenAt,
|
|
794
|
+
lastSeenAt: previous.lastSeenAt >= next.lastSeenAt ? previous.lastSeenAt : next.lastSeenAt,
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function mergeRegisteredRecord(
|
|
799
|
+
records: Map<string, RegisteredProjectRecordV1>,
|
|
800
|
+
next: RegisteredProjectRecordV1,
|
|
801
|
+
): void {
|
|
802
|
+
const previous = records.get(next.projectKey);
|
|
803
|
+
if (previous === undefined) {
|
|
804
|
+
records.set(next.projectKey, next);
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
const newer = next.lastSeenAt >= previous.lastSeenAt ? next : previous;
|
|
808
|
+
records.set(next.projectKey, {
|
|
809
|
+
...newer,
|
|
810
|
+
registeredAt:
|
|
811
|
+
previous.registeredAt <= next.registeredAt ? previous.registeredAt : next.registeredAt,
|
|
812
|
+
lastSeenAt: previous.lastSeenAt >= next.lastSeenAt ? previous.lastSeenAt : next.lastSeenAt,
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function mergeTargets(
|
|
817
|
+
left: ProjectDiscoveryTarget[],
|
|
818
|
+
right: ProjectDiscoveryTarget[],
|
|
819
|
+
): ProjectDiscoveryTarget[] {
|
|
820
|
+
return [...new Set([...left, ...right])].sort();
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function normalizeSessionKey(value: string | null | undefined): string | null {
|
|
824
|
+
if (value === null || value === undefined) return null;
|
|
825
|
+
return /^session-[a-f0-9]{16}$/u.test(value) ? value : `session-${sha256Short(value)}`;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
interface LegacyHookBinding {
|
|
829
|
+
target: ProjectDiscoveryTarget;
|
|
830
|
+
sessionKey: string;
|
|
831
|
+
cwd: string | null;
|
|
832
|
+
updatedAt: string;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
async function listLegacyHookBindings(homeDir: string): Promise<LegacyHookBinding[]> {
|
|
836
|
+
const roots = [
|
|
837
|
+
join(homeDir, ".evodev", "state", "hooks", "sessions"),
|
|
838
|
+
join(homeDir, ".evodev", "STATE", "hooks", "sessions"),
|
|
839
|
+
];
|
|
840
|
+
const bindings: LegacyHookBinding[] = [];
|
|
841
|
+
for (const root of roots) {
|
|
842
|
+
for (const sessionDir of await listDirectoryNames(root)) {
|
|
843
|
+
try {
|
|
844
|
+
const value = JSON.parse(
|
|
845
|
+
await readFile(join(root, sessionDir, "binding.json"), "utf8"),
|
|
846
|
+
) as Record<string, unknown> | null;
|
|
847
|
+
if (
|
|
848
|
+
value === null ||
|
|
849
|
+
(value.target !== "claude" && value.target !== "codex") ||
|
|
850
|
+
!isNonEmptyString(value.sessionKey) ||
|
|
851
|
+
!(value.cwd === null || isAbsoluteString(value.cwd)) ||
|
|
852
|
+
!isNonEmptyString(value.updatedAt)
|
|
853
|
+
) {
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
bindings.push({
|
|
857
|
+
target: value.target,
|
|
858
|
+
sessionKey: value.sessionKey,
|
|
859
|
+
cwd: value.cwd,
|
|
860
|
+
updatedAt: value.updatedAt,
|
|
861
|
+
});
|
|
862
|
+
} catch (error) {
|
|
863
|
+
if (!isNotFoundError(error) && !(error instanceof SyntaxError)) throw error;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
return bindings;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
async function listJsonFilePaths(path: string): Promise<string[]> {
|
|
871
|
+
try {
|
|
872
|
+
return (await readdir(path, { withFileTypes: true }))
|
|
873
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
874
|
+
.map((entry) => join(path, entry.name))
|
|
875
|
+
.sort();
|
|
876
|
+
} catch (error) {
|
|
877
|
+
if (isNotFoundError(error)) return [];
|
|
878
|
+
throw error;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
async function listDirectoryNames(path: string): Promise<string[]> {
|
|
883
|
+
try {
|
|
884
|
+
return (await readdir(path, { withFileTypes: true }))
|
|
885
|
+
.filter((entry) => entry.isDirectory())
|
|
886
|
+
.map((entry) => entry.name)
|
|
887
|
+
.sort();
|
|
888
|
+
} catch (error) {
|
|
889
|
+
if (isNotFoundError(error)) return [];
|
|
890
|
+
throw error;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function assertProjectKey(value: string): void {
|
|
895
|
+
if (!isProjectKey(value)) {
|
|
896
|
+
throw new ProjectRegistrationError("invalid", "projectKey is invalid.");
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function isProjectKey(value: unknown): value is string {
|
|
901
|
+
return (
|
|
902
|
+
typeof value === "string" &&
|
|
903
|
+
value !== "." &&
|
|
904
|
+
value !== ".." &&
|
|
905
|
+
/^[A-Za-z0-9._-]{1,120}$/.test(value)
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
910
|
+
return typeof value === "string" && value.trim() !== "";
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function isAbsoluteString(value: unknown): value is string {
|
|
914
|
+
return isNonEmptyString(value) && isAbsolute(value);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function isWorkspaceKind(value: unknown): value is ProjectWorkspaceKind {
|
|
918
|
+
return value === "git" || value === "directory";
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function isDiscoveryTargetArray(value: unknown): value is ProjectDiscoveryTarget[] {
|
|
922
|
+
return (
|
|
923
|
+
Array.isArray(value) &&
|
|
924
|
+
value.length > 0 &&
|
|
925
|
+
value.every((item) => item === "claude" || item === "codex")
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function requireRecord(value: unknown): Record<string, unknown> {
|
|
930
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
931
|
+
throw new ProjectRegistrationError("invalid", "Project record must be an object.");
|
|
932
|
+
}
|
|
933
|
+
return value as Record<string, unknown>;
|
|
934
|
+
}
|