@lmzhen/dsh-evolution-core 0.1.0-rc.49 → 0.1.0-rc.50

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/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { basename, dirname, join } from "node:path";
2
- import { cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { cp, lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { createHash, randomBytes } from "node:crypto";
4
4
  import { homedir } from "node:os";
5
5
  import { readFileSync } from "node:fs";
@@ -11,6 +11,20 @@ import { readFileSync } from "node:fs";
11
11
  * Native DSH packages pass `ctx.evolutionIo.provider()`; standalone consumers
12
12
  * (and the facade's own tests) can use `nodeEvolutionIo`.
13
13
  */
14
+ /**
15
+ * Run `task` inside `io.transact` when the backend provides it; otherwise fall
16
+ * back to a plain read → task → write/remove sequence (no cross-process lock —
17
+ * callers keep their single-process serialize chain as the second layer).
18
+ */
19
+ async function transactIo(io, path, task) {
20
+ if (io.transact) {
21
+ await io.transact(path, task);
22
+ return;
23
+ }
24
+ const next = await task(await io.readText(path));
25
+ if (next === null) await io.remove(path);
26
+ else await io.writeText(path, next);
27
+ }
14
28
  /** Lazy adapter over an IO provider registry, shared by every evolution consumer. */
15
29
  function evolutionIoAdapter(provider) {
16
30
  return {
@@ -24,6 +38,14 @@ function evolutionIoAdapter(provider) {
24
38
  size: (path) => {
25
39
  const io = provider();
26
40
  return io.size ? io.size(path) : Promise.resolve(null);
41
+ },
42
+ transact: (path, task) => {
43
+ const io = provider();
44
+ return io.transact ? io.transact(path, task) : transactIo(io, path, task);
45
+ },
46
+ isSymlink: (path) => {
47
+ const io = provider();
48
+ return io.isSymlink ? io.isSymlink(path) : Promise.resolve(null);
27
49
  }
28
50
  };
29
51
  }
@@ -82,6 +104,26 @@ function nodeEvolutionIo() {
82
104
  await rename(tmp, path);
83
105
  });
84
106
  },
107
+ async transact(path, task) {
108
+ await mkdir(dirname(path), { recursive: true });
109
+ await withWriteLock(path, async () => {
110
+ let current;
111
+ try {
112
+ current = await readFile(path, "utf8");
113
+ } catch (error) {
114
+ if (isMissing(error)) current = null;
115
+ else throw error;
116
+ }
117
+ const next = await task(current);
118
+ if (next === null) {
119
+ await rm(path, { force: true });
120
+ return;
121
+ }
122
+ const tmp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
123
+ await writeFile(tmp, next, "utf8");
124
+ await rename(tmp, path);
125
+ });
126
+ },
85
127
  async remove(path) {
86
128
  await rm(path, {
87
129
  recursive: true,
@@ -91,8 +133,9 @@ function nodeEvolutionIo() {
91
133
  async list(path) {
92
134
  try {
93
135
  return await readdir(path);
94
- } catch {
95
- return [];
136
+ } catch (error) {
137
+ if (isMissing(error)) return [];
138
+ throw error;
96
139
  }
97
140
  },
98
141
  async exists(path) {
@@ -122,6 +165,13 @@ function nodeEvolutionIo() {
122
165
  if (isMissing(error)) return null;
123
166
  throw error;
124
167
  }
168
+ },
169
+ async isSymlink(path) {
170
+ try {
171
+ return (await lstat(path)).isSymbolicLink();
172
+ } catch {
173
+ return null;
174
+ }
125
175
  }
126
176
  };
127
177
  }
@@ -181,15 +231,33 @@ function normalizeUsageRecord(record) {
181
231
  quality_warn: typeof raw.quality_warn === "boolean" ? raw.quality_warn : void 0
182
232
  };
183
233
  }
184
- async function loadUsage(root, io = nodeEvolutionIo()) {
234
+ /** Parse a raw usage sidecar; malformed content reads as empty (best-effort telemetry). */
235
+ function parseUsage(raw) {
185
236
  const map = /* @__PURE__ */ new Map();
186
- const raw = await io.readText(usageFile(root));
187
- if (raw !== null) try {
237
+ if (raw === null) return map;
238
+ try {
188
239
  const parsed = JSON.parse(raw);
189
240
  for (const [name, record] of Object.entries(parsed)) map.set(name, normalizeUsageRecord(record));
190
241
  } catch {}
191
242
  return map;
192
243
  }
244
+ async function loadUsage(root, io = nodeEvolutionIo()) {
245
+ return parseUsage(await io.readText(usageFile(root)));
246
+ }
247
+ /**
248
+ * Atomic read-modify-write on the usage sidecar (rc.50 P2-2): `task` receives
249
+ * the map parsed from the current on-disk state and may mutate it; the result
250
+ * is persisted inside the same transact so a second process sharing DSH_HOME
251
+ * cannot interleave its RMW and lose a counter update. Callers keep their own
252
+ * single-process serialize chain as the second layer.
253
+ */
254
+ async function mutateUsage(root, io, task) {
255
+ await transactIo(io, usageFile(root), async (current) => {
256
+ const map = parseUsage(current);
257
+ await task(map);
258
+ return JSON.stringify(Object.fromEntries(map.entries()), null, 2);
259
+ });
260
+ }
193
261
  async function saveUsage(root, map, io = nodeEvolutionIo()) {
194
262
  const obj = Object.fromEntries(map.entries());
195
263
  await io.writeText(usageFile(root), JSON.stringify(obj, null, 2));
@@ -240,7 +308,9 @@ function suppressedFile(root) {
240
308
  return join(root, ".curator-suppressed.json");
241
309
  }
242
310
  async function loadSuppressedNames(root, io = nodeEvolutionIo()) {
243
- const raw = await io.readText(suppressedFile(root));
311
+ return parseSuppressed(await io.readText(suppressedFile(root)));
312
+ }
313
+ function parseSuppressed(raw) {
244
314
  if (raw === null) return /* @__PURE__ */ new Set();
245
315
  try {
246
316
  const parsed = JSON.parse(raw);
@@ -256,6 +326,22 @@ async function saveSuppressedNames(root, names, io = nodeEvolutionIo()) {
256
326
  names: [...names].sort()
257
327
  }, null, 2));
258
328
  }
329
+ /**
330
+ * Atomic read-modify-write on the suppression sidecar (rc.50 P2-2): `task`
331
+ * receives the set parsed from the current on-disk state and may mutate it;
332
+ * the result is persisted inside the same transact so a second process
333
+ * sharing DSH_HOME cannot interleave its RMW. Best-effort posture unchanged.
334
+ */
335
+ async function updateSuppressedNames(root, io, task) {
336
+ await transactIo(io, suppressedFile(root), async (current) => {
337
+ const names = parseSuppressed(current);
338
+ await task(names);
339
+ return JSON.stringify({
340
+ version: 1,
341
+ names: [...names].sort()
342
+ }, null, 2);
343
+ });
344
+ }
259
345
  //#endregion
260
346
  //#region lib/types/constants.js
261
347
  /**
@@ -1487,8 +1573,13 @@ function mutationsFile(root) {
1487
1573
  function contentHash(content) {
1488
1574
  return createHash("sha256").update(content).digest("hex");
1489
1575
  }
1490
- async function loadMutations(root, io = nodeEvolutionIo()) {
1491
- const raw = await io.readText(mutationsFile(root));
1576
+ /**
1577
+ * Parse a raw mutations sidecar; malformed content reads as empty (auditing is
1578
+ * best-effort). Versioned shape ({ version, records }) with legacy
1579
+ * plain-array compat, plus a field-level guard for records without the
1580
+ * required identity/timestamp fields (rc.42 audit P2-3).
1581
+ */
1582
+ function parseMutationRecords(raw) {
1492
1583
  if (raw === null) return [];
1493
1584
  try {
1494
1585
  const parsed = JSON.parse(raw);
@@ -1497,15 +1588,20 @@ async function loadMutations(root, io = nodeEvolutionIo()) {
1497
1588
  return [];
1498
1589
  }
1499
1590
  }
1591
+ async function loadMutations(root, io = nodeEvolutionIo()) {
1592
+ return parseMutationRecords(await io.readText(mutationsFile(root)));
1593
+ }
1500
1594
  /** Append one record, trim to `cap`, and write atomically (versioned shape). */
1501
1595
  async function recordMutation(root, io, record, cap = 500) {
1502
- const existing = await loadMutations(root, io);
1503
- existing.push(record);
1504
- const trimmed = existing.length > cap ? existing.slice(existing.length - cap) : existing;
1505
- await io.writeText(mutationsFile(root), JSON.stringify({
1506
- version: 1,
1507
- records: trimmed
1508
- }, null, 2));
1596
+ await transactIo(io, mutationsFile(root), (current) => {
1597
+ const existing = parseMutationRecords(current);
1598
+ existing.push(record);
1599
+ const trimmed = existing.length > cap ? existing.slice(existing.length - cap) : existing;
1600
+ return Promise.resolve(JSON.stringify({
1601
+ version: 1,
1602
+ records: trimmed
1603
+ }, null, 2));
1604
+ });
1509
1605
  }
1510
1606
  //#endregion
1511
1607
  //#region lib/types/quality.js
@@ -2286,6 +2382,12 @@ var SkillLibrary = class {
2286
2382
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 14);
2287
2383
  dest = join(archiveRoot, `${name.trim()}-${stamp}`);
2288
2384
  }
2385
+ if (this.io.isSymlink) {
2386
+ if (await this.io.isSymlink(dir) === true) return {
2387
+ ok: false,
2388
+ message: `Skill "${name}" is a symlink; refusing to archive it.`
2389
+ };
2390
+ }
2289
2391
  try {
2290
2392
  await this.io.rename(dir, dest);
2291
2393
  } catch {
@@ -2422,6 +2524,12 @@ var SkillLibrary = class {
2422
2524
  };
2423
2525
  const source = join(archiveRoot, chosen);
2424
2526
  const dest = this.dirOf(name);
2527
+ if (this.io.isSymlink) {
2528
+ if (await this.io.isSymlink(source) === true) return {
2529
+ ok: false,
2530
+ message: `Archived entry "${chosen}" is a symlink; refusing to restore it.`
2531
+ };
2532
+ }
2425
2533
  try {
2426
2534
  await this.io.rename(source, dest);
2427
2535
  } catch {
@@ -2648,7 +2756,16 @@ var SkillLibrary = class {
2648
2756
  message: "No skill snapshot available."
2649
2757
  };
2650
2758
  await this.snapshotAll("pre-rollback", extras);
2651
- for (const name of await listNames(this.root, this.io)) await this.io.remove(this.dirOf(name));
2759
+ let rootEntries;
2760
+ try {
2761
+ rootEntries = await this.io.list(this.root);
2762
+ } catch {
2763
+ rootEntries = [];
2764
+ }
2765
+ for (const entry of rootEntries) {
2766
+ if (entry.startsWith(".")) continue;
2767
+ await this.io.remove(join(this.root, entry));
2768
+ }
2652
2769
  const manifest = await this.readSnapshotManifest(latest.path);
2653
2770
  if (manifest === null) for (const entry of await this.io.list(latest.path)) {
2654
2771
  if (entry === "manifest.json" || entry === "extras") continue;
@@ -2742,4 +2859,4 @@ var JsonState = class JsonState {
2742
2859
  }
2743
2860
  };
2744
2861
  //#endregion
2745
- export { COMBINED_REVIEW_PROMPT, COMPLETION_SKILL_REVIEW_PROMPT, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_CONSOLIDATION_FAILURES, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_MAX_OPS_PER_PLAN, DEFAULT_MEMORY_CHAR_LIMIT, DEFAULT_MIN_IDLE_HOURS, DEFAULT_MUTATION_CAP, DEFAULT_REVIEW_MEMORY_INTERVAL, DEFAULT_REVIEW_SKILL_INTERVAL, DEFAULT_SKILL_CONTENT_CHARS, DEFAULT_SKILL_LIMITS, DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS, DEFAULT_SKILL_REVIEW_TRIGGER, DEFAULT_STALE_AFTER_DAYS, DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS, DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS, DEFAULT_SUBSTANTIVE_MIN_USER_CHARS, DEFAULT_USER_CHAR_LIMIT, DSH_AUTHORING_STANDARDS, ENTRY_DELIMITER, EvolutionGateSet, JsonState, LOW_QUALITY_THRESHOLD, MAX_DESCRIPTION_LENGTH, MAX_SKILL_CONTENT_CHARS, MAX_SKILL_FILE_BYTES, MAX_SKILL_NAME_LENGTH, MEMORY_REVIEW_PROMPT, MUTATIONS_FILE_VERSION, MemoryStore, PROMPT_BUNDLE, PROMPT_BUNDLE_ID, PROMPT_BUNDLE_VERSION, PROTECTED_BUILTIN_SKILLS, QUALITY_WEIGHTS, SKILL_NAME_RE, SKILL_REVIEW_PROMPT, SNAPSHOT_EXTRA_NAME_RE, SUPPORT_DIRS, SUPPRESSED_FILE_VERSION, SkillLibrary, advanceReview, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpUse, bumpView, computeDedupGroups, computeLifecycleTransitions, computeQualityScores, computeScopeView, contentHash, createGateSet, emptyRecord, evaluateThreat, evolutionHome, evolutionIoAdapter, foldTurn, getRecord, latestActivityAt, lifecycleCandidate, loadMutations, loadSuppressedNames, loadUsage, markAgentCreated, memoryRoot, mutationsFile, nodeEvolutionIo, normalizeUsageRecord, observeEvent, parseCuratorNominations, parseFrontmatter, recordMutation, relatedSkillNames, renderCuratorReportMarkdown, resolveOrigins, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, suppressedFile, usageFile, validateFrontmatter, verifyPromptBundle };
2862
+ export { COMBINED_REVIEW_PROMPT, COMPLETION_SKILL_REVIEW_PROMPT, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_CONSOLIDATION_FAILURES, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_MAX_OPS_PER_PLAN, DEFAULT_MEMORY_CHAR_LIMIT, DEFAULT_MIN_IDLE_HOURS, DEFAULT_MUTATION_CAP, DEFAULT_REVIEW_MEMORY_INTERVAL, DEFAULT_REVIEW_SKILL_INTERVAL, DEFAULT_SKILL_CONTENT_CHARS, DEFAULT_SKILL_LIMITS, DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS, DEFAULT_SKILL_REVIEW_TRIGGER, DEFAULT_STALE_AFTER_DAYS, DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS, DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS, DEFAULT_SUBSTANTIVE_MIN_USER_CHARS, DEFAULT_USER_CHAR_LIMIT, DSH_AUTHORING_STANDARDS, ENTRY_DELIMITER, EvolutionGateSet, JsonState, LOW_QUALITY_THRESHOLD, MAX_DESCRIPTION_LENGTH, MAX_SKILL_CONTENT_CHARS, MAX_SKILL_FILE_BYTES, MAX_SKILL_NAME_LENGTH, MEMORY_REVIEW_PROMPT, MUTATIONS_FILE_VERSION, MemoryStore, PROMPT_BUNDLE, PROMPT_BUNDLE_ID, PROMPT_BUNDLE_VERSION, PROTECTED_BUILTIN_SKILLS, QUALITY_WEIGHTS, SKILL_NAME_RE, SKILL_REVIEW_PROMPT, SNAPSHOT_EXTRA_NAME_RE, SUPPORT_DIRS, SUPPRESSED_FILE_VERSION, SkillLibrary, advanceReview, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpUse, bumpView, computeDedupGroups, computeLifecycleTransitions, computeQualityScores, computeScopeView, contentHash, createGateSet, emptyRecord, evaluateThreat, evolutionHome, evolutionIoAdapter, foldTurn, getRecord, latestActivityAt, lifecycleCandidate, loadMutations, loadSuppressedNames, loadUsage, markAgentCreated, memoryRoot, mutateUsage, mutationsFile, nodeEvolutionIo, normalizeUsageRecord, observeEvent, parseCuratorNominations, parseFrontmatter, recordMutation, relatedSkillNames, renderCuratorReportMarkdown, resolveOrigins, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, suppressedFile, transactIo, updateSuppressedNames, usageFile, validateFrontmatter, verifyPromptBundle };
package/lib/types/io.d.ts CHANGED
@@ -20,7 +20,28 @@ export interface EvolutionIoLike {
20
20
  * consumers treat an unknown size as "guard not applicable".
21
21
  */
22
22
  size?(path: string): Promise<number | null>;
23
+ /**
24
+ * Optional atomic read-modify-write on one file: the read and the write run
25
+ * inside a single cross-process lock, so two processes that share DSH_HOME
26
+ * cannot interleave their RMW sequences. `task` receives the current content
27
+ * (`null` when missing) and returns the next content; returning `null`
28
+ * deletes the file. A backend without it falls back to plain read+write and
29
+ * the caller keeps its single-process chain as the second layer.
30
+ */
31
+ transact?(this: void, path: string, task: (current: string | null) => Promise<string | null>): Promise<void>;
32
+ /**
33
+ * Optional symlink probe (G7). `true` = the path is a symlink, `false` = a
34
+ * real entry, `null` = guard not applicable (backend without the probe or
35
+ * the path does not exist). Consumers treat `null` as "let it through".
36
+ */
37
+ isSymlink?(this: void, path: string): Promise<boolean | null>;
23
38
  }
39
+ /**
40
+ * Run `task` inside `io.transact` when the backend provides it; otherwise fall
41
+ * back to a plain read → task → write/remove sequence (no cross-process lock —
42
+ * callers keep their single-process serialize chain as the second layer).
43
+ */
44
+ export declare function transactIo(io: EvolutionIoLike, path: string, task: (current: string | null) => Promise<string | null>): Promise<void>;
24
45
  /** Lazy adapter over an IO provider registry, shared by every evolution consumer. */
25
46
  export declare function evolutionIoAdapter(provider: () => EvolutionIoLike): EvolutionIoLike;
26
47
  export declare function nodeEvolutionIo(): EvolutionIoLike;
@@ -33,6 +33,14 @@ export declare function emptyRecord(): UsageRecord;
33
33
  */
34
34
  export declare function normalizeUsageRecord(record: unknown): UsageRecord;
35
35
  export declare function loadUsage(root: string, io?: EvolutionIoLike): Promise<UsageMap>;
36
+ /**
37
+ * Atomic read-modify-write on the usage sidecar (rc.50 P2-2): `task` receives
38
+ * the map parsed from the current on-disk state and may mutate it; the result
39
+ * is persisted inside the same transact so a second process sharing DSH_HOME
40
+ * cannot interleave its RMW and lose a counter update. Callers keep their own
41
+ * single-process serialize chain as the second layer.
42
+ */
43
+ export declare function mutateUsage(root: string, io: EvolutionIoLike, task: (map: UsageMap) => void | Promise<void>): Promise<void>;
36
44
  export declare function saveUsage(root: string, map: UsageMap, io?: EvolutionIoLike): Promise<void>;
37
45
  export declare function getRecord(map: UsageMap, name: string): UsageRecord;
38
46
  export declare function bumpView(map: UsageMap, name: string, when?: Date): void;
@@ -50,4 +58,11 @@ export declare const SUPPRESSED_FILE_VERSION = 1;
50
58
  export declare function suppressedFile(root: string): string;
51
59
  export declare function loadSuppressedNames(root: string, io?: EvolutionIoLike): Promise<ReadonlySet<string>>;
52
60
  export declare function saveSuppressedNames(root: string, names: ReadonlySet<string>, io?: EvolutionIoLike): Promise<void>;
61
+ /**
62
+ * Atomic read-modify-write on the suppression sidecar (rc.50 P2-2): `task`
63
+ * receives the set parsed from the current on-disk state and may mutate it;
64
+ * the result is persisted inside the same transact so a second process
65
+ * sharing DSH_HOME cannot interleave its RMW. Best-effort posture unchanged.
66
+ */
67
+ export declare function updateSuppressedNames(root: string, io: EvolutionIoLike, task: (names: Set<string>) => void | Promise<void>): Promise<void>;
53
68
  //# sourceMappingURL=usage.d.ts.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-core",
3
3
  "description": "Shared stores, prompts, signals and lifecycle logic for the dsh-evolution plugin family (community build)",
4
- "version": "0.1.0-rc.49",
4
+ "version": "0.1.0-rc.50",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },