@lmzhen/dsh-evolution-core 0.1.0-rc.43 → 0.1.0-rc.45

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
@@ -149,19 +149,44 @@ function emptyRecord() {
149
149
  archived_at: null
150
150
  };
151
151
  }
152
+ const isTimestamp = (value) => value === null || typeof value === "string";
153
+ /**
154
+ * Field-level normalization for one sidecar record (rc.42 audit P2-3): the
155
+ * spread used to copy any junk through verbatim, so a corrupted file could
156
+ * carry `use_count: "3"` into the quality math and lifecycle comparisons as
157
+ * NaN. Every field falls back to its `emptyRecord()` baseline unless it has
158
+ * exactly the declared type; an invalid `created_at` anchors the age clock at
159
+ * now (first-sight defer semantics for a record whose age is unknowable).
160
+ * Pure — exported for unit tests; `loadUsage` is the production caller.
161
+ */
162
+ function normalizeUsageRecord(record) {
163
+ const base = emptyRecord();
164
+ if (!record || typeof record !== "object" || Array.isArray(record)) return base;
165
+ const raw = record;
166
+ const num = (value, fallback) => typeof value === "number" && Number.isFinite(value) ? value : fallback;
167
+ const bool = (value, fallback) => typeof value === "boolean" ? value : fallback;
168
+ return {
169
+ created_by: typeof raw.created_by === "string" ? raw.created_by : null,
170
+ use_count: num(raw.use_count, base.use_count),
171
+ view_count: num(raw.view_count, base.view_count),
172
+ patch_count: num(raw.patch_count, base.patch_count),
173
+ last_used_at: isTimestamp(raw.last_used_at) ? raw.last_used_at : base.last_used_at,
174
+ last_viewed_at: isTimestamp(raw.last_viewed_at) ? raw.last_viewed_at : base.last_viewed_at,
175
+ last_patched_at: isTimestamp(raw.last_patched_at) ? raw.last_patched_at : base.last_patched_at,
176
+ created_at: typeof raw.created_at === "string" ? raw.created_at : base.created_at,
177
+ state: raw.state === "stale" || raw.state === "archived" ? raw.state : "active",
178
+ pinned: bool(raw.pinned, base.pinned),
179
+ archived_at: isTimestamp(raw.archived_at) ? raw.archived_at : base.archived_at,
180
+ quality_score: typeof raw.quality_score === "number" && Number.isFinite(raw.quality_score) ? raw.quality_score : void 0,
181
+ quality_warn: typeof raw.quality_warn === "boolean" ? raw.quality_warn : void 0
182
+ };
183
+ }
152
184
  async function loadUsage(root, io = nodeEvolutionIo()) {
153
185
  const map = /* @__PURE__ */ new Map();
154
186
  const raw = await io.readText(usageFile(root));
155
187
  if (raw !== null) try {
156
188
  const parsed = JSON.parse(raw);
157
- for (const [name, record] of Object.entries(parsed)) {
158
- const base = emptyRecord();
159
- map.set(name, {
160
- ...base,
161
- ...record,
162
- state: record.state === "stale" || record.state === "archived" ? record.state : "active"
163
- });
164
- }
189
+ for (const [name, record] of Object.entries(parsed)) map.set(name, normalizeUsageRecord(record));
165
190
  } catch {}
166
191
  return map;
167
192
  }
@@ -287,6 +312,8 @@ const DEFAULT_STALE_AFTER_DAYS = 30;
287
312
  const DEFAULT_ARCHIVE_AFTER_DAYS = 90;
288
313
  const DEFAULT_MEMORY_CHAR_LIMIT = 2200;
289
314
  const DEFAULT_USER_CHAR_LIMIT = 1375;
315
+ /** Consolidation-failure backoff cap, shared by MemoryStore and memory-files' Config default. */
316
+ const DEFAULT_CONSOLIDATION_FAILURES = 3;
290
317
  const DEFAULT_SKILL_CONTENT_CHARS = 1e5;
291
318
  //#endregion
292
319
  //#region lib/types/curator.js
@@ -922,6 +949,15 @@ function scanContentThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTION
922
949
  * same 10× bound around a file that should never exceed the store limit).
923
950
  */
924
951
  const READ_GUARD_FACTOR = 10;
952
+ /**
953
+ * Consolidation-failure backoff window (package-private, rc.42 audit P2-1):
954
+ * only failures inside the window count toward `maxConsolidationFailures`.
955
+ * The store cannot observe turn boundaries, so the model-facing "this turn"
956
+ * phrasing is approximated with ten minutes — generous enough to cover one
957
+ * turn's retry loop, short enough that a failure yesterday never makes today's
958
+ * first refusal say "stop retrying".
959
+ */
960
+ const FAILURE_WINDOW_MS = 10 * 6e4;
925
961
  function memoryRoot(env = process.env) {
926
962
  return join(env.DSH_HOME ?? join(homedir(), ".dsh"), "memories");
927
963
  }
