@lmzhen/dsh-evolution-core 0.3.21 → 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
@@ -942,6 +942,13 @@ function isEventRecord(event) {
942
942
  * rebuildable, NOT malformed) or a corrupt one reads as empty; corrupt content
943
943
  * is still refused on append, never overwritten.
944
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
+ *
945
952
  * Per-entry normalization (rc.70 F-1): entries without a numeric `seq` are
946
953
  * skipped here and dropped at the next append — valid entries survive, the
947
954
  * damaged record is the only loss (self-heal semantics, matching the usage
@@ -951,6 +958,7 @@ function parseEvolutionEvents(raw) {
951
958
  if (raw === null || raw.trim() === "") return [];
952
959
  try {
953
960
  const parsed = JSON.parse(raw);
961
+ if (parsed.version !== void 0 && parsed.version !== 1) return [];
954
962
  if (!Array.isArray(parsed.events)) return [];
955
963
  return parsed.events.filter(isEventRecord);
956
964
  } catch {
@@ -989,11 +997,19 @@ async function listEventArchives(io, path) {
989
997
  */
990
998
  async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE_AT) {
991
999
  let assigned = 0;
1000
+ let refuseMessage = "";
992
1001
  await transactIo(io, path, async (current) => {
993
- if (current !== null && current.trim() !== "") try {
994
- JSON.parse(current);
995
- } catch {
996
- 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
+ }
997
1013
  }
998
1014
  const nextEvents = await rotateIfDue(io, path, parseEvolutionEvents(current), rotateAt);
999
1015
  let maxSeq = nextEvents.reduce((max, entry) => Math.max(max, entry.seq), 0);
@@ -1009,7 +1025,7 @@ async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE
1009
1025
  events: [...nextEvents, record]
1010
1026
  }, null, 2);
1011
1027
  });
1012
- 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}`);
1013
1029
  return assigned;
1014
1030
  }
1015
1031
  /**
@@ -1049,7 +1065,10 @@ async function retainEventArchives(io, path) {
1049
1065
  for (const name of excess) await io.remove(join(dir, name)).catch(() => {});
1050
1066
  }
1051
1067
  /** Read the event log; a missing/whitespace-only file reads as empty,
1052
- * 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). */
1053
1072
  async function readEvolutionEvents(io, path) {
1054
1073
  let raw;
1055
1074
  try {
@@ -1066,6 +1085,10 @@ async function readEvolutionEvents(io, path) {
1066
1085
  };
1067
1086
  try {
1068
1087
  const parsed = JSON.parse(raw);
1088
+ if (parsed.version !== void 0 && parsed.version !== 1) return {
1089
+ events: [],
1090
+ malformed: false
1091
+ };
1069
1092
  if (!Array.isArray(parsed.events)) return {
1070
1093
  events: [],
1071
1094
  malformed: false
@@ -1810,6 +1833,14 @@ function render(entries) {
1810
1833
  function stripDatePrefix(entry) {
1811
1834
  return entry.replace(/^## \d{4}-\d{2}-\d{2}\n/, "");
1812
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
+ }
1813
1844
  var MemoryStore = class {
1814
1845
  memoryLimit;
1815
1846
  userLimit;
@@ -1976,6 +2007,16 @@ var MemoryStore = class {
1976
2007
  },
1977
2008
  write: null
1978
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
+ };
1979
2020
  const entries = [...new Set(normalizeEntries(raw))];
1980
2021
  if (entries.some((entry) => stripDatePrefix(entry) === content)) {
1981
2022
  this.resetFailures();
@@ -2078,6 +2119,16 @@ var MemoryStore = class {
2078
2119
  },
2079
2120
  write: null
2080
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
+ };
2081
2132
  if (!working.some((entry) => stripDatePrefix(entry) === body)) working.push(this.addDatePrefix ? `## ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}\n${body}` : body);
2082
2133
  continue;
2083
2134
  }
@@ -2135,6 +2186,16 @@ var MemoryStore = class {
2135
2186
  },
2136
2187
  write: null
2137
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
+ };
2138
2199
  working[matchIndex] = body;
2139
2200
  }
2140
2201
  }
