@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.
Files changed (79) hide show
  1. package/assets/skills/coding/knowledge-distillation/SKILL.md +5 -3
  2. package/assets/team/agents/code-reviewer.md +48 -0
  3. package/assets/team/agents/docs-maintainer.md +51 -0
  4. package/assets/team/agents/implementation-engineer.md +51 -0
  5. package/assets/team/agents/product-scope-analyst.md +58 -0
  6. package/assets/team/agents/release-engineer.md +55 -0
  7. package/assets/team/agents/security-boundary-reviewer.md +50 -0
  8. package/assets/team/agents/solution-architect.md +51 -0
  9. package/assets/team/agents/verification-engineer.md +51 -0
  10. package/assets/team/team.md +102 -0
  11. package/dist/assets/index.js +5 -5
  12. package/dist/config/index.js +793 -241
  13. package/dist/index.js +20840 -12908
  14. package/dist/plugins/index.js +13 -13
  15. package/package.json +1 -1
  16. package/src/agents/index.ts +1 -265
  17. package/src/code-agent-traces/index.ts +11 -12
  18. package/src/config/index.ts +2 -0
  19. package/src/config/settings.ts +116 -7
  20. package/src/config/store.ts +1 -1
  21. package/src/daemon/index.ts +1 -41
  22. package/src/evolution/candidates/index.ts +730 -0
  23. package/src/evolution/control/index.ts +20 -0
  24. package/src/evolution/evidence/analysis.ts +533 -0
  25. package/src/evolution/evidence/index.ts +3 -0
  26. package/src/evolution/evidence/session-memory/analysis.ts +287 -0
  27. package/src/evolution/evidence/session-memory/constants.ts +9 -0
  28. package/src/evolution/evidence/session-memory/index.ts +9 -0
  29. package/src/evolution/evidence/session-memory/paths.ts +29 -0
  30. package/src/evolution/evidence/session-memory/policy.ts +39 -0
  31. package/src/evolution/evidence/session-memory/retention.ts +643 -0
  32. package/src/evolution/evidence/session-memory/segment.ts +216 -0
  33. package/src/evolution/evidence/session-memory/semantic-packet.ts +408 -0
  34. package/src/evolution/evidence/session-memory/sensitivity.ts +335 -0
  35. package/src/evolution/evidence/session-memory/state-machine.ts +249 -0
  36. package/src/evolution/evidence/session-memory/storage.ts +744 -0
  37. package/src/evolution/evidence/session-memory/types.ts +296 -0
  38. package/src/evolution/evidence/session-memory/updater.ts +199 -0
  39. package/src/evolution/formatters.ts +169 -0
  40. package/src/evolution/imports/apply.ts +435 -0
  41. package/src/evolution/imports/diff.ts +472 -0
  42. package/src/evolution/imports/index.ts +7 -0
  43. package/src/evolution/imports/materialize.ts +640 -0
  44. package/src/evolution/imports/paths.ts +129 -0
  45. package/src/evolution/imports/stage.ts +414 -0
  46. package/src/evolution/imports/storage.ts +952 -0
  47. package/src/evolution/imports/types.ts +226 -0
  48. package/src/evolution/index.ts +19 -2827
  49. package/src/evolution/knowledge/change-store.ts +558 -0
  50. package/src/evolution/knowledge/changes.ts +459 -0
  51. package/src/evolution/knowledge/freshness.ts +69 -0
  52. package/src/{knowledge → evolution/knowledge}/index.ts +1532 -206
  53. package/src/evolution/knowledge/review.ts +446 -0
  54. package/src/evolution/knowledge/support.ts +135 -0
  55. package/src/evolution/paths.ts +44 -0
  56. package/src/evolution/processor/distillation.ts +518 -0
  57. package/src/evolution/processor/index.ts +3 -0
  58. package/src/evolution/processor/process.ts +594 -0
  59. package/src/{learning → evolution/review}/index.ts +10 -14
  60. package/src/evolution/schema.ts +639 -0
  61. package/src/evolution/shared.ts +1053 -0
  62. package/src/evolution/triggers/classification.ts +102 -0
  63. package/src/evolution/triggers/index.ts +295 -0
  64. package/src/hooks/index.ts +281 -197
  65. package/src/index.ts +15 -4
  66. package/src/projects/index.ts +934 -0
  67. package/src/runtime-logs/index.ts +100 -13
  68. package/src/team/index.ts +582 -3
  69. package/src/utils/errors.ts +13 -0
  70. package/src/utils/fs.ts +40 -0
  71. package/src/utils/hash.ts +9 -0
  72. package/src/utils/ids.ts +12 -0
  73. package/src/utils/index.ts +7 -0
  74. package/src/utils/parsing.ts +11 -0
  75. package/src/utils/text.ts +18 -0
  76. package/src/utils/time.ts +5 -0
  77. package/src/workflow/index.ts +3 -21
  78. package/src/project/index.ts +0 -507
  79. package/src/task/index.ts +0 -840
@@ -0,0 +1,1053 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { isAbsolute, relative, resolve } from "node:path";
3
+ import { pathExists } from "../utils/index.ts";
4
+ import {
5
+ detectSessionMemorySensitivity,
6
+ redactSessionMemoryCredentialText,
7
+ } from "./evidence/session-memory/sensitivity.ts";
8
+ import type {
9
+ EvolutionDistillationBatch,
10
+ EvolutionEpisodeKind,
11
+ EvolutionEpisodeStatus,
12
+ EvolutionEvidenceEventKind,
13
+ EvolutionEvidenceSourceKind,
14
+ EvolutionEvidenceWindow,
15
+ EvolutionEvosCase,
16
+ EvolutionImprovementEval,
17
+ EvolutionImprovementEvalStatus,
18
+ EvolutionKnowledgeKind,
19
+ EvolutionKnowledgeRecord,
20
+ EvolutionNormalizedEventType,
21
+ EvolutionPrivacyFields,
22
+ EvolutionProposalKind,
23
+ EvolutionRepoProposal,
24
+ EvolutionReviewCandidate,
25
+ EvolutionReviewState,
26
+ EvolutionTriggerReason,
27
+ EvolutionTriggerRecord,
28
+ EvolutionTriggerStatus,
29
+ EvolutionTriggerStrength,
30
+ SegmentEvolutionTriggerReason,
31
+ SegmentEvolutionTriggerRecord,
32
+ SegmentEvolutionTriggerStrength,
33
+ } from "./schema.ts";
34
+
35
+ export const MAX_EVIDENCE_EVENTS = 200;
36
+ export const MAX_TEXT_LENGTH = 600;
37
+ export const MAX_PROPOSED_CHANGE_LENGTH = 16 * 1024;
38
+ export const PROCESS_LOCK_STALE_MS = 5 * 60 * 1000;
39
+ export const EVIDENCE_SOURCE_KINDS: readonly EvolutionEvidenceSourceKind[] = [
40
+ "evodev-execution-event",
41
+ "team-agent-trace",
42
+ "session-trace",
43
+ "session-memory-segment",
44
+ "verification",
45
+ "review-summary",
46
+ "user-feedback",
47
+ "code-agent-trace-ref",
48
+ ];
49
+ export const REVIEW_STATES: readonly EvolutionReviewState[] = [
50
+ "auto-accepted",
51
+ "auto-stored/unreviewed",
52
+ "needs-human",
53
+ "accepted",
54
+ "rejected",
55
+ "deferred",
56
+ "stale",
57
+ "deprecated",
58
+ "superseded",
59
+ "revoked",
60
+ ];
61
+ export const KNOWLEDGE_KINDS: readonly EvolutionKnowledgeKind[] = [
62
+ "run-summary",
63
+ "lesson",
64
+ "rule",
65
+ "workflow-hint",
66
+ "skill-gap",
67
+ "role-note",
68
+ "verification-pattern",
69
+ ];
70
+ export const PROPOSAL_KINDS: readonly EvolutionProposalKind[] = [
71
+ "skill",
72
+ "rule",
73
+ "role-agent",
74
+ "team",
75
+ "ci",
76
+ "test",
77
+ "docs",
78
+ "engineering-practice",
79
+ ];
80
+ export const IMPROVEMENT_EVAL_STATUSES: readonly EvolutionImprovementEvalStatus[] = [
81
+ "not-requested",
82
+ "awaiting-eval",
83
+ "evaluating",
84
+ "effective",
85
+ "ineffective",
86
+ "inconclusive",
87
+ ];
88
+ export const EVENT_KINDS: readonly EvolutionEvidenceEventKind[] = [
89
+ "trace",
90
+ "tool-call",
91
+ "skill-call",
92
+ "subagent",
93
+ "verification",
94
+ "review",
95
+ "user-feedback",
96
+ "error",
97
+ "run-state",
98
+ ];
99
+ export const NORMALIZED_EVENT_TYPES: readonly EvolutionNormalizedEventType[] = [
100
+ "SessionStart",
101
+ "UserPromptSubmit",
102
+ "UserPromptExpansion",
103
+ "PreToolUse",
104
+ "PermissionRequest",
105
+ "PostToolUse",
106
+ "PostToolUseFailure",
107
+ "PostToolBatch",
108
+ "PermissionDenied",
109
+ "SubagentStart",
110
+ "Stop",
111
+ "StopFailure",
112
+ "TeammateIdle",
113
+ "SubagentStop",
114
+ "TaskCreated",
115
+ "TaskCompleted",
116
+ "PreCompact",
117
+ "PostCompact",
118
+ "SessionEnd",
119
+ "ConfigChange",
120
+ "CwdChanged",
121
+ "FileChanged",
122
+ "WorktreeCreate",
123
+ "WorktreeRemove",
124
+ "unknown",
125
+ ];
126
+ export const TRIGGER_STRENGTHS: readonly EvolutionTriggerStrength[] = [
127
+ "none",
128
+ "conditional",
129
+ "strong",
130
+ ];
131
+ export const TRIGGER_REASONS: readonly EvolutionTriggerReason[] = [
132
+ "none",
133
+ "turn-completed",
134
+ "task-completed",
135
+ "session-ended",
136
+ "role-completed",
137
+ "stop-failure",
138
+ "tool-failure",
139
+ "permission-denied",
140
+ "explicit-command",
141
+ "failure-signal",
142
+ ];
143
+ export const EPISODE_KINDS: readonly EvolutionEpisodeKind[] = ["session", "turn", "task", "role"];
144
+ export const EPISODE_STATUSES: readonly EvolutionEpisodeStatus[] = ["open", "closed", "failed"];
145
+ export const TRIGGER_STATUSES: readonly EvolutionTriggerStatus[] = [
146
+ "pending",
147
+ "processing",
148
+ "consumed",
149
+ "failed",
150
+ "skipped",
151
+ ];
152
+ export const SEGMENT_TRIGGER_REASONS: readonly SegmentEvolutionTriggerReason[] = [
153
+ "session-memory-init",
154
+ "session-memory-threshold",
155
+ "intent-refinement",
156
+ "user-interruption",
157
+ "failure-signal",
158
+ "permission-denied",
159
+ "verification-after-fix",
160
+ "explicit-memory-intent",
161
+ "historical-import",
162
+ ];
163
+ export const SEGMENT_TRIGGER_STRENGTHS: readonly SegmentEvolutionTriggerStrength[] = [
164
+ "normal",
165
+ "strong",
166
+ ];
167
+ export const FORBIDDEN_RAW_KEYS = new Set([
168
+ "commandhistory",
169
+ "commandoutput",
170
+ "credential",
171
+ "credentials",
172
+ "env",
173
+ "fullsource",
174
+ "memorybody",
175
+ "password",
176
+ "privatekey",
177
+ "prompt",
178
+ "promptbody",
179
+ "prompttext",
180
+ "rawcommand",
181
+ "rawcommandoutput",
182
+ "rawlog",
183
+ "rawlogs",
184
+ "rawoutput",
185
+ "rawpayload",
186
+ "rawprompt",
187
+ "secret",
188
+ "secretvalue",
189
+ "source",
190
+ "sourcebody",
191
+ "sourcecode",
192
+ "sourcecontent",
193
+ "sourcetext",
194
+ "stderr",
195
+ "stdout",
196
+ "token",
197
+ "transcript",
198
+ "transcriptbody",
199
+ "transcripttext",
200
+ ]);
201
+ export function validateEvolutionEvidenceWindow(window: EvolutionEvidenceWindow): void {
202
+ if (!isRecord(window)) throw new Error("Evidence window must be an object.");
203
+ if (window.schemaVersion !== 1) throw new Error("Evidence window schemaVersion must be 1.");
204
+ if (window.kind !== "evidence-window") throw new Error("Evidence window kind is invalid.");
205
+ assertString("projectKey", window.projectKey);
206
+ assertString("runId", window.runId);
207
+ assertString("id", window.id);
208
+ assertString("createdAt", window.createdAt);
209
+ assertPrivacy(window.privacy);
210
+ if (!Array.isArray(window.sourceRefs)) throw new Error("sourceRefs must be an array.");
211
+ for (const sourceRef of window.sourceRefs) {
212
+ assertString("sourceRefs.id", sourceRef.id);
213
+ assertEnum("sourceRefs.kind", sourceRef.kind, EVIDENCE_SOURCE_KINDS);
214
+ assertString("sourceRefs.path", sourceRef.path);
215
+ if (sourceRef.rawContentStored !== false)
216
+ throw new Error("sourceRefs.rawContentStored must be false.");
217
+ if (sourceRef.externalContentCopied !== false) {
218
+ throw new Error("sourceRefs.externalContentCopied must be false.");
219
+ }
220
+ }
221
+ if (!Array.isArray(window.events)) throw new Error("events must be an array.");
222
+ for (const event of window.events) {
223
+ assertString("events.id", event.id);
224
+ assertEnum("events.kind", event.kind, EVENT_KINDS);
225
+ assertEnum("events.eventType", event.eventType, NORMALIZED_EVENT_TYPES);
226
+ assertEnum("events.triggerStrength", event.triggerStrength, TRIGGER_STRENGTHS);
227
+ assertEnum("events.triggerReason", event.triggerReason, TRIGGER_REASONS);
228
+ assertString("events.summary", event.summary);
229
+ assertString("events.evidenceRef", event.evidenceRef);
230
+ if (!Array.isArray(event.episodeIds)) throw new Error("events.episodeIds must be an array.");
231
+ for (const episodeId of event.episodeIds) assertString("events.episodeIds", episodeId);
232
+ if (event.rawContentStored !== false) throw new Error("events.rawContentStored must be false.");
233
+ }
234
+ if (!Array.isArray(window.episodes)) throw new Error("episodes must be an array.");
235
+ for (const episode of window.episodes) {
236
+ assertString("episodes.id", episode.id);
237
+ assertEnum("episodes.kind", episode.kind, EPISODE_KINDS);
238
+ assertEnum("episodes.status", episode.status, EPISODE_STATUSES);
239
+ assertEnum("episodes.triggerStrength", episode.triggerStrength, TRIGGER_STRENGTHS);
240
+ assertEnum("episodes.triggerReason", episode.triggerReason, TRIGGER_REASONS);
241
+ assertString("episodes.summary", episode.summary);
242
+ if (!Array.isArray(episode.eventIds)) throw new Error("episodes.eventIds must be an array.");
243
+ for (const eventId of episode.eventIds) assertString("episodes.eventIds", eventId);
244
+ if (episode.rawContentStored !== false)
245
+ throw new Error("episodes.rawContentStored must be false.");
246
+ }
247
+ assertEnum("triggerPolicy.strongest", window.triggerPolicy.strongest, TRIGGER_STRENGTHS);
248
+ if (!Array.isArray(window.triggerPolicy.reasons))
249
+ throw new Error("triggerPolicy.reasons must be an array.");
250
+ for (const reason of window.triggerPolicy.reasons) {
251
+ assertEnum("triggerPolicy.reasons", reason, TRIGGER_REASONS);
252
+ }
253
+ assertNoForbiddenRawFields(window);
254
+ }
255
+
256
+ export function validateEvolutionKnowledgeRecord(record: EvolutionKnowledgeRecord): void {
257
+ if (!isRecord(record)) throw new Error("Knowledge record must be an object.");
258
+ if (record.schemaVersion !== 1) throw new Error("Knowledge record schemaVersion must be 1.");
259
+ assertEnum("kind", record.kind, KNOWLEDGE_KINDS);
260
+ assertEnum("reviewState", record.reviewState, REVIEW_STATES);
261
+ assertEnum("authority", record.authority, ["contextual", "reviewed"]);
262
+ if (record.reviewState === "accepted" && record.authority !== "reviewed") {
263
+ throw new Error("Accepted knowledge must use reviewed authority.");
264
+ }
265
+ if (
266
+ (record.reviewState === "auto-stored/unreviewed" ||
267
+ record.reviewState === "auto-accepted" ||
268
+ record.reviewState === "needs-human") &&
269
+ record.authority !== "contextual"
270
+ ) {
271
+ throw new Error("Unreviewed knowledge must remain contextual.");
272
+ }
273
+ if (record.runtime.hardBlocking !== false) {
274
+ throw new Error("Knowledge runtime.hardBlocking must be false.");
275
+ }
276
+ assertPrivacy(record.privacy);
277
+ assertProvenance(record.provenance);
278
+ assertNoForbiddenRawFields(record);
279
+ }
280
+
281
+ export function validateEvolutionDistillationBatch(batch: EvolutionDistillationBatch): void {
282
+ if (!isRecord(batch)) throw new Error("Distillation batch must be an object.");
283
+ if (batch.schemaVersion !== 1) throw new Error("Distillation batch schemaVersion must be 1.");
284
+ validateEvolutionEvidenceWindow(batch.evidenceWindow);
285
+ for (const record of batch.knowledgeRecords) validateEvolutionKnowledgeRecord(record);
286
+ for (const evosCase of batch.evosCases) validateEvolutionEvosCase(evosCase);
287
+ for (const proposal of batch.repoProposals) validateEvolutionRepoProposal(proposal);
288
+ assertNoForbiddenRawFields(batch);
289
+ }
290
+
291
+ export function createEvolutionKnowledgeRecord(
292
+ input: Omit<EvolutionKnowledgeRecord, "schemaVersion">,
293
+ ): EvolutionKnowledgeRecord {
294
+ const record: EvolutionKnowledgeRecord = {
295
+ ...input,
296
+ schemaVersion: 1,
297
+ id: sanitizeId(input.id),
298
+ title: sanitizeText(input.title),
299
+ projectKey: sanitizeId(input.projectKey),
300
+ summary: sanitizeText(input.summary),
301
+ body: sanitizeText(input.body),
302
+ roleTags: uniqueSanitizedIds(input.roleTags),
303
+ tags: uniqueSanitizedIds(input.tags),
304
+ privacy: { ...input.privacy },
305
+ };
306
+ record.privacy.internalLinksStored =
307
+ detectSessionMemorySensitivity(record).reasons.includes("url");
308
+ validateEvolutionKnowledgeRecord(record);
309
+ return record;
310
+ }
311
+
312
+ export function createEvolutionEvosCase(
313
+ input: Omit<EvolutionEvosCase, "schemaVersion" | "kind">,
314
+ ): EvolutionEvosCase {
315
+ const evosCase: EvolutionEvosCase = {
316
+ ...input,
317
+ schemaVersion: 1,
318
+ kind: "evos-case",
319
+ id: sanitizeId(input.id),
320
+ projectKey: sanitizeId(input.projectKey),
321
+ title: sanitizeText(input.title),
322
+ roleTags: uniqueSanitizedIds(input.roleTags),
323
+ tags: uniqueSanitizedIds(input.tags),
324
+ trigger: {
325
+ kind: input.trigger.kind,
326
+ summary: sanitizeText(input.trigger.summary),
327
+ },
328
+ intervention: {
329
+ summary: sanitizeText(input.intervention.summary),
330
+ roleIds: uniqueSanitizedIds(input.intervention.roleIds),
331
+ },
332
+ result: {
333
+ summary: sanitizeText(input.result.summary),
334
+ verificationSignals: input.result.verificationSignals.map(sanitizeText),
335
+ },
336
+ expectedFutureBehavior: sanitizeText(input.expectedFutureBehavior),
337
+ privacy: { ...input.privacy },
338
+ };
339
+ evosCase.privacy.internalLinksStored =
340
+ detectSessionMemorySensitivity(evosCase).reasons.includes("url");
341
+ validateEvolutionEvosCase(evosCase);
342
+ return evosCase;
343
+ }
344
+
345
+ export function createEvolutionRepoProposal(
346
+ input: Omit<EvolutionRepoProposal, "schemaVersion">,
347
+ ): EvolutionRepoProposal {
348
+ const proposal: EvolutionRepoProposal = {
349
+ ...input,
350
+ schemaVersion: 1,
351
+ id: sanitizeId(input.id),
352
+ projectKey: sanitizeId(input.projectKey),
353
+ title: sanitizeText(input.title),
354
+ summary: sanitizeText(input.summary),
355
+ rationale: sanitizeText(input.rationale),
356
+ roleTags: uniqueSanitizedIds(input.roleTags),
357
+ tags: uniqueSanitizedIds(input.tags),
358
+ plannedFiles: input.plannedFiles.map((file) => ({
359
+ relativePath: sanitizeRelativePath(file.relativePath),
360
+ action: file.action,
361
+ reason: sanitizeText(file.reason),
362
+ ...(file.proposedChange === undefined
363
+ ? {}
364
+ : { proposedChange: sanitizeProposedChange(file.proposedChange) }),
365
+ })),
366
+ ...(input.currentState === undefined
367
+ ? {}
368
+ : {
369
+ currentState: {
370
+ ...input.currentState,
371
+ checkedPaths: uniqueSorted(input.currentState.checkedPaths.map(sanitizeRelativePath)),
372
+ relatedCommits: uniqueSanitizedIds(input.currentState.relatedCommits),
373
+ summary: sanitizeText(input.currentState.summary),
374
+ },
375
+ }),
376
+ ...(input.recurrence === undefined
377
+ ? {}
378
+ : {
379
+ recurrence: {
380
+ ...input.recurrence,
381
+ gapKey: sanitizeStorageId("gapKey", input.recurrence.gapKey),
382
+ sourceSessionKeys: uniqueSanitizedIds(input.recurrence.sourceSessionKeys),
383
+ },
384
+ }),
385
+ ...(input.supersession === undefined
386
+ ? {}
387
+ : {
388
+ supersession: {
389
+ ...input.supersession,
390
+ reason: sanitizeText(input.supersession.reason),
391
+ },
392
+ }),
393
+ ...(input.improvementEval === undefined
394
+ ? {}
395
+ : { improvementEval: sanitizeEvolutionImprovementEval(input.improvementEval) }),
396
+ privacy: { ...input.privacy },
397
+ };
398
+ proposal.privacy.internalLinksStored =
399
+ detectSessionMemorySensitivity(proposal).reasons.includes("url");
400
+ validateEvolutionRepoProposal(proposal);
401
+ return proposal;
402
+ }
403
+
404
+ export function validateEvolutionEvosCase(evosCase: EvolutionEvosCase): void {
405
+ if (!isRecord(evosCase)) throw new Error("Evos case must be an object.");
406
+ if (evosCase.schemaVersion !== 1) throw new Error("Evos case schemaVersion must be 1.");
407
+ if (evosCase.kind !== "evos-case") throw new Error("Evos case kind is invalid.");
408
+ assertEnum("reviewState", evosCase.reviewState, REVIEW_STATES);
409
+ assertPrivacy(evosCase.privacy);
410
+ assertProvenance(evosCase.provenance);
411
+ assertNoForbiddenRawFields(evosCase);
412
+ }
413
+
414
+ export function validateEvolutionRepoProposal(proposal: EvolutionRepoProposal): void {
415
+ if (!isRecord(proposal)) throw new Error("Repo proposal must be an object.");
416
+ if (proposal.schemaVersion !== 1) throw new Error("Repo proposal schemaVersion must be 1.");
417
+ assertEnum("kind", proposal.kind, PROPOSAL_KINDS);
418
+ assertEnum("reviewState", proposal.reviewState, [
419
+ "pending",
420
+ "accepted",
421
+ "rejected",
422
+ "deferred",
423
+ "applied",
424
+ "superseded",
425
+ ]);
426
+ if (
427
+ proposal.reviewStateChangedAt !== undefined &&
428
+ Number.isNaN(new Date(proposal.reviewStateChangedAt).getTime())
429
+ ) {
430
+ throw new Error("Repo proposal reviewStateChangedAt must be a valid timestamp.");
431
+ }
432
+ for (const file of proposal.plannedFiles) {
433
+ if (file.proposedChange !== undefined && file.proposedChange.trim() === "") {
434
+ throw new Error("Repo proposal proposedChange must not be empty.");
435
+ }
436
+ }
437
+ if (proposal.currentState !== undefined) {
438
+ if (!isRecord(proposal.currentState)) {
439
+ throw new Error("Repo proposal currentState must be an object.");
440
+ }
441
+ assertTimestamp("currentState.checkedAt", proposal.currentState.checkedAt);
442
+ if (!/^[a-f0-9]{40}([a-f0-9]{24})?$/.test(proposal.currentState.headSha)) {
443
+ throw new Error("Repo proposal currentState.headSha must be a Git object id.");
444
+ }
445
+ if (!/^[a-f0-9]{64}$/.test(proposal.currentState.statusFingerprint)) {
446
+ throw new Error("Repo proposal currentState.statusFingerprint must be a SHA-256 digest.");
447
+ }
448
+ assertEnum("currentState.verdict", proposal.currentState.verdict, [
449
+ "unresolved",
450
+ "resolved",
451
+ "unknown",
452
+ ]);
453
+ assertEnum("currentState.ownership", proposal.currentState.ownership, [
454
+ "governance",
455
+ "implementation",
456
+ "not-applicable",
457
+ ]);
458
+ if (typeof proposal.currentState.explicitUserRequest !== "boolean") {
459
+ throw new Error("Repo proposal currentState.explicitUserRequest must be a boolean.");
460
+ }
461
+ assertStringArray("currentState.checkedPaths", proposal.currentState.checkedPaths);
462
+ assertStringArray("currentState.relatedCommits", proposal.currentState.relatedCommits);
463
+ for (const commit of proposal.currentState.relatedCommits) {
464
+ if (!/^[a-f0-9]{7,64}$/.test(commit)) {
465
+ throw new Error("Repo proposal currentState.relatedCommits must contain Git object ids.");
466
+ }
467
+ }
468
+ assertString("currentState.summary", proposal.currentState.summary);
469
+ }
470
+ if (proposal.recurrence !== undefined) {
471
+ if (!isRecord(proposal.recurrence)) {
472
+ throw new Error("Repo proposal recurrence must be an object.");
473
+ }
474
+ assertString("recurrence.gapKey", proposal.recurrence.gapKey);
475
+ assertStringArray("recurrence.sourceSessionKeys", proposal.recurrence.sourceSessionKeys);
476
+ if (
477
+ !Number.isInteger(proposal.recurrence.independentSessionCount) ||
478
+ proposal.recurrence.independentSessionCount < 2 ||
479
+ proposal.recurrence.independentSessionCount !==
480
+ new Set(proposal.recurrence.sourceSessionKeys).size
481
+ ) {
482
+ throw new Error(
483
+ "Repo proposal recurrence must contain at least two distinct source sessions.",
484
+ );
485
+ }
486
+ }
487
+ if (proposal.supersession !== undefined) {
488
+ if (!isRecord(proposal.supersession)) {
489
+ throw new Error("Repo proposal supersession must be an object.");
490
+ }
491
+ if (proposal.supersession.policy !== "session-proposal-v2") {
492
+ throw new Error("Repo proposal supersession policy is invalid.");
493
+ }
494
+ assertString("supersession.reason", proposal.supersession.reason);
495
+ assertTimestamp("supersession.supersededAt", proposal.supersession.supersededAt);
496
+ }
497
+ if (proposal.reviewState === "superseded" && proposal.supersession === undefined) {
498
+ throw new Error("A superseded repo proposal must include supersession metadata.");
499
+ }
500
+ if (proposal.reviewState !== "superseded" && proposal.supersession !== undefined) {
501
+ throw new Error("Repo proposal supersession metadata requires superseded review state.");
502
+ }
503
+ if (proposal.lastDecision !== undefined) {
504
+ assertEnum("lastDecision.state", proposal.lastDecision.state, [
505
+ "accepted",
506
+ "rejected",
507
+ "deferred",
508
+ ]);
509
+ if (
510
+ proposal.lastDecision.reason !== null &&
511
+ (proposal.lastDecision.reason.trim() === "" || proposal.lastDecision.reason.length > 500)
512
+ ) {
513
+ throw new Error("Repo proposal decision reason must be 1-500 characters.");
514
+ }
515
+ if (Number.isNaN(new Date(proposal.lastDecision.decidedAt).getTime())) {
516
+ throw new Error("Repo proposal decision timestamp must be valid.");
517
+ }
518
+ }
519
+ if (proposal.improvementEval !== undefined) {
520
+ validateEvolutionImprovementEval(proposal.improvementEval);
521
+ }
522
+ if (proposal.apply.autoApply !== false || proposal.apply.requiresExplicitCommand !== true) {
523
+ throw new Error("Repo proposal must require explicit apply.");
524
+ }
525
+ assertPrivacy(proposal.privacy);
526
+ assertProvenance(proposal.provenance);
527
+ assertNoForbiddenRawFields(proposal);
528
+ }
529
+
530
+ export function isEvolutionImprovementEvalRecommended(kind: EvolutionProposalKind): boolean {
531
+ return !["ci", "test", "docs"].includes(kind);
532
+ }
533
+
534
+ export function createEvolutionImprovementEval(input: {
535
+ kind: EvolutionProposalKind;
536
+ requested?: boolean;
537
+ now?: string | Date;
538
+ }): EvolutionImprovementEval {
539
+ const requested = input.requested ?? false;
540
+ const requestedAt = requested ? normalizeTimestampValue(input.now) : null;
541
+ return {
542
+ recommended: isEvolutionImprovementEvalRecommended(input.kind),
543
+ requested,
544
+ status: requested ? "awaiting-eval" : "not-requested",
545
+ requestedAt,
546
+ evaluatedAt: null,
547
+ evidenceRef: null,
548
+ result: null,
549
+ };
550
+ }
551
+
552
+ export function hasConcreteRepoProposalChanges(proposal: EvolutionRepoProposal): boolean {
553
+ return (
554
+ proposal.targetRepoPath !== null &&
555
+ proposal.targetRepoPath.trim() !== "" &&
556
+ proposal.plannedFiles.length > 0 &&
557
+ proposal.plannedFiles.every(
558
+ (file) => file.proposedChange !== undefined && file.proposedChange.trim() !== "",
559
+ )
560
+ );
561
+ }
562
+
563
+ function validateEvolutionImprovementEval(value: EvolutionImprovementEval): void {
564
+ if (!isRecord(value)) throw new Error("Repo proposal improvementEval must be an object.");
565
+ if (typeof value.recommended !== "boolean" || typeof value.requested !== "boolean") {
566
+ throw new Error("Repo proposal improvementEval flags must be booleans.");
567
+ }
568
+ assertEnum("improvementEval.status", value.status, IMPROVEMENT_EVAL_STATUSES);
569
+ assertNullableTimestamp("improvementEval.requestedAt", value.requestedAt);
570
+ assertNullableTimestamp("improvementEval.evaluatedAt", value.evaluatedAt);
571
+ if (value.evidenceRef !== null) assertString("improvementEval.evidenceRef", value.evidenceRef);
572
+ if (!value.requested) {
573
+ if (
574
+ value.status !== "not-requested" ||
575
+ value.requestedAt !== null ||
576
+ value.evaluatedAt !== null ||
577
+ value.result !== null
578
+ ) {
579
+ throw new Error("A non-requested improvement eval cannot have evaluation state.");
580
+ }
581
+ return;
582
+ }
583
+ if (value.status === "not-requested" || value.requestedAt === null) {
584
+ throw new Error("A requested improvement eval must be awaiting or completed.");
585
+ }
586
+ const completed = ["effective", "ineffective", "inconclusive"].includes(value.status);
587
+ if (completed !== (value.result !== null && value.evaluatedAt !== null)) {
588
+ throw new Error("Completed improvement eval state and result must be stored together.");
589
+ }
590
+ if (value.result === null) return;
591
+ if (value.result.targetImproved !== null && typeof value.result.targetImproved !== "boolean") {
592
+ throw new Error("Improvement eval targetImproved must be boolean or null.");
593
+ }
594
+ if (value.result.hardRegression !== null && typeof value.result.hardRegression !== "boolean") {
595
+ throw new Error("Improvement eval hardRegression must be boolean or null.");
596
+ }
597
+ if (
598
+ value.status === "effective" &&
599
+ (value.result.targetImproved !== true || value.result.hardRegression !== false)
600
+ ) {
601
+ throw new Error("An effective improvement eval must improve the target without regression.");
602
+ }
603
+ if (
604
+ value.status === "ineffective" &&
605
+ value.result.targetImproved !== false &&
606
+ value.result.hardRegression !== true
607
+ ) {
608
+ throw new Error("An ineffective improvement eval must fail the target or a hard gate.");
609
+ }
610
+ if (
611
+ value.status === "inconclusive" &&
612
+ (value.result.targetImproved === false ||
613
+ value.result.hardRegression === true ||
614
+ (value.result.targetImproved === true && value.result.hardRegression === false))
615
+ ) {
616
+ throw new Error("An inconclusive improvement eval cannot contain a definitive gate result.");
617
+ }
618
+ assertString("improvementEval.result.summary", value.result.summary);
619
+ if (!Array.isArray(value.result.assertions) || value.result.assertions.length > 8) {
620
+ throw new Error("Improvement eval assertions must contain at most 8 items.");
621
+ }
622
+ for (const assertion of value.result.assertions) {
623
+ assertString("improvementEval.result.assertions.label", assertion.label);
624
+ assertEnum("improvementEval.result.assertions.status", assertion.status, [
625
+ "pass",
626
+ "fail",
627
+ "unknown",
628
+ ]);
629
+ }
630
+ if (
631
+ value.result.evaluatedBy !== "local-code-agent" &&
632
+ value.result.evaluatedBy !== "local-eval-orchestrator"
633
+ ) {
634
+ throw new Error("Improvement eval evaluatedBy is invalid.");
635
+ }
636
+ if (!isRecord(value.result.metrics)) {
637
+ throw new Error("Improvement eval metrics must be an object.");
638
+ }
639
+ if (
640
+ !isNonNegativeInteger(value.result.metrics.durationMs) ||
641
+ !isNonNegativeInteger(value.result.metrics.modelCalls)
642
+ ) {
643
+ throw new Error("Improvement eval duration and model call count are required.");
644
+ }
645
+ if (
646
+ (value.result.evaluatedBy === "local-code-agent" && value.result.metrics.modelCalls !== 1) ||
647
+ (value.result.evaluatedBy === "local-eval-orchestrator" &&
648
+ value.result.metrics.modelCalls !== 0)
649
+ ) {
650
+ throw new Error("Improvement eval model call metadata is inconsistent.");
651
+ }
652
+ for (const [field, metric] of [
653
+ ["inputTokens", value.result.metrics.inputTokens],
654
+ ["outputTokens", value.result.metrics.outputTokens],
655
+ ["toolCalls", value.result.metrics.toolCalls],
656
+ ] as const) {
657
+ if (metric !== null && !isNonNegativeInteger(metric)) {
658
+ throw new Error(`Improvement eval metric ${field} must be a non-negative integer or null.`);
659
+ }
660
+ }
661
+ if (
662
+ value.result.retention.metadataOnly !== true ||
663
+ value.result.retention.replayInputStored !== false ||
664
+ value.result.retention.replayOutputStored !== false
665
+ ) {
666
+ throw new Error("Improvement eval must retain metadata-only results.");
667
+ }
668
+ }
669
+
670
+ function sanitizeEvolutionImprovementEval(
671
+ value: EvolutionImprovementEval,
672
+ ): EvolutionImprovementEval {
673
+ return {
674
+ ...value,
675
+ evidenceRef: value.evidenceRef === null ? null : sanitizeId(value.evidenceRef),
676
+ result:
677
+ value.result === null
678
+ ? null
679
+ : {
680
+ ...value.result,
681
+ summary: sanitizeText(value.result.summary),
682
+ assertions: value.result.assertions.slice(0, 8).map((assertion) => ({
683
+ label: sanitizeText(assertion.label),
684
+ status: assertion.status,
685
+ })),
686
+ metrics: { ...value.result.metrics },
687
+ retention: { ...value.result.retention },
688
+ },
689
+ };
690
+ }
691
+
692
+ function assertNullableTimestamp(field: string, value: string | null): void {
693
+ if (value !== null && Number.isNaN(new Date(value).getTime())) {
694
+ throw new Error(`${field} must be null or a valid timestamp.`);
695
+ }
696
+ }
697
+
698
+ function assertTimestamp(field: string, value: unknown): asserts value is string {
699
+ assertString(field, value);
700
+ if (Number.isNaN(new Date(value).getTime())) {
701
+ throw new Error(`${field} must be a valid timestamp.`);
702
+ }
703
+ }
704
+
705
+ function assertStringArray(field: string, value: unknown): asserts value is string[] {
706
+ if (!Array.isArray(value)) throw new Error(`${field} must be an array.`);
707
+ for (const [index, item] of value.entries()) {
708
+ assertString(`${field}[${index}]`, item);
709
+ }
710
+ }
711
+
712
+ function isNonNegativeInteger(value: unknown): value is number {
713
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
714
+ }
715
+
716
+ function normalizeTimestampValue(value: string | Date | undefined): string {
717
+ const timestamp =
718
+ value instanceof Date ? value.toISOString() : (value ?? new Date().toISOString());
719
+ if (Number.isNaN(new Date(timestamp).getTime())) {
720
+ throw new Error("Improvement eval timestamp must be valid.");
721
+ }
722
+ return timestamp;
723
+ }
724
+
725
+ export function validateEvolutionReviewCandidate(candidate: EvolutionReviewCandidate): void {
726
+ if (!isRecord(candidate)) throw new Error("Review candidate must be an object.");
727
+ if (candidate.schemaVersion !== 1) {
728
+ throw new Error("Review candidate schemaVersion must be 1.");
729
+ }
730
+ if (candidate.kind !== "evolution-review-candidate") {
731
+ throw new Error("Review candidate kind is invalid.");
732
+ }
733
+ assertString("reviewCandidate.id", candidate.id);
734
+ assertString("reviewCandidate.projectKey", candidate.projectKey);
735
+ assertString("reviewCandidate.runId", candidate.runId);
736
+ assertString("reviewCandidate.createdAt", candidate.createdAt);
737
+ assertString("reviewCandidate.candidateKind", candidate.candidateKind);
738
+ assertString("reviewCandidate.title", candidate.title);
739
+ assertString("reviewCandidate.targetStore", candidate.targetStore);
740
+ assertString("reviewCandidate.targetPath", candidate.targetPath);
741
+ assertString("reviewCandidate.stableKey", candidate.stableKey);
742
+ assertEnum("reviewCandidate.reviewState", candidate.reviewState, [
743
+ "needs-human",
744
+ "accepted",
745
+ "rejected",
746
+ "deferred",
747
+ ]);
748
+ if (
749
+ candidate.reviewStateChangedAt !== undefined &&
750
+ Number.isNaN(new Date(candidate.reviewStateChangedAt).getTime())
751
+ ) {
752
+ throw new Error("Review candidate reviewStateChangedAt must be a valid timestamp.");
753
+ }
754
+ if (!Array.isArray(candidate.reasons))
755
+ throw new Error("Review candidate reasons must be an array.");
756
+ for (const reason of candidate.reasons) assertString("reviewCandidate.reasons", reason);
757
+ if (!isRecord(candidate.provenance))
758
+ throw new Error("Review candidate provenance must be an object.");
759
+ assertString("reviewCandidate.provenance.runId", candidate.provenance.runId);
760
+ assertString(
761
+ "reviewCandidate.provenance.evidenceWindowId",
762
+ candidate.provenance.evidenceWindowId,
763
+ );
764
+ if (candidate.provenance.createdBy !== "evodev") {
765
+ throw new Error("Review candidate provenance.createdBy must be evodev.");
766
+ }
767
+ if (!Array.isArray(candidate.provenance.evidenceRefs)) {
768
+ throw new Error("Review candidate evidenceRefs must be an array.");
769
+ }
770
+ for (const evidenceRef of candidate.provenance.evidenceRefs) {
771
+ assertString("reviewCandidate.provenance.evidenceRefs", evidenceRef);
772
+ }
773
+ if (
774
+ candidate.provenance.rawLogsStored !== false ||
775
+ candidate.provenance.rawPromptsStored !== false ||
776
+ candidate.provenance.sourceDumpsStored !== false ||
777
+ candidate.provenance.rawCommandOutputStored !== false
778
+ ) {
779
+ throw new Error("Review candidate provenance must remain metadata-only.");
780
+ }
781
+ assertPrivacy(candidate.privacy);
782
+ assertNoForbiddenRawFields(candidate);
783
+ }
784
+
785
+ export function validateEvolutionTriggerRecord(trigger: EvolutionTriggerRecord): void {
786
+ if (!isRecord(trigger)) throw new Error("Evolution trigger must be an object.");
787
+ if (trigger.schemaVersion !== 1) throw new Error("Evolution trigger schemaVersion must be 1.");
788
+ if (trigger.kind !== "evolution-trigger") throw new Error("Evolution trigger kind is invalid.");
789
+ assertString("trigger.id", trigger.id);
790
+ assertString("trigger.projectKey", trigger.projectKey);
791
+ assertString("trigger.runId", trigger.runId);
792
+ assertEnum("trigger.eventType", trigger.eventType, NORMALIZED_EVENT_TYPES);
793
+ assertEnum("trigger.triggerStrength", trigger.triggerStrength, TRIGGER_STRENGTHS);
794
+ assertEnum("trigger.triggerReason", trigger.triggerReason, TRIGGER_REASONS);
795
+ assertEnum("trigger.status", trigger.status, TRIGGER_STATUSES);
796
+ assertString("trigger.summary", trigger.summary);
797
+ assertString("trigger.createdAt", trigger.createdAt);
798
+ assertString("trigger.updatedAt", trigger.updatedAt);
799
+ if (typeof trigger.attempts !== "number" || trigger.attempts < 0) {
800
+ throw new Error("trigger.attempts must be a non-negative number.");
801
+ }
802
+ if (trigger.rawContentStored !== false)
803
+ throw new Error("trigger.rawContentStored must be false.");
804
+ assertPrivacy(trigger.privacy);
805
+ assertNoForbiddenRawFields(trigger);
806
+ }
807
+
808
+ export function validateSegmentEvolutionTriggerRecord(
809
+ trigger: SegmentEvolutionTriggerRecord,
810
+ ): void {
811
+ if (!isRecord(trigger)) throw new Error("Segment evolution trigger must be an object.");
812
+ if (trigger.schemaVersion !== 1)
813
+ throw new Error("Segment evolution trigger schemaVersion must be 1.");
814
+ if (trigger.kind !== "segment-evolution-trigger") {
815
+ throw new Error("Segment evolution trigger kind is invalid.");
816
+ }
817
+ assertString("segmentTrigger.id", trigger.id);
818
+ assertString("segmentTrigger.projectKey", trigger.projectKey);
819
+ assertString("segmentTrigger.sessionKey", trigger.sessionKey);
820
+ assertString("segmentTrigger.segmentId", trigger.segmentId);
821
+ assertString("segmentTrigger.segmentPath", trigger.segmentPath);
822
+ assertEnum("segmentTrigger.strength", trigger.strength, SEGMENT_TRIGGER_STRENGTHS);
823
+ assertEnum("segmentTrigger.reason", trigger.reason, SEGMENT_TRIGGER_REASONS);
824
+ assertEnum("segmentTrigger.status", trigger.status, TRIGGER_STATUSES);
825
+ assertString("segmentTrigger.summary", trigger.summary);
826
+ assertString("segmentTrigger.createdAt", trigger.createdAt);
827
+ assertString("segmentTrigger.updatedAt", trigger.updatedAt);
828
+ if (trigger.runId !== null) assertString("segmentTrigger.runId", trigger.runId);
829
+ if (trigger.roleId !== null) assertString("segmentTrigger.roleId", trigger.roleId);
830
+ if (typeof trigger.attempts !== "number" || trigger.attempts < 0) {
831
+ throw new Error("segmentTrigger.attempts must be a non-negative number.");
832
+ }
833
+ if (trigger.rawContentStored !== false) {
834
+ throw new Error("segmentTrigger.rawContentStored must be false.");
835
+ }
836
+ }
837
+
838
+ export function parseKnowledgeRecord(value: unknown): EvolutionKnowledgeRecord {
839
+ validateEvolutionKnowledgeRecord(value as EvolutionKnowledgeRecord);
840
+ return value as EvolutionKnowledgeRecord;
841
+ }
842
+
843
+ export function parseEvosCase(value: unknown): EvolutionEvosCase {
844
+ validateEvolutionEvosCase(value as EvolutionEvosCase);
845
+ return value as EvolutionEvosCase;
846
+ }
847
+
848
+ export function parseRepoProposal(value: unknown): EvolutionRepoProposal {
849
+ validateEvolutionRepoProposal(value as EvolutionRepoProposal);
850
+ return value as EvolutionRepoProposal;
851
+ }
852
+
853
+ export function parseReviewCandidate(value: unknown): EvolutionReviewCandidate {
854
+ validateEvolutionReviewCandidate(value as EvolutionReviewCandidate);
855
+ return value as EvolutionReviewCandidate;
856
+ }
857
+
858
+ export function parseTrigger(value: unknown): EvolutionTriggerRecord {
859
+ validateEvolutionTriggerRecord(value as EvolutionTriggerRecord);
860
+ return value as EvolutionTriggerRecord;
861
+ }
862
+
863
+ export function parseSegmentTrigger(value: unknown): SegmentEvolutionTriggerRecord {
864
+ validateSegmentEvolutionTriggerRecord(value as SegmentEvolutionTriggerRecord);
865
+ return value as SegmentEvolutionTriggerRecord;
866
+ }
867
+
868
+ export async function listDirectoryNames(dir: string): Promise<string[]> {
869
+ if (!(await pathExists(dir))) return [];
870
+ const entries = await readdir(dir, { withFileTypes: true });
871
+ return entries
872
+ .filter((entry) => entry.isDirectory())
873
+ .map((entry) => entry.name)
874
+ .sort();
875
+ }
876
+
877
+ export function uniqueSorted(values: string[]): string[] {
878
+ return [...new Set(values)].sort();
879
+ }
880
+
881
+ export function collectRoleIds(evidenceWindow: EvolutionEvidenceWindow): string[] {
882
+ return uniqueSanitizedIds([
883
+ ...evidenceWindow.sourceRefs.flatMap((sourceRef) =>
884
+ sourceRef.roleId === null ? [] : [sourceRef.roleId],
885
+ ),
886
+ ...evidenceWindow.events.flatMap((event) => (event.roleId === null ? [] : [event.roleId])),
887
+ ]);
888
+ }
889
+
890
+ export function createPrivacyFields(value?: unknown): EvolutionPrivacyFields {
891
+ return {
892
+ classification: "local-private",
893
+ rawPromptsStored: false,
894
+ rawLogsStored: false,
895
+ sourceDumpsStored: false,
896
+ rawCommandOutputStored: false,
897
+ secretsStored: false,
898
+ internalLinksStored:
899
+ value !== undefined && detectSessionMemorySensitivity(value).reasons.includes("url"),
900
+ };
901
+ }
902
+
903
+ export function assertPrivacy(privacy: EvolutionPrivacyFields): void {
904
+ if (!isRecord(privacy)) throw new Error("privacy must be an object.");
905
+ if (privacy.classification !== "local-private") {
906
+ throw new Error("privacy.classification must be local-private.");
907
+ }
908
+ if (
909
+ privacy.rawPromptsStored !== false ||
910
+ privacy.rawLogsStored !== false ||
911
+ privacy.sourceDumpsStored !== false ||
912
+ privacy.rawCommandOutputStored !== false ||
913
+ privacy.secretsStored !== false
914
+ ) {
915
+ throw new Error("Evolution privacy fields must forbid raw content and credentials.");
916
+ }
917
+ if (typeof privacy.internalLinksStored !== "boolean") {
918
+ throw new Error("privacy.internalLinksStored must be boolean.");
919
+ }
920
+ }
921
+
922
+ export function assertProvenance(provenance: EvolutionKnowledgeRecord["provenance"]): void {
923
+ if (!isRecord(provenance)) throw new Error("provenance must be an object.");
924
+ assertString("provenance.runId", provenance.runId);
925
+ assertString("provenance.evidenceWindowId", provenance.evidenceWindowId);
926
+ assertString("provenance.createdAt", provenance.createdAt);
927
+ if (provenance.createdBy !== "evodev") throw new Error("provenance.createdBy must be evodev.");
928
+ if (
929
+ provenance.rawLogsStored !== false ||
930
+ provenance.rawPromptsStored !== false ||
931
+ provenance.sourceDumpsStored !== false ||
932
+ provenance.rawCommandOutputStored !== false
933
+ ) {
934
+ throw new Error(
935
+ "Evolution provenance cannot store raw logs, prompts, source, or command output.",
936
+ );
937
+ }
938
+ }
939
+
940
+ function assertEnum<T extends string>(
941
+ field: string,
942
+ value: unknown,
943
+ allowed: readonly T[],
944
+ ): asserts value is T {
945
+ if (typeof value !== "string" || !allowed.includes(value as T)) {
946
+ throw new Error(`${field} must be one of: ${allowed.join(", ")}`);
947
+ }
948
+ }
949
+
950
+ export function assertString(field: string, value: unknown): asserts value is string {
951
+ if (typeof value !== "string" || value.trim() === "") {
952
+ throw new Error(`${field} must be a non-empty string.`);
953
+ }
954
+ }
955
+
956
+ export function assertNoForbiddenRawFields(value: unknown, path = ""): void {
957
+ if (value === null || value === undefined) return;
958
+ if (typeof value === "string") {
959
+ if (detectSessionMemorySensitivity(value).classification === "credential") {
960
+ throw new Error(`Evolution record contains a credential at ${path || "value"}.`);
961
+ }
962
+ return;
963
+ }
964
+ if (typeof value !== "object") return;
965
+ if (Array.isArray(value)) {
966
+ value.forEach((item, index) => assertNoForbiddenRawFields(item, `${path}[${index}]`));
967
+ return;
968
+ }
969
+ for (const [key, child] of Object.entries(value)) {
970
+ const normalizedKey = key.toLowerCase().replace(/[^a-z0-9]/g, "");
971
+ if (FORBIDDEN_RAW_KEYS.has(normalizedKey)) {
972
+ throw new Error(
973
+ `Evolution record contains forbidden raw field: ${path ? `${path}.` : ""}${key}`,
974
+ );
975
+ }
976
+ assertNoForbiddenRawFields(child, path ? `${path}.${key}` : key);
977
+ }
978
+ }
979
+
980
+ export function sanitizeText(value: string): string {
981
+ return redactSessionMemoryCredentialText(value)
982
+ .value.replace(/\s+/g, " ")
983
+ .trim()
984
+ .slice(0, MAX_TEXT_LENGTH);
985
+ }
986
+
987
+ export function sanitizeProposedChange(value: string): string {
988
+ return redactSessionMemoryCredentialText(value)
989
+ .value.replaceAll("\r\n", "\n")
990
+ .replaceAll("\r", "\n")
991
+ .trim()
992
+ .slice(0, MAX_PROPOSED_CHANGE_LENGTH);
993
+ }
994
+
995
+ export function sanitizeId(value: string): string {
996
+ return (
997
+ value
998
+ .replace(/[^a-zA-Z0-9._/-]/g, "-")
999
+ .replace(/[\\/]+/g, "-")
1000
+ .slice(0, 160) || "local"
1001
+ );
1002
+ }
1003
+
1004
+ export function sanitizeStorageId(field: string, value: string): string {
1005
+ const sanitized = sanitizeId(value);
1006
+ if (sanitized === "." || sanitized === "..") {
1007
+ throw new Error(`Invalid evolution ${field}: ${value}`);
1008
+ }
1009
+ if (sanitized.includes("/") || sanitized.includes("\\")) {
1010
+ throw new Error(`Invalid evolution ${field}: ${value}`);
1011
+ }
1012
+ return sanitized;
1013
+ }
1014
+
1015
+ export function assertPathDescendant(root: string, candidate: string, field: string): void {
1016
+ const normalizedRoot = resolve(root);
1017
+ const normalizedCandidate = resolve(candidate);
1018
+ const relativePath = relative(normalizedRoot, normalizedCandidate);
1019
+ if (relativePath === "" || relativePath.startsWith("..") || isAbsolute(relativePath)) {
1020
+ throw new Error(`Evolution path ${field} escaped expected root.`);
1021
+ }
1022
+ }
1023
+
1024
+ export function sanitizeOptionalId(value: unknown): string | null {
1025
+ if (typeof value !== "string" || value.trim() === "") return null;
1026
+ return sanitizeId(value);
1027
+ }
1028
+
1029
+ export function uniqueSanitizedIds(values: string[]): string[] {
1030
+ return [...new Set(values.map(sanitizeId).filter((value) => value !== ""))];
1031
+ }
1032
+
1033
+ export function sanitizeRelativePath(value: string): string {
1034
+ const sanitized = value.replace(/\\/g, "/").replace(/^\/+/, "");
1035
+ if (sanitized === "" || sanitized.startsWith("../") || sanitized.includes("/../")) {
1036
+ throw new Error(`Invalid relative path in repo proposal: ${value}`);
1037
+ }
1038
+ return sanitized;
1039
+ }
1040
+
1041
+ export function displayPath(homeDir: string, path: string): string {
1042
+ const relativePath = relative(homeDir, path);
1043
+ if (relativePath !== "" && !relativePath.startsWith("..")) return `~/${relativePath}`;
1044
+ return path;
1045
+ }
1046
+
1047
+ export function optionalString(value: unknown): string | null {
1048
+ return typeof value === "string" && value.trim() !== "" ? value : null;
1049
+ }
1050
+
1051
+ export function isRecord(value: unknown): value is Record<string, unknown> {
1052
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1053
+ }