@evo-dev/core 0.0.1-alpha.3 → 0.0.1-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/config/index.js +164 -125
  2. package/dist/index.js +8965 -7632
  3. package/package.json +1 -1
  4. package/src/agents/index.ts +1 -1
  5. package/src/code-agent-traces/index.ts +3 -10
  6. package/src/config/settings.ts +14 -0
  7. package/src/config/store.ts +1 -1
  8. package/src/daemon/index.ts +1 -1
  9. package/src/evolution/candidates/index.ts +505 -0
  10. package/src/evolution/control/index.ts +19 -0
  11. package/src/evolution/evidence/analysis.ts +529 -0
  12. package/src/evolution/evidence/index.ts +3 -0
  13. package/src/evolution/evidence/session-memory/constants.ts +12 -0
  14. package/src/evolution/evidence/session-memory/index.ts +5 -0
  15. package/src/evolution/evidence/session-memory/paths.ts +29 -0
  16. package/src/evolution/evidence/session-memory/policy.ts +39 -0
  17. package/src/evolution/evidence/session-memory/segment.ts +192 -0
  18. package/src/evolution/evidence/session-memory/state-machine.ts +249 -0
  19. package/src/evolution/evidence/session-memory/storage.ts +266 -0
  20. package/src/evolution/evidence/session-memory/types.ts +207 -0
  21. package/src/evolution/evidence/session-memory/updater.ts +184 -0
  22. package/src/evolution/formatters.ts +169 -0
  23. package/src/evolution/index.ts +16 -2827
  24. package/src/{knowledge → evolution/knowledge}/index.ts +16 -81
  25. package/src/evolution/paths.ts +44 -0
  26. package/src/evolution/processor/distillation.ts +445 -0
  27. package/src/evolution/processor/index.ts +3 -0
  28. package/src/evolution/processor/process.ts +235 -0
  29. package/src/evolution/schema.ts +544 -0
  30. package/src/evolution/shared.ts +737 -0
  31. package/src/evolution/triggers/classification.ts +102 -0
  32. package/src/evolution/triggers/index.ts +295 -0
  33. package/src/hooks/index.ts +54 -7
  34. package/src/index.ts +10 -2
  35. package/src/runtime-logs/index.ts +4 -12
  36. package/src/team/index.ts +1 -1
  37. package/src/utils/errors.ts +13 -0
  38. package/src/utils/fs.ts +40 -0
  39. package/src/utils/hash.ts +9 -0
  40. package/src/utils/ids.ts +12 -0
  41. package/src/utils/index.ts +7 -0
  42. package/src/utils/parsing.ts +11 -0
  43. package/src/utils/text.ts +18 -0
  44. package/src/utils/time.ts +5 -0
  45. /package/src/{learning → evolution/review}/index.ts +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evo-dev/core",
3
- "version": "0.0.1-alpha.3",
3
+ "version": "0.0.1-alpha.5",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -3,7 +3,7 @@ import {
3
3
  type ScopedKnowledgeContextPack,
4
4
  createScopedKnowledgeContextPack,
5
5
  formatScopedKnowledgePromptBlock,
6
- } from "../knowledge/index.ts";
6
+ } from "../evolution/knowledge/index.ts";
7
7
  import type { TaskContract, TaskExecutionMode } from "../task/index.ts";
8
8
 
9
9
  export type AgentPersistence = "named" | "dynamic";
@@ -1,8 +1,8 @@
1
- import { createHash } from "node:crypto";
2
1
  import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
3
2
  import { dirname, isAbsolute, join, normalize, relative } from "node:path";
4
3
  import { resolveEvoDevPaths } from "../config/paths.ts";
5
4
  import { resolveTraceSessionKey, resolveTraceTeamContext } from "../runtime-logs/index.ts";
5
+ import { normalizeTimestamp, sha256Short } from "../utils/index.ts";
6
6
 
7
7
  export type CodeAgentTraceTarget = "claude" | "codex";
8
8
  export type CodeAgentTraceRefSource =