@@ -2829,7 +2890,9 @@ function skillsRoot(env = process.env) {
2829
2890
  * the skills tree — tool-skill-manage / evolution-skill-catalog / skill-usage
2830
2891
  * / evolution-learning-graph used to each resolve `config.root || skillsRoot()`
2831
2892
  * (and the graph ignored config entirely). Empty/whitespace config falls
2832
- * 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. */
2833
2896
  function resolveSkillsRoot(config = {}) {
2834
2897
  return (config.root ?? "").trim() || skillsRoot();
2835
2898
  }
@@ -3176,15 +3239,9 @@ function fuzzyPatch(content, oldString, newString, replaceAll = false) {
3176
3239
  const boundary = trimPatternBoundaries(oldString);
3177
3240
  if (boundary === "") return null;
3178
3241
  if (boundary !== oldString) {
3179
- if (fuzzyIndexOf(content, boundary) !== null) {
3180
- const patched = fuzzyReplace(content, boundary, newString, replaceAll);
3181
- return patched === content ? null : patched;
3182
- }
3183
- }
3184
- if (fuzzyIndexOf(content, oldString) !== null) {
3185
- const patched = fuzzyReplace(content, oldString, newString, replaceAll);
3186
- return patched === content ? null : patched;
3242
+ if (fuzzyIndexOf(content, boundary) !== null) return fuzzyReplace(content, boundary, newString, replaceAll);
3187
3243
  }
3244
+ if (fuzzyIndexOf(content, oldString) !== null) return fuzzyReplace(content, oldString, newString, replaceAll);
3188
3245
  return null;
3189
3246
  }
3190
3247
  /**
@@ -3255,11 +3312,47 @@ var SkillLibrary = class {
3255
3312
  limits;
3256
3313
  io;
3257
3314
  onMutation;
3258
- 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) {
3259
3323
  this.root = root;
3260
3324
  this.io = io;
3261
3325
  this.limits = limits;
3262
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;
3263
3356
  }
3264
3357
  /** Notify the mutation observer after a successful write; observers must never fail the mutation. */
3265
3358
  notifyMutation(event) {
@@ -3516,9 +3609,10 @@ var SkillLibrary = class {
3516
3609
  ok: false,
3517
3610
  message: `Skill "${normalized}" already exists.`
3518
3611
  };
3519
- 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);
3520
3614
  if (origin !== "foreground") await this.io.writeText(markerPath(dir, "hermes-managed"), "");
3521
- await this.audit(normalized, "create", null, finalContent, "created");
3615
+ await this.audit(normalized, "create", null, onDisk, "created");
3522
3616
  this.notifyMutation({
3523
3617
  action: "create",
3524
3618
  name: normalized,
@@ -3533,17 +3627,16 @@ var SkillLibrary = class {
3533
3627
  }
3534
3628
  async update(rawName, content, origin = "foreground") {
3535
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");
3536
3635
  const badName = this.badName(name);
3537
3636
  if (badName) return {
3538
3637
  ok: false,
3539
3638
  message: badName
3540
3639
  };
3541
- const dir = this.dirOf(name);
3542
- const md = await this.io.readText(join(dir, "SKILL.md"));
3543
- if (!md) return {
3544
- ok: false,
3545
- message: `Skill "${name}" not found.`
3546
- };
3547
3640
  const protection = await this.writeProtection(name, origin);
3548
3641
  if (protection) return {
3549
3642
  ok: false,
@@ -3572,27 +3665,52 @@ var SkillLibrary = class {
3572
3665
  ok: false,
3573
3666
  message: threat
3574
3667
  };
3575
- await this.io.writeText(join(dir, "SKILL.md"), finalContent.trimEnd() + "\n");
3576
- await this.audit(name, "update", md, finalContent, "updated");
3577
- this.notifyMutation({
3578
- action: "update",
3579
- name,
3580
- 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
+ };
3581
3707
  });
3582
- return {
3583
- ok: true,
3584
- message: `Skill "${name}" updated.`,
3585
- path: dir,
3586
- ...norm.changed ? { normalizedFrontmatterFields: norm.fields } : {}
3587
- };
3588
3708
  }
3589
3709
  async patch(rawName, oldString, newString, filePath = "", replaceAll = false, origin = "foreground") {
3590
3710
  const name = rawName.trim();
3591
- const badName = this.badName(name);
3592
- if (badName) return {
3593
- ok: false,
3594
- message: badName
3595
- };
3711
+ return await this.serial(() => this.patchCore(name, oldString, newString, filePath, replaceAll, origin));
3712
+ }
3713
+ async patchCore(name, oldString, newString, filePath, replaceAll, origin) {
3596
3714
  const dir = this.dirOf(name);
3597
3715
  const skillMd = join(dir, "SKILL.md");
3598
3716
  if (!await this.io.exists(skillMd)) return {
@@ -3615,71 +3733,109 @@ var SkillLibrary = class {
3615
3733
  target = join(dir, ...filePath.replace(/\\/g, "/").split("/").filter(Boolean));
3616
3734
  patchLabel = filePath;
3617
3735
  }
3618
- const md = await this.io.readText(target);
3619
- if (!md) return {
3620
- ok: false,
3621
- message: `File not found: ${patchLabel}`
3622
- };
3623
- const patched = fuzzyPatch(md, oldString, newString, replaceAll);
3624
- if (patched === null) return {
3625
- ok: false,
3626
- message: `Could not find old_string in "${name}/${patchLabel}". Use update for a full rewrite.`
3627
- };
3628
- let writeContent = patched;
3629
- let normalizedFields;
3630
- if (target === skillMd) {
3631
- const validation = validateFrontmatter(patched, name, this.limits);
3632
- if (validation) return {
3633
- ok: false,
3634
- message: `Patch rejected: ${validation}`
3635
- };
3636
- const norm = normalizeFrontmatter(patched);
3637
- if (norm.issues.length > 0) return {
3638
- ok: false,
3639
- 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
3640
3744
  };
3641
- if (norm.changed) {
3642
- writeContent = norm.content;
3643
- normalizedFields = norm.fields;
3644
- const revalidated = validateFrontmatter(writeContent, name, this.limits);
3645
- if (revalidated) return {
3745
+ const patched = fuzzyPatch(md, oldString, newString, replaceAll);
3746
+ if (patched === null) return {
3747
+ result: {
3646
3748
  ok: false,
3647
- 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
3648
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
+ }
3649
3784
  }
3650
- }
3651
- if (Buffer.byteLength(writeContent, "utf8") > this.limits.maxSkillFileBytes && target !== skillMd) return {
3652
- ok: false,
3653
- message: `Patched file exceeds ${this.limits.maxSkillFileBytes} bytes.`
3654
- };
3655
- if (writeContent.length > this.limits.maxSkillContentChars && target === skillMd) return {
3656
- ok: false,
3657
- message: `Patched content exceeds ${this.limits.maxSkillContentChars} characters. Consider splitting into a smaller SKILL.md with supporting files.`
3658
- };
3659
- const threat = scanContentThreats(writeContent);
3660
- if (threat) return {
3661
- ok: false,
3662
- message: threat
3663
- };
3664
- if (writeContent.trimEnd() + "\n" === md) return {
3665
- ok: true,
3666
- message: `Skill "${name}" unchanged: old_string already equals the replacement (${patchLabel}); nothing written.`,
3667
- noop: true,
3668
- path: dir
3669
- };
3670
- await this.io.writeText(target, writeContent.trimEnd() + "\n");
3671
- await this.audit(name, "patch", md, writeContent, `patched ${patchLabel}`);
3672
- this.notifyMutation({
3673
- action: "patch",
3674
- name,
3675
- 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
+ };
3676
3838
  });
3677
- return {
3678
- ok: true,
3679
- message: `Skill "${name}" patched (${patchLabel}).`,
3680
- path: dir,
3681
- ...normalizedFields ? { normalizedFrontmatterFields: normalizedFields } : {}
3682
- };
3683
3839
  }
3684
3840
  async archive(rawName, options = {}) {
3685
3841
  const name = rawName.trim();
@@ -3933,6 +4089,9 @@ var SkillLibrary = class {
3933
4089
  */
3934
4090
  async restructure(rawName, moves, origin = "foreground") {
3935
4091
  const name = rawName.trim();
4092
+ return await this.serial(() => this.restructureCore(name, moves, origin));
4093
+ }
4094
+ async restructureCore(name, moves, origin) {
3936
4095
  const badName = this.badName(name);
3937
4096
  if (badName) return {
3938
4097
  ok: false,
@@ -4181,12 +4340,15 @@ var SkillLibrary = class {
4181
4340
  }
4182
4341
  async writeSupportFile(rawName, filePath, content, origin = "foreground") {
4183
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);
4184
4347
  const badName = this.badName(name);
4185
4348
  if (badName) return {
4186
4349
  ok: false,
4187
4350
  message: badName
4188
4351
  };
4189
- const dir = this.dirOf(name);
4190
4352
  if (!await this.io.exists(join(dir, "SKILL.md"))) return {
4191
4353
  ok: false,
4192
4354
  message: `Skill "${name}" not found.`
@@ -4211,20 +4373,29 @@ var SkillLibrary = class {
4211
4373
  message: threat
4212
4374
  };
4213
4375
  const target = join(dir, ...filePath.replace(/\\/g, "/").split("/").filter(Boolean));
4214
- const existing = await this.io.readText(target).catch(() => null);
4215
- await this.io.writeText(target, content);
4216
- await this.audit(name, "write_file", existing, content, `wrote ${filePath}`);
4217
- this.notifyMutation({
4218
- action: "write_file",
4219
- name,
4220
- skillDir: dir,
4221
- 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
+ };
4222
4398
  });
4223
- return {
4224
- ok: true,
4225
- message: `Support file "${filePath}" written to "${name}".`,
4226
- path: target
4227
- };
4228
4399
  }
4229
4400
  async removeSupportFile(rawName, filePath, origin = "foreground") {
4230
4401
  const name = rawName.trim();
@@ -4457,8 +4628,18 @@ var SkillLibrary = class {
4457
4628
  * Evolution home path helper: `$DSH_HOME/evolution` for plugin-owned sidecar
4458
4629
  * state (reports, activity store, feedback file, state-domain data).
4459
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). */
4460
4641
  function evolutionHome(env = process.env) {
4461
- return join(env.DSH_HOME || join(homedir(), ".dsh"), "evolution");
4642
+ return join(evolutionRoot(env), "evolution");
4462
4643
  }
4463
4644
  //#endregion
4464
- 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, renameWithRetry, 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 };
@@ -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
@@ -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.21",
4
+ "version": "0.3.22",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },