@lmzhen/dsh-evolution-core 0.3.20 → 0.3.22

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/README.md CHANGED
@@ -18,6 +18,26 @@ Zero direct token effect from this package; consumers add any model-visible toke
18
18
 
19
19
  Independent of request-prefix construction. This package does not alter the assembled prompt or tool list.
20
20
 
21
+ ## SkillLibrary concurrency model
22
+
23
+ Skill-library mutations are read-modify-write on one file, so `SkillLibrary`
24
+ serializes them in-process with a `makeSerialQueue` chain: `update`, `patch`,
25
+ `restructure` and `writeSupportFile` run their whole read→validate→write under
26
+ one serial task, so two concurrent mutators on one skill never interleave in
27
+ this process. Single-file writes (`update`, `patch`, `writeSupportFile`)
28
+ additionally run the read and the write inside `transactIo` when a caller
29
+ injects a `transact` into the constructor — that is the cross-process lock, so
30
+ two processes sharing `DSH_HOME` cannot interleave their RMW on one file.
31
+ `create` writes a new file and `archive`/`consolidate` already own a two-phase
32
+ commit, so they deliberately stay outside the serial chain.
33
+
34
+ When no `transact` is injected (the current default callers), only the
35
+ in-process serial chain protects the RMW; same-skill concurrent writes from
36
+ different surfaces (foreground `skill_manage`, the review pipeline, the
37
+ curator, `/evolution restructure`) still resolve **last writer wins**. Wire a
38
+ `transact` at every SkillLibrary instantiation point to extend that guarantee
39
+ across processes.
40
+
21
41
  ## Known Limitations and Deferred Work
22
42
 
23
43
  - This package is a library, not a Cordis row; do not mount it as a plugin.
package/lib/index.js CHANGED
@@ -52,6 +52,49 @@ function evolutionIoAdapter(provider) {
52
52
  }
53
53
  };
54
54
  }
