@evo-dev/core 0.0.1-alpha.14 → 0.0.1-alpha.16
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.
- package/dist/config/index.js +26 -2
- package/dist/index.js +12873 -11411
- package/package.json +1 -1
- package/src/config/settings.ts +18 -7
- package/src/evolution/candidates/index.ts +50 -0
- package/src/evolution/evidence/session-memory/retention.ts +2 -1
- package/src/evolution/knowledge/change-store.ts +558 -0
- package/src/evolution/knowledge/changes.ts +453 -0
- package/src/evolution/knowledge/freshness.ts +109 -0
- package/src/evolution/knowledge/index.ts +471 -55
- package/src/evolution/knowledge/review.ts +446 -0
- package/src/evolution/schema.ts +28 -1
- package/src/evolution/shared.ts +108 -0
- package/src/index.ts +4 -0
package/package.json
CHANGED
package/src/config/settings.ts
CHANGED
|
@@ -15,13 +15,21 @@ export interface PluginSettings {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export interface MemorySettings {
|
|
18
|
-
|
|
18
|
+
reviewKnowledgeUpdates: boolean;
|
|
19
19
|
runtimeInjection: boolean;
|
|
20
20
|
staleReview: boolean;
|
|
21
21
|
lexicalIndex: boolean;
|
|
22
22
|
sessionMemory: SessionMemoryPolicySnapshot;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
type LegacyMemorySettingsInput = Partial<MemorySettings> & {
|
|
26
|
+
/**
|
|
27
|
+
* Deprecated compatibility input. This flag was never connected to OKF activation and is
|
|
28
|
+
* intentionally omitted from canonical settings output.
|
|
29
|
+
*/
|
|
30
|
+
autoAccept?: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
25
33
|
export interface EvolutionSettings {
|
|
26
34
|
automation: {
|
|
27
35
|
knowledge: boolean;
|
|
@@ -70,7 +78,7 @@ export type SettingsInput = Partial<{
|
|
|
70
78
|
doctor: Partial<EvoDevSettings["doctor"]>;
|
|
71
79
|
hooks: unknown;
|
|
72
80
|
teamRuntime: Partial<TeamRuntimeSettings>;
|
|
73
|
-
memory:
|
|
81
|
+
memory: LegacyMemorySettingsInput;
|
|
74
82
|
evolution: Partial<{
|
|
75
83
|
automation: Partial<EvolutionSettings["automation"]>;
|
|
76
84
|
}>;
|
|
@@ -130,7 +138,7 @@ export function createDefaultTeamRuntimeSettings(): TeamRuntimeSettings {
|
|
|
130
138
|
|
|
131
139
|
export function createDefaultMemorySettings(): MemorySettings {
|
|
132
140
|
return {
|
|
133
|
-
|
|
141
|
+
reviewKnowledgeUpdates: true,
|
|
134
142
|
runtimeInjection: true,
|
|
135
143
|
staleReview: true,
|
|
136
144
|
lexicalIndex: true,
|
|
@@ -294,11 +302,14 @@ function parseEvolutionSettings(value: unknown, path: string): EvolutionSettings
|
|
|
294
302
|
function parseMemorySettings(value: unknown, path: string): MemorySettings {
|
|
295
303
|
const input = expectRecord(value, path);
|
|
296
304
|
const defaults = createDefaultMemorySettings();
|
|
305
|
+
if (input.autoAccept !== undefined) {
|
|
306
|
+
expectBoolean(input.autoAccept, `${path}.autoAccept`);
|
|
307
|
+
}
|
|
297
308
|
return {
|
|
298
|
-
|
|
299
|
-
input.
|
|
300
|
-
? defaults.
|
|
301
|
-
: expectBoolean(input.
|
|
309
|
+
reviewKnowledgeUpdates:
|
|
310
|
+
input.reviewKnowledgeUpdates === undefined
|
|
311
|
+
? defaults.reviewKnowledgeUpdates
|
|
312
|
+
: expectBoolean(input.reviewKnowledgeUpdates, `${path}.reviewKnowledgeUpdates`),
|
|
302
313
|
runtimeInjection:
|
|
303
314
|
input.runtimeInjection === undefined
|
|
304
315
|
? defaults.runtimeInjection
|
|
@@ -437,6 +437,56 @@ export async function updateEvolutionRepoProposalReviewState(input: {
|
|
|
437
437
|
);
|
|
438
438
|
}
|
|
439
439
|
|
|
440
|
+
export async function supersedeEvolutionRepoProposal(input: {
|
|
441
|
+
homeDir: string;
|
|
442
|
+
proposalId: string;
|
|
443
|
+
projectKey?: string;
|
|
444
|
+
expectedReviewState?: EvolutionRepoProposal["reviewState"];
|
|
445
|
+
reason: string;
|
|
446
|
+
now?: string | Date;
|
|
447
|
+
}): Promise<{ path: string; record: EvolutionRepoProposal; changed: boolean }> {
|
|
448
|
+
return await withEvolutionReviewDecisionLock(
|
|
449
|
+
{ homeDir: input.homeDir, kind: "repo-proposal", itemId: input.proposalId },
|
|
450
|
+
async () => {
|
|
451
|
+
const record = await readEvolutionRepoProposalById(input);
|
|
452
|
+
const paths = resolveEvolutionPaths({
|
|
453
|
+
homeDir: input.homeDir,
|
|
454
|
+
projectKey: record.projectKey,
|
|
455
|
+
runId: record.provenance.runId,
|
|
456
|
+
});
|
|
457
|
+
const path = join(paths.repoProposalsDir, `${record.id}.json`);
|
|
458
|
+
if (record.reviewState === "superseded") return { path, record, changed: false };
|
|
459
|
+
if (
|
|
460
|
+
input.expectedReviewState !== undefined &&
|
|
461
|
+
record.reviewState !== input.expectedReviewState
|
|
462
|
+
) {
|
|
463
|
+
throw new Error("Repo proposal review state changed before supersession was applied.");
|
|
464
|
+
}
|
|
465
|
+
if (record.reviewState !== "pending" && record.reviewState !== "deferred") {
|
|
466
|
+
throw new Error("Only pending or deferred repo proposals can be superseded.");
|
|
467
|
+
}
|
|
468
|
+
const reason = input.reason.trim();
|
|
469
|
+
if (reason === "" || reason.length > 500) {
|
|
470
|
+
throw new Error("Repo proposal supersession reason must be 1-500 characters.");
|
|
471
|
+
}
|
|
472
|
+
const changedAt = normalizeTimestamp(input.now);
|
|
473
|
+
const next: EvolutionRepoProposal = {
|
|
474
|
+
...record,
|
|
475
|
+
reviewState: "superseded",
|
|
476
|
+
reviewStateChangedAt: changedAt,
|
|
477
|
+
supersession: {
|
|
478
|
+
policy: "session-proposal-v2",
|
|
479
|
+
reason: sanitizeText(reason),
|
|
480
|
+
supersededAt: changedAt,
|
|
481
|
+
},
|
|
482
|
+
};
|
|
483
|
+
validateEvolutionRepoProposal(next);
|
|
484
|
+
await writeRepoProposalAndIndex({ homeDir: input.homeDir, proposal: next, changedAt });
|
|
485
|
+
return { path, record: next, changed: true };
|
|
486
|
+
},
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
440
490
|
export async function updateEvolutionRepoProposalImprovementEval(input: {
|
|
441
491
|
homeDir: string;
|
|
442
492
|
proposalId: string;
|
|
@@ -312,7 +312,8 @@ function hasPendingDerivedReview(
|
|
|
312
312
|
(proposal) =>
|
|
313
313
|
proposal.provenance.runId === runId &&
|
|
314
314
|
proposal.reviewState !== "rejected" &&
|
|
315
|
-
proposal.reviewState !== "applied"
|
|
315
|
+
proposal.reviewState !== "applied" &&
|
|
316
|
+
proposal.reviewState !== "superseded",
|
|
316
317
|
)
|
|
317
318
|
) {
|
|
318
319
|
return true;
|
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
appendFile,
|
|
4
|
+
mkdir,
|
|
5
|
+
readFile,
|
|
6
|
+
readdir,
|
|
7
|
+
rename,
|
|
8
|
+
rm,
|
|
9
|
+
stat,
|
|
10
|
+
writeFile,
|
|
11
|
+
} from "node:fs/promises";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { resolveEvoDevPaths } from "../../config/paths.ts";
|
|
14
|
+
import type {
|
|
15
|
+
OkfKnowledgeChangeOperation,
|
|
16
|
+
OkfKnowledgeChangeState,
|
|
17
|
+
OkfKnowledgeRuntimeDiffV1,
|
|
18
|
+
OkfKnowledgeRuntimeProjectionV1,
|
|
19
|
+
} from "./changes.ts";
|
|
20
|
+
import type { OkfKnowledgeFreshness } from "./freshness.ts";
|
|
21
|
+
import type { OkfKnowledgeEvalSet, OkfKnowledgePlanCandidate } from "./index.ts";
|
|
22
|
+
|
|
23
|
+
export interface OkfKnowledgeChangeCandidateV1 {
|
|
24
|
+
schemaVersion: 1;
|
|
25
|
+
kind: "okf-knowledge-change-candidate";
|
|
26
|
+
id: string;
|
|
27
|
+
projectKey: string;
|
|
28
|
+
runId: string;
|
|
29
|
+
operation: OkfKnowledgeChangeOperation;
|
|
30
|
+
state: OkfKnowledgeChangeState;
|
|
31
|
+
stableKey: string;
|
|
32
|
+
targetPath: string;
|
|
33
|
+
base: null | {
|
|
34
|
+
conceptId: string;
|
|
35
|
+
sourceLink: string;
|
|
36
|
+
revision: string;
|
|
37
|
+
runtimeProjection: OkfKnowledgeRuntimeProjectionV1;
|
|
38
|
+
};
|
|
39
|
+
candidate: {
|
|
40
|
+
revision: string;
|
|
41
|
+
planCandidate: OkfKnowledgePlanCandidate;
|
|
42
|
+
evalSets: OkfKnowledgeEvalSet[];
|
|
43
|
+
};
|
|
44
|
+
diff: OkfKnowledgeRuntimeDiffV1;
|
|
45
|
+
freshness: {
|
|
46
|
+
before: OkfKnowledgeFreshness | null;
|
|
47
|
+
after: OkfKnowledgeFreshness;
|
|
48
|
+
reason: string;
|
|
49
|
+
};
|
|
50
|
+
provenance: {
|
|
51
|
+
evidenceWindowId: string;
|
|
52
|
+
evidenceRefs: string[];
|
|
53
|
+
createdAt: string;
|
|
54
|
+
rawContentStored: false;
|
|
55
|
+
};
|
|
56
|
+
decision: null | {
|
|
57
|
+
state: "accepted" | "rejected" | "deferred";
|
|
58
|
+
reason: string | null;
|
|
59
|
+
decidedAt: string;
|
|
60
|
+
};
|
|
61
|
+
updatedAt: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface OkfKnowledgeChangeHistoryEntryV1 {
|
|
65
|
+
schemaVersion: 1;
|
|
66
|
+
kind: "okf-knowledge-change-history";
|
|
67
|
+
changeId: string;
|
|
68
|
+
projectKey: string;
|
|
69
|
+
operation: OkfKnowledgeChangeOperation;
|
|
70
|
+
fromState: OkfKnowledgeChangeState | null;
|
|
71
|
+
toState: OkfKnowledgeChangeState;
|
|
72
|
+
baseRevision: string | null;
|
|
73
|
+
candidateRevision: string;
|
|
74
|
+
runtimeFields: string[];
|
|
75
|
+
reason: string | null;
|
|
76
|
+
changedAt: string;
|
|
77
|
+
rawContentStored: false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class OkfKnowledgeChangeLockError extends Error {
|
|
81
|
+
readonly code = "KNOWLEDGE_CHANGE_LOCK_BUSY";
|
|
82
|
+
|
|
83
|
+
constructor() {
|
|
84
|
+
super("Knowledge change is currently being reviewed by another process.");
|
|
85
|
+
this.name = "OkfKnowledgeChangeLockError";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface KnowledgeChangePaths {
|
|
90
|
+
rootDir: string;
|
|
91
|
+
projectDir: string;
|
|
92
|
+
candidatesDir: string;
|
|
93
|
+
historyPath: string;
|
|
94
|
+
locksDir: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const KNOWLEDGE_CHANGE_LOCK_STALE_MS = 5 * 60 * 1000;
|
|
98
|
+
|
|
99
|
+
export function createOkfKnowledgeChangeCandidate(input: {
|
|
100
|
+
projectKey: string;
|
|
101
|
+
runId: string;
|
|
102
|
+
operation: OkfKnowledgeChangeOperation;
|
|
103
|
+
stableKey: string;
|
|
104
|
+
targetPath: string;
|
|
105
|
+
base: OkfKnowledgeChangeCandidateV1["base"];
|
|
106
|
+
candidateRevision: string;
|
|
107
|
+
planCandidate: OkfKnowledgePlanCandidate;
|
|
108
|
+
evalSets?: OkfKnowledgeEvalSet[];
|
|
109
|
+
diff: OkfKnowledgeRuntimeDiffV1;
|
|
110
|
+
freshness: OkfKnowledgeChangeCandidateV1["freshness"];
|
|
111
|
+
evidenceWindowId: string;
|
|
112
|
+
evidenceRefs: string[];
|
|
113
|
+
createdAt: string;
|
|
114
|
+
}): OkfKnowledgeChangeCandidateV1 {
|
|
115
|
+
const projectKey = sanitizePathSegment(input.projectKey, "project key");
|
|
116
|
+
const createdAt = normalizeTimestamp(input.createdAt);
|
|
117
|
+
const seed = [
|
|
118
|
+
projectKey,
|
|
119
|
+
input.runId,
|
|
120
|
+
input.operation,
|
|
121
|
+
input.stableKey,
|
|
122
|
+
input.base?.revision ?? "none",
|
|
123
|
+
input.candidateRevision,
|
|
124
|
+
].join("\0");
|
|
125
|
+
return {
|
|
126
|
+
schemaVersion: 1,
|
|
127
|
+
kind: "okf-knowledge-change-candidate",
|
|
128
|
+
id: `knowledge-change-${createHash("sha256").update(seed).digest("hex").slice(0, 24)}`,
|
|
129
|
+
projectKey,
|
|
130
|
+
runId: sanitizeText(input.runId),
|
|
131
|
+
operation: input.operation,
|
|
132
|
+
state: "pending",
|
|
133
|
+
stableKey: sanitizeText(input.stableKey),
|
|
134
|
+
targetPath: sanitizeTargetPath(input.targetPath),
|
|
135
|
+
base: input.base,
|
|
136
|
+
candidate: {
|
|
137
|
+
revision: input.candidateRevision,
|
|
138
|
+
planCandidate: input.planCandidate,
|
|
139
|
+
evalSets: input.evalSets ?? [],
|
|
140
|
+
},
|
|
141
|
+
diff: {
|
|
142
|
+
runtimeFields: [...new Set(input.diff.runtimeFields)].sort(),
|
|
143
|
+
metadataFields: [...new Set(input.diff.metadataFields)].sort(),
|
|
144
|
+
summary: sanitizeText(input.diff.summary),
|
|
145
|
+
},
|
|
146
|
+
freshness: {
|
|
147
|
+
before: input.freshness.before,
|
|
148
|
+
after: input.freshness.after,
|
|
149
|
+
reason: sanitizeText(input.freshness.reason),
|
|
150
|
+
},
|
|
151
|
+
provenance: {
|
|
152
|
+
evidenceWindowId: sanitizeText(input.evidenceWindowId),
|
|
153
|
+
evidenceRefs: [...new Set(input.evidenceRefs.map(sanitizeText))].sort(),
|
|
154
|
+
createdAt,
|
|
155
|
+
rawContentStored: false,
|
|
156
|
+
},
|
|
157
|
+
decision: null,
|
|
158
|
+
updatedAt: createdAt,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function writeOkfKnowledgeChangeCandidate(input: {
|
|
163
|
+
homeDir: string;
|
|
164
|
+
change: OkfKnowledgeChangeCandidateV1;
|
|
165
|
+
}): Promise<{ path: string; change: OkfKnowledgeChangeCandidateV1 }> {
|
|
166
|
+
validateOkfKnowledgeChangeCandidate(input.change);
|
|
167
|
+
const paths = resolveKnowledgeChangePaths(input.homeDir, input.change.projectKey);
|
|
168
|
+
const path = join(paths.candidatesDir, `${input.change.id}.json`);
|
|
169
|
+
await mkdir(paths.candidatesDir, { recursive: true, mode: 0o700 });
|
|
170
|
+
try {
|
|
171
|
+
const existing = await readOkfKnowledgeChangeCandidate({
|
|
172
|
+
homeDir: input.homeDir,
|
|
173
|
+
changeId: input.change.id,
|
|
174
|
+
projectKey: input.change.projectKey,
|
|
175
|
+
});
|
|
176
|
+
if (JSON.stringify(existing.change) !== JSON.stringify(input.change)) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`Knowledge change id already exists with different content: ${input.change.id}`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
return existing;
|
|
182
|
+
} catch (error) {
|
|
183
|
+
if (!isNotFoundError(error)) throw error;
|
|
184
|
+
}
|
|
185
|
+
await atomicWriteJson(path, input.change);
|
|
186
|
+
await appendOkfKnowledgeChangeHistory({
|
|
187
|
+
homeDir: input.homeDir,
|
|
188
|
+
change: input.change,
|
|
189
|
+
fromState: null,
|
|
190
|
+
reason: null,
|
|
191
|
+
});
|
|
192
|
+
return { path, change: input.change };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function listOkfKnowledgeChangeCandidates(input: {
|
|
196
|
+
homeDir: string;
|
|
197
|
+
projectKey?: string;
|
|
198
|
+
state?: OkfKnowledgeChangeState;
|
|
199
|
+
}): Promise<OkfKnowledgeChangeCandidateV1[]> {
|
|
200
|
+
const rootDir = join(
|
|
201
|
+
resolveEvoDevPaths(input.homeDir).stateDir,
|
|
202
|
+
"evolution",
|
|
203
|
+
"knowledge-changes",
|
|
204
|
+
);
|
|
205
|
+
const projectKeys =
|
|
206
|
+
input.projectKey === undefined
|
|
207
|
+
? await listDirectoryNames(rootDir)
|
|
208
|
+
: [sanitizePathSegment(input.projectKey, "project key")];
|
|
209
|
+
const changes: OkfKnowledgeChangeCandidateV1[] = [];
|
|
210
|
+
for (const projectKey of projectKeys) {
|
|
211
|
+
const paths = resolveKnowledgeChangePaths(input.homeDir, projectKey);
|
|
212
|
+
for (const name of await listJsonFileNames(paths.candidatesDir)) {
|
|
213
|
+
const value = JSON.parse(await readFile(join(paths.candidatesDir, name), "utf8")) as unknown;
|
|
214
|
+
const change = parseOkfKnowledgeChangeCandidate(value);
|
|
215
|
+
if (input.state === undefined || change.state === input.state) changes.push(change);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return changes.sort((left, right) => {
|
|
219
|
+
const updated = right.updatedAt.localeCompare(left.updatedAt);
|
|
220
|
+
return updated === 0 ? left.id.localeCompare(right.id) : updated;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function readOkfKnowledgeChangeCandidate(input: {
|
|
225
|
+
homeDir: string;
|
|
226
|
+
changeId: string;
|
|
227
|
+
projectKey?: string;
|
|
228
|
+
}): Promise<{ path: string; change: OkfKnowledgeChangeCandidateV1 }> {
|
|
229
|
+
const changeId = sanitizeChangeId(input.changeId);
|
|
230
|
+
if (input.projectKey !== undefined) {
|
|
231
|
+
const paths = resolveKnowledgeChangePaths(input.homeDir, input.projectKey);
|
|
232
|
+
const path = join(paths.candidatesDir, `${changeId}.json`);
|
|
233
|
+
const change = parseOkfKnowledgeChangeCandidate(
|
|
234
|
+
JSON.parse(await readFile(path, "utf8")) as unknown,
|
|
235
|
+
);
|
|
236
|
+
return { path, change };
|
|
237
|
+
}
|
|
238
|
+
const matches = await listOkfKnowledgeChangeCandidates({ homeDir: input.homeDir });
|
|
239
|
+
const filtered = matches.filter((change) => change.id === changeId);
|
|
240
|
+
if (filtered.length === 0) throw createNotFoundError(`Knowledge change not found: ${changeId}`);
|
|
241
|
+
if (filtered.length > 1) throw new Error(`Knowledge change id is ambiguous: ${changeId}`);
|
|
242
|
+
const change = filtered[0] as OkfKnowledgeChangeCandidateV1;
|
|
243
|
+
const paths = resolveKnowledgeChangePaths(input.homeDir, change.projectKey);
|
|
244
|
+
return { path: join(paths.candidatesDir, `${change.id}.json`), change };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function replaceOkfKnowledgeChangeCandidate(input: {
|
|
248
|
+
homeDir: string;
|
|
249
|
+
previous: OkfKnowledgeChangeCandidateV1;
|
|
250
|
+
next: OkfKnowledgeChangeCandidateV1;
|
|
251
|
+
reason: string | null;
|
|
252
|
+
}): Promise<{ path: string; change: OkfKnowledgeChangeCandidateV1 }> {
|
|
253
|
+
validateOkfKnowledgeChangeCandidate(input.previous);
|
|
254
|
+
validateOkfKnowledgeChangeCandidate(input.next);
|
|
255
|
+
if (input.previous.id !== input.next.id || input.previous.projectKey !== input.next.projectKey) {
|
|
256
|
+
throw new Error("Knowledge change identity cannot be replaced.");
|
|
257
|
+
}
|
|
258
|
+
const paths = resolveKnowledgeChangePaths(input.homeDir, input.next.projectKey);
|
|
259
|
+
const path = join(paths.candidatesDir, `${input.next.id}.json`);
|
|
260
|
+
const current = parseOkfKnowledgeChangeCandidate(
|
|
261
|
+
JSON.parse(await readFile(path, "utf8")) as unknown,
|
|
262
|
+
);
|
|
263
|
+
if (
|
|
264
|
+
current.state !== input.previous.state ||
|
|
265
|
+
current.updatedAt !== input.previous.updatedAt ||
|
|
266
|
+
current.candidate.revision !== input.previous.candidate.revision
|
|
267
|
+
) {
|
|
268
|
+
throw new Error("Knowledge change was modified by another decision.");
|
|
269
|
+
}
|
|
270
|
+
await atomicWriteJson(path, input.next);
|
|
271
|
+
await appendOkfKnowledgeChangeHistory({
|
|
272
|
+
homeDir: input.homeDir,
|
|
273
|
+
change: input.next,
|
|
274
|
+
fromState: input.previous.state,
|
|
275
|
+
reason: input.reason,
|
|
276
|
+
});
|
|
277
|
+
return { path, change: input.next };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export async function withOkfKnowledgeChangeLock<T>(input: {
|
|
281
|
+
homeDir: string;
|
|
282
|
+
projectKey: string;
|
|
283
|
+
stableKey: string;
|
|
284
|
+
conceptId: string | null;
|
|
285
|
+
run: () => Promise<T>;
|
|
286
|
+
}): Promise<T> {
|
|
287
|
+
const paths = resolveKnowledgeChangePaths(input.homeDir, input.projectKey);
|
|
288
|
+
await mkdir(paths.locksDir, { recursive: true, mode: 0o700 });
|
|
289
|
+
const lockKey = createHash("sha256")
|
|
290
|
+
.update(`${input.stableKey}\0${input.conceptId ?? "new"}`)
|
|
291
|
+
.digest("hex")
|
|
292
|
+
.slice(0, 32);
|
|
293
|
+
const lockPath = join(paths.locksDir, `${lockKey}.lock`);
|
|
294
|
+
const lockToken = randomUUID();
|
|
295
|
+
try {
|
|
296
|
+
await writeFile(
|
|
297
|
+
lockPath,
|
|
298
|
+
`${JSON.stringify({ pid: process.pid, token: lockToken, acquiredAt: new Date().toISOString() })}\n`,
|
|
299
|
+
{ encoding: "utf8", flag: "wx", mode: 0o600 },
|
|
300
|
+
);
|
|
301
|
+
} catch (error) {
|
|
302
|
+
if (isAlreadyExistsError(error)) {
|
|
303
|
+
if (await recoverStaleKnowledgeChangeLock(lockPath)) {
|
|
304
|
+
return await withOkfKnowledgeChangeLock(input);
|
|
305
|
+
}
|
|
306
|
+
throw new OkfKnowledgeChangeLockError();
|
|
307
|
+
}
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
return await input.run();
|
|
312
|
+
} finally {
|
|
313
|
+
if ((await readKnowledgeChangeLockToken(lockPath)) === lockToken) {
|
|
314
|
+
await rm(lockPath, { force: true });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function parseOkfKnowledgeChangeCandidate(value: unknown): OkfKnowledgeChangeCandidateV1 {
|
|
320
|
+
validateOkfKnowledgeChangeCandidate(value);
|
|
321
|
+
return value;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function validateOkfKnowledgeChangeCandidate(
|
|
325
|
+
value: unknown,
|
|
326
|
+
): asserts value is OkfKnowledgeChangeCandidateV1 {
|
|
327
|
+
if (!isRecord(value)) throw new Error("Invalid knowledge change candidate.");
|
|
328
|
+
if (value.schemaVersion !== 1 || value.kind !== "okf-knowledge-change-candidate") {
|
|
329
|
+
throw new Error("Invalid knowledge change candidate contract.");
|
|
330
|
+
}
|
|
331
|
+
for (const field of ["id", "projectKey", "runId", "stableKey", "targetPath", "updatedAt"]) {
|
|
332
|
+
if (typeof value[field] !== "string" || value[field].trim() === "") {
|
|
333
|
+
throw new Error(`Invalid knowledge change ${field}.`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (!["create", "update", "supersede", "revoke"].includes(String(value.operation))) {
|
|
337
|
+
throw new Error("Invalid knowledge change operation.");
|
|
338
|
+
}
|
|
339
|
+
if (
|
|
340
|
+
!["pending", "accepted", "rejected", "deferred", "applied", "superseded"].includes(
|
|
341
|
+
String(value.state),
|
|
342
|
+
)
|
|
343
|
+
) {
|
|
344
|
+
throw new Error("Invalid knowledge change state.");
|
|
345
|
+
}
|
|
346
|
+
if (!isRecord(value.candidate) || typeof value.candidate.revision !== "string") {
|
|
347
|
+
throw new Error("Invalid knowledge change candidate revision.");
|
|
348
|
+
}
|
|
349
|
+
if (!isRecord(value.diff) || !Array.isArray(value.diff.runtimeFields)) {
|
|
350
|
+
throw new Error("Invalid knowledge change diff.");
|
|
351
|
+
}
|
|
352
|
+
if (!isRecord(value.provenance) || value.provenance.rawContentStored !== false) {
|
|
353
|
+
throw new Error("Knowledge change provenance must remain metadata-only.");
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function appendOkfKnowledgeChangeHistory(input: {
|
|
358
|
+
homeDir: string;
|
|
359
|
+
change: OkfKnowledgeChangeCandidateV1;
|
|
360
|
+
fromState: OkfKnowledgeChangeState | null;
|
|
361
|
+
reason: string | null;
|
|
362
|
+
}): Promise<void> {
|
|
363
|
+
const paths = resolveKnowledgeChangePaths(input.homeDir, input.change.projectKey);
|
|
364
|
+
await mkdir(dirname(paths.historyPath), { recursive: true, mode: 0o700 });
|
|
365
|
+
const entry: OkfKnowledgeChangeHistoryEntryV1 = {
|
|
366
|
+
schemaVersion: 1,
|
|
367
|
+
kind: "okf-knowledge-change-history",
|
|
368
|
+
changeId: input.change.id,
|
|
369
|
+
projectKey: input.change.projectKey,
|
|
370
|
+
operation: input.change.operation,
|
|
371
|
+
fromState: input.fromState,
|
|
372
|
+
toState: input.change.state,
|
|
373
|
+
baseRevision: input.change.base?.revision ?? null,
|
|
374
|
+
candidateRevision: input.change.candidate.revision,
|
|
375
|
+
runtimeFields: input.change.diff.runtimeFields,
|
|
376
|
+
reason: input.reason === null ? null : sanitizeText(input.reason),
|
|
377
|
+
changedAt: input.change.updatedAt,
|
|
378
|
+
rawContentStored: false,
|
|
379
|
+
};
|
|
380
|
+
await appendFile(paths.historyPath, `${JSON.stringify(entry)}\n`, {
|
|
381
|
+
encoding: "utf8",
|
|
382
|
+
mode: 0o600,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function resolveKnowledgeChangePaths(homeDir: string, projectKey: string): KnowledgeChangePaths {
|
|
387
|
+
const safeProjectKey = sanitizePathSegment(projectKey, "project key");
|
|
388
|
+
const evolutionStateDir = join(resolveEvoDevPaths(homeDir).stateDir, "evolution");
|
|
389
|
+
const rootDir = join(evolutionStateDir, "knowledge-changes");
|
|
390
|
+
const projectDir = join(rootDir, safeProjectKey);
|
|
391
|
+
return {
|
|
392
|
+
rootDir,
|
|
393
|
+
projectDir,
|
|
394
|
+
candidatesDir: join(projectDir, "candidates"),
|
|
395
|
+
historyPath: join(projectDir, "history.jsonl"),
|
|
396
|
+
locksDir: join(evolutionStateDir, ".knowledge-review-locks"),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function readKnowledgeChangeLockToken(path: string): Promise<string | null> {
|
|
401
|
+
try {
|
|
402
|
+
const value = JSON.parse(await readFile(path, "utf8")) as unknown;
|
|
403
|
+
return isRecord(value) && typeof value.token === "string" ? value.token : null;
|
|
404
|
+
} catch {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function recoverStaleKnowledgeChangeLock(path: string): Promise<boolean> {
|
|
410
|
+
const [metadata, owner] = await Promise.all([
|
|
411
|
+
stat(path).catch(() => null),
|
|
412
|
+
readKnowledgeChangeLockOwner(path),
|
|
413
|
+
]);
|
|
414
|
+
if (metadata === null) return true;
|
|
415
|
+
const expired = Date.now() - metadata.mtimeMs > KNOWLEDGE_CHANGE_LOCK_STALE_MS;
|
|
416
|
+
const ownerGone = owner?.pid !== null && owner?.pid !== undefined && !isProcessAlive(owner.pid);
|
|
417
|
+
const unknownOwnerExpired = owner?.pid === null || owner === null ? expired : false;
|
|
418
|
+
if (!ownerGone && !unknownOwnerExpired) return false;
|
|
419
|
+
const quarantinePath = `${path}.stale.${randomUUID()}`;
|
|
420
|
+
try {
|
|
421
|
+
await rename(path, quarantinePath);
|
|
422
|
+
} catch (error) {
|
|
423
|
+
if (isNotFoundError(error)) return true;
|
|
424
|
+
throw error;
|
|
425
|
+
}
|
|
426
|
+
await rm(quarantinePath, { force: true });
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function readKnowledgeChangeLockOwner(
|
|
431
|
+
path: string,
|
|
432
|
+
): Promise<{ pid: number | null; token: string | null } | null> {
|
|
433
|
+
try {
|
|
434
|
+
const value = JSON.parse(await readFile(path, "utf8")) as unknown;
|
|
435
|
+
if (!isRecord(value)) return null;
|
|
436
|
+
return {
|
|
437
|
+
pid:
|
|
438
|
+
typeof value.pid === "number" && Number.isSafeInteger(value.pid) && value.pid > 0
|
|
439
|
+
? value.pid
|
|
440
|
+
: null,
|
|
441
|
+
token: typeof value.token === "string" ? value.token : null,
|
|
442
|
+
};
|
|
443
|
+
} catch {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function isProcessAlive(pid: number): boolean {
|
|
449
|
+
try {
|
|
450
|
+
process.kill(pid, 0);
|
|
451
|
+
return true;
|
|
452
|
+
} catch (error) {
|
|
453
|
+
return isRecord(error) && error.code === "EPERM";
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function atomicWriteJson(path: string, value: unknown): Promise<void> {
|
|
458
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
459
|
+
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
460
|
+
try {
|
|
461
|
+
await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, {
|
|
462
|
+
encoding: "utf8",
|
|
463
|
+
mode: 0o600,
|
|
464
|
+
});
|
|
465
|
+
await rename(temporaryPath, path);
|
|
466
|
+
} finally {
|
|
467
|
+
await rm(temporaryPath, { force: true });
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function listDirectoryNames(path: string): Promise<string[]> {
|
|
472
|
+
try {
|
|
473
|
+
return (await readdir(path, { withFileTypes: true }))
|
|
474
|
+
.filter((entry) => entry.isDirectory())
|
|
475
|
+
.map((entry) => entry.name)
|
|
476
|
+
.sort();
|
|
477
|
+
} catch (error) {
|
|
478
|
+
if (isNotFoundError(error)) return [];
|
|
479
|
+
throw error;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function listJsonFileNames(path: string): Promise<string[]> {
|
|
484
|
+
try {
|
|
485
|
+
return (await readdir(path, { withFileTypes: true }))
|
|
486
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
487
|
+
.map((entry) => entry.name)
|
|
488
|
+
.sort();
|
|
489
|
+
} catch (error) {
|
|
490
|
+
if (isNotFoundError(error)) return [];
|
|
491
|
+
throw error;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function sanitizePathSegment(value: string, label: string): string {
|
|
496
|
+
const normalized = value.trim();
|
|
497
|
+
if (!/^[a-zA-Z0-9._-]+$/u.test(normalized) || normalized === "." || normalized === "..") {
|
|
498
|
+
throw new Error(`Invalid knowledge change ${label}.`);
|
|
499
|
+
}
|
|
500
|
+
return normalized;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function sanitizeChangeId(value: string): string {
|
|
504
|
+
const normalized = sanitizePathSegment(value, "id");
|
|
505
|
+
if (!normalized.startsWith("knowledge-change-")) {
|
|
506
|
+
throw new Error("Invalid knowledge change id.");
|
|
507
|
+
}
|
|
508
|
+
return normalized;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function sanitizeTargetPath(value: string): string {
|
|
512
|
+
const normalized = value.trim().replace(/\\/gu, "/").replace(/^\/+/u, "");
|
|
513
|
+
if (
|
|
514
|
+
normalized === "" ||
|
|
515
|
+
normalized.startsWith("../") ||
|
|
516
|
+
normalized.split("/").some((segment) => segment === "" || segment === "." || segment === "..")
|
|
517
|
+
) {
|
|
518
|
+
throw new Error("Invalid knowledge change target path.");
|
|
519
|
+
}
|
|
520
|
+
return normalized;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function sanitizeText(value: string): string {
|
|
524
|
+
return [...value]
|
|
525
|
+
.filter((character) => {
|
|
526
|
+
const code = character.charCodeAt(0);
|
|
527
|
+
return code === 9 || code === 10 || code === 13 || (code >= 32 && code !== 127);
|
|
528
|
+
})
|
|
529
|
+
.join("")
|
|
530
|
+
.trim();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function normalizeTimestamp(value: string): string {
|
|
534
|
+
const time = Date.parse(value);
|
|
535
|
+
if (!Number.isFinite(time)) throw new Error("Invalid knowledge change timestamp.");
|
|
536
|
+
return new Date(time).toISOString();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function createNotFoundError(message: string): Error & { code: "ENOENT" } {
|
|
540
|
+
return Object.assign(new Error(message), { code: "ENOENT" as const });
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
544
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function isNotFoundError(error: unknown): boolean {
|
|
548
|
+
return (
|
|
549
|
+
isRecord(error) &&
|
|
550
|
+
(error.code === "ENOENT" ||
|
|
551
|
+
(typeof error.message === "string" &&
|
|
552
|
+
error.message.startsWith("Knowledge change not found:")))
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function isAlreadyExistsError(error: unknown): boolean {
|
|
557
|
+
return isRecord(error) && error.code === "EEXIST";
|
|
558
|
+
}
|