@@ -945,6 +981,7 @@ var MemoryStore = class {
945
981
  maxFailures;
946
982
  io;
947
983
  failureCount = 0;
984
+ lastFailureAt = 0;
948
985
  constructor(options = {}) {
949
986
  this.io = options.io ?? nodeEvolutionIo();
950
987
  this.memoryLimit = options.memoryCharLimit ?? 2200;
@@ -984,6 +1021,8 @@ var MemoryStore = class {
984
1021
  this.failureCount = 0;
985
1022
  }
986
1023
  failure(target, message, entries) {
1024
+ if (Date.now() - this.lastFailureAt > FAILURE_WINDOW_MS) this.failureCount = 0;
1025
+ this.lastFailureAt = Date.now();
987
1026
  this.failureCount += 1;
988
1027
  const chars = entries.join(ENTRY_DELIMITER).length;
989
1028
  if (this.failureCount > this.maxFailures) return {
@@ -1313,11 +1352,17 @@ var MemoryStore = class {
1313
1352
  * blank lines, leading/trailing delimiters) that indicate the file was
1314
1353
  * edited outside MemoryStore. Purely single-canonical content reaches the
1315
1354
  * same serialization and returns false, so a normal write is never flagged.
1355
+ *
1356
+ * An absent, empty, or whitespace-only file is the "never written" state
1357
+ * (rc.42 audit P1-6): it parses to zero entries, so the canonical form
1358
+ * `'\n'` can never byte-match it and every write path was permanently
1359
+ * refused with "External drift detected" — including the repairs the model
1360
+ * would need to make. Such files are adopted instead of flagged.
1316
1361
  */
1317
1362
  async detectDrift(target) {
1318
1363
  if (await this.oversizedFile(target)) return true;
1319
1364
  const raw = await this.io.readText(fileFor(this.root, target));
1320
- if (raw === null) return false;
1365
+ if (raw === null || raw.trim() === "") return false;
1321
1366
  const entries = normalizeEntries(raw);
1322
1367
  const limit = this.limitFor(target);
1323
1368
  if (limit > 0 && entries.some((entry) => entry.length > limit)) return true;
@@ -1346,7 +1391,7 @@ async function loadMutations(root, io = nodeEvolutionIo()) {
1346
1391
  if (raw === null) return [];
1347
1392
  try {
1348
1393
  const parsed = JSON.parse(raw);
1349
- return (Array.isArray(parsed) ? parsed : typeof parsed === "object" && parsed !== null && Array.isArray(parsed.records) ? parsed.records : []).filter((entry) => typeof entry === "object" && entry !== null && typeof entry.skillName === "string" && typeof entry.action === "string");
1394
+ return (Array.isArray(parsed) ? parsed : typeof parsed === "object" && parsed !== null && Array.isArray(parsed.records) ? parsed.records : []).filter((entry) => typeof entry === "object" && entry !== null && typeof entry.skillName === "string" && typeof entry.action === "string" && typeof entry.at === "string");
1350
1395
  } catch {
1351
1396
  return [];
1352
1397
  }
@@ -1622,6 +1667,26 @@ function parseFrontmatter(content) {
1622
1667
  body
1623
1668
  };
1624
1669
  }
1670
+ /**
1671
+ * Skill names referenced by a SKILL.md's `related_skills` frontmatter
1672
+ * (B-line G3, rc.44): the single parsing source for the quality references
1673
+ * factor and the learning-graph edges. The DSH frontmatter parser keeps the
1674
+ * YAML value as a string (`"[a, b]"`), so names are scanned out of it; each
1675
+ * must satisfy the skill-name shape and the referencing skill itself is
1676
+ * excluded. Pure and deduplicated.
1677
+ */
1678
+ function relatedSkillNames(content, exclude) {
1679
+ const parsed = parseFrontmatter(content);
1680
+ if (!parsed) return [];
1681
+ const raw = parsed.frontmatter["related_skills"];
1682
+ if (typeof raw !== "string") return [];
1683
+ const names = /* @__PURE__ */ new Set();
1684
+ for (const match of Array.from(raw.matchAll(/[a-z0-9][a-z0-9-]*/g))) {
1685
+ const target = match[0];
1686
+ if (target && SKILL_NAME_RE.test(target) && target !== exclude) names.add(target);
1687
+ }
1688
+ return [...names];
1689
+ }
1625
1690
  function validateFrontmatter(content, expectedName, limits = DEFAULT_SKILL_LIMITS) {
1626
1691
  const parsed = parseFrontmatter(content);
1627
1692
  if (!parsed) return "SKILL.md must start and end with YAML frontmatter and include a body.";
@@ -1748,7 +1813,7 @@ var SkillLibrary = class {
1748
1813
  async list() {
1749
1814
  const summaries = [];
1750
1815
  for (const name of await listNames(this.root, this.io)) {
1751
- const dir = skillDir(this.root, name);
1816
+ const dir = this.dirOf(name);
1752
1817
  const md = await this.io.readText(join(dir, "SKILL.md"));
1753
1818
  if (!md) continue;
1754
1819
  const parsed = parseFrontmatter(md);
@@ -1765,9 +1830,24 @@ var SkillLibrary = class {
1765
1830
  }
1766
1831
  return summaries;
1767
1832
  }
1768
- async read(name) {
1833
+ async read(rawName) {
1834
+ const name = rawName.trim();
1769
1835
  if (this.badName(name) !== null) return null;
1770
- return this.io.readText(join(skillDir(this.root, name), "SKILL.md"));
1836
+ return this.io.readText(join(this.dirOf(name), "SKILL.md"));
1837
+ }
1838
+ /**
1839
+
1840
+ * Single path-building choke point (rc.42 audit P2-5): every directory path
1841
+
1842
+ * is built from the TRIMMED name, so a name that passes `badName` (which
1843
+
1844
+ * trims before validating) can never mint a second, whitespace-padded
1845
+
1846
+ * directory next to the real one. Callers keep passing raw user input.
1847
+
1848
+ */
1849
+ dirOf(name) {
1850
+ return skillDir(this.root, name.trim());
1771
1851
  }
1772
1852
  /** Name-format guard shared by every path-building mutator/reader. */
1773
1853
  badName(name) {
@@ -1775,14 +1855,16 @@ var SkillLibrary = class {
1775
1855
  if (!SKILL_NAME_RE.test(normalized) || normalized.length > this.limits.maxNameLength) return `Invalid skill name "${normalized}". Use lowercase letters, digits, and hyphens (<= ${this.limits.maxNameLength}).`;
1776
1856
  return null;
1777
1857
  }
1778
- async writeProtection(name, origin = "foreground") {
1779
- const dir = skillDir(this.root, name);
1858
+ async writeProtection(rawName, origin = "foreground") {
1859
+ const name = rawName.trim();
1860
+ const dir = this.dirOf(name);
1780
1861
  for (const marker of ["bundled", "hub-installed"]) if (await this.io.exists(markerPath(dir, marker))) return marker;
1781
1862
  if (origin === "background_review" && await this.io.exists(markerPath(dir, "pinned"))) return "pinned";
1782
1863
  return null;
1783
1864
  }
1784
- async deleteProtection(name, options = {}) {
1785
- const dir = skillDir(this.root, name);
1865
+ async deleteProtection(rawName, options = {}) {
1866
+ const name = rawName.trim();
1867
+ const dir = this.dirOf(name);
1786
1868
  const markers = options.allowBundled ? ["hub-installed", "pinned"] : [
1787
1869
  "bundled",
1788
1870
  "hub-installed",
@@ -1791,26 +1873,30 @@ var SkillLibrary = class {
1791
1873
  for (const marker of markers) if (await this.io.exists(markerPath(dir, marker))) return marker;
1792
1874
  return null;
1793
1875
  }
1794
- async isManaged(name) {
1795
- const dir = skillDir(this.root, name);
1876
+ async isManaged(rawName) {
1877
+ const name = rawName.trim();
1878
+ const dir = this.dirOf(name);
1796
1879
  return await this.io.exists(markerPath(dir, "hermes-managed"));
1797
1880
  }
1798
1881
  /** Whether the skill carries the bundled marker (curator prune-builtins eligibility). */
1799
- async isBundled(name) {
1882
+ async isBundled(rawName) {
1883
+ const name = rawName.trim();
1800
1884
  if (this.badName(name) !== null) return false;
1801
- const dir = skillDir(this.root, name);
1885
+ const dir = this.dirOf(name);
1802
1886
  return await this.io.exists(markerPath(dir, "bundled"));
1803
1887
  }
1804
1888
  /** Whether the skill carries the pinned marker (the marker is the factual source; usage.pinned mirrors it). */
1805
- async isPinned(name) {
1889
+ async isPinned(rawName) {
1890
+ const name = rawName.trim();
1806
1891
  if (this.badName(name) !== null) return false;
1807
- const dir = skillDir(this.root, name);
1892
+ const dir = this.dirOf(name);
1808
1893
  return await this.io.exists(markerPath(dir, "pinned"));
1809
1894
  }
1810
1895
  /** Count non-empty support subdirectories (richness input for quality scoring). */
1811
- async countSupportDirs(name) {
1896
+ async countSupportDirs(rawName) {
1897
+ const name = rawName.trim();
1812
1898
  if (this.badName(name) !== null) return 0;
1813
- const dir = skillDir(this.root, name);
1899
+ const dir = this.dirOf(name);
1814
1900
  let entries;
1815
1901
  try {
1816
1902
  entries = await this.io.list(dir);
@@ -1859,7 +1945,7 @@ var SkillLibrary = class {
1859
1945
  ok: false,
1860
1946
  message: "Only the foreground (user or the main agent) may pin or unpin skills."
1861
1947
  };
1862
- const dir = skillDir(this.root, normalized);
1948
+ const dir = this.dirOf(normalized);
1863
1949
  const marker = markerPath(dir, "pinned");
1864
1950
  const existing = await this.io.exists(marker);
1865
1951
  if (pinned && existing) return {
@@ -1901,7 +1987,7 @@ var SkillLibrary = class {
1901
1987
  ok: false,
1902
1988
  message: threat
1903
1989
  };
1904
- const dir = skillDir(this.root, normalized);
1990
+ const dir = this.dirOf(normalized);
1905
1991
  if (await this.io.exists(join(dir, "SKILL.md"))) return {
1906
1992
  ok: false,
1907
1993
  message: `Skill "${normalized}" already exists.`
@@ -1915,13 +2001,14 @@ var SkillLibrary = class {
1915
2001
  path: dir
1916
2002
  };
1917
2003
  }
1918
- async update(name, content, origin = "foreground") {
2004
+ async update(rawName, content, origin = "foreground") {
2005
+ const name = rawName.trim();
1919
2006
  const badName = this.badName(name);
1920
2007
  if (badName) return {
1921
2008
  ok: false,
1922
2009
  message: badName
1923
2010
  };
1924
- const dir = skillDir(this.root, name);
2011
+ const dir = this.dirOf(name);
1925
2012
  const md = await this.io.readText(join(dir, "SKILL.md"));
1926
2013
  if (!md) return {
1927
2014
  ok: false,
@@ -1950,13 +2037,14 @@ var SkillLibrary = class {
1950
2037
  path: dir
1951
2038
  };
1952
2039
  }
1953
- async patch(name, oldString, newString, filePath = "", replaceAll = false, origin = "foreground") {
2040
+ async patch(rawName, oldString, newString, filePath = "", replaceAll = false, origin = "foreground") {
2041
+ const name = rawName.trim();
1954
2042
  const badName = this.badName(name);
1955
2043
  if (badName) return {
1956
2044
  ok: false,
1957
2045
  message: badName
1958
2046
  };
1959
- const dir = skillDir(this.root, name);
2047
+ const dir = this.dirOf(name);
1960
2048
  const skillMd = join(dir, "SKILL.md");
1961
2049
  if (!await this.io.exists(skillMd)) return {
1962
2050
  ok: false,
@@ -2016,13 +2104,14 @@ var SkillLibrary = class {
2016
2104
  path: dir
2017
2105
  };
2018
2106
  }
2019
- async archive(name, options = {}) {
2107
+ async archive(rawName, options = {}) {
2108
+ const name = rawName.trim();
2020
2109
  const badName = this.badName(name);
2021
2110
  if (badName) return {
2022
2111
  ok: false,
2023
2112
  message: badName
2024
2113
  };
2025
- const dir = skillDir(this.root, name);
2114
+ const dir = this.dirOf(name);
2026
2115
  const md = await this.io.readText(join(dir, "SKILL.md"));
2027
2116
  if (!md) return {
2028
2117
  ok: false,
@@ -2034,14 +2123,17 @@ var SkillLibrary = class {
2034
2123
  message: `Skill "${name}" is protected (${protection}).`
2035
2124
  };
2036
2125
  if (options.absorbedInto) {
2037
- if (!await this.io.readText(join(skillDir(this.root, options.absorbedInto), "SKILL.md"))) return {
2126
+ if (!await this.io.readText(join(this.dirOf(options.absorbedInto), "SKILL.md"))) return {
2038
2127
  ok: false,
2039
2128
  message: `absorbed_into="${options.absorbedInto}" does not exist.`
2040
2129
  };
2041
2130
  }
2042
2131
  const archiveRoot = join(this.root, ".archive");
2043
- let dest = join(archiveRoot, name);
2044
- if (await this.io.exists(dest)) dest = join(archiveRoot, `${name}-${(/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 14)}`);
2132
+ let dest = join(archiveRoot, name.trim());
2133
+ if (await this.io.exists(dest)) {
2134
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 14);
2135
+ dest = join(archiveRoot, `${name.trim()}-${stamp}`);
2136
+ }
2045
2137
  try {
2046
2138
  await this.io.rename(dir, dest);
2047
2139
  } catch {
@@ -2063,25 +2155,26 @@ var SkillLibrary = class {
2063
2155
  * collapse into one, and the originals stay recoverable under `.archive/`.
2064
2156
  */
2065
2157
  async consolidate(target, sources, origin = "foreground") {
2066
- const normalizedSources = [...new Set(sources)].filter((name) => name !== target);
2158
+ const targetName = target.trim();
2159
+ const normalizedSources = [...new Set(sources.map((name) => name.trim()))].filter((name) => name !== targetName);
2067
2160
  if (normalizedSources.length === 0) return {
2068
2161
  ok: false,
2069
2162
  message: "Consolidation requires at least one distinct source skill."
2070
2163
  };
2071
- for (const name of [target, ...normalizedSources]) if (!SKILL_NAME_RE.test(name)) return {
2164
+ for (const name of [targetName, ...normalizedSources]) if (!SKILL_NAME_RE.test(name)) return {
2072
2165
  ok: false,
2073
2166
  message: `Invalid skill name "${name}". Use lowercase letters, digits, and hyphens.`
2074
2167
  };
2075
- const targetDir = skillDir(this.root, target);
2168
+ const targetDir = this.dirOf(targetName);
2076
2169
  const targetMd = await this.io.readText(join(targetDir, "SKILL.md"));
2077
2170
  if (!targetMd) return {
2078
2171
  ok: false,
2079
- message: `Skill "${target}" not found.`
2172
+ message: `Skill "${targetName}" not found.`
2080
2173
  };
2081
- const targetProtection = await this.writeProtection(target, origin);
2174
+ const targetProtection = await this.writeProtection(targetName, origin);
2082
2175
  if (targetProtection) return {
2083
2176
  ok: false,
2084
- message: `Skill "${target}" is protected (${targetProtection}).`
2177
+ message: `Skill "${targetName}" is protected (${targetProtection}).`
2085
2178
  };
2086
2179
  const parts = [];
2087
2180
  for (const source of normalizedSources) {
@@ -2090,7 +2183,7 @@ var SkillLibrary = class {
2090
2183
  ok: false,
2091
2184
  message: `Skill "${source}" is protected (${protection}).`
2092
2185
  };
2093
- const sourceMd = await this.io.readText(join(skillDir(this.root, source), "SKILL.md"));
2186
+ const sourceMd = await this.io.readText(join(this.dirOf(source), "SKILL.md"));
2094
2187
  if (!sourceMd) return {
2095
2188
  ok: false,
2096
2189
  message: `Skill "${source}" not found.`
@@ -2103,7 +2196,7 @@ var SkillLibrary = class {
2103
2196
  parts.push(`\n<!-- consolidated from ${source} at ${(/* @__PURE__ */ new Date()).toISOString()} -->\n${parsed.body.trim()}`);
2104
2197
  }
2105
2198
  const merged = targetMd.trimEnd() + parts.join("\n") + "\n";
2106
- const validation = validateFrontmatter(merged, target, this.limits);
2199
+ const validation = validateFrontmatter(merged, targetName, this.limits);
2107
2200
  if (validation) return {
2108
2201
  ok: false,
2109
2202
  message: `Consolidation rejected: ${validation}`
@@ -2116,7 +2209,7 @@ var SkillLibrary = class {
2116
2209
  const archived = [];
2117
2210
  try {
2118
2211
  for (const source of normalizedSources) {
2119
- const result = await this.archive(source, { absorbedInto: target });
2212
+ const result = await this.archive(source, { absorbedInto: targetName });
2120
2213
  if (!result.ok) throw new Error(result.message);
2121
2214
  archived.push(source);
2122
2215
  }
@@ -2131,7 +2224,7 @@ var SkillLibrary = class {
2131
2224
  }
2132
2225
  return {
2133
2226
  ok: true,
2134
- message: `Consolidated ${normalizedSources.join(", ")} into "${target}".`,
2227
+ message: `Consolidated ${normalizedSources.join(", ")} into "${targetName}".`,
2135
2228
  path: targetDir
2136
2229
  };
2137
2230
  }
@@ -2140,12 +2233,13 @@ var SkillLibrary = class {
2140
2233
  * recoverability: archival never deletes, and this is the control-plane
2141
2234
  * path back. The `.archive-reason` marker is dropped on restore.
2142
2235
  */
2143
- async restoreFromArchive(name) {
2236
+ async restoreFromArchive(rawName) {
2237
+ const name = rawName.trim();
2144
2238
  if (!SKILL_NAME_RE.test(name)) return {
2145
2239
  ok: false,
2146
2240
  message: `Invalid skill name "${name}". Use lowercase letters, digits, and hyphens.`
2147
2241
  };
2148
- if (await this.io.exists(join(skillDir(this.root, name), "SKILL.md"))) return {
2242
+ if (await this.io.exists(join(this.dirOf(name), "SKILL.md"))) return {
2149
2243
  ok: false,
2150
2244
  message: `Skill "${name}" already exists in the active root; refusing to overwrite.`
2151
2245
  };
@@ -2165,7 +2259,7 @@ var SkillLibrary = class {
2165
2259
  message: `Skill "${name}" is not in .archive.`
2166
2260
  };
2167
2261
  const source = join(archiveRoot, chosen);
2168
- const dest = skillDir(this.root, name);
2262
+ const dest = this.dirOf(name);
2169
2263
  try {
2170
2264
  await this.io.rename(source, dest);
2171
2265
  } catch {
@@ -2179,13 +2273,14 @@ var SkillLibrary = class {
2179
2273
  path: dest
2180
2274
  };
2181
2275
  }
2182
- async writeSupportFile(name, filePath, content, origin = "foreground") {
2276
+ async writeSupportFile(rawName, filePath, content, origin = "foreground") {
2277
+ const name = rawName.trim();
2183
2278
  const badName = this.badName(name);
2184
2279
  if (badName) return {
2185
2280
  ok: false,
2186
2281
  message: badName
2187
2282
  };
2188
- const dir = skillDir(this.root, name);
2283
+ const dir = this.dirOf(name);
2189
2284
  if (!await this.io.exists(join(dir, "SKILL.md"))) return {
2190
2285
  ok: false,
2191
2286
  message: `Skill "${name}" not found.`
@@ -2219,13 +2314,14 @@ var SkillLibrary = class {
2219
2314
  path: target
2220
2315
  };
2221
2316
  }
2222
- async removeSupportFile(name, filePath, origin = "foreground") {
2317
+ async removeSupportFile(rawName, filePath, origin = "foreground") {
2318
+ const name = rawName.trim();
2223
2319
  const badName = this.badName(name);
2224
2320
  if (badName) return {
2225
2321
  ok: false,
2226
2322
  message: badName
2227
2323
  };
2228
- const dir = skillDir(this.root, name);
2324
+ const dir = this.dirOf(name);
2229
2325
  if (!await this.io.exists(join(dir, "SKILL.md"))) return {
2230
2326
  ok: false,
2231
2327
  message: `Skill "${name}" not found.`
@@ -2266,7 +2362,7 @@ var SkillLibrary = class {
2266
2362
  let dest = join(backupRoot, `skills-${stamp}`);
2267
2363
  while (await this.io.exists(dest)) dest = join(backupRoot, `skills-${stamp}-${Math.random().toString(36).slice(2, 8)}`);
2268
2364
  const names = await listNames(this.root, this.io);
2269
- for (const name of names) await this.io.copy(skillDir(this.root, name), join(dest, name));
2365
+ for (const name of names) await this.io.copy(this.dirOf(name), join(dest, name));
2270
2366
  const sidecars = [];
2271
2367
  for (const sidecar of [usageFile(this.root), suppressedFile(this.root)]) if (await this.io.exists(sidecar)) {
2272
2368
  const name = basename(sidecar);
@@ -2374,7 +2470,7 @@ var SkillLibrary = class {
2374
2470
  message: "No skill snapshot available."
2375
2471
  };
2376
2472
  await this.snapshotAll("pre-rollback", extras);
2377
- for (const name of await listNames(this.root, this.io)) await this.io.remove(skillDir(this.root, name));
2473
+ for (const name of await listNames(this.root, this.io)) await this.io.remove(this.dirOf(name));
2378
2474
  const manifest = await this.readSnapshotManifest(latest.path);
2379
2475
  if (manifest === null) for (const entry of await this.io.list(latest.path)) {
2380
2476
  if (entry === "manifest.json" || entry === "extras") continue;
@@ -2464,4 +2560,4 @@ var JsonState = class JsonState {
2464
2560
  }
2465
2561
  };
2466
2562
  //#endregion
2467
- export { COMBINED_REVIEW_PROMPT, COMPLETION_SKILL_REVIEW_PROMPT, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEFAULT_ARCHIVE_AFTER_DAYS, 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, 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, emptyRecord, evaluateThreat, evolutionHome, evolutionIoAdapter, foldTurn, getRecord, latestActivityAt, lifecycleCandidate, loadMutations, loadSuppressedNames, loadUsage, markAgentCreated, memoryRoot, mutationsFile, nodeEvolutionIo, observeEvent, parseCuratorNominations, parseFrontmatter, recordMutation, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, suppressedFile, usageFile, validateFrontmatter, verifyPromptBundle };
2563
+ 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, 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, emptyRecord, evaluateThreat, evolutionHome, evolutionIoAdapter, foldTurn, getRecord, latestActivityAt, lifecycleCandidate, loadMutations, loadSuppressedNames, loadUsage, markAgentCreated, memoryRoot, mutationsFile, nodeEvolutionIo, normalizeUsageRecord, observeEvent, parseCuratorNominations, parseFrontmatter, recordMutation, relatedSkillNames, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, suppressedFile, usageFile, validateFrontmatter, verifyPromptBundle };
@@ -47,5 +47,7 @@ export declare const DEFAULT_STALE_AFTER_DAYS = 30;
47
47
  export declare const DEFAULT_ARCHIVE_AFTER_DAYS = 90;
48
48
  export declare const DEFAULT_MEMORY_CHAR_LIMIT = 2200;
49
49
  export declare const DEFAULT_USER_CHAR_LIMIT = 1375;
50
+ /** Consolidation-failure backoff cap, shared by MemoryStore and memory-files' Config default. */
51
+ export declare const DEFAULT_CONSOLIDATION_FAILURES = 3;
50
52
  export declare const DEFAULT_SKILL_CONTENT_CHARS = 100000;
51
53
  //# sourceMappingURL=constants.d.ts.map
@@ -35,6 +35,7 @@ export declare class MemoryStore {
35
35
  private readonly maxFailures;
36
36
  private readonly io;
37
37
  private failureCount;
38
+ private lastFailureAt;
38
39
  constructor(options?: MemoryStoreOptions);
39
40
  limitFor(target: MemoryTarget): number;
40
41
  /**
@@ -83,6 +84,12 @@ export declare class MemoryStore {
83
84
  * blank lines, leading/trailing delimiters) that indicate the file was
84
85
  * edited outside MemoryStore. Purely single-canonical content reaches the
85
86
  * same serialization and returns false, so a normal write is never flagged.
87
+ *
88
+ * An absent, empty, or whitespace-only file is the "never written" state
89
+ * (rc.42 audit P1-6): it parses to zero entries, so the canonical form
90
+ * `'\n'` can never byte-match it and every write path was permanently
91
+ * refused with "External drift detected" — including the repairs the model
92
+ * would need to make. Such files are adopted instead of flagged.
86
93
  */
87
94
  detectDrift(target: MemoryTarget): Promise<boolean>;
88
95
  }
@@ -69,6 +69,15 @@ export declare function parseFrontmatter(content: string): {
69
69
  frontmatter: Frontmatter;
70
70
  body: string;
71
71
  } | null;
72
+ /**
73
+ * Skill names referenced by a SKILL.md's `related_skills` frontmatter
74
+ * (B-line G3, rc.44): the single parsing source for the quality references
75
+ * factor and the learning-graph edges. The DSH frontmatter parser keeps the
76
+ * YAML value as a string (`"[a, b]"`), so names are scanned out of it; each
77
+ * must satisfy the skill-name shape and the referencing skill itself is
78
+ * excluded. Pure and deduplicated.
79
+ */
80
+ export declare function relatedSkillNames(content: string, exclude?: string): string[];
72
81
  export declare function validateFrontmatter(content: string, expectedName?: string, limits?: SkillLimits): string | null;
73
82
  export declare class SkillLibrary {
74
83
  readonly root: string;
@@ -76,20 +85,32 @@ export declare class SkillLibrary {
76
85
  private readonly io;
77
86
  constructor(root?: string, io?: EvolutionIoLike, limits?: SkillLimits);
78
87
  list(): Promise<SkillSummary[]>;
79
- read(name: string): Promise<string | null>;
88
+ read(rawName: string): Promise<string | null>;
89
+ /**
90
+
91
+ * Single path-building choke point (rc.42 audit P2-5): every directory path
92
+
93
+ * is built from the TRIMMED name, so a name that passes `badName` (which
94
+
95
+ * trims before validating) can never mint a second, whitespace-padded
96
+
97
+ * directory next to the real one. Callers keep passing raw user input.
98
+
99
+ */
100
+ private dirOf;
80
101
  /** Name-format guard shared by every path-building mutator/reader. */
81
102
  private badName;
82
- writeProtection(name: string, origin?: WriteOrigin): Promise<string | null>;
83
- deleteProtection(name: string, options?: {
103
+ writeProtection(rawName: string, origin?: WriteOrigin): Promise<string | null>;
104
+ deleteProtection(rawName: string, options?: {
84
105
  allowBundled?: boolean;
85
106
  }): Promise<string | null>;
86
- isManaged(name: string): Promise<boolean>;
107
+ isManaged(rawName: string): Promise<boolean>;
87
108
  /** Whether the skill carries the bundled marker (curator prune-builtins eligibility). */
88
- isBundled(name: string): Promise<boolean>;
109
+ isBundled(rawName: string): Promise<boolean>;
89
110
  /** Whether the skill carries the pinned marker (the marker is the factual source; usage.pinned mirrors it). */
90
- isPinned(name: string): Promise<boolean>;
111
+ isPinned(rawName: string): Promise<boolean>;
91
112
  /** Count non-empty support subdirectories (richness input for quality scoring). */
92
- countSupportDirs(name: string): Promise<number>;
113
+ countSupportDirs(rawName: string): Promise<number>;
93
114
  /** Best-effort audit trail entry; never blocks the mutation. */
94
115
  private audit;
95
116
  /** Recent mutation audit records (read-only inspection surface). */
@@ -102,9 +123,9 @@ export declare class SkillLibrary {
102
123
  */
103
124
  setPinned(name: string, pinned: boolean, origin?: WriteOrigin): Promise<SkillActionResult>;
104
125
  create(name: string, content: string, origin?: WriteOrigin): Promise<SkillActionResult>;
105
- update(name: string, content: string, origin?: WriteOrigin): Promise<SkillActionResult>;
106
- patch(name: string, oldString: string, newString: string, filePath?: string, replaceAll?: boolean, origin?: WriteOrigin): Promise<SkillActionResult>;
107
- archive(name: string, options?: ArchiveOptions): Promise<SkillActionResult>;
126
+ update(rawName: string, content: string, origin?: WriteOrigin): Promise<SkillActionResult>;
127
+ patch(rawName: string, oldString: string, newString: string, filePath?: string, replaceAll?: boolean, origin?: WriteOrigin): Promise<SkillActionResult>;
128
+ archive(rawName: string, options?: ArchiveOptions): Promise<SkillActionResult>;
108
129
  /**
109
130
  * Merge the bodies of `sources` into `target` and archive the sources with
110
131
  * an absorbed-into marker. Hermes-style consolidation: overlapping skills
@@ -116,9 +137,9 @@ export declare class SkillLibrary {
116
137
  * recoverability: archival never deletes, and this is the control-plane
117
138
  * path back. The `.archive-reason` marker is dropped on restore.
118
139
  */
119
- restoreFromArchive(name: string): Promise<SkillActionResult>;
120
- writeSupportFile(name: string, filePath: string, content: string, origin?: WriteOrigin): Promise<SkillActionResult>;
121
- removeSupportFile(name: string, filePath: string, origin?: WriteOrigin): Promise<SkillActionResult>;
140
+ restoreFromArchive(rawName: string): Promise<SkillActionResult>;
141
+ writeSupportFile(rawName: string, filePath: string, content: string, origin?: WriteOrigin): Promise<SkillActionResult>;
142
+ removeSupportFile(rawName: string, filePath: string, origin?: WriteOrigin): Promise<SkillActionResult>;
122
143
  /**
123
144
  * Snapshot the recoverable skills state: active tree, usage/suppression
124
145
  * sidecars, `.archive/` and caller-supplied extras. `extras` are opaque
@@ -16,12 +16,22 @@ export interface UsageRecord {
16
16
  state: SkillState;
17
17
  pinned: boolean;
18
18
  archived_at: string | null;
19
- quality_score?: number;
20
- quality_warn?: boolean;
19
+ quality_score?: number | undefined;
20
+ quality_warn?: boolean | undefined;
21
21
  }
22
22
  export type UsageMap = Map<string, UsageRecord>;
23
23
  export declare function usageFile(root: string): string;
24
24
  export declare function emptyRecord(): UsageRecord;
25
+ /**
26
+ * Field-level normalization for one sidecar record (rc.42 audit P2-3): the
27
+ * spread used to copy any junk through verbatim, so a corrupted file could
28
+ * carry `use_count: "3"` into the quality math and lifecycle comparisons as
29
+ * NaN. Every field falls back to its `emptyRecord()` baseline unless it has
30
+ * exactly the declared type; an invalid `created_at` anchors the age clock at
31
+ * now (first-sight defer semantics for a record whose age is unknowable).
32
+ * Pure — exported for unit tests; `loadUsage` is the production caller.
33
+ */
34
+ export declare function normalizeUsageRecord(record: unknown): UsageRecord;
25
35
  export declare function loadUsage(root: string, io?: EvolutionIoLike): Promise<UsageMap>;
26
36
  export declare function saveUsage(root: string, map: UsageMap, io?: EvolutionIoLike): Promise<void>;
27
37
  export declare function getRecord(map: UsageMap, name: string): UsageRecord;
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.43",
4
+ "version": "0.1.0-rc.45",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },