@evo-dev/core 0.0.1-alpha.4 → 0.0.1-alpha.6

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.
@@ -1,4 +1,5 @@
1
1
  import { join } from "node:path";
2
+ import { resolveProjectWorkspaceFromCwd } from "../../../projects/index.ts";
2
3
  import {
3
4
  resolveProjectLogKey,
4
5
  resolveTraceSessionKey,
@@ -42,9 +43,15 @@ export async function updateSessionMemoryFromHook(
42
43
  environment: input.environment,
43
44
  payload: input.rawPayload,
44
45
  });
46
+ const cwd = optionalString(input.rawPayload.cwd);
47
+ const workspace =
48
+ team === null && cwd !== null
49
+ ? await resolveProjectWorkspaceFromCwd({ homeDir: input.homeDir, cwd })
50
+ : null;
45
51
  const projectKey = sanitizeStorageId(
46
52
  team?.projectKey ??
47
- resolveProjectLogKey(input.homeDir, optionalString(input.rawPayload.cwd) ?? input.homeDir),
53
+ workspace?.projectKey ??
54
+ resolveProjectLogKey(input.homeDir, cwd ?? input.homeDir),
48
55
  "project",
49
56
  );
50
57
  const sessionKey = sanitizeStorageId(resolveTraceSessionKey(input.rawPayload), "session");
@@ -6,7 +6,11 @@ export * from "./candidates/index.ts";
6
6
  export * from "./triggers/index.ts";
7
7
  export * from "./formatters.ts";
8
8
  export {
9
+ createEvolutionRepoProposal,
10
+ hasConcreteRepoProposalChanges,
11
+ sanitizeProposedChange,
9
12
  validateEvolutionDistillationBatch,
10
13
  validateEvolutionEvidenceWindow,
11
14
  validateEvolutionKnowledgeRecord,
15
+ validateEvolutionRepoProposal,
12
16
  } from "./shared.ts";
@@ -1656,10 +1656,10 @@ export function decideOkfKnowledgeCandidate(
1656
1656
  if (highRiskRequiresHuman) {
1657
1657
  return {
1658
1658
  ...sanitized,
1659
- decision: "needs-human",
1659
+ decision: "no_write",
1660
1660
  scores,
1661
- reviewState: "needs-human",
1662
- decisionReason: "Needs human review: high-risk domain requires review.",
1661
+ reviewState: "auto-stored/unreviewed",
1662
+ decisionReason: "No write: high-risk knowledge is not eligible for automatic activation.",
1663
1663
  };
1664
1664
  }
1665
1665
 
@@ -1677,12 +1677,12 @@ export function decideOkfKnowledgeCandidate(
1677
1677
  if (reusable) {
1678
1678
  return {
1679
1679
  ...sanitized,
1680
- decision: "needs-human",
1680
+ decision: "no_write",
1681
1681
  scores,
1682
- reviewState: "needs-human",
1682
+ reviewState: "auto-stored/unreviewed",
1683
1683
  decisionReason:
1684
1684
  conflict?.reason ??
1685
- explainNeedsHumanDecision(sanitized, scores, hasVerification, hasScopeTags),
1685
+ explainAutomaticNoWriteDecision(sanitized, scores, hasVerification, hasScopeTags),
1686
1686
  };
1687
1687
  }
1688
1688
 
@@ -1746,12 +1746,7 @@ export async function activateOkfKnowledgePlan(input: {
1746
1746
  continue;
1747
1747
  }
1748
1748
  if (candidate.decision === "needs-human") {
1749
- await writeNeedsHumanKnowledgeCandidate({
1750
- homeDir: input.homeDir,
1751
- plan: input.plan,
1752
- candidate,
1753
- });
1754
- needsHumanCandidates.push(candidate.id);
1749
+ skippedCandidates.push(candidate.id);
1755
1750
  continue;
1756
1751
  }
1757
1752
  if (candidate.targetStore !== "okf") {
@@ -3165,66 +3160,6 @@ function createOverlayUpdates(
3165
3160
  ];
3166
3161
  }
3167
3162
 
3168
- async function writeNeedsHumanKnowledgeCandidate(input: {
3169
- homeDir: string;
3170
- plan: OkfKnowledgePlan;
3171
- candidate: OkfKnowledgePlanCandidate;
3172
- }): Promise<string> {
3173
- const root = join(resolveEvoDevPaths(input.homeDir).stateDir, "evolution");
3174
- const targetDir = join(
3175
- root,
3176
- sanitizePlanStorageId("projectKey", input.plan.projectKey),
3177
- sanitizePlanStorageId("runId", input.plan.runId),
3178
- "review-candidates",
3179
- );
3180
- const targetPath = join(
3181
- targetDir,
3182
- `${sanitizePlanStorageId("candidateId", input.candidate.id)}.json`,
3183
- );
3184
- assertPathDescendant(root, targetPath, "reviewCandidatePath");
3185
- const candidateSnapshot = sanitizeReviewQueueValue(input.candidate);
3186
- await writeJson(
3187
- targetPath,
3188
- {
3189
- schemaVersion: 1,
3190
- kind: "evolution-review-candidate",
3191
- id: input.candidate.id,
3192
- projectKey: input.plan.projectKey,
3193
- runId: input.plan.runId,
3194
- createdAt: input.plan.createdAt,
3195
- candidateKind: input.candidate.kind,
3196
- title: sanitizeOkfText(input.candidate.title),
3197
- targetStore: input.candidate.targetStore,
3198
- targetPath: sanitizeOkfText(input.candidate.targetPath),
3199
- stableKey: sanitizeOkfText(input.candidate.stableKey),
3200
- reviewState: "needs-human",
3201
- reasons: [sanitizeOkfText(input.candidate.decisionReason)],
3202
- candidate: candidateSnapshot,
3203
- provenance: {
3204
- runId: input.plan.runId,
3205
- evidenceWindowId: input.plan.evidenceWindowId,
3206
- evidenceRefs: input.candidate.evidenceRefs.map(sanitizeOkfText),
3207
- createdBy: "evodev",
3208
- rawLogsStored: false,
3209
- rawPromptsStored: false,
3210
- sourceDumpsStored: false,
3211
- rawCommandOutputStored: false,
3212
- },
3213
- privacy: {
3214
- classification: "local-private",
3215
- rawPromptsStored: false,
3216
- rawLogsStored: false,
3217
- sourceDumpsStored: false,
3218
- rawCommandOutputStored: false,
3219
- secretsStored: false,
3220
- internalLinksStored: false,
3221
- },
3222
- },
3223
- { overwrite: true },
3224
- );
3225
- return targetPath;
3226
- }
3227
-
3228
3163
  function sanitizeOkfPlanCandidate(candidate: OkfKnowledgePlanCandidate): OkfKnowledgePlanCandidate {
3229
3164
  return sanitizeReviewQueueValue(candidate) as OkfKnowledgePlanCandidate;
3230
3165
  }
@@ -3292,7 +3227,7 @@ function hasHighRiskHumanReviewSignal(candidate: unknown): boolean {
3292
3227
  return HUMAN_REVIEW_DOMAIN_PATTERN.test(values.join(" "));
3293
3228
  }
3294
3229
 
3295
- function explainNeedsHumanDecision(
3230
+ function explainAutomaticNoWriteDecision(
3296
3231
  candidate: OkfKnowledgePlanCandidate,
3297
3232
  scores: OkfKnowledgeCandidateScores,
3298
3233
  hasVerification: boolean,
@@ -3308,7 +3243,7 @@ function explainNeedsHumanDecision(
3308
3243
  if (scores.privacyRisk > 2) reasons.push("privacy risk is above auto-accept");
3309
3244
  if (scores.stability < 3) reasons.push("stability is below auto-accept");
3310
3245
  if (scores.duplicationRisk > 2) reasons.push("duplication risk is above auto-accept");
3311
- return `Needs human review: ${reasons.join("; ") || "auto-accept gates were not all satisfied"}.`;
3246
+ return `No write: ${reasons.join("; ") || "auto-accept gates were not all satisfied"}.`;
3312
3247
  }
3313
3248
 
3314
3249
  function resolveCandidateReviewStateForWrite(
@@ -12,12 +12,14 @@ export function resolveEvolutionPaths(input: {
12
12
  const projectKey = sanitizeStorageId("projectKey", input.projectKey);
13
13
  const runId = sanitizeStorageId("runId", input.runId);
14
14
  const evolutionStateDir = join(paths.stateDir, "evolution");
15
+ const projectStateDir = join(evolutionStateDir, projectKey);
15
16
  const runStateDir = join(evolutionStateDir, projectKey, runId);
16
17
  const repoProposalsDir = join(runStateDir, "proposals");
17
18
  const reviewCandidatesDir = join(runStateDir, "review-candidates");
18
19
  const knowledgeProjectDir = join(paths.knowledgeDir, projectKey);
19
20
  const evosCasesProjectDir = join(paths.evosCasesDir, projectKey);
20
21
  assertPathDescendant(evolutionStateDir, runStateDir, "runStateDir");
22
+ assertPathDescendant(evolutionStateDir, projectStateDir, "projectStateDir");
21
23
  assertPathDescendant(evolutionStateDir, reviewCandidatesDir, "reviewCandidatesDir");
22
24
  assertPathDescendant(paths.knowledgeDir, knowledgeProjectDir, "knowledgeProjectDir");
23
25
  assertPathDescendant(paths.evosCasesDir, evosCasesProjectDir, "evosCasesProjectDir");
@@ -35,6 +37,7 @@ export function resolveEvolutionPaths(input: {
35
37
  knowledgeProjectDir,
36
38
  knowledgeRecordsDir: join(knowledgeProjectDir, "records"),
37
39
  knowledgeIndexPath: join(knowledgeProjectDir, "index.json"),
40
+ knowledgeReviewHistoryPath: join(projectStateDir, "knowledge-review-history.jsonl"),
38
41
  evosCasesProjectDir,
39
42
  evosIndexPath: paths.evosIndexPath,
40
43
  };
@@ -26,8 +26,8 @@ import {
26
26
  collectRoleIds,
27
27
  createEvolutionEvosCase,
28
28
  createEvolutionKnowledgeRecord,
29
- createEvolutionRepoProposal,
30
29
  createPrivacyFields,
30
+ hasConcreteRepoProposalChanges,
31
31
  listDirectoryNames,
32
32
  parseEvosCase,
33
33
  parseKnowledgeRecord,
@@ -36,6 +36,7 @@ import {
36
36
  validateEvolutionDistillationBatch,
37
37
  validateEvolutionEvidenceWindow,
38
38
  validateEvolutionEvosCase,
39
+ validateEvolutionRepoProposal,
39
40
  } from "../shared.ts";
40
41
  import { chooseEvosTriggerKind, createTriggerSummary } from "../triggers/classification.ts";
41
42
 
@@ -97,10 +98,7 @@ export function createEvolutionDistillationBatch(input: {
97
98
  relations: {
98
99
  relatedKnowledgeIds: [],
99
100
  evosCaseIds: [createStableId("evo", [input.evidenceWindow.id, "case"])],
100
- proposalIds:
101
- failedSignals.length === 0
102
- ? []
103
- : [createStableId("proposal", [input.evidenceWindow.id, "engineering-practice"])],
101
+ proposalIds: [],
104
102
  supersedes: [],
105
103
  },
106
104
  privacy: createPrivacyFields(),
@@ -146,34 +144,9 @@ export function createEvolutionDistillationBatch(input: {
146
144
  }),
147
145
  ];
148
146
 
149
- const repoProposals =
150
- failedSignals.length === 0
151
- ? []
152
- : [
153
- createEvolutionRepoProposal({
154
- id: createStableId("proposal", [input.evidenceWindow.id, "engineering-practice"]),
155
- kind: "engineering-practice",
156
- projectKey: input.evidenceWindow.projectKey,
157
- title: `Review repeated failure signals for ${input.evidenceWindow.runId}`,
158
- summary:
159
- "Failure or issue signals appeared in redacted run evidence; review whether repository rules, tests, skills, or role agents need improvement.",
160
- rationale:
161
- "EvoDev stores this as a proposal only. It must not modify the user repository until an explicit apply command is implemented and invoked.",
162
- roleTags: roleIds,
163
- tags: ["proposal", "failure-signal"],
164
- reviewState: "pending",
165
- confidence: "low",
166
- targetRepoPath: null,
167
- plannedFiles: [],
168
- apply: {
169
- autoApply: false,
170
- requiresExplicitCommand: true,
171
- rollbackPlan: "No repository files are changed by this proposal.",
172
- },
173
- provenance: createProvenance(input.evidenceWindow, createdAt, sourceRefs),
174
- privacy: createPrivacyFields(),
175
- }),
176
- ];
147
+ // Failure metadata is evidence, not a repository change proposal. A proposal is only valid
148
+ // after another stage supplies concrete target files and proposed changes.
149
+ const repoProposals: EvolutionRepoProposal[] = [];
177
150
 
178
151
  const batch: EvolutionDistillationBatch = {
179
152
  schemaVersion: 1,
@@ -302,6 +275,42 @@ export async function writeEvolutionDistillationBatch(input: {
302
275
  };
303
276
  }
304
277
 
278
+ export async function writeEvolutionRepoProposal(input: {
279
+ homeDir: string;
280
+ proposal: EvolutionRepoProposal;
281
+ }): Promise<{ proposalPath: string; indexPath: string }> {
282
+ validateEvolutionRepoProposal(input.proposal);
283
+ if (!hasConcreteRepoProposalChanges(input.proposal)) {
284
+ throw new Error("Repo proposal must include a target repository and concrete file changes.");
285
+ }
286
+ const paths = resolveEvolutionPaths({
287
+ homeDir: input.homeDir,
288
+ projectKey: input.proposal.projectKey,
289
+ runId: input.proposal.provenance.runId,
290
+ });
291
+ const proposalPath = join(paths.repoProposalsDir, `${input.proposal.id}.json`);
292
+ await writeJson(proposalPath, input.proposal, { overwrite: false });
293
+
294
+ const allRunProposals = await readJsonFiles(paths.repoProposalsDir, parseRepoProposal);
295
+ await writeJson(
296
+ paths.repoProposalsIndexPath,
297
+ {
298
+ schemaVersion: 1,
299
+ projectKey: input.proposal.projectKey,
300
+ runId: input.proposal.provenance.runId,
301
+ updatedAt: input.proposal.provenance.createdAt,
302
+ proposals: allRunProposals.map((proposal) => ({
303
+ id: proposal.id,
304
+ kind: proposal.kind,
305
+ title: proposal.title,
306
+ reviewState: proposal.reviewState,
307
+ })),
308
+ },
309
+ { overwrite: true },
310
+ );
311
+ return { proposalPath, indexPath: paths.repoProposalsIndexPath };
312
+ }
313
+
305
314
  export async function activateEvolutionDistillationBatch(input: {
306
315
  homeDir: string;
307
316
  batch: EvolutionDistillationBatch;
@@ -1,22 +1,14 @@
1
1
  import { mkdir, rm, stat, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import { resolveEvoDevPaths } from "../../config/paths.ts";
4
- import {
5
- createStableId,
6
- isFileExistsError,
7
- normalizeTimestamp,
8
- writeJsonFile as writeJson,
9
- } from "../../utils/index.ts";
4
+ import { createStableId, isFileExistsError, normalizeTimestamp } from "../../utils/index.ts";
10
5
  import { analyzeEvolutionRun } from "../evidence/analysis.ts";
11
- import { resolveEvolutionPaths } from "../paths.ts";
12
6
  import type {
13
7
  EvolutionProcessInput,
14
8
  EvolutionProcessResult,
15
- EvolutionReviewCandidate,
16
9
  EvolutionTriggerStatus,
17
- SegmentEvolutionTriggerRecord,
18
10
  } from "../schema.ts";
19
- import { createPrivacyFields, sanitizeText, validateEvolutionReviewCandidate } from "../shared.ts";
11
+ import { sanitizeText } from "../shared.ts";
20
12
  import {
21
13
  groupTriggersByRun,
22
14
  listEvolutionTriggers,
@@ -103,16 +95,11 @@ export async function processEvolutionTriggers(
103
95
  attempts: (current) => current.attempts + 1,
104
96
  });
105
97
  try {
106
- await writeSegmentReviewCandidate({
107
- homeDir: input.homeDir,
108
- trigger,
109
- now,
110
- });
111
98
  result.consumed += 1;
112
99
  await updateSegmentTriggers(input.homeDir, [trigger], {
113
100
  status: "consumed",
114
101
  updatedAt: now,
115
- processedBatchId: createStableId("segment-review", [trigger.id]),
102
+ processedBatchId: createStableId("segment-evidence", [trigger.id]),
116
103
  lastError: null,
117
104
  });
118
105
  } catch (error) {
@@ -246,61 +233,3 @@ async function acquireEvolutionProcessLock(
246
233
  async function releaseEvolutionProcessLock(lock: { path: string }): Promise<void> {
247
234
  await rm(lock.path, { force: true });
248
235
  }
249
-
250
- async function writeSegmentReviewCandidate(input: {
251
- homeDir: string;
252
- trigger: SegmentEvolutionTriggerRecord;
253
- now: string;
254
- }): Promise<EvolutionReviewCandidate> {
255
- const runId = input.trigger.runId ?? `session-${input.trigger.sessionKey}`;
256
- const paths = resolveEvolutionPaths({
257
- homeDir: input.homeDir,
258
- projectKey: input.trigger.projectKey,
259
- runId,
260
- });
261
- const candidate: EvolutionReviewCandidate = {
262
- schemaVersion: 1,
263
- kind: "evolution-review-candidate",
264
- id: createStableId("review", [input.trigger.id]),
265
- projectKey: input.trigger.projectKey,
266
- runId: paths.runId,
267
- createdAt: input.now,
268
- candidateKind: "session-memory-segment",
269
- title: sanitizeText(`Review session segment ${input.trigger.reason}`),
270
- targetStore: "session-memory-segments",
271
- targetPath: input.trigger.segmentPath,
272
- stableKey: createStableId("segment-candidate", [
273
- input.trigger.projectKey,
274
- input.trigger.sessionKey,
275
- input.trigger.segmentId,
276
- ]),
277
- reviewState: "needs-human",
278
- reasons: [input.trigger.reason, input.trigger.strength],
279
- candidate: {
280
- segmentId: input.trigger.segmentId,
281
- sessionKey: input.trigger.sessionKey,
282
- roleId: input.trigger.roleId,
283
- summary: input.trigger.summary,
284
- reviewAction:
285
- "Open the referenced local session segment for raw evidence before approving memory or OKF changes.",
286
- directOkfWrite: false,
287
- localOnly: true,
288
- },
289
- provenance: {
290
- runId: paths.runId,
291
- evidenceWindowId: input.trigger.segmentId,
292
- evidenceRefs: [input.trigger.id, input.trigger.segmentId],
293
- createdBy: "evodev",
294
- rawLogsStored: false,
295
- rawPromptsStored: false,
296
- sourceDumpsStored: false,
297
- rawCommandOutputStored: false,
298
- },
299
- privacy: createPrivacyFields(),
300
- };
301
- validateEvolutionReviewCandidate(candidate);
302
- await writeJson(join(paths.reviewCandidatesDir, `${candidate.id}.json`), candidate, {
303
- overwrite: true,
304
- });
305
- return candidate;
306
- }
@@ -31,7 +31,6 @@ export interface LearningCandidate {
31
31
  };
32
32
  provenance: {
33
33
  taskId: string | null;
34
- taskContractRef: string | null;
35
34
  workflowRunId: string | null;
36
35
  evidenceRefs: string[];
37
36
  sourceType:
@@ -198,7 +197,6 @@ export function createLearningCandidate(input: {
198
197
  },
199
198
  provenance: {
200
199
  taskId: sanitizeNullableId(input.provenance.taskId),
201
- taskContractRef: sanitizeNullableText(input.provenance.taskContractRef),
202
200
  workflowRunId: sanitizeNullableId(input.provenance.workflowRunId),
203
201
  evidenceRefs: input.provenance.evidenceRefs.map(sanitizeText),
204
202
  sourceType: input.provenance.sourceType,
@@ -626,13 +624,9 @@ async function parseJsonOrJsonlFile<T>(
626
624
  }
627
625
 
628
626
  function candidatePathFields(candidate: LearningCandidate): Array<[string, string]> {
629
- return [
630
- ["provenance.taskContractRef", candidate.provenance.taskContractRef],
631
- ...candidate.provenance.evidenceRefs.map((ref, index): [string, string] => [
632
- `provenance.evidenceRefs[${index}]`,
633
- ref,
634
- ]),
635
- ].filter((entry): entry is [string, string] => typeof entry[1] === "string");
627
+ return candidate.provenance.evidenceRefs
628
+ .map((ref, index): [string, string] => [`provenance.evidenceRefs[${index}]`, ref])
629
+ .filter((entry): entry is [string, string] => typeof entry[1] === "string");
636
630
  }
637
631
 
638
632
  function isCandidateStale(candidate: LearningCandidate, now: string | undefined): boolean {
@@ -4,7 +4,6 @@ export type EvolutionEvidenceSourceKind =
4
4
  | "evodev-execution-event"
5
5
  | "team-agent-trace"
6
6
  | "session-trace"
7
- | "task-contract"
8
7
  | "verification"
9
8
  | "review-summary"
10
9
  | "user-feedback"
@@ -278,6 +277,19 @@ export interface EvolutionKnowledgeRecord {
278
277
  };
279
278
  }
280
279
 
280
+ export interface EvolutionKnowledgeReviewHistoryRecord {
281
+ version: 1;
282
+ kind: "evolution-knowledge-review-state-changed";
283
+ knowledgeId: string;
284
+ projectKey: string;
285
+ roleTags: string[];
286
+ artifactCreatedAt: string;
287
+ previousReviewState: EvolutionReviewState;
288
+ nextReviewState: EvolutionReviewState;
289
+ changedAt: string;
290
+ metadataOnly: true;
291
+ }
292
+
281
293
  export interface EvolutionEvosCase {
282
294
  schemaVersion: 1;
283
295
  id: string;
@@ -327,13 +339,20 @@ export interface EvolutionRepoProposal {
327
339
  roleTags: string[];
328
340
  tags: string[];
329
341
  reviewState: "pending" | "accepted" | "rejected" | "deferred" | "applied";
342
+ reviewStateChangedAt?: string;
330
343
  confidence: EvolutionConfidence;
331
344
  targetRepoPath: string | null;
332
345
  plannedFiles: Array<{
333
346
  relativePath: string;
334
347
  action: "create" | "update";
335
348
  reason: string;
349
+ proposedChange?: string;
336
350
  }>;
351
+ lastDecision?: {
352
+ state: "accepted" | "rejected" | "deferred";
353
+ reason: string | null;
354
+ decidedAt: string;
355
+ };
337
356
  apply: {
338
357
  autoApply: false;
339
358
  requiresExplicitCommand: true;
@@ -366,7 +385,8 @@ export interface EvolutionReviewCandidate {
366
385
  targetStore: string;
367
386
  targetPath: string;
368
387
  stableKey: string;
369
- reviewState: "needs-human";
388
+ reviewState: "needs-human" | "accepted" | "rejected" | "deferred";
389
+ reviewStateChangedAt?: string;
370
390
  reasons: string[];
371
391
  candidate: unknown;
372
392
  provenance: {
@@ -455,6 +475,7 @@ export interface EvolutionResolvedPaths {
455
475
  knowledgeProjectDir: string;
456
476
  knowledgeRecordsDir: string;
457
477
  knowledgeIndexPath: string;
478
+ knowledgeReviewHistoryPath: string;
458
479
  evosCasesProjectDir: string;
459
480
  evosIndexPath: string;
460
481
  }
@@ -27,6 +27,7 @@ import type {
27
27
 
28
28
  export const MAX_EVIDENCE_EVENTS = 200;
29
29
  export const MAX_TEXT_LENGTH = 600;
30
+ export const MAX_PROPOSED_CHANGE_LENGTH = 16 * 1024;
30
31
  export const PROCESS_LOCK_STALE_MS = 5 * 60 * 1000;
31
32
  export const REVIEW_STATES: readonly EvolutionReviewState[] = [
32
33
  "auto-accepted",
@@ -330,6 +331,9 @@ export function createEvolutionRepoProposal(
330
331
  relativePath: sanitizeRelativePath(file.relativePath),
331
332
  action: file.action,
332
333
  reason: sanitizeText(file.reason),
334
+ ...(file.proposedChange === undefined
335
+ ? {}
336
+ : { proposedChange: sanitizeProposedChange(file.proposedChange) }),
333
337
  })),
334
338
  };
335
339
  validateEvolutionRepoProposal(proposal);
@@ -357,6 +361,33 @@ export function validateEvolutionRepoProposal(proposal: EvolutionRepoProposal):
357
361
  "deferred",
358
362
  "applied",
359
363
  ]);
364
+ if (
365
+ proposal.reviewStateChangedAt !== undefined &&
366
+ Number.isNaN(new Date(proposal.reviewStateChangedAt).getTime())
367
+ ) {
368
+ throw new Error("Repo proposal reviewStateChangedAt must be a valid timestamp.");
369
+ }
370
+ for (const file of proposal.plannedFiles) {
371
+ if (file.proposedChange !== undefined && file.proposedChange.trim() === "") {
372
+ throw new Error("Repo proposal proposedChange must not be empty.");
373
+ }
374
+ }
375
+ if (proposal.lastDecision !== undefined) {
376
+ assertEnum("lastDecision.state", proposal.lastDecision.state, [
377
+ "accepted",
378
+ "rejected",
379
+ "deferred",
380
+ ]);
381
+ if (
382
+ proposal.lastDecision.reason !== null &&
383
+ (proposal.lastDecision.reason.trim() === "" || proposal.lastDecision.reason.length > 500)
384
+ ) {
385
+ throw new Error("Repo proposal decision reason must be 1-500 characters.");
386
+ }
387
+ if (Number.isNaN(new Date(proposal.lastDecision.decidedAt).getTime())) {
388
+ throw new Error("Repo proposal decision timestamp must be valid.");
389
+ }
390
+ }
360
391
  if (proposal.apply.autoApply !== false || proposal.apply.requiresExplicitCommand !== true) {
361
392
  throw new Error("Repo proposal must require explicit apply.");
362
393
  }
@@ -365,6 +396,17 @@ export function validateEvolutionRepoProposal(proposal: EvolutionRepoProposal):
365
396
  assertNoForbiddenRawFields(proposal);
366
397
  }
367
398
 
399
+ export function hasConcreteRepoProposalChanges(proposal: EvolutionRepoProposal): boolean {
400
+ return (
401
+ proposal.targetRepoPath !== null &&
402
+ proposal.targetRepoPath.trim() !== "" &&
403
+ proposal.plannedFiles.length > 0 &&
404
+ proposal.plannedFiles.every(
405
+ (file) => file.proposedChange !== undefined && file.proposedChange.trim() !== "",
406
+ )
407
+ );
408
+ }
409
+
368
410
  export function validateEvolutionReviewCandidate(candidate: EvolutionReviewCandidate): void {
369
411
  if (!isRecord(candidate)) throw new Error("Review candidate must be an object.");
370
412
  if (candidate.schemaVersion !== 1) {
@@ -382,8 +424,17 @@ export function validateEvolutionReviewCandidate(candidate: EvolutionReviewCandi
382
424
  assertString("reviewCandidate.targetStore", candidate.targetStore);
383
425
  assertString("reviewCandidate.targetPath", candidate.targetPath);
384
426
  assertString("reviewCandidate.stableKey", candidate.stableKey);
385
- if (candidate.reviewState !== "needs-human") {
386
- throw new Error("Review candidate reviewState must be needs-human.");
427
+ assertEnum("reviewCandidate.reviewState", candidate.reviewState, [
428
+ "needs-human",
429
+ "accepted",
430
+ "rejected",
431
+ "deferred",
432
+ ]);
433
+ if (
434
+ candidate.reviewStateChangedAt !== undefined &&
435
+ Number.isNaN(new Date(candidate.reviewStateChangedAt).getTime())
436
+ ) {
437
+ throw new Error("Review candidate reviewStateChangedAt must be a valid timestamp.");
387
438
  }
388
439
  if (!Array.isArray(candidate.reasons))
389
440
  throw new Error("Review candidate reasons must be an array.");
@@ -616,6 +667,15 @@ export function sanitizeText(value: string): string {
616
667
  .slice(0, MAX_TEXT_LENGTH);
617
668
  }
618
669
 
670
+ export function sanitizeProposedChange(value: string): string {
671
+ return value
672
+ .replace(SENSITIVE_TEXT_REPLACE_PATTERN, "[redacted]")
673
+ .replaceAll("\r\n", "\n")
674
+ .replaceAll("\r", "\n")
675
+ .trim()
676
+ .slice(0, MAX_PROPOSED_CHANGE_LENGTH);
677
+ }
678
+
619
679
  export function sanitizeId(value: string): string {
620
680
  return (
621
681
  value