@evo-dev/core 0.0.1-alpha.15 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evo-dev/core",
3
- "version": "0.0.1-alpha.15",
3
+ "version": "0.0.1-alpha.16",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -15,13 +15,21 @@ export interface PluginSettings {
15
15
  }
16
16
 
17
17
  export interface MemorySettings {
18
- autoAccept: boolean;
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: Partial<MemorySettings>;
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
- autoAccept: true,
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
- autoAccept:
299
- input.autoAccept === undefined
300
- ? defaults.autoAccept
301
- : expectBoolean(input.autoAccept, `${path}.autoAccept`),
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
+ }