@@ -440,7 +440,7 @@ function hasRawTraversalSegment(path: string): boolean {
440
440
 
441
441
  function hashOptionalIdentifier(value: string | null): string | null {
442
442
  if (value === null || value.trim() === "") return null;
443
- return `sha256-${createHash("sha256").update(value).digest("hex").slice(0, 16)}`;
443
+ return `sha256-${sha256Short(value)}`;
444
444
  }
445
445
 
446
446
  function sanitizeHashMetadata(value: string | null): string | null {
@@ -454,8 +454,7 @@ function sanitizePersistentIdentifier(value: string, prefix: string): string {
454
454
  if (!SENSITIVE_IDENTIFIER_PATTERN.test(value) && !SENSITIVE_IDENTIFIER_PATTERN.test(pathSafe)) {
455
455
  return pathSafe;
456
456
  }
457
- const hash = createHash("sha256").update(value).digest("hex").slice(0, 16);
458
- return `${prefix}-${hash}`;
457
+ return `${prefix}-${sha256Short(value)}`;
459
458
  }
460
459
 
461
460
  function sanitizeNotes(notes: string[]): string[] {
@@ -471,12 +470,6 @@ function sanitizeNote(note: string): string {
471
470
  return truncated;
472
471
  }
473
472
 
474
- function normalizeTimestamp(value?: Date | string): string {
475
- if (value instanceof Date) return value.toISOString();
476
- if (typeof value === "string" && value.trim() !== "") return new Date(value).toISOString();
477
- return new Date().toISOString();
478
- }
479
-
480
473
  function optionalString(value: unknown): string | null {
481
474
  return typeof value === "string" && value.trim() !== "" ? value : null;
482
475
  }
@@ -1,4 +1,9 @@
1
1
  import { readFile } from "node:fs/promises";
2
+ import {
3
+ type SessionMemoryPolicySnapshot,
4
+ createDefaultSessionMemoryPolicy,
5
+ parseSessionMemoryPolicy,
6
+ } from "../evolution/evidence/session-memory/index.ts";
2
7
  import { type HookSettings, createDefaultHookSettings, parseHookSettings } from "../hooks/index.ts";
3
8
  import { EvoDevConfigError, describeType } from "./errors.ts";
4
9
  import { resolveEvoDevPaths } from "./paths.ts";
@@ -14,6 +19,7 @@ export interface MemorySettings {
14
19
  runtimeInjection: boolean;
15
20
  staleReview: boolean;
16
21
  lexicalIndex: boolean;
22
+ sessionMemory: SessionMemoryPolicySnapshot;
17
23
  }
18
24
 
19
25
  export interface EvoDevSettings {
@@ -115,6 +121,7 @@ export function createDefaultMemorySettings(): MemorySettings {
115
121
  runtimeInjection: true,
116
122
  staleReview: true,
117
123
  lexicalIndex: true,
124
+ sessionMemory: createDefaultSessionMemoryPolicy(),
118
125
  };
119
126
  }
120
127
 
@@ -247,6 +254,9 @@ function parseMemorySettings(value: unknown, path: string): MemorySettings {
247
254
  input.lexicalIndex === undefined
248
255
  ? defaults.lexicalIndex
249
256
  : expectBoolean(input.lexicalIndex, `${path}.lexicalIndex`),
257
+ sessionMemory: parseSessionMemoryPolicy(
258
+ isPlainRecord(input.sessionMemory) ? input.sessionMemory : defaults.sessionMemory,
259
+ ),
250
260
  };
251
261
  }
252
262
 
@@ -311,6 +321,10 @@ function expectRecord(value: unknown, path: string): Record<string, unknown> {
311
321
  return value as Record<string, unknown>;
312
322
  }
313
323
 
324
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
325
+ return typeof value === "object" && value !== null && !Array.isArray(value);
326
+ }
327
+
314
328
  function isNotFoundError(error: unknown): boolean {
315
329
  return (
316
330
  error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT"
@@ -1,6 +1,6 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { dirname } from "node:path";
3
- import { ensureOkfKnowledgeBase } from "../knowledge/index.ts";
3
+ import { ensureOkfKnowledgeBase } from "../evolution/knowledge/index.ts";
4
4
  import { EvoDevConfigError } from "./errors.ts";
5
5
  import { type EvoDevPaths, resolveEvoDevPaths } from "./paths.ts";
6
6
  import { type EvoDevRegistry, createDefaultRegistry, parseRegistry } from "./registry.ts";
@@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto";
2
2
  import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { type IncomingMessage, createServer } from "node:http";
4
4
  import { dirname, join } from "node:path";
5
- import { listEvolutionTriggers, processEvolutionTriggers } from "../evolution/index.ts";
5
+ import { listEvolutionTriggers, processEvolutionTriggers } from "../evolution/control/index.ts";
6
6
  import { listObservabilityEvents } from "../observability/index.ts";
7
7
  import {
8
8
  type TeamRuntimeAdapter,
@@ -0,0 +1,505 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { resolveEvoDevPaths } from "../../config/paths.ts";
5
+ import { pathExists, readJsonFiles, writeJsonFile as writeJson } from "../../utils/index.ts";
6
+ import { resolveEvolutionPaths } from "../paths.ts";
7
+ import type {
8
+ EvolutionEvosCase,
9
+ EvolutionEvosCaseQueryResult,
10
+ EvolutionKnowledgeRecord,
11
+ EvolutionKnowledgeReviewHistoryRecord,
12
+ EvolutionRepoProposal,
13
+ EvolutionReviewCandidate,
14
+ EvolutionReviewSnapshot,
15
+ EvolutionReviewState,
16
+ EvolutionTriggerRecord,
17
+ } from "../schema.ts";
18
+ import {
19
+ REVIEW_STATES,
20
+ hasConcreteRepoProposalChanges,
21
+ listDirectoryNames,
22
+ parseEvosCase,
23
+ parseKnowledgeRecord,
24
+ parseRepoProposal,
25
+ parseReviewCandidate,
26
+ parseTrigger,
27
+ sanitizeId,
28
+ sanitizeStorageId,
29
+ sanitizeText,
30
+ uniqueSorted,
31
+ validateEvolutionKnowledgeRecord,
32
+ validateEvolutionRepoProposal,
33
+ } from "../shared.ts";
34
+
35
+ const REVIEW_LOCK_WAIT_MS = 2_000;
36
+ const REVIEW_LOCK_RETRY_MS = 10;
37
+
38
+ export async function withEvolutionReviewDecisionLock<T>(
39
+ input: {
40
+ homeDir: string;
41
+ kind: "learning" | "knowledge" | "repo-proposal";
42
+ itemId: string;
43
+ },
44
+ operation: () => Promise<T>,
45
+ ): Promise<T> {
46
+ const paths = resolveEvoDevPaths(input.homeDir);
47
+ const key = createHash("sha256").update(`${input.kind}\0${input.itemId}`).digest("hex");
48
+ const lockPath = join(paths.stateDir, "evolution", ".review-locks", `${key}.lock`);
49
+ const deadline = Date.now() + REVIEW_LOCK_WAIT_MS;
50
+ await mkdir(dirname(lockPath), { recursive: true });
51
+
52
+ while (true) {
53
+ try {
54
+ await writeFile(
55
+ lockPath,
56
+ `${JSON.stringify({ version: 1, kind: input.kind, acquiredAt: new Date().toISOString(), pid: process.pid })}\n`,
57
+ { encoding: "utf8", flag: "wx" },
58
+ );
59
+ break;
60
+ } catch (error) {
61
+ if (!isAlreadyExistsError(error)) throw error;
62
+ if (Date.now() >= deadline) throw new Error("Review decision lock is busy or stale.");
63
+ await sleep(REVIEW_LOCK_RETRY_MS);
64
+ }
65
+ }
66
+
67
+ try {
68
+ return await operation();
69
+ } finally {
70
+ await rm(lockPath, { force: true });
71
+ }
72
+ }
73
+
74
+ export async function readEvolutionReviewSnapshot(input: {
75
+ homeDir: string;
76
+ projectKey?: string;
77
+ }): Promise<EvolutionReviewSnapshot> {
78
+ const paths = resolveEvoDevPaths(input.homeDir);
79
+ const projectKeys =
80
+ input.projectKey === undefined
81
+ ? await listEvolutionReviewProjectKeys(paths)
82
+ : [sanitizeStorageId("projectKey", input.projectKey)];
83
+ const knowledgeRecords: EvolutionKnowledgeRecord[] = [];
84
+ const evosCases: EvolutionEvosCase[] = [];
85
+ const repoProposals: EvolutionRepoProposal[] = [];
86
+ const reviewCandidates: EvolutionReviewCandidate[] = [];
87
+ const triggers: EvolutionTriggerRecord[] = [];
88
+
89
+ for (const projectKey of projectKeys) {
90
+ const resolved = resolveEvolutionPaths({
91
+ homeDir: input.homeDir,
92
+ projectKey,
93
+ runId: "review",
94
+ });
95
+ knowledgeRecords.push(
96
+ ...(await readJsonFiles(resolved.knowledgeRecordsDir, parseKnowledgeRecord)),
97
+ );
98
+ evosCases.push(...(await readJsonFiles(resolved.evosCasesProjectDir, parseEvosCase)));
99
+ }
100
+
101
+ const evolutionStateDir = join(paths.stateDir, "evolution");
102
+ const stateProjectKeys =
103
+ input.projectKey === undefined
104
+ ? await listDirectoryNames(evolutionStateDir)
105
+ : [sanitizeStorageId("projectKey", input.projectKey)];
106
+ for (const projectKey of stateProjectKeys) {
107
+ const projectStateDir = join(evolutionStateDir, projectKey);
108
+ const runIds = await listDirectoryNames(projectStateDir);
109
+ for (const runId of runIds) {
110
+ const proposalsDir = join(projectStateDir, runId, "proposals");
111
+ const reviewCandidatesDir = join(projectStateDir, runId, "review-candidates");
112
+ repoProposals.push(...(await readJsonFiles(proposalsDir, parseRepoProposal)));
113
+ reviewCandidates.push(...(await readJsonFiles(reviewCandidatesDir, parseReviewCandidate)));
114
+ triggers.push(
115
+ ...(await readJsonFiles(join(projectStateDir, runId, "triggers"), parseTrigger)),
116
+ );
117
+ }
118
+ }
119
+
120
+ return {
121
+ projectKey:
122
+ input.projectKey === undefined ? null : sanitizeStorageId("projectKey", input.projectKey),
123
+ knowledgeRecords,
124
+ evosCases,
125
+ repoProposals,
126
+ reviewCandidates,
127
+ triggers,
128
+ };
129
+ }
130
+
131
+ async function listEvolutionReviewProjectKeys(
132
+ paths: ReturnType<typeof resolveEvoDevPaths>,
133
+ ): Promise<string[]> {
134
+ const legacyKnowledgeProjectKeys = (
135
+ await Promise.all(
136
+ (
137
+ await listDirectoryNames(paths.knowledgeDir)
138
+ ).map(async (projectKey) =>
139
+ (await pathExists(join(paths.knowledgeDir, projectKey, "records"))) ? projectKey : null,
140
+ ),
141
+ )
142
+ ).filter((projectKey): projectKey is string => projectKey !== null);
143
+ return uniqueSorted([
144
+ ...legacyKnowledgeProjectKeys,
145
+ ...(await listDirectoryNames(paths.evosCasesDir)),
146
+ ]);
147
+ }
148
+
149
+ export async function listEvolutionKnowledgeRecords(input: {
150
+ homeDir: string;
151
+ projectKey?: string;
152
+ }): Promise<EvolutionKnowledgeRecord[]> {
153
+ return (
154
+ await readEvolutionReviewSnapshot({
155
+ homeDir: input.homeDir,
156
+ projectKey: input.projectKey,
157
+ })
158
+ ).knowledgeRecords.sort((left, right) => left.id.localeCompare(right.id));
159
+ }
160
+
161
+ export async function listEvolutionKnowledgeReviewHistory(input: {
162
+ homeDir: string;
163
+ projectKey?: string;
164
+ }): Promise<EvolutionKnowledgeReviewHistoryRecord[]> {
165
+ const paths = resolveEvoDevPaths(input.homeDir);
166
+ const projectKeys =
167
+ input.projectKey === undefined
168
+ ? await listDirectoryNames(join(paths.stateDir, "evolution"))
169
+ : [sanitizeStorageId("projectKey", input.projectKey)];
170
+ const records: EvolutionKnowledgeReviewHistoryRecord[] = [];
171
+
172
+ for (const projectKey of projectKeys) {
173
+ const historyPath = resolveEvolutionPaths({
174
+ homeDir: input.homeDir,
175
+ projectKey,
176
+ runId: "review",
177
+ }).knowledgeReviewHistoryPath;
178
+ if (!(await pathExists(historyPath))) continue;
179
+ for (const line of (await readFile(historyPath, "utf8")).split("\n")) {
180
+ if (line.trim() === "") continue;
181
+ records.push(parseKnowledgeReviewHistoryRecord(JSON.parse(line) as unknown));
182
+ }
183
+ }
184
+
185
+ return records.sort((left, right) => {
186
+ const changedAt = left.changedAt.localeCompare(right.changedAt);
187
+ if (changedAt !== 0) return changedAt;
188
+ return left.knowledgeId.localeCompare(right.knowledgeId);
189
+ });
190
+ }
191
+
192
+ export async function listEvolutionEvosCases(input: {
193
+ homeDir: string;
194
+ projectKey?: string;
195
+ roleId?: string;
196
+ reviewStates?: EvolutionReviewState[];
197
+ }): Promise<EvolutionEvosCaseQueryResult> {
198
+ const paths = resolveEvoDevPaths(input.homeDir);
199
+ const projectKeys =
200
+ input.projectKey === undefined
201
+ ? await listDirectoryNames(paths.evosCasesDir)
202
+ : [sanitizeStorageId("projectKey", input.projectKey)];
203
+ const roleId = input.roleId === undefined ? null : sanitizeId(input.roleId);
204
+ const reviewStates = input.reviewStates ?? ["accepted", "auto-accepted"];
205
+ const cases: EvolutionEvosCase[] = [];
206
+ const warnings: string[] = [];
207
+
208
+ for (const projectKey of projectKeys) {
209
+ const projectDir = join(paths.evosCasesDir, projectKey);
210
+ if (!(await pathExists(projectDir))) continue;
211
+ const entries = await readdir(projectDir, { withFileTypes: true });
212
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
213
+ if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
214
+ const sourceLink = `cases/${projectKey}/${entry.name}`;
215
+ try {
216
+ const value = JSON.parse(await readFile(join(projectDir, entry.name), "utf8")) as unknown;
217
+ const evosCase = parseEvosCase(value);
218
+ if (!reviewStates.includes(evosCase.reviewState)) continue;
219
+ if (
220
+ roleId !== null &&
221
+ evosCase.roleTags.length > 0 &&
222
+ !evosCase.roleTags.includes(roleId)
223
+ ) {
224
+ continue;
225
+ }
226
+ cases.push(evosCase);
227
+ } catch {
228
+ warnings.push(
229
+ `Omitted unsafe or invalid evos case: ${sourceLink}. Run evodev knowledge lint.`,
230
+ );
231
+ }
232
+ }
233
+ }
234
+
235
+ return {
236
+ cases: cases.sort((left, right) => {
237
+ const project = left.projectKey.localeCompare(right.projectKey);
238
+ if (project !== 0) return project;
239
+ return left.id.localeCompare(right.id);
240
+ }),
241
+ warnings,
242
+ };
243
+ }
244
+
245
+ export async function readEvolutionKnowledgeRecordById(input: {
246
+ homeDir: string;
247
+ knowledgeId: string;
248
+ projectKey?: string;
249
+ }): Promise<EvolutionKnowledgeRecord> {
250
+ const matches = (await listEvolutionKnowledgeRecords(input)).filter(
251
+ (record) => record.id === input.knowledgeId,
252
+ );
253
+ if (matches.length === 0) throw new Error(`Knowledge record not found: ${input.knowledgeId}`);
254
+ if (matches.length > 1) {
255
+ throw new Error(`Knowledge record id is ambiguous across projects: ${input.knowledgeId}`);
256
+ }
257
+ return matches[0] as EvolutionKnowledgeRecord;
258
+ }
259
+
260
+ export async function updateEvolutionKnowledgeReviewState(input: {
261
+ homeDir: string;
262
+ knowledgeId: string;
263
+ projectKey?: string;
264
+ reviewState: "accepted" | "rejected" | "deferred";
265
+ expectedReviewState?: EvolutionReviewState;
266
+ now?: string | Date;
267
+ }): Promise<{ path: string; record: EvolutionKnowledgeRecord; changed: boolean }> {
268
+ return await withEvolutionReviewDecisionLock(
269
+ { homeDir: input.homeDir, kind: "knowledge", itemId: input.knowledgeId },
270
+ async () => {
271
+ const record = await readEvolutionKnowledgeRecordById(input);
272
+ if (
273
+ input.expectedReviewState !== undefined &&
274
+ record.reviewState !== input.expectedReviewState
275
+ ) {
276
+ throw new Error("Knowledge review state changed before the decision was applied.");
277
+ }
278
+ const paths = resolveEvolutionPaths({
279
+ homeDir: input.homeDir,
280
+ projectKey: record.projectKey,
281
+ runId: "review",
282
+ });
283
+ const path = join(paths.knowledgeRecordsDir, `${record.id}.json`);
284
+ if (record.reviewState === input.reviewState) return { path, record, changed: false };
285
+
286
+ const changedAt = normalizeTimestamp(input.now);
287
+ const next: EvolutionKnowledgeRecord = {
288
+ ...record,
289
+ reviewState: input.reviewState,
290
+ authority: input.reviewState === "accepted" ? "reviewed" : "contextual",
291
+ runtime: {
292
+ ...record.runtime,
293
+ // Legacy JSON review records are not active OKF concepts.
294
+ canLoad: false,
295
+ hardBlocking: false,
296
+ },
297
+ };
298
+ validateEvolutionKnowledgeRecord(next);
299
+ await writeJson(path, next, { overwrite: true });
300
+ const allProjectKnowledgeRecords = await readJsonFiles(
301
+ paths.knowledgeRecordsDir,
302
+ parseKnowledgeRecord,
303
+ );
304
+ await writeJson(
305
+ paths.knowledgeIndexPath,
306
+ {
307
+ version: 1,
308
+ kind: "evolution-knowledge-index",
309
+ projectKey: next.projectKey,
310
+ updatedAt: changedAt,
311
+ records: allProjectKnowledgeRecords.map((item) => ({
312
+ id: item.id,
313
+ kind: item.kind,
314
+ title: item.title,
315
+ roleTags: item.roleTags,
316
+ tags: item.tags,
317
+ reviewState: item.reviewState,
318
+ authority: item.authority,
319
+ confidence: item.confidence,
320
+ runtime: item.runtime,
321
+ })),
322
+ },
323
+ { overwrite: true },
324
+ );
325
+ const history: EvolutionKnowledgeReviewHistoryRecord = {
326
+ version: 1,
327
+ kind: "evolution-knowledge-review-state-changed",
328
+ knowledgeId: next.id,
329
+ projectKey: next.projectKey,
330
+ roleTags: uniqueSorted(next.roleTags),
331
+ artifactCreatedAt: next.provenance.createdAt,
332
+ previousReviewState: record.reviewState,
333
+ nextReviewState: next.reviewState,
334
+ changedAt,
335
+ metadataOnly: true,
336
+ };
337
+ await mkdir(dirname(paths.knowledgeReviewHistoryPath), { recursive: true });
338
+ await writeFile(paths.knowledgeReviewHistoryPath, `${JSON.stringify(history)}\n`, {
339
+ encoding: "utf8",
340
+ flag: "a",
341
+ });
342
+ return { path, record: next, changed: true };
343
+ },
344
+ );
345
+ }
346
+
347
+ export async function readEvolutionRepoProposalById(input: {
348
+ homeDir: string;
349
+ proposalId: string;
350
+ projectKey?: string;
351
+ }): Promise<EvolutionRepoProposal> {
352
+ const matches = (
353
+ await readEvolutionReviewSnapshot({
354
+ homeDir: input.homeDir,
355
+ projectKey: input.projectKey,
356
+ })
357
+ ).repoProposals.filter((proposal) => proposal.id === input.proposalId);
358
+ if (matches.length === 0) throw new Error(`Repo proposal not found: ${input.proposalId}`);
359
+ if (matches.length > 1) {
360
+ throw new Error(`Repo proposal id is ambiguous across runs: ${input.proposalId}`);
361
+ }
362
+ return matches[0] as EvolutionRepoProposal;
363
+ }
364
+
365
+ export async function updateEvolutionRepoProposalReviewState(input: {
366
+ homeDir: string;
367
+ proposalId: string;
368
+ projectKey?: string;
369
+ reviewState: "accepted" | "rejected" | "deferred";
370
+ expectedReviewState?: EvolutionRepoProposal["reviewState"];
371
+ reason?: string;
372
+ now?: string | Date;
373
+ }): Promise<{ path: string; record: EvolutionRepoProposal; changed: boolean }> {
374
+ return await withEvolutionReviewDecisionLock(
375
+ { homeDir: input.homeDir, kind: "repo-proposal", itemId: input.proposalId },
376
+ async () => {
377
+ const record = await readEvolutionRepoProposalById(input);
378
+ if (!hasConcreteRepoProposalChanges(record)) {
379
+ throw new Error("Repo proposal has no concrete repository changes to review.");
380
+ }
381
+ const reason = input.reason?.trim();
382
+ if (input.reviewState === "rejected" && reason === undefined) {
383
+ throw new Error("Rejecting a repo proposal requires a reason.");
384
+ }
385
+ if (reason !== undefined && (reason === "" || reason.length > 500)) {
386
+ throw new Error("Repo proposal review reason must be 1-500 characters.");
387
+ }
388
+ if (
389
+ input.expectedReviewState !== undefined &&
390
+ record.reviewState !== input.expectedReviewState
391
+ ) {
392
+ throw new Error("Repo proposal review state changed before the decision was applied.");
393
+ }
394
+ const paths = resolveEvolutionPaths({
395
+ homeDir: input.homeDir,
396
+ projectKey: record.projectKey,
397
+ runId: record.provenance.runId,
398
+ });
399
+ const path = join(paths.repoProposalsDir, `${record.id}.json`);
400
+ if (record.reviewState === input.reviewState) return { path, record, changed: false };
401
+
402
+ const changedAt = normalizeTimestamp(input.now);
403
+ const next: EvolutionRepoProposal = {
404
+ ...record,
405
+ reviewState: input.reviewState,
406
+ reviewStateChangedAt: changedAt,
407
+ lastDecision: {
408
+ state: input.reviewState,
409
+ reason: reason === undefined ? null : sanitizeText(reason),
410
+ decidedAt: changedAt,
411
+ },
412
+ };
413
+ validateEvolutionRepoProposal(next);
414
+ await writeJson(path, next, { overwrite: true });
415
+ const allRunProposals = await readJsonFiles(paths.repoProposalsDir, parseRepoProposal);
416
+ await writeJson(
417
+ paths.repoProposalsIndexPath,
418
+ {
419
+ schemaVersion: 1,
420
+ projectKey: next.projectKey,
421
+ runId: next.provenance.runId,
422
+ updatedAt: changedAt,
423
+ proposals: allRunProposals.map((proposal) => ({
424
+ id: proposal.id,
425
+ kind: proposal.kind,
426
+ title: proposal.title,
427
+ reviewState: proposal.reviewState,
428
+ })),
429
+ },
430
+ { overwrite: true },
431
+ );
432
+ return { path, record: next, changed: true };
433
+ },
434
+ );
435
+ }
436
+
437
+ function parseKnowledgeReviewHistoryRecord(value: unknown): EvolutionKnowledgeReviewHistoryRecord {
438
+ if (!isRecord(value)) throw new Error("Knowledge review history record must be an object.");
439
+ const allowedKeys = new Set([
440
+ "version",
441
+ "kind",
442
+ "knowledgeId",
443
+ "projectKey",
444
+ "roleTags",
445
+ "artifactCreatedAt",
446
+ "previousReviewState",
447
+ "nextReviewState",
448
+ "changedAt",
449
+ "metadataOnly",
450
+ ]);
451
+ if (Object.keys(value).some((key) => !allowedKeys.has(key))) {
452
+ throw new Error("Knowledge review history record contains unsupported fields.");
453
+ }
454
+ if (
455
+ value.version !== 1 ||
456
+ value.kind !== "evolution-knowledge-review-state-changed" ||
457
+ typeof value.knowledgeId !== "string" ||
458
+ typeof value.projectKey !== "string" ||
459
+ !Array.isArray(value.roleTags) ||
460
+ !value.roleTags.every((roleId) => typeof roleId === "string") ||
461
+ typeof value.artifactCreatedAt !== "string" ||
462
+ typeof value.previousReviewState !== "string" ||
463
+ !REVIEW_STATES.includes(value.previousReviewState as EvolutionReviewState) ||
464
+ typeof value.nextReviewState !== "string" ||
465
+ !REVIEW_STATES.includes(value.nextReviewState as EvolutionReviewState) ||
466
+ typeof value.changedAt !== "string" ||
467
+ value.metadataOnly !== true
468
+ ) {
469
+ throw new Error("Invalid knowledge review history record.");
470
+ }
471
+ normalizeTimestamp(value.artifactCreatedAt);
472
+ normalizeTimestamp(value.changedAt);
473
+ return {
474
+ version: 1,
475
+ kind: "evolution-knowledge-review-state-changed",
476
+ knowledgeId: sanitizeId(value.knowledgeId),
477
+ projectKey: sanitizeStorageId("projectKey", value.projectKey),
478
+ roleTags: uniqueSorted(value.roleTags.map(sanitizeId)),
479
+ artifactCreatedAt: value.artifactCreatedAt,
480
+ previousReviewState: value.previousReviewState as EvolutionReviewState,
481
+ nextReviewState: value.nextReviewState as EvolutionReviewState,
482
+ changedAt: value.changedAt,
483
+ metadataOnly: true,
484
+ };
485
+ }
486
+
487
+ function normalizeTimestamp(value: string | Date | undefined): string {
488
+ const date = value === undefined ? new Date() : value instanceof Date ? value : new Date(value);
489
+ if (Number.isNaN(date.getTime())) throw new Error("Invalid knowledge review history timestamp.");
490
+ return date.toISOString();
491
+ }
492
+
493
+ function isAlreadyExistsError(error: unknown): boolean {
494
+ return (
495
+ error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "EEXIST"
496
+ );
497
+ }
498
+
499
+ async function sleep(milliseconds: number): Promise<void> {
500
+ await new Promise((resolve) => setTimeout(resolve, milliseconds));
501
+ }
502
+
503
+ function isRecord(value: unknown): value is Record<string, unknown> {
504
+ return typeof value === "object" && value !== null && !Array.isArray(value);
505
+ }
@@ -0,0 +1,19 @@
1
+ export {
2
+ formatEvolutionProcessResult,
3
+ formatEvolutionReviewSnapshot,
4
+ } from "../formatters.ts";
5
+ export { processEvolutionTriggers } from "../processor/index.ts";
6
+ export { readEvolutionReviewSnapshot } from "../candidates/index.ts";
7
+ export {
8
+ listEvolutionTriggers,
9
+ listSegmentEvolutionTriggers,
10
+ } from "../triggers/index.ts";
11
+ export type {
12
+ EvolutionProcessInput,
13
+ EvolutionProcessResult,
14
+ EvolutionReviewSnapshot,
15
+ EvolutionTriggerListInput,
16
+ EvolutionTriggerRecord,
17
+ SegmentEvolutionTriggerListInput,
18
+ SegmentEvolutionTriggerRecord,
19
+ } from "../schema.ts";