@evo-dev/core 0.0.1-alpha.15 → 0.0.1-alpha.17

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.
@@ -0,0 +1,446 @@
1
+ import { detectSessionMemorySensitivity } from "../evidence/session-memory/sensitivity.ts";
2
+ import {
3
+ type OkfKnowledgeChangeCandidateV1,
4
+ readOkfKnowledgeChangeCandidate,
5
+ replaceOkfKnowledgeChangeCandidate,
6
+ withOkfKnowledgeChangeLock,
7
+ } from "./change-store.ts";
8
+ import {
9
+ type OkfKnowledgeChangeState,
10
+ createOkfKnowledgeRevision,
11
+ createOkfKnowledgeRuntimeProjectionFromConcept,
12
+ } from "./changes.ts";
13
+ import {
14
+ type OkfKnowledgeActivationResult,
15
+ type OkfKnowledgePlan,
16
+ activateOkfKnowledgePlan,
17
+ listOkfKnowledgeConcepts,
18
+ readOkfKnowledgeConcept,
19
+ revokeOkfKnowledgeConcept,
20
+ supersedeOkfKnowledgeConcept,
21
+ } from "./index.ts";
22
+
23
+ export type OkfKnowledgeChangeReviewDecision = "accept" | "reject" | "defer";
24
+
25
+ export interface OkfKnowledgeChangeDecisionPreview {
26
+ changeId: string;
27
+ operation: OkfKnowledgeChangeCandidateV1["operation"];
28
+ currentState: OkfKnowledgeChangeState;
29
+ decision: OkfKnowledgeChangeReviewDecision;
30
+ baseRevision: string | null;
31
+ candidateRevision: string;
32
+ runtimeFields: string[];
33
+ willMutateActiveKnowledge: boolean;
34
+ }
35
+
36
+ export interface OkfKnowledgeChangeDecisionResult extends OkfKnowledgeChangeDecisionPreview {
37
+ state: OkfKnowledgeChangeState;
38
+ activation: OkfKnowledgeActivationResult | null;
39
+ conceptPaths: string[];
40
+ }
41
+
42
+ export async function previewOkfKnowledgeChangeDecision(input: {
43
+ homeDir: string;
44
+ changeId: string;
45
+ decision: OkfKnowledgeChangeReviewDecision;
46
+ expectedState: OkfKnowledgeChangeState;
47
+ expectedBaseRevision: string | null;
48
+ }): Promise<OkfKnowledgeChangeDecisionPreview> {
49
+ const { change } = await readOkfKnowledgeChangeCandidate({
50
+ homeDir: input.homeDir,
51
+ changeId: input.changeId,
52
+ });
53
+ await assertReviewPreconditions({
54
+ homeDir: input.homeDir,
55
+ change,
56
+ expectedState: input.expectedState,
57
+ expectedBaseRevision: input.expectedBaseRevision,
58
+ });
59
+ return toPreview(change, input.decision);
60
+ }
61
+
62
+ export async function decideOkfKnowledgeChange(input: {
63
+ homeDir: string;
64
+ changeId: string;
65
+ decision: OkfKnowledgeChangeReviewDecision;
66
+ expectedState: OkfKnowledgeChangeState;
67
+ expectedBaseRevision: string | null;
68
+ reason?: string | null;
69
+ now?: string | Date;
70
+ }): Promise<OkfKnowledgeChangeDecisionResult> {
71
+ const initial = await readOkfKnowledgeChangeCandidate({
72
+ homeDir: input.homeDir,
73
+ changeId: input.changeId,
74
+ });
75
+ return await withOkfKnowledgeChangeLock({
76
+ homeDir: input.homeDir,
77
+ projectKey: initial.change.projectKey,
78
+ stableKey: initial.change.stableKey,
79
+ conceptId: initial.change.base?.conceptId ?? null,
80
+ run: async () => {
81
+ const { change } = await readOkfKnowledgeChangeCandidate({
82
+ homeDir: input.homeDir,
83
+ changeId: input.changeId,
84
+ projectKey: initial.change.projectKey,
85
+ });
86
+ assertReviewInputPreconditions({
87
+ change,
88
+ expectedState: input.expectedState,
89
+ expectedBaseRevision: input.expectedBaseRevision,
90
+ });
91
+ if (change.state === "accepted" && input.decision !== "accept") {
92
+ throw new Error(
93
+ "Knowledge change state conflict: an accepted change can only resume acceptance.",
94
+ );
95
+ }
96
+ if (change.state === "accepted" && input.decision === "accept") {
97
+ const recovered = await readAlreadyAppliedKnowledgeChange({
98
+ homeDir: input.homeDir,
99
+ change,
100
+ });
101
+ if (recovered !== null) {
102
+ const completed: OkfKnowledgeChangeCandidateV1 = {
103
+ ...change,
104
+ state: "applied",
105
+ updatedAt: resolveNow(input.now),
106
+ };
107
+ await replaceOkfKnowledgeChangeCandidate({
108
+ homeDir: input.homeDir,
109
+ previous: change,
110
+ next: completed,
111
+ reason: input.reason ?? null,
112
+ });
113
+ return {
114
+ ...toPreview(change, input.decision),
115
+ state: "applied",
116
+ activation: null,
117
+ conceptPaths: recovered.conceptPaths,
118
+ };
119
+ }
120
+ }
121
+ try {
122
+ await assertReviewPreconditions({
123
+ homeDir: input.homeDir,
124
+ change,
125
+ expectedState: input.expectedState,
126
+ expectedBaseRevision: input.expectedBaseRevision,
127
+ });
128
+ } catch (error) {
129
+ if (isBaseRevisionConflict(error) && change.state !== "superseded") {
130
+ const superseded = {
131
+ ...change,
132
+ state: "superseded" as const,
133
+ updatedAt: resolveNow(input.now),
134
+ };
135
+ await replaceOkfKnowledgeChangeCandidate({
136
+ homeDir: input.homeDir,
137
+ previous: change,
138
+ next: superseded,
139
+ reason: "Base revision changed before review.",
140
+ });
141
+ }
142
+ throw error;
143
+ }
144
+
145
+ const reason = normalizeDecisionReason(input.reason);
146
+ const decidedAt = resolveNow(input.now);
147
+ const decisionState =
148
+ input.decision === "accept"
149
+ ? ("accepted" as const)
150
+ : input.decision === "reject"
151
+ ? ("rejected" as const)
152
+ : ("deferred" as const);
153
+ const decided =
154
+ change.state === "accepted" && input.decision === "accept"
155
+ ? change
156
+ : {
157
+ ...change,
158
+ state: decisionState,
159
+ decision: {
160
+ state: decisionState,
161
+ reason,
162
+ decidedAt,
163
+ },
164
+ updatedAt: decidedAt,
165
+ };
166
+ if (decided !== change) {
167
+ await replaceOkfKnowledgeChangeCandidate({
168
+ homeDir: input.homeDir,
169
+ previous: change,
170
+ next: decided,
171
+ reason,
172
+ });
173
+ }
174
+ const preview = toPreview(change, input.decision);
175
+ if (input.decision !== "accept") {
176
+ return {
177
+ ...preview,
178
+ state: decided.state,
179
+ activation: null,
180
+ conceptPaths: [],
181
+ };
182
+ }
183
+
184
+ const applied = await applyAcceptedChange({
185
+ homeDir: input.homeDir,
186
+ change: decided,
187
+ now: decidedAt,
188
+ reason,
189
+ });
190
+ const current = (
191
+ await readOkfKnowledgeChangeCandidate({
192
+ homeDir: input.homeDir,
193
+ changeId: input.changeId,
194
+ projectKey: change.projectKey,
195
+ })
196
+ ).change;
197
+ const completed: OkfKnowledgeChangeCandidateV1 = {
198
+ ...current,
199
+ state: "applied",
200
+ updatedAt: resolveNow(input.now),
201
+ };
202
+ await replaceOkfKnowledgeChangeCandidate({
203
+ homeDir: input.homeDir,
204
+ previous: current,
205
+ next: completed,
206
+ reason,
207
+ });
208
+ return {
209
+ ...preview,
210
+ state: "applied",
211
+ activation: applied.activation,
212
+ conceptPaths: applied.conceptPaths,
213
+ };
214
+ },
215
+ });
216
+ }
217
+
218
+ async function assertReviewPreconditions(input: {
219
+ homeDir: string;
220
+ change: OkfKnowledgeChangeCandidateV1;
221
+ expectedState: OkfKnowledgeChangeState;
222
+ expectedBaseRevision: string | null;
223
+ }): Promise<void> {
224
+ assertReviewInputPreconditions(input);
225
+ if (input.change.base === null) {
226
+ const concepts = await listOkfKnowledgeConcepts({ homeDir: input.homeDir });
227
+ if (
228
+ concepts.some(
229
+ (concept) =>
230
+ concept.stableKey === input.change.stableKey ||
231
+ concept.id === conceptIdFromTargetPath(input.change.targetPath),
232
+ )
233
+ ) {
234
+ throw createBaseRevisionConflict(
235
+ "Knowledge change base revision conflict: a concept now uses this stable key or target path.",
236
+ );
237
+ }
238
+ return;
239
+ }
240
+ const current = await readOkfKnowledgeConcept({
241
+ homeDir: input.homeDir,
242
+ conceptId: input.change.base.conceptId,
243
+ });
244
+ const currentRevision = createOkfKnowledgeRevision(
245
+ createOkfKnowledgeRuntimeProjectionFromConcept(current),
246
+ );
247
+ if (currentRevision !== input.change.base.revision) {
248
+ throw createBaseRevisionConflict(
249
+ `Knowledge change base revision conflict: expected ${input.change.base.revision}, found ${currentRevision}.`,
250
+ );
251
+ }
252
+ }
253
+
254
+ function assertReviewInputPreconditions(input: {
255
+ change: OkfKnowledgeChangeCandidateV1;
256
+ expectedState: OkfKnowledgeChangeState;
257
+ expectedBaseRevision: string | null;
258
+ }): void {
259
+ if (input.change.state !== input.expectedState) {
260
+ throw new Error(
261
+ `Knowledge change state conflict: expected ${input.expectedState}, found ${input.change.state}.`,
262
+ );
263
+ }
264
+ const storedBaseRevision = input.change.base?.revision ?? null;
265
+ if (storedBaseRevision !== input.expectedBaseRevision) {
266
+ throw new Error(
267
+ `Knowledge change base revision input does not match candidate: expected ${storedBaseRevision ?? "none"}.`,
268
+ );
269
+ }
270
+ }
271
+
272
+ async function readAlreadyAppliedKnowledgeChange(input: {
273
+ homeDir: string;
274
+ change: OkfKnowledgeChangeCandidateV1;
275
+ }): Promise<{ conceptPaths: string[] } | null> {
276
+ if (input.change.operation === "supersede") {
277
+ if (input.change.base === null) return null;
278
+ const replacement = await readConceptIfPresent(
279
+ input.homeDir,
280
+ conceptIdFromTargetPath(input.change.targetPath),
281
+ );
282
+ const previous = await readConceptIfPresent(input.homeDir, input.change.base.conceptId);
283
+ if (
284
+ replacement === null ||
285
+ previous === null ||
286
+ replacement.stableKey !== input.change.stableKey ||
287
+ previous.lifecycle.status !== "superseded" ||
288
+ previous.lifecycle.supersededBy !== replacement.id ||
289
+ !replacement.lifecycle.supersedes.includes(previous.id)
290
+ ) {
291
+ return null;
292
+ }
293
+ return { conceptPaths: [previous.path, replacement.path] };
294
+ }
295
+
296
+ const conceptId =
297
+ input.change.operation === "create"
298
+ ? conceptIdFromTargetPath(input.change.targetPath)
299
+ : input.change.base?.conceptId;
300
+ if (conceptId === undefined) return null;
301
+ const concept = await readConceptIfPresent(input.homeDir, conceptId);
302
+ if (concept === null || concept.stableKey !== input.change.stableKey) return null;
303
+ const revision = createOkfKnowledgeRevision(
304
+ createOkfKnowledgeRuntimeProjectionFromConcept(concept),
305
+ );
306
+ return revision === input.change.candidate.revision ? { conceptPaths: [concept.path] } : null;
307
+ }
308
+
309
+ async function readConceptIfPresent(
310
+ homeDir: string,
311
+ conceptId: string,
312
+ ): Promise<Awaited<ReturnType<typeof readOkfKnowledgeConcept>> | null> {
313
+ try {
314
+ return await readOkfKnowledgeConcept({ homeDir, conceptId });
315
+ } catch (error) {
316
+ if (error instanceof Error && /not found/iu.test(error.message)) return null;
317
+ throw error;
318
+ }
319
+ }
320
+
321
+ function conceptIdFromTargetPath(targetPath: string): string {
322
+ return targetPath.replace(/^\/+/u, "").replace(/\.md$/u, "");
323
+ }
324
+
325
+ function createBaseRevisionConflict(message: string): Error {
326
+ return Object.assign(new Error(message), {
327
+ code: "KNOWLEDGE_BASE_REVISION_CONFLICT",
328
+ });
329
+ }
330
+
331
+ async function applyAcceptedChange(input: {
332
+ homeDir: string;
333
+ change: OkfKnowledgeChangeCandidateV1;
334
+ now: string;
335
+ reason: string | null;
336
+ }): Promise<{ activation: OkfKnowledgeActivationResult | null; conceptPaths: string[] }> {
337
+ if (input.change.operation === "revoke") {
338
+ if (input.change.base === null) throw new Error("Knowledge revoke requires an active base.");
339
+ const result = await revokeOkfKnowledgeConcept({
340
+ homeDir: input.homeDir,
341
+ conceptId: input.change.base.conceptId,
342
+ reason: input.reason ?? input.change.candidate.planCandidate.decisionReason.slice(0, 500),
343
+ now: input.now,
344
+ });
345
+ return { activation: null, conceptPaths: result.conceptPaths };
346
+ }
347
+
348
+ const planCandidate = {
349
+ ...input.change.candidate.planCandidate,
350
+ decision: input.change.operation === "update" ? ("update" as const) : ("create" as const),
351
+ reviewState: "accepted" as const,
352
+ targetPath:
353
+ input.change.operation === "update" && input.change.base !== null
354
+ ? input.change.base.sourceLink.replace(/^\/+/u, "")
355
+ : input.change.targetPath,
356
+ };
357
+ const plan: OkfKnowledgePlan = {
358
+ schemaVersion: 1,
359
+ kind: "evodev-knowledge-plan",
360
+ projectKey: input.change.projectKey,
361
+ runId: input.change.runId,
362
+ createdAt: input.now,
363
+ evidenceWindowId: input.change.provenance.evidenceWindowId,
364
+ summary: `Apply reviewed knowledge change ${input.change.id}.`,
365
+ evidenceRefs: input.change.provenance.evidenceRefs.map((id) => ({
366
+ id,
367
+ kind: "knowledge-change-evidence",
368
+ source: `knowledge-change:${input.change.id}`,
369
+ rawContentStored: false,
370
+ externalContentCopied: false,
371
+ })),
372
+ evoEvalSets: input.change.candidate.evalSets,
373
+ candidates: [planCandidate],
374
+ droppedSignals: [],
375
+ conflicts: [],
376
+ privacyCheck: planCandidate.privacyCheck,
377
+ };
378
+ const activation = await activateOkfKnowledgePlan({
379
+ homeDir: input.homeDir,
380
+ plan,
381
+ overwrite: input.change.operation === "update",
382
+ reviewedChangeId: input.change.id,
383
+ });
384
+ const conceptPaths = [...activation.conceptPaths];
385
+ if (input.change.operation === "supersede") {
386
+ if (input.change.base === null) throw new Error("Knowledge supersede requires an active base.");
387
+ const replacementId = input.change.targetPath.replace(/^\/+/u, "").replace(/\.md$/u, "");
388
+ const lifecycle = await supersedeOkfKnowledgeConcept({
389
+ homeDir: input.homeDir,
390
+ oldConceptId: input.change.base.conceptId,
391
+ newConceptId: replacementId,
392
+ now: input.now,
393
+ });
394
+ conceptPaths.push(...lifecycle.conceptPaths);
395
+ }
396
+ return { activation, conceptPaths: [...new Set(conceptPaths)] };
397
+ }
398
+
399
+ function toPreview(
400
+ change: OkfKnowledgeChangeCandidateV1,
401
+ decision: OkfKnowledgeChangeReviewDecision,
402
+ ): OkfKnowledgeChangeDecisionPreview {
403
+ return {
404
+ changeId: change.id,
405
+ operation: change.operation,
406
+ currentState: change.state,
407
+ decision,
408
+ baseRevision: change.base?.revision ?? null,
409
+ candidateRevision: change.candidate.revision,
410
+ runtimeFields: change.diff.runtimeFields,
411
+ willMutateActiveKnowledge: decision === "accept",
412
+ };
413
+ }
414
+
415
+ function normalizeDecisionReason(value: string | null | undefined): string | null {
416
+ if (value === undefined || value === null || value.trim() === "") return null;
417
+ const reason = [...value]
418
+ .filter((character) => {
419
+ const code = character.charCodeAt(0);
420
+ return code === 9 || code === 10 || code === 13 || (code >= 32 && code !== 127);
421
+ })
422
+ .join("")
423
+ .trim();
424
+ if (reason.length > 500)
425
+ throw new Error("Knowledge review reason must be 500 characters or less.");
426
+ if (detectSessionMemorySensitivity(reason).classification === "credential") {
427
+ throw new Error("Knowledge review reason contains a credential.");
428
+ }
429
+ return reason;
430
+ }
431
+
432
+ function resolveNow(value: string | Date | undefined): string {
433
+ const date =
434
+ value instanceof Date ? value : typeof value === "string" ? new Date(value) : new Date();
435
+ if (!Number.isFinite(date.getTime())) throw new Error("Invalid knowledge review timestamp.");
436
+ return date.toISOString();
437
+ }
438
+
439
+ function isBaseRevisionConflict(error: unknown): boolean {
440
+ return (
441
+ typeof error === "object" &&
442
+ error !== null &&
443
+ "code" in error &&
444
+ error.code === "KNOWLEDGE_BASE_REVISION_CONFLICT"
445
+ );
446
+ }
@@ -62,6 +62,7 @@ export async function processEvolutionTriggers(
62
62
  pending: 0,
63
63
  triggerIds: [],
64
64
  batchIds: [],
65
+ pendingChangeIds: [],
65
66
  warnings,
66
67
  dryRun,
67
68
  };