55
+ /**
56
+ * F-367 (②): lock paths whose release (the finally `rm`) failed. The next write
57
+ * to the same file proactively recycles our own leftover lock — the holder is
58
+ * us, so a leftover is stale by definition. Module-level by design: it must
59
+ * survive across `nodeEvolutionIo()` instances for the self-heal to be
60
+ * effective. (Not a pure function — the cross-call state is the intent.)
61
+ */
62
+ const pendingSelfCleanup = /* @__PURE__ */ new Set();
63
+ /**
64
+ * Retry a rename that a peer is temporarily holding on Windows (EPERM/EBUSY):
65
+ * a short 50ms backoff, at most 3 retries (~150ms budget), matching the
66
+ * write-lock cadence. A non-transient code surfaces immediately. `fn` is the
67
+ * rename primitive, injectable for deterministic tests.
68
+ *
69
+ * @param tmp - the source path to rename.
70
+ * @param target - the destination path.
71
+ * @param fn - the rename primitive (defaults to `node:fs/promises.rename`).
72
+ * @returns a promise that resolves once the rename succeeds.
73
+ */
74
+ async function renameWithRetry(tmp, target, fn = rename) {
75
+ for (let retry = 0;; retry += 1) try {
76
+ await fn(tmp, target);
77
+ return;
78
+ } catch (error) {
79
+ const code = error?.code;
80
+ if (code !== "EPERM" && code !== "EBUSY") throw error;
81
+ if (retry >= 3) throw error;
82
+ await new Promise((resolve) => setTimeout(resolve, 50));
83
+ }
84
+ }
85
+ /**
86
+ * F-366: commit a freshly-written tmp to its target inside the write lock. On a
87
+ * still-failing rename the tmp is deleted immediately rather than left for the
88
+ * (1h + dead-pid) sweep, so a live writer never leaks a tmp it abandoned.
89
+ */
90
+ async function commitTmp(tmp, target) {
91
+ try {
92
+ await renameWithRetry(tmp, target);
93
+ } catch (error) {
94
+ await rm(tmp, { force: true }).catch(() => {});
95
+ throw error;
96
+ }
97
+ }
55
98
  function nodeEvolutionIo() {
56
99
  const isMissing = (error) => {
57
100
  const code = error?.code;
@@ -84,6 +127,13 @@ function nodeEvolutionIo() {
84
127
  * retry budget (budget >= 2 x threshold), so a dead holder's lock is
85
128
  * actually recoverable within one budget instead of being arithmetically
86
129
  * unreachable.
130
+ * 0.3.21 (F-101): takeover re-reads the lock right before removing it and
131
+ * only removes it when the content still names the dead pid — a peer that
132
+ * acquired the lock after our stale probe wrote its own pid, and deleting a
133
+ * LIVE lock is the double-hold (concurrent task) the probe must prevent.
134
+ * 0.3.21 (F-367): a self-pid lock is this process's own leftover (a failed
135
+ * release or a crash) and is recycled immediately regardless of age; a
136
+ * failure to release in finally is recorded so the next write self-heals.
87
137
  */
88
138
  const withWriteLock = async (path, task) => {
89
139
  const lock = `${path}.lock`;
@@ -95,14 +145,23 @@ function nodeEvolutionIo() {
95
145
  if (code !== "EEXIST" && code !== "EPERM") throw error;
96
146
  try {
97
147
  const st = await stat(lock);
98
- if (Date.now() - st.mtimeMs > 1e3) {
99
- const holder = Number(await readFile(lock, "utf8").catch(() => ""));
100
- if (!(Number.isInteger(holder) && holder > 0 && isAlive(holder))) {
148
+ const holderContent = await readFile(lock, "utf8").catch(() => "");
149
+ const holder = Number(holderContent);
150
+ const holderAlive = Number.isInteger(holder) && holder > 0 && isAlive(holder);
151
+ if (holder === process.pid && (pendingSelfCleanup.has(lock) || Date.now() - st.mtimeMs > 1e3)) {
152
+ if (await readFile(lock, "utf8").catch(() => "") === holderContent) {
101
153
  try {
102
154
  await rm(lock, { force: true });
103
155
  } catch {}
104
- continue;
156
+ pendingSelfCleanup.delete(lock);
105
157
  }
158
+ continue;
159
+ }
160
+ if (Date.now() - st.mtimeMs > 1e3 && !holderAlive) {
161
+ if (await readFile(lock, "utf8").catch(() => "") === holderContent) try {
162
+ await rm(lock, { force: true });
163
+ } catch {}
164
+ continue;
106
165
  }
107
166
  } catch {
108
167
  continue;
@@ -113,7 +172,9 @@ function nodeEvolutionIo() {
113
172
  try {
114
173
  return await task();
115
174
  } finally {
116
- await rm(lock, { force: true }).catch(() => {});
175
+ await rm(lock, { force: true }).catch(() => {
176
+ pendingSelfCleanup.add(lock);
177
+ });
117
178
  }
118
179
  }
119
180
  throw new Error(`could not acquire write lock for ${path} after 40 attempts`);
@@ -139,7 +200,7 @@ function nodeEvolutionIo() {
139
200
  try {
140
201
  const st = await stat(tmpPath);
141
202
  const deadHolder = !Number.isInteger(holder) || holder <= 0 || !isAlive(holder);
142
- if (Date.now() - st.mtimeMs > 36e5 && deadHolder) await rm(tmpPath, { force: true });
203
+ if (holder === process.pid || Date.now() - st.mtimeMs > 36e5 && deadHolder) await rm(tmpPath, { force: true });
143
204
  } catch {}
144
205
  }
145
206
  };
@@ -158,7 +219,7 @@ function nodeEvolutionIo() {
158
219
  await sweepStaleTmps(path);
159
220
  const tmp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
160
221
  await writeFile(tmp, content, "utf8");
161
- await rename(tmp, path);
222
+ await commitTmp(tmp, path);
162
223
  });
163
224
  },
164
225
  async transact(path, task) {
@@ -179,7 +240,7 @@ function nodeEvolutionIo() {
179
240
  }
180
241
  const tmp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
181
242
  await writeFile(tmp, next, "utf8");
182
- await rename(tmp, path);
243
+ await commitTmp(tmp, path);
183
244
  });
184
245
  },
185
246
  async remove(path) {
@@ -881,6 +942,13 @@ function isEventRecord(event) {
881
942
  * rebuildable, NOT malformed) or a corrupt one reads as empty; corrupt content
882
943
  * is still refused on append, never overwritten.
883
944
  *
945
+ * This reader is **v1-only** (F-338): a body carrying a `version` other than
946
+ * `EVENT_LOG_VERSION` is a future-format log this reader cannot interpret, so
947
+ * it reads as an EMPTY timeline rather than being mis-parsed as v1. The read
948
+ * side never overwrites it on its own — `appendEvolutionEvent` rejects a
949
+ * version mismatch up front and preserves the original bytes, so a newer log
950
+ * is never silently downgraded here.
951
+ *
884
952
  * Per-entry normalization (rc.70 F-1): entries without a numeric `seq` are
885
953
  * skipped here and dropped at the next append — valid entries survive, the
886
954
  * damaged record is the only loss (self-heal semantics, matching the usage
@@ -890,6 +958,7 @@ function parseEvolutionEvents(raw) {
890
958
  if (raw === null || raw.trim() === "") return [];
891
959
  try {
892
960
  const parsed = JSON.parse(raw);
961
+ if (parsed.version !== void 0 && parsed.version !== 1) return [];
893
962
  if (!Array.isArray(parsed.events)) return [];
894
963
  return parsed.events.filter(isEventRecord);
895
964
  } catch {
@@ -928,11 +997,19 @@ async function listEventArchives(io, path) {
928
997
  */
929
998
  async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE_AT) {
930
999
  let assigned = 0;
1000
+ let refuseMessage = "";
931
1001
  await transactIo(io, path, async (current) => {
932
- if (current !== null && current.trim() !== "") try {
933
- JSON.parse(current);
934
- } catch {
935
- return current;
1002
+ if (current !== null && current.trim() !== "") {
1003
+ let shape;
1004
+ try {
1005
+ shape = JSON.parse(current);
1006
+ } catch {
1007
+ return current;
1008
+ }
1009
+ if (shape.version !== void 0 && shape.version !== 1) {
1010
+ refuseMessage = `evolution event log version mismatch (found ${typeof shape.version === "number" || typeof shape.version === "string" ? String(shape.version) : "unknown"}, expected 1) and was not touched`;
1011
+ return current;
1012
+ }
936
1013
  }
937
1014
  const nextEvents = await rotateIfDue(io, path, parseEvolutionEvents(current), rotateAt);
938
1015
  let maxSeq = nextEvents.reduce((max, entry) => Math.max(max, entry.seq), 0);
@@ -948,7 +1025,7 @@ async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE
948
1025
  events: [...nextEvents, record]
949
1026
  }, null, 2);
950
1027
  });
951
- if (assigned === 0) throw new Error(`evolution event log is malformed and was not touched: ${path}`);
1028
+ if (assigned === 0) throw new Error(`${refuseMessage || "evolution event log is malformed and was not touched"}: ${path}`);
952
1029
  return assigned;
953
1030
  }
954
1031
  /**
@@ -988,7 +1065,10 @@ async function retainEventArchives(io, path) {
988
1065
  for (const name of excess) await io.remove(join(dir, name)).catch(() => {});
989
1066
  }
990
1067
  /** Read the event log; a missing/whitespace-only file reads as empty,
991
- * corrupt content is flagged (and refused on append). */
1068
+ * corrupt content is flagged (and refused on append). A well-formed future-
1069
+ * version body is v1-incompatible and reads as empty, NOT malformed (F-338:
1070
+ * the reader must never mis-shape a newer format; the append path refuses it
1071
+ * up front so the original bytes survive). */
992
1072
  async function readEvolutionEvents(io, path) {
993
1073
  let raw;
994
1074
  try {
@@ -1005,6 +1085,10 @@ async function readEvolutionEvents(io, path) {
1005
1085
  };
1006
1086
  try {
1007
1087
  const parsed = JSON.parse(raw);
1088
+ if (parsed.version !== void 0 && parsed.version !== 1) return {
1089
+ events: [],
1090
+ malformed: false
1091
+ };
1008
1092
  if (!Array.isArray(parsed.events)) return {
1009
1093
  events: [],
1010
1094
  malformed: false
@@ -1749,6 +1833,14 @@ function render(entries) {
1749
1833
  function stripDatePrefix(entry) {
1750
1834
  return entry.replace(/^## \d{4}-\d{2}-\d{2}\n/, "");
1751
1835
  }
1836
+ /** F-201: does `content` carry the on-disk entry delimiter or a trailing
1837
+ * `\n§` fragment that would combine with the render terminator into a real
1838
+ * delimiter boundary? Both split the fact into multiple entries on read-back
1839
+ * (and a delimiter-ending fact is permanent drift — `render(entries)!==raw`
1840
+ * bricks every later write). A leading/plain `§` is safe and round-trips. */
1841
+ function hasEntryDelimiter(content) {
1842
+ return content.includes("\n§\n") || content.endsWith("\n§");
1843
+ }
1752
1844
  var MemoryStore = class {
1753
1845
  memoryLimit;
1754
1846
  userLimit;
@@ -1915,6 +2007,16 @@ var MemoryStore = class {
1915
2007
  },
1916
2008
  write: null
1917
2009
  };
2010
+ if (hasEntryDelimiter(content)) return {
2011
+ result: {
2012
+ ok: false,
2013
+ message: "Operation 1 (add): Fact contains the entry delimiter (§) and would split into multiple entries; rewrite it as separate facts.",
2014
+ entries: [],
2015
+ chars: 0,
2016
+ limit: this.limitFor(target)
2017
+ },
2018
+ write: null
2019
+ };
1918
2020
  const entries = [...new Set(normalizeEntries(raw))];
1919
2021
  if (entries.some((entry) => stripDatePrefix(entry) === content)) {
1920
2022
  this.resetFailures();
@@ -2017,6 +2119,16 @@ var MemoryStore = class {
2017
2119
  },
2018
2120
  write: null
2019
2121
  };
2122
+ if (hasEntryDelimiter(body)) return {
2123
+ result: {
2124
+ ok: false,
2125
+ message: `Operation ${position} (add): Fact contains the entry delimiter (§) and would split into multiple entries; rewrite it as separate facts.`,
2126
+ entries,
2127
+ chars: entries.join(ENTRY_DELIMITER).length,
2128
+ limit: this.limitFor(target)
2129
+ },
2130
+ write: null
2131
+ };
2020
2132
  if (!working.some((entry) => stripDatePrefix(entry) === body)) working.push(this.addDatePrefix ? `## ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}\n${body}` : body);
2021
2133
  continue;
2022
2134
  }
@@ -2074,6 +2186,16 @@ var MemoryStore = class {
2074
2186
  },
2075
2187
  write: null
2076
2188
  };
2189
+ if (hasEntryDelimiter(body)) return {
2190
+ result: {
2191
+ ok: false,
2192
+ message: `Operation ${position} (replace): Fact contains the entry delimiter (§) and would split into multiple entries; rewrite it as separate facts.`,
2193
+ entries,
2194
+ chars: entries.join(ENTRY_DELIMITER).length,
2195
+ limit: this.limitFor(target)
2196
+ },
2197
+ write: null
2198
+ };
2077
2199
  working[matchIndex] = body;
2078
2200
  }
2079
2201
  }
@@ -2768,7 +2890,9 @@ function skillsRoot(env = process.env) {
2768
2890
  * the skills tree — tool-skill-manage / evolution-skill-catalog / skill-usage
2769
2891
  * / evolution-learning-graph used to each resolve `config.root || skillsRoot()`
2770
2892
  * (and the graph ignored config entirely). Empty/whitespace config falls
2771
- * through to the default; callers pass their raw Config. */
2893
+ * through to the default; callers pass their raw Config. The optional field is
2894
+ * declared `| undefined` so a config object whose root field is explicitly
2895
+ * `string | undefined` still assignable under exactOptionalPropertyTypes. */
2772
2896
  function resolveSkillsRoot(config = {}) {
2773
2897
  return (config.root ?? "").trim() || skillsRoot();
2774
2898
  }
@@ -3115,15 +3239,9 @@ function fuzzyPatch(content, oldString, newString, replaceAll = false) {
3115
3239
  const boundary = trimPatternBoundaries(oldString);
3116
3240
  if (boundary === "") return null;
3117
3241
  if (boundary !== oldString) {
3118
- if (fuzzyIndexOf(content, boundary) !== null) {
3119
- const patched = fuzzyReplace(content, boundary, newString, replaceAll);
3120
- return patched === content ? null : patched;
3121
- }
3122
- }
3123
- if (fuzzyIndexOf(content, oldString) !== null) {
3124
- const patched = fuzzyReplace(content, oldString, newString, replaceAll);
3125
- return patched === content ? null : patched;
3242
+ if (fuzzyIndexOf(content, boundary) !== null) return fuzzyReplace(content, boundary, newString, replaceAll);
3126
3243
  }
3244
+ if (fuzzyIndexOf(content, oldString) !== null) return fuzzyReplace(content, oldString, newString, replaceAll);
3127
3245
  return null;
3128
3246
  }
3129
3247
  /**
@@ -3194,11 +3312,47 @@ var SkillLibrary = class {
3194
3312
  limits;
3195
3313
  io;
3196
3314
  onMutation;
3197
- constructor(root = skillsRoot(), io = nodeEvolutionIo(), limits = DEFAULT_SKILL_LIMITS, onMutation) {
3315
+ /** 0.3.21 (F-208): optional cross-process RMW transactor injected by callers.
3316
+ * When unset each single-file write falls back to read→task→write. */
3317
+ transact;
3318
+ /** 0.3.21 (F-208): in-process serialize queue so two concurrent mutators on
3319
+ * one skill never interleave their read-modify-write (the cross-process layer
3320
+ * is the IO backend's transact lock; this chain is the second layer). */
3321
+ serial;
3322
+ constructor(root = skillsRoot(), io = nodeEvolutionIo(), limits = DEFAULT_SKILL_LIMITS, onMutation, transact) {
3198
3323
  this.root = root;
3199
3324
  this.io = io;
3200
3325
  this.limits = limits;
3201
3326
  this.onMutation = onMutation;
3327
+ this.transact = transact;
3328
+ this.serial = makeSerialQueue();
3329
+ }
3330
+ /**
3331
+ * Run one single-file read-modify-write for a mutator. When `transact` was
3332
+ * injected the read and the write run inside it (cross-process atomicity);
3333
+ * otherwise a plain read → task → write sequence runs (the process-level
3334
+ * `serial` chain is the second layer). `task` receives the current content
3335
+ * (null when missing) and returns a {@link SingleWriteOutcome}. Audit and the
3336
+ * mutation event are issued ONLY when a write actually lands, so a no-op
3337
+ * never inflates the mutation-maturity counter.
3338
+ */
3339
+ async runSingleWrite(path, task) {
3340
+ let outcome;
3341
+ const run = async (current) => {
3342
+ const o = await task(current ?? null);
3343
+ outcome = o;
3344
+ return o.write ?? current ?? null;
3345
+ };
3346
+ if (this.transact) await this.transact(this.io, path, run);
3347
+ else {
3348
+ const current = await this.io.readText(path);
3349
+ const next = await run(current);
3350
+ if (next !== null && next !== current) await this.io.writeText(path, next);
3351
+ }
3352
+ const o = outcome;
3353
+ if (o.write !== null && o.audit) await this.audit(o.audit.skillName, o.audit.action, o.audit.before, o.audit.after, o.audit.summary);
3354
+ if (o.write !== null && o.event) this.notifyMutation(o.event);
3355
+ return o.result;
3202
3356
  }
3203
3357
  /** Notify the mutation observer after a successful write; observers must never fail the mutation. */
3204
3358
  notifyMutation(event) {
@@ -3455,9 +3609,10 @@ var SkillLibrary = class {
3455
3609
  ok: false,
3456
3610
  message: `Skill "${normalized}" already exists.`
3457
3611
  };
3458
- await this.io.writeText(join(dir, "SKILL.md"), finalContent.trimEnd() + "\n");
3612
+ const onDisk = finalContent.trimEnd() + "\n";
3613
+ await this.io.writeText(join(dir, "SKILL.md"), onDisk);
3459
3614
  if (origin !== "foreground") await this.io.writeText(markerPath(dir, "hermes-managed"), "");
3460
- await this.audit(normalized, "create", null, finalContent, "created");
3615
+ await this.audit(normalized, "create", null, onDisk, "created");
3461
3616
  this.notifyMutation({
3462
3617
  action: "create",
3463
3618
  name: normalized,
@@ -3472,17 +3627,16 @@ var SkillLibrary = class {
3472
3627
  }
3473
3628
  async update(rawName, content, origin = "foreground") {
3474
3629
  const name = rawName.trim();
3630
+ return await this.serial(() => this.updateCore(name, content, origin));
3631
+ }
3632
+ async updateCore(name, content, origin) {
3633
+ const dir = this.dirOf(name);
3634
+ const path = join(dir, "SKILL.md");
3475
3635
  const badName = this.badName(name);
3476
3636
  if (badName) return {
3477
3637
  ok: false,
3478
3638
  message: badName
3479
3639
  };
3480
- const dir = this.dirOf(name);
3481
- const md = await this.io.readText(join(dir, "SKILL.md"));
3482
- if (!md) return {
3483
- ok: false,
3484
- message: `Skill "${name}" not found.`
3485
- };
3486
3640
  const protection = await this.writeProtection(name, origin);
3487
3641
  if (protection) return {
3488
3642
  ok: false,
@@ -3511,27 +3665,52 @@ var SkillLibrary = class {
3511
3665
  ok: false,
3512
3666
  message: threat
3513
3667
  };
3514
- await this.io.writeText(join(dir, "SKILL.md"), finalContent.trimEnd() + "\n");
3515
- await this.audit(name, "update", md, finalContent, "updated");
3516
- this.notifyMutation({
3517
- action: "update",
3518
- name,
3519
- skillDir: dir
3668
+ return await this.runSingleWrite(path, (current) => {
3669
+ if (current === null) return {
3670
+ result: {
3671
+ ok: false,
3672
+ message: `Skill "${name}" not found.`
3673
+ },
3674
+ write: null
3675
+ };
3676
+ if (finalContent.trimEnd() === current.trimEnd()) return {
3677
+ result: {
3678
+ ok: true,
3679
+ message: `Skill "${name}" unchanged: the supplied content already matches the current file; nothing written.`,
3680
+ noop: true,
3681
+ path: dir
3682
+ },
3683
+ write: null
3684
+ };
3685
+ const onDisk = finalContent.trimEnd() + "\n";
3686
+ return {
3687
+ result: {
3688
+ ok: true,
3689
+ message: `Skill "${name}" updated.`,
3690
+ path: dir,
3691
+ ...norm.changed ? { normalizedFrontmatterFields: norm.fields } : {}
3692
+ },
3693
+ write: onDisk,
3694
+ audit: {
3695
+ skillName: name,
3696
+ action: "update",
3697
+ before: current,
3698
+ after: onDisk,
3699
+ summary: "updated"
3700
+ },
3701
+ event: {
3702
+ action: "update",
3703
+ name,
3704
+ skillDir: dir
3705
+ }
3706
+ };
3520
3707
  });
3521
- return {
3522
- ok: true,
3523
- message: `Skill "${name}" updated.`,
3524
- path: dir,
3525
- ...norm.changed ? { normalizedFrontmatterFields: norm.fields } : {}
3526
- };
3527
3708
  }
3528
3709
  async patch(rawName, oldString, newString, filePath = "", replaceAll = false, origin = "foreground") {
3529
3710
  const name = rawName.trim();
3530
- const badName = this.badName(name);
3531
- if (badName) return {
3532
- ok: false,
3533
- message: badName
3534
- };
3711
+ return await this.serial(() => this.patchCore(name, oldString, newString, filePath, replaceAll, origin));
3712
+ }
3713
+ async patchCore(name, oldString, newString, filePath, replaceAll, origin) {
3535
3714
  const dir = this.dirOf(name);
3536
3715
  const skillMd = join(dir, "SKILL.md");
3537
3716
  if (!await this.io.exists(skillMd)) return {
@@ -3554,71 +3733,109 @@ var SkillLibrary = class {
3554
3733
  target = join(dir, ...filePath.replace(/\\/g, "/").split("/").filter(Boolean));
3555
3734
  patchLabel = filePath;
3556
3735
  }
3557
- const md = await this.io.readText(target);
3558
- if (!md) return {
3559
- ok: false,
3560
- message: `File not found: ${patchLabel}`
3561
- };
3562
- const patched = fuzzyPatch(md, oldString, newString, replaceAll);
3563
- if (patched === null) return {
3564
- ok: false,
3565
- message: `Could not find old_string in "${name}/${patchLabel}". Use update for a full rewrite.`
3566
- };
3567
- let writeContent = patched;
3568
- let normalizedFields;
3569
- if (target === skillMd) {
3570
- const validation = validateFrontmatter(patched, name, this.limits);
3571
- if (validation) return {
3572
- ok: false,
3573
- message: `Patch rejected: ${validation}`
3574
- };
3575
- const norm = normalizeFrontmatter(patched);
3576
- if (norm.issues.length > 0) return {
3577
- ok: false,
3578
- message: `Patch rejected: frontmatter cannot be auto-fixed (${norm.issues[0]}).`
3736
+ return await this.runSingleWrite(target, (current) => {
3737
+ const md = current;
3738
+ if (md === null) return {
3739
+ result: {
3740
+ ok: false,
3741
+ message: `File not found: ${patchLabel}`
3742
+ },
3743
+ write: null
3579
3744
  };
3580
- if (norm.changed) {
3581
- writeContent = norm.content;
3582
- normalizedFields = norm.fields;
3583
- const revalidated = validateFrontmatter(writeContent, name, this.limits);
3584
- if (revalidated) return {
3745
+ const patched = fuzzyPatch(md, oldString, newString, replaceAll);
3746
+ if (patched === null) return {
3747
+ result: {
3585
3748
  ok: false,
3586
- message: `Patch rejected: ${revalidated}`
3749
+ message: `Could not find old_string in "${name}/${patchLabel}". Use update for a full rewrite.`
3750
+ },
3751
+ write: null
3752
+ };
3753
+ let writeContent = patched;
3754
+ let normalizedFields;
3755
+ if (target === skillMd) {
3756
+ const validation = validateFrontmatter(patched, name, this.limits);
3757
+ if (validation) return {
3758
+ result: {
3759
+ ok: false,
3760
+ message: `Patch rejected: ${validation}`
3761
+ },
3762
+ write: null
3587
3763
  };
3764
+ const norm = normalizeFrontmatter(patched);
3765
+ if (norm.issues.length > 0) return {
3766
+ result: {
3767
+ ok: false,
3768
+ message: `Patch rejected: frontmatter cannot be auto-fixed (${norm.issues[0]}).`
3769
+ },
3770
+ write: null
3771
+ };
3772
+ if (norm.changed) {
3773
+ writeContent = norm.content;
3774
+ normalizedFields = norm.fields;
3775
+ const revalidated = validateFrontmatter(writeContent, name, this.limits);
3776
+ if (revalidated) return {
3777
+ result: {
3778
+ ok: false,
3779
+ message: `Patch rejected: ${revalidated}`
3780
+ },
3781
+ write: null
3782
+ };
3783
+ }
3588
3784
  }
3589
- }
3590
- if (Buffer.byteLength(writeContent, "utf8") > this.limits.maxSkillFileBytes && target !== skillMd) return {
3591
- ok: false,
3592
- message: `Patched file exceeds ${this.limits.maxSkillFileBytes} bytes.`
3593
- };
3594
- if (writeContent.length > this.limits.maxSkillContentChars && target === skillMd) return {
3595
- ok: false,
3596
- message: `Patched content exceeds ${this.limits.maxSkillContentChars} characters. Consider splitting into a smaller SKILL.md with supporting files.`
3597
- };
3598
- const threat = scanContentThreats(writeContent);
3599
- if (threat) return {
3600
- ok: false,
3601
- message: threat
3602
- };
3603
- if (writeContent.trimEnd() + "\n" === md) return {
3604
- ok: true,
3605
- message: `Skill "${name}" unchanged: old_string already equals the replacement (${patchLabel}); nothing written.`,
3606
- noop: true,
3607
- path: dir
3608
- };
3609
- await this.io.writeText(target, writeContent.trimEnd() + "\n");
3610
- await this.audit(name, "patch", md, writeContent, `patched ${patchLabel}`);
3611
- this.notifyMutation({
3612
- action: "patch",
3613
- name,
3614
- skillDir: dir
3785
+ if (Buffer.byteLength(writeContent, "utf8") > this.limits.maxSkillFileBytes && target !== skillMd) return {
3786
+ result: {
3787
+ ok: false,
3788
+ message: `Patched file exceeds ${this.limits.maxSkillFileBytes} bytes.`
3789
+ },
3790
+ write: null
3791
+ };
3792
+ if (writeContent.length > this.limits.maxSkillContentChars && target === skillMd) return {
3793
+ result: {
3794
+ ok: false,
3795
+ message: `Patched content exceeds ${this.limits.maxSkillContentChars} characters. Consider splitting into a smaller SKILL.md with supporting files.`
3796
+ },
3797
+ write: null
3798
+ };
3799
+ const threat = scanContentThreats(writeContent);
3800
+ if (threat) return {
3801
+ result: {
3802
+ ok: false,
3803
+ message: threat
3804
+ },
3805
+ write: null
3806
+ };
3807
+ if (writeContent.trimEnd() === md.trimEnd()) return {
3808
+ result: {
3809
+ ok: true,
3810
+ message: `Skill "${name}" unchanged: old_string already equals the replacement (${patchLabel}); nothing written.`,
3811
+ noop: true,
3812
+ path: dir
3813
+ },
3814
+ write: null
3815
+ };
3816
+ const onDisk = writeContent.trimEnd() + "\n";
3817
+ return {
3818
+ result: {
3819
+ ok: true,
3820
+ message: `Skill "${name}" patched (${patchLabel}).`,
3821
+ path: dir,
3822
+ ...normalizedFields ? { normalizedFrontmatterFields: normalizedFields } : {}
3823
+ },
3824
+ write: onDisk,
3825
+ audit: {
3826
+ skillName: name,
3827
+ action: "patch",
3828
+ before: md,
3829
+ after: onDisk,
3830
+ summary: `patched ${patchLabel}`
3831
+ },
3832
+ event: {
3833
+ action: "patch",
3834
+ name,
3835
+ skillDir: dir
3836
+ }
3837
+ };
3615
3838
  });
3616
- return {
3617
- ok: true,
3618
- message: `Skill "${name}" patched (${patchLabel}).`,
3619
- path: dir,
3620
- ...normalizedFields ? { normalizedFrontmatterFields: normalizedFields } : {}
3621
- };
3622
3839
  }
3623
3840
  async archive(rawName, options = {}) {
3624
3841
  const name = rawName.trim();
@@ -3872,6 +4089,9 @@ var SkillLibrary = class {
3872
4089
  */
3873
4090
  async restructure(rawName, moves, origin = "foreground") {
3874
4091
  const name = rawName.trim();
4092
+ return await this.serial(() => this.restructureCore(name, moves, origin));
4093
+ }
4094
+ async restructureCore(name, moves, origin) {
3875
4095
  const badName = this.badName(name);
3876
4096
  if (badName) return {
3877
4097
  ok: false,
@@ -4120,12 +4340,15 @@ var SkillLibrary = class {
4120
4340
  }
4121
4341
  async writeSupportFile(rawName, filePath, content, origin = "foreground") {
4122
4342
  const name = rawName.trim();
4343
+ return await this.serial(() => this.writeSupportFileCore(name, filePath, content, origin));
4344
+ }
4345
+ async writeSupportFileCore(name, filePath, content, origin) {
4346
+ const dir = this.dirOf(name);
4123
4347
  const badName = this.badName(name);
4124
4348
  if (badName) return {
4125
4349
  ok: false,
4126
4350
  message: badName
4127
4351
  };
4128
- const dir = this.dirOf(name);
4129
4352
  if (!await this.io.exists(join(dir, "SKILL.md"))) return {
4130
4353
  ok: false,
4131
4354
  message: `Skill "${name}" not found.`
@@ -4150,20 +4373,29 @@ var SkillLibrary = class {
4150
4373
  message: threat
4151
4374
  };
4152
4375
  const target = join(dir, ...filePath.replace(/\\/g, "/").split("/").filter(Boolean));
4153
- const existing = await this.io.readText(target).catch(() => null);
4154
- await this.io.writeText(target, content);
4155
- await this.audit(name, "write_file", existing, content, `wrote ${filePath}`);
4156
- this.notifyMutation({
4157
- action: "write_file",
4158
- name,
4159
- skillDir: dir,
4160
- file: target
4376
+ return await this.runSingleWrite(target, (current) => {
4377
+ return {
4378
+ result: {
4379
+ ok: true,
4380
+ message: `Support file "${filePath}" written to "${name}".`,
4381
+ path: target
4382
+ },
4383
+ write: content,
4384
+ audit: {
4385
+ skillName: name,
4386
+ action: "write_file",
4387
+ before: current,
4388
+ after: content,
4389
+ summary: `wrote ${filePath}`
4390
+ },
4391
+ event: {
4392
+ action: "write_file",
4393
+ name,
4394
+ skillDir: dir,
4395
+ file: target
4396
+ }
4397
+ };
4161
4398
  });
4162
- return {
4163
- ok: true,
4164
- message: `Support file "${filePath}" written to "${name}".`,
4165
- path: target
4166
- };
4167
4399
  }
4168
4400
  async removeSupportFile(rawName, filePath, origin = "foreground") {
4169
4401
  const name = rawName.trim();
@@ -4396,8 +4628,18 @@ var SkillLibrary = class {
4396
4628
  * Evolution home path helper: `$DSH_HOME/evolution` for plugin-owned sidecar
4397
4629
  * state (reports, activity store, feedback file, state-domain data).
4398
4630
  */
4631
+ /**
4632
+ * DSH home root: `$DSH_HOME` or `~/.dsh`. Single source of the empty-string
4633
+ * fallback (`||`, not `??`) — an EMPTY DSH_HOME resolves to the default home,
4634
+ * never to a CWD-relative path (0.3.19 W1.3, 0.3.22 F-207).
4635
+ */
4636
+ function evolutionRoot(env = process.env) {
4637
+ return env.DSH_HOME || join(homedir(), ".dsh");
4638
+ }
4639
+ /** Evolution home path helper: `$DSH_HOME/evolution` for plugin-owned sidecar
4640
+ * state (reports, activity store, feedback file, state-domain data). */
4399
4641
  function evolutionHome(env = process.env) {
4400
- return join(env.DSH_HOME || join(homedir(), ".dsh"), "evolution");
4642
+ return join(evolutionRoot(env), "evolution");
4401
4643
  }
4402
4644
  //#endregion
4403
- export { AUTHORING_DESCRIPTION_BAR, COMBINED_REVIEW_PLAN_PROMPT, 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_HEALTH_THRESHOLDS, 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, DRIFT_MAX_LINE_CHARS, DRIFT_SIGNALS_VERSION, DRIFT_SIGNAL_NOUNS, DSH_AUTHORING_STANDARDS, ENTRY_DELIMITER, EVENT_ARCHIVE_PREFIX, EVENT_LOG_RETAIN_ARCHIVES, EVENT_LOG_ROTATE_AT, EVENT_LOG_VERSION, EVOLUTION_WRITE_TOOLS, EvolutionGateSet, FORBIDDEN_CONTROL_KEYS, HEALTH_STAMP_RE, LOW_QUALITY_THRESHOLD, MAINTAIN_PROMPT, MAX_DESCRIPTION_LENGTH, MAX_RESTRUCTURE_MOVES, MAX_SKILL_CONTENT_CHARS, MAX_SKILL_FILE_BYTES, MAX_SKILL_NAME_LENGTH, MEMORY_REVIEW_PROMPT, MIN_STAMP_BODY_CHARS, MUTATIONS_FILE_VERSION, MemoryStore, PROMPT_BUNDLE, PROMPT_BUNDLE_ID, PROMPT_BUNDLE_VERSION, PROTECTED_BUILTIN_SKILLS, QUALITY_WEIGHTS, RESTRUCTURE_TARGET_RE, SKILLS_GUIDANCE, SKILL_NAME_RE, SKILL_REVIEW_PLAN_PROMPT, SKILL_REVIEW_PROMPT, SNAPSHOT_EXTRA_NAME_RE, SUPPORT_DIRS, SUPPRESSED_FILE_VERSION, SkillLibrary, advanceReview, appendEvolutionEvent, applyCuratorFields, applyCuratorLifecycleFields, applyCuratorMetaFields, assessStructureHealth, authoringFeedback, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpUse, bumpView, composePresetComposition, computeDedupGroups, computeDriftSignals, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, contentHash, createGateSet, duplicateHeadings, emptyRecord, evaluateThreat, eventsFile, evolutionHome, evolutionIoAdapter, findDriftSignal, foldCuratorFields, foldTurn, frontmatterBlock, frontmatterYamlUnsafeValues, getRecord, latestActivityAt, lifecycleCandidate, listEventArchives, loadMutations, loadSuppressedNames, loadUsage, makeSerialQueue, markAgentCreated, memoryRoot, missingSupportPointers, mutateUsage, mutationsFile, narrowNameMatches, nodeEvolutionIo, normalizeFrontmatter, normalizeUsageRecord, observeEvent, overlongLines, parseCuratorNominations, parseEvolutionEvents, parseFrontmatter, readEvolutionEvents, readEvolutionTimeline, recordMutation, redactSecrets, relatedSkillNames, renderCuratorReportMarkdown, resolveOrigins, resolveSkillsRoot, retainEventArchives, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, suppressedFile, transactIo, updateSuppressedNames, usageFile, usageObserved, validateFrontmatter, verifyPromptBundle, yamlPlainScalarNeedsQuotes };
4645
+ export { AUTHORING_DESCRIPTION_BAR, COMBINED_REVIEW_PLAN_PROMPT, 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_HEALTH_THRESHOLDS, 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, DRIFT_MAX_LINE_CHARS, DRIFT_SIGNALS_VERSION, DRIFT_SIGNAL_NOUNS, DSH_AUTHORING_STANDARDS, ENTRY_DELIMITER, EVENT_ARCHIVE_PREFIX, EVENT_LOG_RETAIN_ARCHIVES, EVENT_LOG_ROTATE_AT, EVENT_LOG_VERSION, EVOLUTION_WRITE_TOOLS, EvolutionGateSet, FORBIDDEN_CONTROL_KEYS, HEALTH_STAMP_RE, LOW_QUALITY_THRESHOLD, MAINTAIN_PROMPT, MAX_DESCRIPTION_LENGTH, MAX_RESTRUCTURE_MOVES, MAX_SKILL_CONTENT_CHARS, MAX_SKILL_FILE_BYTES, MAX_SKILL_NAME_LENGTH, MEMORY_REVIEW_PROMPT, MIN_STAMP_BODY_CHARS, MUTATIONS_FILE_VERSION, MemoryStore, PROMPT_BUNDLE, PROMPT_BUNDLE_ID, PROMPT_BUNDLE_VERSION, PROTECTED_BUILTIN_SKILLS, QUALITY_WEIGHTS, RESTRUCTURE_TARGET_RE, SKILLS_GUIDANCE, SKILL_NAME_RE, SKILL_REVIEW_PLAN_PROMPT, SKILL_REVIEW_PROMPT, SNAPSHOT_EXTRA_NAME_RE, SUPPORT_DIRS, SUPPRESSED_FILE_VERSION, SkillLibrary, advanceReview, appendEvolutionEvent, applyCuratorFields, applyCuratorLifecycleFields, applyCuratorMetaFields, assessStructureHealth, authoringFeedback, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpUse, bumpView, composePresetComposition, computeDedupGroups, computeDriftSignals, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, contentHash, createGateSet, duplicateHeadings, emptyRecord, evaluateThreat, eventsFile, evolutionHome, evolutionIoAdapter, evolutionRoot, findDriftSignal, foldCuratorFields, foldTurn, frontmatterBlock, frontmatterYamlUnsafeValues, getRecord, latestActivityAt, lifecycleCandidate, listEventArchives, loadMutations, loadSuppressedNames, loadUsage, makeSerialQueue, markAgentCreated, memoryRoot, missingSupportPointers, mutateUsage, mutationsFile, narrowNameMatches, nodeEvolutionIo, normalizeFrontmatter, normalizeUsageRecord, observeEvent, overlongLines, parseCuratorNominations, parseEvolutionEvents, parseFrontmatter, readEvolutionEvents, readEvolutionTimeline, recordMutation, redactSecrets, relatedSkillNames, renameWithRetry, renderCuratorReportMarkdown, resolveOrigins, resolveSkillsRoot, retainEventArchives, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, suppressedFile, transactIo, updateSuppressedNames, usageFile, usageObserved, validateFrontmatter, verifyPromptBundle, yamlPlainScalarNeedsQuotes };
@@ -17,7 +17,7 @@
17
17
  * Package-private tunables (used by exactly one package) stay in that package,
18
18
  * not here — see evolution-replay's `DEFAULT_WEIGHTS` and evolution-feedback's
19
19
  * threshold, which are intentionally left where they are used.
20
- * @module @deepseek-ai/dsh-evolution-core
20
+ * @module @lmzhen/dsh-evolution-core
21
21
  */
22
22
  /** Skill frontmatter `name` validated for the file name (lowercase + hyphen). */
23
23
  export declare const SKILL_NAME_RE: RegExp;
@@ -73,6 +73,13 @@ export declare function eventsFile(home: string): string;
73
73
  * rebuildable, NOT malformed) or a corrupt one reads as empty; corrupt content
74
74
  * is still refused on append, never overwritten.
75
75
  *
76
+ * This reader is **v1-only** (F-338): a body carrying a `version` other than
77
+ * `EVENT_LOG_VERSION` is a future-format log this reader cannot interpret, so
78
+ * it reads as an EMPTY timeline rather than being mis-parsed as v1. The read
79
+ * side never overwrites it on its own — `appendEvolutionEvent` rejects a
80
+ * version mismatch up front and preserves the original bytes, so a newer log
81
+ * is never silently downgraded here.
82
+ *
76
83
  * Per-entry normalization (rc.70 F-1): entries without a numeric `seq` are
77
84
  * skipped here and dropped at the next append — valid entries survive, the
78
85
  * damaged record is the only loss (self-heal semantics, matching the usage
@@ -124,7 +131,10 @@ export interface EventLogRead {
124
131
  malformed: boolean;
125
132
  }
126
133
  /** Read the event log; a missing/whitespace-only file reads as empty,
127
- * corrupt content is flagged (and refused on append). */
134
+ * corrupt content is flagged (and refused on append). A well-formed future-
135
+ * version body is v1-incompatible and reads as empty, NOT malformed (F-338:
136
+ * the reader must never mis-shape a newer format; the append path refuses it
137
+ * up front so the original bytes survive). */
128
138
  export declare function readEvolutionEvents(io: EvolutionIoLike, path: string): Promise<EventLogRead>;
129
139
  /**
130
140
  * Read the full timeline (rc.71): active log + all archives, merged by seq
@@ -9,7 +9,7 @@
9
9
  * protections (pinned / bundled / hub-installed) are file markers resolved by
10
10
  * `SkillLibrary.writeProtection` / `deleteProtection` — they depend on the
11
11
  * filesystem and the write origin, not on a name list.
12
- * @module @deepseek-ai/dsh-evolution-core
12
+ * @module @lmzhen/dsh-evolution-core
13
13
  */
14
14
  export type GateReason = 'excluded' | 'referenced' | 'suppressed' | 'protected-builtin';
15
15
  export interface GateSetInputs {
@@ -5,7 +5,7 @@
5
5
  * types, and session-event augmentations. This package owns no Cordis plugin
6
6
  * entry of its own; consumers import named exports from the package root so
7
7
  * published npm bundles never depend on source subpaths.
8
- * @module @deepseek-ai/dsh-evolution-core
8
+ * @module @lmzhen/dsh-evolution-core
9
9
  */
10
10
  export * from './curator.ts';
11
11
  export * from './evolution-events.ts';
package/lib/types/io.d.ts CHANGED
@@ -53,5 +53,17 @@ export interface EvolutionIoLike {
53
53
  export declare function transactIo(io: EvolutionIoLike, path: string, task: (current: string | null) => string | null | Promise<string | null>): Promise<void>;
54
54
  /** Lazy adapter over an IO provider registry, shared by every evolution consumer. */
55
55
  export declare function evolutionIoAdapter(provider: () => EvolutionIoLike): EvolutionIoLike;
56
+ /**
57
+ * Retry a rename that a peer is temporarily holding on Windows (EPERM/EBUSY):
58
+ * a short 50ms backoff, at most 3 retries (~150ms budget), matching the
59
+ * write-lock cadence. A non-transient code surfaces immediately. `fn` is the
60
+ * rename primitive, injectable for deterministic tests.
61
+ *
62
+ * @param tmp - the source path to rename.
63
+ * @param target - the destination path.
64
+ * @param fn - the rename primitive (defaults to `node:fs/promises.rename`).
65
+ * @returns a promise that resolves once the rename succeeds.
66
+ */
67
+ export declare function renameWithRetry(tmp: string, target: string, fn?: (from: string, to: string) => Promise<void>): Promise<void>;
56
68
  export declare function nodeEvolutionIo(): EvolutionIoLike;
57
69
  //# sourceMappingURL=io.d.ts.map
@@ -2,7 +2,7 @@
2
2
  * Curator/author audit trail: `.mutations.json` records every skill mutation
3
3
  * with before/after content hashes so any automated edit is reviewable and
4
4
  * replayable. Best-effort persistence, mirroring the usage sidecar posture.
5
- * @module @deepseek-ai/dsh-evolution-core
5
+ * @module @lmzhen/dsh-evolution-core
6
6
  */
7
7
  import { type EvolutionIoLike } from './io.ts';
8
8
  export interface MutationRecord {
@@ -7,7 +7,7 @@
7
7
  * mutation maturity is a documented DSH approximation (single per-month patch
8
8
  * trend ratio replaces the claw timestamp-trend formula, since DSH usage
9
9
  * records only carry the last patched timestamp).
10
- * @module @deepseek-ai/dsh-evolution-core
10
+ * @module @lmzhen/dsh-evolution-core
11
11
  */
12
12
  import type { UsageMap } from './usage.ts';
13
13
  export interface QualityFactors {
@@ -6,7 +6,7 @@
6
6
  * it created unless a `.hermes-managed` marker opts a skill in. Archival is a
7
7
  * move to `.archive/` — never a hard delete.
8
8
  */
9
- import { type EvolutionIoLike } from './io.ts';
9
+ import { transactIo, type EvolutionIoLike } from './io.ts';
10
10
  import { type MutationRecord } from './mutations.ts';
11
11
  import { type SkillHealthAssessment, type SkillHealthThresholds } from './skill-health.ts';
12
12
  import type { EvolutionSkillMutatedEvent } from './events.ts';
@@ -88,9 +88,11 @@ export declare function skillsRoot(env?: NodeJS.ProcessEnv): string;
88
88
  * the skills tree — tool-skill-manage / evolution-skill-catalog / skill-usage
89
89
  * / evolution-learning-graph used to each resolve `config.root || skillsRoot()`
90
90
  * (and the graph ignored config entirely). Empty/whitespace config falls
91
- * through to the default; callers pass their raw Config. */
91
+ * through to the default; callers pass their raw Config. The optional field is
92
+ * declared `| undefined` so a config object whose root field is explicitly
93
+ * `string | undefined` still assignable under exactOptionalPropertyTypes. */
92
94
  export declare function resolveSkillsRoot(config?: {
93
- root?: string;
95
+ root?: string | undefined;
94
96
  }): string;
95
97
  /**
96
98
  * Map a requesting session onto the two origin surfaces (rc.44 plan M2-2.3):
@@ -212,7 +214,24 @@ export declare class SkillLibrary {
212
214
  readonly limits: SkillLimits;
213
215
  private readonly io;
214
216
  private readonly onMutation;
215
- constructor(root?: string, io?: EvolutionIoLike, limits?: SkillLimits, onMutation?: (event: EvolutionSkillMutatedEvent) => void);
217
+ /** 0.3.21 (F-208): optional cross-process RMW transactor injected by callers.
218
+ * When unset each single-file write falls back to read→task→write. */
219
+ private readonly transact;
220
+ /** 0.3.21 (F-208): in-process serialize queue so two concurrent mutators on
221
+ * one skill never interleave their read-modify-write (the cross-process layer
222
+ * is the IO backend's transact lock; this chain is the second layer). */
223
+ private readonly serial;
224
+ constructor(root?: string, io?: EvolutionIoLike, limits?: SkillLimits, onMutation?: (event: EvolutionSkillMutatedEvent) => void, transact?: typeof transactIo);
225
+ /**
226
+ * Run one single-file read-modify-write for a mutator. When `transact` was
227
+ * injected the read and the write run inside it (cross-process atomicity);
228
+ * otherwise a plain read → task → write sequence runs (the process-level
229
+ * `serial` chain is the second layer). `task` receives the current content
230
+ * (null when missing) and returns a {@link SingleWriteOutcome}. Audit and the
231
+ * mutation event are issued ONLY when a write actually lands, so a no-op
232
+ * never inflates the mutation-maturity counter.
233
+ */
234
+ private runSingleWrite;
216
235
  /** Notify the mutation observer after a successful write; observers must never fail the mutation. */
217
236
  private notifyMutation;
218
237
  list(): Promise<SkillSummary[]>;
@@ -271,7 +290,9 @@ export declare class SkillLibrary {
271
290
  setPinned(name: string, pinned: boolean, origin?: WriteOrigin): Promise<SkillActionResult>;
272
291
  create(name: string, content: string, origin?: WriteOrigin): Promise<SkillActionResult>;
273
292
  update(rawName: string, content: string, origin?: WriteOrigin): Promise<SkillActionResult>;
293
+ private updateCore;
274
294
  patch(rawName: string, oldString: string, newString: string, filePath?: string, replaceAll?: boolean, origin?: WriteOrigin): Promise<SkillActionResult>;
295
+ private patchCore;
275
296
  archive(rawName: string, options?: ArchiveOptions): Promise<SkillActionResult>;
276
297
  /**
277
298
  * Merge the bodies of `sources` into `target` and archive the sources with
@@ -307,6 +328,7 @@ export declare class SkillLibrary {
307
328
  * same package; the moved text belongs in references/ beside them).
308
329
  */
309
330
  restructure(rawName: string, moves: SkillRestructureMove[], origin?: WriteOrigin): Promise<SkillActionResult>;
331
+ private restructureCore;
310
332
  /**
311
333
  * Unified tree-change commit point (009 kernel): owns validation order,
312
334
  * pre-read rollback bytes, two-phase write with byte-level rollback, audit
@@ -322,6 +344,7 @@ export declare class SkillLibrary {
322
344
  */
323
345
  restoreFromArchive(rawName: string): Promise<SkillActionResult>;
324
346
  writeSupportFile(rawName: string, filePath: string, content: string, origin?: WriteOrigin): Promise<SkillActionResult>;
347
+ private writeSupportFileCore;
325
348
  removeSupportFile(rawName: string, filePath: string, origin?: WriteOrigin): Promise<SkillActionResult>;
326
349
  /**
327
350
  * Snapshot the recoverable skills state: active tree, usage/suppression
@@ -2,5 +2,13 @@
2
2
  * Evolution home path helper: `$DSH_HOME/evolution` for plugin-owned sidecar
3
3
  * state (reports, activity store, feedback file, state-domain data).
4
4
  */
5
+ /**
6
+ * DSH home root: `$DSH_HOME` or `~/.dsh`. Single source of the empty-string
7
+ * fallback (`||`, not `??`) — an EMPTY DSH_HOME resolves to the default home,
8
+ * never to a CWD-relative path (0.3.19 W1.3, 0.3.22 F-207).
9
+ */
10
+ export declare function evolutionRoot(env?: NodeJS.ProcessEnv): string;
11
+ /** Evolution home path helper: `$DSH_HOME/evolution` for plugin-owned sidecar
12
+ * state (reports, activity store, feedback file, state-domain data). */
5
13
  export declare function evolutionHome(env?: NodeJS.ProcessEnv): string;
6
14
  //# sourceMappingURL=state-store.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.3.20",
4
+ "version": "0.3.22",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -25,10 +25,8 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "files": [
28
- "lib/index.js",
29
- "lib/invariant.js",
30
- "lib/types/**/*.d.ts",
31
- "lib/types/invariant.d.ts"
28
+ "lib/*.js",
29
+ "lib/types/**/*.d.ts"
32
30
  ],
33
31
  "license": "MIT",
34
32
  "dependencies": {