@evo-dev/core 0.0.1-alpha.4 → 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.
@@ -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
- }
@@ -278,6 +278,19 @@ export interface EvolutionKnowledgeRecord {
278
278
  };
279
279
  }
280
280
 
281
+ export interface EvolutionKnowledgeReviewHistoryRecord {
282
+ version: 1;
283
+ kind: "evolution-knowledge-review-state-changed";
284
+ knowledgeId: string;
285
+ projectKey: string;
286
+ roleTags: string[];
287
+ artifactCreatedAt: string;
288
+ previousReviewState: EvolutionReviewState;
289
+ nextReviewState: EvolutionReviewState;
290
+ changedAt: string;
291
+ metadataOnly: true;
292
+ }
293
+
281
294
  export interface EvolutionEvosCase {
282
295
  schemaVersion: 1;
283
296
  id: string;
@@ -327,13 +340,20 @@ export interface EvolutionRepoProposal {
327
340
  roleTags: string[];
328
341
  tags: string[];
329
342
  reviewState: "pending" | "accepted" | "rejected" | "deferred" | "applied";
343
+ reviewStateChangedAt?: string;
330
344
  confidence: EvolutionConfidence;
331
345
  targetRepoPath: string | null;
332
346
  plannedFiles: Array<{
333
347
  relativePath: string;
334
348
  action: "create" | "update";
335
349
  reason: string;
350
+ proposedChange?: string;
336
351
  }>;
352
+ lastDecision?: {
353
+ state: "accepted" | "rejected" | "deferred";
354
+ reason: string | null;
355
+ decidedAt: string;
356
+ };
337
357
  apply: {
338
358
  autoApply: false;
339
359
  requiresExplicitCommand: true;
@@ -366,7 +386,8 @@ export interface EvolutionReviewCandidate {
366
386
  targetStore: string;
367
387
  targetPath: string;
368
388
  stableKey: string;
369
- reviewState: "needs-human";
389
+ reviewState: "needs-human" | "accepted" | "rejected" | "deferred";
390
+ reviewStateChangedAt?: string;
370
391
  reasons: string[];
371
392
  candidate: unknown;
372
393
  provenance: {
@@ -455,6 +476,7 @@ export interface EvolutionResolvedPaths {
455
476
  knowledgeProjectDir: string;
456
477
  knowledgeRecordsDir: string;
457
478
  knowledgeIndexPath: string;
479
+ knowledgeReviewHistoryPath: string;
458
480
  evosCasesProjectDir: string;
459
481
  evosIndexPath: string;
460
482
  }
@@ -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