@@ -108,6 +109,7 @@ export async function processEvolutionTriggers(
108
109
  ...pending.map((trigger) => trigger.id),
109
110
  ],
110
111
  batchIds: [],
112
+ pendingChangeIds: [],
111
113
  warnings,
112
114
  dryRun,
113
115
  };
@@ -215,6 +217,7 @@ export async function processEvolutionTriggers(
215
217
  });
216
218
  result.consumed += 1;
217
219
  result.batchIds.push(batch.id);
220
+ result.pendingChangeIds.push(...curated.activation.okf.pendingChangeIds);
218
221
  await updateSegmentTriggers(input.homeDir, [attemptedTrigger], {
219
222
  status: "consumed",
220
223
  updatedAt: now,
@@ -303,13 +306,14 @@ export async function processEvolutionTriggers(
303
306
  now,
304
307
  });
305
308
  if (lock !== null) await assertEvolutionProcessLockOwned(lock);
306
- await activateEvolutionDistillationBatch({
309
+ const activation = await activateEvolutionDistillationBatch({
307
310
  homeDir: input.homeDir,
308
311
  batch,
309
312
  overwrite: true,
310
313
  });
311
314
  result.consumed += triggers.length;
312
315
  result.batchIds.push(batch.id);
316
+ result.pendingChangeIds.push(...activation.okf.pendingChangeIds);
313
317
  await updateTriggers(input.homeDir, attemptedTriggers, {
314
318
  status: "consumed",
315
319
  updatedAt: now,
@@ -372,6 +372,30 @@ export interface EvolutionEvosCase {
372
372
  privacy: EvolutionPrivacyFields;
373
373
  }
374
374
 
375
+ export interface EvolutionRepoProposalCurrentState {
376
+ checkedAt: string;
377
+ headSha: string;
378
+ statusFingerprint: string;
379
+ verdict: "unresolved" | "resolved" | "unknown";
380
+ ownership: "governance" | "implementation" | "not-applicable";
381
+ explicitUserRequest: boolean;
382
+ checkedPaths: string[];
383
+ relatedCommits: string[];
384
+ summary: string;
385
+ }
386
+
387
+ export interface EvolutionRepoProposalRecurrence {
388
+ gapKey: string;
389
+ independentSessionCount: number;
390
+ sourceSessionKeys: string[];
391
+ }
392
+
393
+ export interface EvolutionRepoProposalSupersession {
394
+ policy: "session-proposal-v2";
395
+ reason: string;
396
+ supersededAt: string;
397
+ }
398
+
375
399
  export interface EvolutionRepoProposal {
376
400
  schemaVersion: 1;
377
401
  id: string;
@@ -382,10 +406,13 @@ export interface EvolutionRepoProposal {
382
406
  rationale: string;
383
407
  roleTags: string[];
384
408
  tags: string[];
385
- reviewState: "pending" | "accepted" | "rejected" | "deferred" | "applied";
409
+ reviewState: "pending" | "accepted" | "rejected" | "deferred" | "applied" | "superseded";
386
410
  reviewStateChangedAt?: string;
387
411
  confidence: EvolutionConfidence;
388
412
  targetRepoPath: string | null;
413
+ currentState?: EvolutionRepoProposalCurrentState;
414
+ recurrence?: EvolutionRepoProposalRecurrence;
415
+ supersession?: EvolutionRepoProposalSupersession;
389
416
  plannedFiles: Array<{
390
417
  relativePath: string;
391
418
  action: "create" | "update";
@@ -606,6 +633,7 @@ export interface EvolutionProcessResult {
606
633
  pending: number;
607
634
  triggerIds: string[];
608
635
  batchIds: string[];
636
+ pendingChangeIds: string[];
609
637
  warnings: string[];
610
638
  dryRun: boolean;
611
639
  }
@@ -363,6 +363,33 @@ export function createEvolutionRepoProposal(
363
363
  ? {}
364
364
  : { proposedChange: sanitizeProposedChange(file.proposedChange) }),
365
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
+ }),
366
393
  ...(input.improvementEval === undefined
367
394
  ? {}
368
395
  : { improvementEval: sanitizeEvolutionImprovementEval(input.improvementEval) }),
@@ -394,6 +421,7 @@ export function validateEvolutionRepoProposal(proposal: EvolutionRepoProposal):
394
421
  "rejected",
395
422
  "deferred",
396
423
  "applied",
424
+ "superseded",
397
425
  ]);
398
426
  if (
399
427
  proposal.reviewStateChangedAt !== undefined &&
@@ -406,6 +434,72 @@ export function validateEvolutionRepoProposal(proposal: EvolutionRepoProposal):
406
434
  throw new Error("Repo proposal proposedChange must not be empty.");
407
435
  }
408
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
+ }
409
503
  if (proposal.lastDecision !== undefined) {
410
504
  assertEnum("lastDecision.state", proposal.lastDecision.state, [
411
505
  "accepted",
@@ -601,6 +695,20 @@ function assertNullableTimestamp(field: string, value: string | null): void {
601
695
  }
602
696
  }
603
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
+
604
712
  function isNonNegativeInteger(value: unknown): value is number {
605
713
  return typeof value === "number" && Number.isInteger(value) && value >= 0;
606
714
  }
package/src/index.ts CHANGED
@@ -10,6 +10,10 @@ export * as evolutionEvidence from "./evolution/evidence/index.ts";
10
10
  export * from "./evolution/evidence/session-memory/index.ts";
11
11
  export * from "./evolution/knowledge/index.ts";
12
12
  export * as evolutionKnowledge from "./evolution/knowledge/index.ts";
13
+ export * from "./evolution/knowledge/changes.ts";
14
+ export * from "./evolution/knowledge/change-store.ts";
15
+ export * from "./evolution/knowledge/freshness.ts";
16
+ export * from "./evolution/knowledge/review.ts";
13
17
  export * as evolutionProcessor from "./evolution/processor/index.ts";
14
18
  export * from "./evolution/review/index.ts";
15
19
  export * as evolutionReview from "./evolution/review/index.ts";