@lmzhen/dsh-evolution-core 0.3.65 → 0.3.67

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
@@ -79,6 +79,18 @@ const RENAME_RETRY_MAX_ATTEMPTS = 6;
79
79
  * rethrows with a pointer at the usual causes instead of a bare errno.
80
80
  * `fn` is the rename primitive, injectable for deterministic tests.
81
81
  *
82
+ * v22 (LOCK-5) documented platform limit: POSIX rename-over-existing is
83
+ * atomic for concurrent READERS, but Windows MoveFileExW(REPLACE_EXISTING)
84
+ * can make the target briefly invisible (ENOENT) while the replacement is in
85
+ * flight. The retry budget above covers the WRITE side only — a LOCKLESS
86
+ * reader (`readText`/`readJson` on a path it does not hold the lock for) can
87
+ * observe that window and see "missing" for a file that was just committed.
88
+ * Verified harmless today: every lockless-read consumer treats the transient
89
+ * miss as self-healing state (no consumer persists a read of null into a
90
+ * write), and distinguishing that ENOENT from a genuinely absent file in
91
+ * `readText` would tax every ordinary missing-file probe. Revisit only if a
92
+ * consumer appears that must never transiently miss.
93
+ *
82
94
  * @param tmp - the source path to rename.
83
95
  * @param target - the destination path.
84
96
  * @param fn - the rename primitive (defaults to `node:fs/promises.rename`).
@@ -189,8 +201,18 @@ function isProcessAlive(pid) {
189
201
  const LOCK_SUFFIX = ".lock";
190
202
  /** Writer-lock body shape: a decimal pid, a colon, then the claim token. A
191
203
  * torn body (no parsable pid) still matches the `\\d+:` prefix rule only when
192
- * the pid part is intact, which is what the takeover probe needs. */
193
- const LOCK_BODY_RE = /^\d+:[0-9a-f]*$/;
204
+ * the pid part is intact, which is what the takeover probe needs. The capture
205
+ * group feeds `parseLockBody` (v20 A-1: skill-store's sweepers consume this
206
+ * helper instead of re-inlining a third regex copy — F-17 single-source). */
207
+ const LOCK_BODY_RE = /^(\d+):[0-9a-f]*$/;
208
+ /** Parse a writer-lock body into its holder pid. `null` when the body does
209
+ * not have the `pid:token` shape at all (e.g. a user support file named
210
+ * `*.lock`) — callers leave such files alone. A shape-matching body always
211
+ * yields a number (possibly `0`, which `isProcessAlive` treats as dead). */
212
+ function parseLockBody(body) {
213
+ const match = LOCK_BODY_RE.exec(body.trim());
214
+ return match === null ? null : Number(match[1]);
215
+ }
194
216
  /**
195
217
  * Build the Node IO backend. `lockAttempts` scales the write-lock retry budget
196
218
  * (attempts × 50ms); the default 40 (~2s, rc.69) covers production contention,
@@ -309,7 +331,18 @@ function nodeEvolutionIo(lockAttempts = 40) {
309
331
  } catch {
310
332
  continue;
311
333
  }
312
- const verify = await readFile(lock, "utf8").catch(() => "");
334
+ const verifyRead = await readFile(lock, "utf8").then((body) => ({
335
+ ok: true,
336
+ body
337
+ }), () => ({
338
+ ok: false,
339
+ body: ""
340
+ }));
341
+ if (!verifyRead.ok) {
342
+ await rm(ticket, { force: true }).catch(() => {});
343
+ continue;
344
+ }
345
+ const verify = verifyRead.body;
313
346
  if (verify === holderContent) {
314
347
  const verifyHolder = Number(verify.split(":")[0] ?? "");
315
348
  if (!(Number.isInteger(verifyHolder) && verifyHolder > 0 && isAlive(verifyHolder))) await rm(lock, { force: true }).catch(() => {});
@@ -580,23 +613,40 @@ async function mutateUsage(root, io, task, options = {}) {
580
613
  await transactIo(io, usageFile(root), async (current) => {
581
614
  let shapePreserved = false;
582
615
  let recovered = null;
583
- if (current !== null) try {
584
- const probe = JSON.parse(current);
585
- if (probe === null || Array.isArray(probe) || typeof probe !== "object") shapePreserved = true;
586
- else {
587
- const record = probe;
616
+ const quarantineCopy = async () => {
617
+ const corruptPath = `${usageFile(root)}.corrupt`;
618
+ return io.writeText(corruptPath, current).then(() => true, () => false);
619
+ };
620
+ if (current !== null) {
621
+ let parsed = null;
622
+ try {
623
+ const probe = JSON.parse(current);
624
+ if (probe !== null && typeof probe === "object" && !Array.isArray(probe)) parsed = probe;
625
+ } catch {
626
+ parsed = null;
627
+ }
628
+ if (parsed === null) {
629
+ if (!await quarantineCopy()) {
630
+ options.onQuarantine?.(`usage sidecar ${usageFile(root)} is unreadable; the .corrupt copy could not be written — refusing this write so the original bytes stay recoverable (telemetry is frozen until the file is recovered manually)`);
631
+ return current;
632
+ }
633
+ options.onQuarantine?.(`usage sidecar ${usageFile(root)} is unreadable (unparsable JSON or wrong top-level shape); the original bytes were copied to ${usageFile(root)}.corrupt and the sidecar restarts empty`);
634
+ } else {
635
+ const record = parsed;
588
636
  const isMalformed = (value) => value === null || typeof value !== "object" || Array.isArray(value);
589
- if (typeof record.version === "number" && record.version > 1) shapePreserved = true;
590
- else if (Object.values(record).some(isMalformed)) {
637
+ if (typeof record.version === "number" && record.version > 1) {
638
+ options.onQuarantine?.(`usage sidecar ${usageFile(root)} carries schema version ${String(record.version)} (> this runtime) — writes stay frozen and the bytes preserved until the runtime is upgraded`);
639
+ shapePreserved = true;
640
+ } else if (Object.values(record).some(isMalformed)) {
591
641
  const bad = Object.keys(record).filter((key) => isMalformed(record[key]));
592
- const corruptPath = `${usageFile(root)}.corrupt`;
593
- await io.writeText(corruptPath, current).catch(() => {});
642
+ if (!await quarantineCopy()) {
643
+ options.onQuarantine?.(`usage sidecar ${usageFile(root)} carried ${bad.length} malformed entr${bad.length === 1 ? "y" : "ies"} (${bad.slice(0, 5).join(", ")}); the .corrupt copy could not be written — refusing this write so the original bytes stay recoverable`);
644
+ return current;
645
+ }
594
646
  recovered = JSON.stringify(Object.fromEntries(Object.entries(record).filter(([, value]) => !isMalformed(value))));
595
- options.onQuarantine?.(`usage sidecar ${usageFile(root)} carried ${bad.length} malformed entr${bad.length === 1 ? "y" : "ies"} (${bad.slice(0, 5).join(", ")}); the original bytes were copied to ${corruptPath} and the remaining entries continue to be served`);
647
+ options.onQuarantine?.(`usage sidecar ${usageFile(root)} carried ${bad.length} malformed entr${bad.length === 1 ? "y" : "ies"} (${bad.slice(0, 5).join(", ")}); the original bytes were copied to ${usageFile(root)}.corrupt and the remaining entries continue to be served`);
596
648
  }
597
649
  }
598
- } catch {
599
- return current;
600
650
  }
601
651
  if (shapePreserved) return current;
602
652
  const map = parseUsage(recovered ?? current);
@@ -746,6 +796,14 @@ function parseSuppressed(raw) {
746
796
  }
747
797
  }
748
798
  async function saveSuppressedNames(root, names, io = nodeEvolutionIo()) {
799
+ const current = await io.readText(suppressedFile(root)).catch(() => null);
800
+ if (current !== null) try {
801
+ const parsed = JSON.parse(current);
802
+ if (parsed !== null && typeof parsed.version === "number" && parsed.version > 1) {
803
+ console.warn(`suppression sidecar ${suppressedFile(root)} declares version ${String(parsed.version)} (newer than 1); not overwritten`);
804
+ return;
805
+ }
806
+ } catch {}
749
807
  await io.writeText(suppressedFile(root), JSON.stringify({
750
808
  version: 1,
751
809
  names: [...names].sort()
@@ -759,10 +817,17 @@ async function saveSuppressedNames(root, names, io = nodeEvolutionIo()) {
759
817
  */
760
818
  async function updateSuppressedNames(root, io, task) {
761
819
  await transactIo(io, suppressedFile(root), async (current) => {
762
- if (current !== null) try {
763
- JSON.parse(current);
764
- } catch {
765
- return current;
820
+ if (current !== null) {
821
+ let parsed = null;
822
+ try {
823
+ parsed = JSON.parse(current);
824
+ } catch {
825
+ return current;
826
+ }
827
+ if (parsed !== null && typeof parsed.version === "number" && parsed.version > 1) {
828
+ console.warn(`suppression sidecar ${suppressedFile(root)} declares version ${String(parsed.version)} (newer than 1); not overwritten`);
829
+ return current;
830
+ }
766
831
  }
767
832
  const names = parseSuppressed(current);
768
833
  await task(names);
@@ -1315,7 +1380,7 @@ async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE
1315
1380
  }
1316
1381
  const events = await rotateIfDue(io, path, v1EventRecords(parsedBody), rotateAt);
1317
1382
  let maxSeq = events.reduce((max, entry) => Math.max(max, entry.seq), 0);
1318
- if (maxSeq === 0) for (const name of await listEventArchives(io, path)) maxSeq = Math.max(maxSeq, Number.parseInt(name.slice(7, name.length - 5), 10));
1383
+ for (const name of await listEventArchives(io, path)) maxSeq = Math.max(maxSeq, Number.parseInt(name.slice(7, name.length - 5), 10));
1319
1384
  const record = {
1320
1385
  ...event,
1321
1386
  seq: maxSeq + 1,
@@ -2072,19 +2137,19 @@ const PATTERNS = [
2072
2137
  label: "send_to_url",
2073
2138
  category: "exfiltration",
2074
2139
  scope: "strict",
2075
- regex: /(?:send|post|upload|transmit)\s+[^\n]{0,512}\s+(?:to|at)\s+https?:\/\//i
2140
+ regex: /(?:send|post|upload|transmit)\s+[\s\S]{0,512}?\s+(?:to|at)\s+https?:\/\//i
2076
2141
  },
2077
2142
  {
2078
2143
  label: "exfil_curl",
2079
2144
  category: "exfiltration",
2080
2145
  scope: "all",
2081
- regex: /\bcurl\s+[^\n]{0,512}\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)/i
2146
+ regex: /\bcurl\s+[\s\S]{0,512}?\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)/i
2082
2147
  },
2083
2148
  {
2084
2149
  label: "exfil_wget",
2085
2150
  category: "exfiltration",
2086
2151
  scope: "all",
2087
- regex: /\bwget\s+[^\n]{0,512}\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)/i
2152
+ regex: /\bwget\s+[\s\S]{0,512}?\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)/i
2088
2153
  },
2089
2154
  {
2090
2155
  label: "read_secrets",
@@ -2150,7 +2215,37 @@ const PATTERNS = [
2150
2215
  label: "private_key_block",
2151
2216
  category: "hardcoded_secrets",
2152
2217
  scope: "all",
2153
- regex: /-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----/
2218
+ regex: /-----BEGIN\s+(?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED|PGP)\s+)?PRIVATE\s+KEY(?:\s+BLOCK)?-----/
2219
+ },
2220
+ {
2221
+ label: "prompt_injection_ignore_zh",
2222
+ category: "prompt_injection",
2223
+ scope: "all",
2224
+ regex: /(?:忽略|无视|抛开)[\s\S]{0,8}(?:之前|以上|上面|先前|以前)?[\s\S]{0,8}(?:所有|全部|任何)?[\s\S]{0,4}(?:指令|规则|设定|约束)/
2225
+ },
2226
+ {
2227
+ label: "deception_hide_zh",
2228
+ category: "deception",
2229
+ scope: "all",
2230
+ regex: /(?:不要|别|勿)(?:告诉|告知|透露)(?:给)?[\s\S]{0,6}(?:用户|任何人|主人|开发者)/
2231
+ },
2232
+ {
2233
+ label: "system_prompt_leak_zh",
2234
+ category: "deception",
2235
+ scope: "context",
2236
+ regex: /(?:泄露|输出|打印|透露|导出)[\s\S]{0,10}系统提示|系统提示[\s\S]{0,10}(?:泄露|透露|发送|导出)/
2237
+ },
2238
+ {
2239
+ label: "context_exfil_zh",
2240
+ category: "exfiltration",
2241
+ scope: "strict",
2242
+ regex: /(?:对话记录|聊天记录|全部上下文|完整上下文)[\s\S]{0,30}(?:发送|上传|传输|外传|泄露)[\s\S]{0,30}(?:到|至|给)/
2243
+ },
2244
+ {
2245
+ label: "secret_exfil_zh",
2246
+ category: "exfiltration",
2247
+ scope: "strict",
2248
+ regex: /(?:密钥|凭据|口令|密码|环境变量)[\s\S]{0,30}(?:发送|上传|传输|外传|泄露)[\s\S]{0,30}(?:到|至)\s*(?:https?:\/\/|[\w.-]+\.(?:com|net|org|io|cn|dev|xyz|ru)\b)/
2154
2249
  }
2155
2250
  ];
2156
2251
  const ZERO_WIDTH_CHARS = new RegExp(`[\\u034f\\u200b\\u2060\\u2061\\u2062\\u2063\\u2064\\u206a-\\u206f\\ufeff\\u{e0000}-\\u{e007f}]`, "u");
@@ -2199,11 +2294,13 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_
2199
2294
  scope: "all"
2200
2295
  });
2201
2296
  const normalized = text.normalize("NFKC");
2297
+ const SPACE_SPLITTERS = /[\u00ad\u061c\u180e\u200c\ufe00-\ufe0f]/gu;
2298
+ const patternTexts = [normalized.replace(SPACE_SPLITTERS, " "), normalized.replace(SPACE_SPLITTERS, "")];
2202
2299
  const windows = [];
2203
- if (normalized.length <= windowSize) windows.push(normalized);
2300
+ for (const patternText of patternTexts) if (patternText.length <= windowSize) windows.push(patternText);
2204
2301
  else {
2205
2302
  const step = Math.max(Math.floor(windowSize / 2), 1);
2206
- for (let start = 0; start < normalized.length; start += step) windows.push(normalized.slice(start, start + windowSize));
2303
+ for (let start = 0; start < patternText.length; start += step) windows.push(patternText.slice(start, start + windowSize));
2207
2304
  }
2208
2305
  const seen = /* @__PURE__ */ new Set();
2209
2306
  for (const window of windows) for (const pattern of PATTERNS) {
@@ -2236,27 +2333,26 @@ function scanMemoryThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTIONS
2236
2333
  const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars, options);
2237
2334
  if (!blocked) return null;
2238
2335
  const pattern = findings.find((f) => f.category !== "unicode_obfuscation");
2239
- const exemptionHint = " A deployment that needs a specific label can exempt it via threatExemptLabels (see the README env/dial reference).";
2240
- if (pattern) return `Blocked by security scan (${pattern.label}). Rephrase without instruction-like language.${exemptionHint}`;
2241
- return `Blocked by security scan: invisible or potentially malicious Unicode detected.${exemptionHint}`;
2336
+ if (pattern) return `Blocked by security scan (${pattern.label}). Rephrase without instruction-like language.${THREAT_EXEMPTION_HINT}`;
2337
+ return `Blocked by security scan: invisible or potentially malicious Unicode detected.${THREAT_EXEMPTION_HINT}`;
2242
2338
  }
2243
2339
  /** User-facing block message for skill content writes. */
2244
2340
  function scanContentThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
2245
2341
  const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars, options);
2246
2342
  if (!blocked) return null;
2247
- return `Blocked by security scan (${findings[0]?.label ?? "unknown"}). This content appears to contain potentially malicious instructions. Installations with a known-innocent label can exempt it via threatExemptLabels (README dial reference).`;
2343
+ return `Blocked by security scan (${findings[0]?.label ?? "unknown"}). This content appears to contain potentially malicious instructions.${THREAT_EXEMPTION_HINT}`;
2248
2344
  }
2249
2345
  /**
2250
- * V10-03 (P2-18): suffix the SkillLibrary/MemoryStore write gates append to a
2251
- * block message the hit label is already embedded by scanContentThreats /
2252
- * scanMemoryThreats, this names the deployable self-heal path so the model
2253
- * (or operator) can allowlist a known-benign label. P2-4 (v14): the
2254
- * evolution-threat guard channel now carries `threatExemptLabels` too, so its
2255
- * block message (which embeds the same exemption sentence) is accurate there
2256
- * as well; this suffix stays store-side because the guard returns the scan
2257
- * message verbatim.
2346
+ * WD2 (0.3.56): the shared tail of every user-facing threat block — names the
2347
+ * deployable self-heal path so the model (or operator) can allowlist a
2348
+ * known-benign label. v20 (B-2) single source: the scan builders embed this
2349
+ * constant verbatim; do NOT re-word it per call site (the three former copies
2350
+ * memory inline, content inline, and the dead `THREAT_EXEMPT_HINT` export,
2351
+ * whose docblock still claimed a store-side append that memory-store/skill-store
2352
+ * had already removed had drifted apart). The evolution-threat guard channel
2353
+ * returns the scan message verbatim, so the hint rides along there too.
2258
2354
  */
2259
- const THREAT_EXEMPT_HINT = " If this is a legitimate false positive, the deployment can allow its label via the threatExemptLabels store option.";
2355
+ const THREAT_EXEMPTION_HINT = " A deployment that needs a specific label can exempt it via threatExemptLabels (see the README env/dial reference).";
2260
2356
  //#endregion
2261
2357
  //#region lib/types/memory-store.js
2262
2358
  /**
@@ -2955,7 +3051,7 @@ function injectCatalogDescriptionCap(composition) {
2955
3051
  const next = lines[j] ?? "";
2956
3052
  if (next.trim() === "") break;
2957
3053
  if (!/^\s/.test(next)) break;
2958
- if (/^\s+config:(\s|$)/.test(next)) hasConfig = true;
3054
+ if (/^ {2}config:(\s|$)/.test(next)) hasConfig = true;
2959
3055
  end = j;
2960
3056
  }
2961
3057
  if (hasConfig) continue;
@@ -3156,9 +3252,14 @@ const SECRET_PATTERNS = [
3156
3252
  ["gitlab token", /glpat-[A-Za-z0-9_-]{16,}/g],
3157
3253
  ["slack token", /xox[baprs]-[A-Za-z0-9-]{10,}/g],
3158
3254
  ["jwt", /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g],
3255
+ ["npm token", /npm_[A-Za-z0-9]{20,}/g],
3256
+ ["stripe key", /[sr]k_(?:live|test)_[A-Za-z0-9]{16,}/g],
3257
+ ["github fine-grained token", /github_pat_[A-Za-z0-9_]{20,}/g],
3258
+ ["google api key", /AIza[0-9A-Za-z_-]{30,}/g],
3159
3259
  ["bearer credential", /Bearer[\s]+[a-z0-9._~+/=\-]{16,}/gi]
3160
3260
  ];
3161
3261
  const INLINE_ASSIGNMENT_PATTERN = /* @__PURE__ */ new RegExp("(^|[^\\w-])([\\w-]{0,64}[_\\-])?((?:token|api[_-]?key|secret|password|passwd)(?:[_\\-][\\w-]{0,64})?)\\b([\\t ]*[:=][\\t ]*)([^\\r\\n]+)", "gi");
3262
+ const URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s/@]+)@/gi;
3162
3263
  /**
3163
3264
  * Mask credential-shaped text before it crosses a session boundary.
3164
3265
  * @param text - the text about to be sent to a model outside this session.
@@ -3167,7 +3268,12 @@ const INLINE_ASSIGNMENT_PATTERN = /* @__PURE__ */ new RegExp("(^|[^\\w-])([\\w-]
3167
3268
  function redactSecrets(text) {
3168
3269
  let out = text;
3169
3270
  for (const [, pattern] of SECRET_PATTERNS) out = out.replace(pattern, "<redacted>");
3271
+ out = out.replace(URL_CREDENTIALS_PATTERN, (_match, lead) => `${lead ?? ""}<redacted>@`);
3170
3272
  out = out.replace(INLINE_ASSIGNMENT_PATTERN, (_match, lead, prefix, key, separator) => `${lead ?? ""}${prefix ?? ""}${key ?? ""}${separator ?? ""}<redacted>`);
3273
+ out = out.split("\n").map((line) => {
3274
+ if (!/<redacted>/.test(line) && !/\baws\b|\bAKIA\b|\bsecret\b/i.test(line)) return line;
3275
+ return line.replace(/(?<![A-Za-z0-9/+=])[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])/g, "<redacted>");
3276
+ }).join("\n");
3171
3277
  return out;
3172
3278
  }
3173
3279
  //#endregion
@@ -3616,6 +3722,8 @@ const MARKER_LOCK_NAMES = [
3616
3722
  `.pinned${LOCK_SUFFIX}`,
3617
3723
  `.hermes-managed${LOCK_SUFFIX}`
3618
3724
  ];
3725
+ /** v23 (ML-1): `.archive` retention window (see pruneExpiredArchives). */
3726
+ const ARCHIVE_RETENTION_DAYS = 365;
3619
3727
  function markerPath(dir, marker) {
3620
3728
  return join(dir, markerEntryName(marker));
3621
3729
  }
@@ -4435,13 +4543,22 @@ var SkillLibrary = class {
4435
4543
  ok: false,
4436
4544
  message: `Skill "${normalized}" not found.`
4437
4545
  };
4438
- if (pinned) try {
4439
- await this.io.writeText(marker, "");
4440
- } catch (error) {
4441
- if (!isCommittedOnly(error)) throw error;
4442
- durabilityWarning = error instanceof Error ? error.message : String(error);
4443
- }
4444
- else await this.io.remove(marker);
4546
+ if (pinned) {
4547
+ try {
4548
+ await this.io.writeText(marker, "");
4549
+ } catch (error) {
4550
+ if (!isCommittedOnly(error)) throw error;
4551
+ durabilityWarning = error instanceof Error ? error.message : String(error);
4552
+ }
4553
+ if (!await this.io.exists(join(dir, "SKILL.md"))) {
4554
+ await this.io.remove(marker).catch(() => {});
4555
+ if ((await this.io.list(dir).catch(() => [])).length === 0) await this.io.remove(dir).catch(() => {});
4556
+ return {
4557
+ ok: false,
4558
+ message: `Skill "${normalized}" was archived concurrently while pinning; the partial marker was removed — retry after the mover settles.`
4559
+ };
4560
+ }
4561
+ } else await this.io.remove(marker);
4445
4562
  await this.audit(normalized, pinned ? "pin" : "unpin", null, null, pinned ? "pinned" : "unpinned");
4446
4563
  return {
4447
4564
  ok: true,
@@ -4487,6 +4604,10 @@ var SkillLibrary = class {
4487
4604
  ok: false,
4488
4605
  message: `Skill "${normalized}" already exists.`
4489
4606
  };
4607
+ for (const entry of await this.io.list(this.root).catch(() => [])) if (typeof entry === "string" && entry !== normalized && entry.toLowerCase() === normalized.toLowerCase()) return {
4608
+ ok: false,
4609
+ message: `Skill "${normalized}" collides with the existing case-variant directory "${entry}" (skill names are lowercase-only); rename one of them.`
4610
+ };
4490
4611
  const protection = await this.writeProtection(normalized, origin);
4491
4612
  if (protection) return {
4492
4613
  ok: false,
@@ -4530,7 +4651,17 @@ var SkillLibrary = class {
4530
4651
  ok: false,
4531
4652
  message: `Skill "${normalized}" already exists.`
4532
4653
  };
4533
- if (origin !== "foreground") await this.io.writeText(markerPath(dir, "hermes-managed"), "");
4654
+ if (origin !== "foreground") {
4655
+ await this.io.writeText(markerPath(dir, "hermes-managed"), "");
4656
+ if (!await this.io.exists(createPath)) {
4657
+ await this.io.remove(markerPath(dir, "hermes-managed")).catch(() => {});
4658
+ if ((await this.io.list(dir).catch(() => [])).length === 0) await this.io.remove(dir).catch(() => {});
4659
+ return {
4660
+ ok: false,
4661
+ message: `Skill "${normalized}" was archived concurrently while being created; the partial marker was removed — retry once the mover settles.`
4662
+ };
4663
+ }
4664
+ }
4534
4665
  await this.audit(normalized, "create", null, onDisk, "created");
4535
4666
  this.notifyMutation({
4536
4667
  action: "create",
@@ -4863,9 +4994,7 @@ var SkillLibrary = class {
4863
4994
  * stolen by the sweep. A dead-pid residue would otherwise permanently
4864
4995
  * refuse archive/restore. */
4865
4996
  async deleteStrandedLocks(dir) {
4866
- await this.sweepLockIfStranded(join(dir, "SKILL.md.lock"));
4867
- await this.sweepLockIfStranded(join(dir, ".pinned.lock"));
4868
- await this.sweepLockIfStranded(join(dir, ".hermes-managed.lock"));
4997
+ for (const markerLock of MARKER_LOCK_NAMES) await this.sweepLockIfStranded(join(dir, markerLock));
4869
4998
  for (const supportDir of SUPPORT_DIRS) {
4870
4999
  let entries = [];
4871
5000
  try {
@@ -4882,9 +5011,8 @@ var SkillLibrary = class {
4882
5011
  async sweepLockIfStranded(lockPath) {
4883
5012
  const body = await this.io.readText(lockPath).catch(() => null);
4884
5013
  if (body === null) return;
4885
- const match = /^(\d+):[0-9a-f]*$/.exec(body.trim());
4886
- if (match === null) return;
4887
- const pid = Number(match[1]);
5014
+ const pid = parseLockBody(body);
5015
+ if (pid === null) return;
4888
5016
  if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid)) return;
4889
5017
  await this.io.remove(lockPath).catch(() => {});
4890
5018
  }
@@ -4908,9 +5036,8 @@ var SkillLibrary = class {
4908
5036
  throw new Error(`snapshot restore refused: cannot verify ${label} (${error instanceof Error ? error.message : String(error)}); a live writer may hold it`);
4909
5037
  }
4910
5038
  if (body === null) return;
4911
- const match = /^(\d+):[0-9a-f]*$/.exec(body.trim());
4912
- if (match === null) return;
4913
- const pid = Number(match[1]);
5039
+ const pid = parseLockBody(body);
5040
+ if (pid === null) return;
4914
5041
  if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid)) throw new Error(`snapshot restore refused: ${label} is being written (write lock present); retry once the write completes`);
4915
5042
  await this.io.remove(lockPath).catch(() => {});
4916
5043
  }
@@ -5099,10 +5226,12 @@ var SkillLibrary = class {
5099
5226
  };
5100
5227
  const writes = [];
5101
5228
  for (const reference of referenceWrites) {
5102
- const base = (await this.io.readText(reference.target).catch(() => null))?.trimEnd() ?? "";
5229
+ const previous = await this.io.readText(reference.target).catch(() => null);
5230
+ const base = previous?.trimEnd() ?? "";
5103
5231
  writes.push({
5104
5232
  target: reference.target,
5105
- content: base === "" ? reference.content : `${base}\n\n${reference.content}`
5233
+ content: base === "" ? reference.content : `${base}\n\n${reference.content}`,
5234
+ expected: previous
5106
5235
  });
5107
5236
  }
5108
5237
  if (mode === "append") {
@@ -5114,7 +5243,8 @@ var SkillLibrary = class {
5114
5243
  };
5115
5244
  writes.push({
5116
5245
  target: join(targetDir, "SKILL.md"),
5117
- content: merged
5246
+ content: merged,
5247
+ expected: freshTargetMd
5118
5248
  });
5119
5249
  } else {
5120
5250
  const pointerLines = normalizedSources.map((source) => `\n${POINTER_LINE_PREFIX}${source}.md`).join("");
@@ -5126,7 +5256,8 @@ var SkillLibrary = class {
5126
5256
  };
5127
5257
  writes.push({
5128
5258
  target: join(targetDir, "SKILL.md"),
5129
- content: extended
5259
+ content: extended,
5260
+ expected: freshTargetMd
5130
5261
  });
5131
5262
  }
5132
5263
  return await this.applyTreeChange({
@@ -5194,6 +5325,11 @@ var SkillLibrary = class {
5194
5325
  message: `Restructure exceeds 5 moves.`
5195
5326
  };
5196
5327
  for (const move of moves) {
5328
+ const raw = move;
5329
+ if (raw === null || typeof raw !== "object") return {
5330
+ ok: false,
5331
+ message: "Every restructure move must be an object with a heading."
5332
+ };
5197
5333
  if (typeof move.heading !== "string" || !move.heading.trim()) return {
5198
5334
  ok: false,
5199
5335
  message: "Every restructure move needs a non-empty heading."
@@ -5247,15 +5383,18 @@ var SkillLibrary = class {
5247
5383
  const writes = [];
5248
5384
  for (const entry of byRel.values()) {
5249
5385
  const target = join(dir, ...entry.rel.split("/"));
5250
- const base = (await this.io.readText(target).catch(() => null))?.trimEnd() ?? "";
5386
+ const previous = await this.io.readText(target).catch(() => null);
5387
+ const base = previous?.trimEnd() ?? "";
5251
5388
  writes.push({
5252
5389
  target,
5253
- content: base === "" ? entry.texts.join("\n\n") : `${base}\n\n${entry.texts.join("\n\n")}`
5390
+ content: base === "" ? entry.texts.join("\n\n") : `${base}\n\n${entry.texts.join("\n\n")}`,
5391
+ expected: previous
5254
5392
  });
5255
5393
  }
5256
5394
  writes.push({
5257
5395
  target: join(dir, "SKILL.md"),
5258
- content: finalMd
5396
+ content: finalMd,
5397
+ expected: md
5259
5398
  });
5260
5399
  const result = await this.applyTreeChange({
5261
5400
  name,
@@ -5321,7 +5460,8 @@ var SkillLibrary = class {
5321
5460
  landing.push({
5322
5461
  target: write.target,
5323
5462
  content: write.content,
5324
- previous
5463
+ previous,
5464
+ expected: write.expected
5325
5465
  });
5326
5466
  }
5327
5467
  const written = [];
@@ -5329,7 +5469,18 @@ var SkillLibrary = class {
5329
5469
  try {
5330
5470
  for (const entry of landing) {
5331
5471
  try {
5332
- await this.io.writeText(entry.target, entry.content);
5472
+ if (this.transact) {
5473
+ const baseline = entry.expected === void 0 ? entry.previous : entry.expected;
5474
+ const drift = { seen: false };
5475
+ await this.transact(this.io, entry.target, (current) => {
5476
+ if (current !== baseline) {
5477
+ drift.seen = true;
5478
+ return current;
5479
+ }
5480
+ return entry.content;
5481
+ });
5482
+ if (drift.seen) throw new Error(`concurrent modification detected: ${entry.target} changed after the plan was computed (a concurrent writer won the race); no further writes were performed`);
5483
+ } else await this.io.writeText(entry.target, entry.content);
5333
5484
  } catch (error) {
5334
5485
  if (error?.committed !== true) throw error;
5335
5486
  durabilityWarning = error instanceof Error ? error.message : String(error);
@@ -5544,19 +5695,54 @@ var SkillLibrary = class {
5544
5695
  };
5545
5696
  }
5546
5697
  /**
5698
+ * v23 (ML-1): `.archive` retention. Archived skills are recoverable history,
5699
+ * but nothing bounded their growth — the curator auto-archives idle skills,
5700
+ * consolidation and manual deletes take the same path, and every snapshot
5701
+ * copies the whole `.archive` (keep-5 retention amplifies it ×6). Entries
5702
+ * older than this many days are pruned at snapshot time. Generous by
5703
+ * design: a year-old auto-archive is effectively dead recoverability.
5704
+ * Backends without the mtime probe skip pruning (no false deletes on
5705
+ * unknown age).
5706
+ */
5707
+ async pruneExpiredArchives() {
5708
+ const archiveRoot = join(this.root, ".archive");
5709
+ if (!this.io.mtime) return;
5710
+ let entries = [];
5711
+ try {
5712
+ entries = await this.io.list(archiveRoot);
5713
+ } catch {
5714
+ return;
5715
+ }
5716
+ const cutoff = Date.now() - ARCHIVE_RETENTION_DAYS * 864e5;
5717
+ for (const entry of entries) {
5718
+ const entryPath = join(archiveRoot, entry);
5719
+ const mtime = await this.io.mtime(entryPath).catch(() => null);
5720
+ if (mtime === null || mtime > cutoff) continue;
5721
+ await this.io.remove(entryPath).catch(() => {});
5722
+ console.warn(`skill-store: pruned archived skill "${entry}" (older than ${ARCHIVE_RETENTION_DAYS} days; recoverable from snapshots until they rotate)`);
5723
+ }
5724
+ }
5725
+ /**
5547
5726
  * Snapshot the recoverable skills state: active tree, usage/suppression
5548
5727
  * sidecars, `.archive/` and caller-supplied extras. `extras` are opaque
5549
5728
  * side files the Snapshot owner cares about (curator state); they are
5550
5729
  * listed in the manifest and only those names are ever read back.
5551
5730
  */
5552
5731
  async snapshotAll(reason = "pre-mutation", extras = []) {
5732
+ await this.pruneExpiredArchives();
5553
5733
  const backupRoot = join(this.root, ".backups");
5554
5734
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
5555
5735
  let dest = join(backupRoot, `skills-${stamp}`);
5556
5736
  while (await this.io.exists(dest)) dest = join(backupRoot, `skills-${stamp}-${Math.random().toString(36).slice(2, 8)}`);
5557
5737
  try {
5558
5738
  const names = await listNames(this.root, this.io);
5559
- const copyFailure = (await Promise.allSettled(names.map(async (name) => {
5739
+ const skipped = [];
5740
+ const copyable = [];
5741
+ for (const name of names) if (await this.hasWriteLock(this.dirOf(name))) {
5742
+ console.warn(`skill-store: snapshot skipped "${name}" — a byte-writer holds its write lock; the skill is recorded as skipped in the manifest`);
5743
+ skipped.push(name);
5744
+ } else copyable.push(name);
5745
+ const copyFailure = (await Promise.allSettled(copyable.map(async (name) => {
5560
5746
  await this.io.copy(this.dirOf(name), join(dest, name));
5561
5747
  }))).find((result) => result.status === "rejected");
5562
5748
  if (copyFailure) throw copyFailure.reason;
@@ -5581,7 +5767,8 @@ var SkillLibrary = class {
5581
5767
  await this.io.writeText(join(dest, "manifest.json"), JSON.stringify({
5582
5768
  reason,
5583
5769
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
5584
- skills: names,
5770
+ skills: copyable,
5771
+ skipped,
5585
5772
  sidecars,
5586
5773
  hasArchive,
5587
5774
  extras: extraNames
@@ -5594,6 +5781,24 @@ var SkillLibrary = class {
5594
5781
  return dest;
5595
5782
  }
5596
5783
  /** Read and normalize a snapshot manifest; null when the file is missing or unparsable. */
5784
+ /**
5785
+ * V26-03 (v25/v26): sanitize the manifest's `skipped` list before it can
5786
+ * reach a user-visible restore message. Entries must pass the same name
5787
+ * gate as snapshot entries (a corrupted or hand-edited manifest cannot
5788
+ * inject arbitrary text into the result), bounded to 50 entries of at most
5789
+ * 64 chars each (the name-rule maximum — real skill names always fit).
5790
+ */
5791
+ sanitizeSkippedNames(raw) {
5792
+ if (!Array.isArray(raw)) return [];
5793
+ const out = [];
5794
+ for (const entry of raw) {
5795
+ if (typeof entry !== "string") continue;
5796
+ if (!this.safeSnapshotEntryName(entry)) continue;
5797
+ out.push(entry.length > 64 ? entry.slice(0, 64) : entry);
5798
+ if (out.length >= 50) break;
5799
+ }
5800
+ return out;
5801
+ }
5597
5802
  async readSnapshotManifest(path) {
5598
5803
  const raw = await this.io.readText(join(path, "manifest.json"));
5599
5804
  if (raw === null) return null;
@@ -5604,6 +5809,7 @@ var SkillLibrary = class {
5604
5809
  reason: typeof manifest.reason === "string" ? manifest.reason : "",
5605
5810
  createdAt: typeof manifest.createdAt === "string" ? manifest.createdAt : "",
5606
5811
  skills: manifest.skills,
5812
+ skipped: this.sanitizeSkippedNames(manifest.skipped),
5607
5813
  sidecars: Array.isArray(manifest.sidecars) ? manifest.sidecars : [],
5608
5814
  ...typeof manifest.hasArchive === "boolean" ? { hasArchive: manifest.hasArchive } : {},
5609
5815
  extras: Array.isArray(manifest.extras) ? manifest.extras : []
@@ -5631,7 +5837,14 @@ var SkillLibrary = class {
5631
5837
  for (const name of entries.sort().reverse()) {
5632
5838
  if (!name.startsWith("skills-")) continue;
5633
5839
  const manifest = await this.readSnapshotManifest(join(backupRoot, name));
5634
- if (manifest === null) continue;
5840
+ if (manifest === null) {
5841
+ out.push({
5842
+ path: join(backupRoot, name),
5843
+ createdAt: "",
5844
+ reason: "unreadable or missing manifest (orphan snapshot)"
5845
+ });
5846
+ continue;
5847
+ }
5635
5848
  out.push({
5636
5849
  path: join(backupRoot, name),
5637
5850
  createdAt: manifest.createdAt,
@@ -5673,8 +5886,9 @@ var SkillLibrary = class {
5673
5886
  message: "No skill snapshot available."
5674
5887
  };
5675
5888
  const preRollbackPath = await this.snapshotAll("pre-rollback", extras);
5889
+ let skipped = [];
5676
5890
  try {
5677
- await this.restoreSnapshotIntoRoot(latest.path);
5891
+ skipped = await this.restoreSnapshotIntoRoot(latest.path);
5678
5892
  } catch (error) {
5679
5893
  const reason = error instanceof Error ? error.message : String(error);
5680
5894
  try {
@@ -5695,9 +5909,10 @@ var SkillLibrary = class {
5695
5909
  action: "restore",
5696
5910
  name: "snapshot"
5697
5911
  });
5912
+ const skippedNote = skipped.length === 0 ? "" : ` NOTE: ${skipped.length} skill(s) were skipped when this snapshot was taken (a live writer held their lock) and are NOT restored: ${skipped.join(", ")} — recover them from .backups if a copy exists.`;
5698
5913
  return {
5699
5914
  ok: true,
5700
- message: `Restored skill tree from ${latest.path}`,
5915
+ message: `Restored skill tree from ${latest.path}.${skippedNote}`,
5701
5916
  path: latest.path,
5702
5917
  ...snapshotExtras.length === 0 ? {} : { extras: snapshotExtras }
5703
5918
  };
@@ -5762,7 +5977,8 @@ var SkillLibrary = class {
5762
5977
  if (entry.startsWith(".")) continue;
5763
5978
  await this.deleteStrandedLocks(join(this.root, entry));
5764
5979
  }
5980
+ return manifest.skipped;
5765
5981
  }
5766
5982
  };
5767
5983
  //#endregion
5768
- 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_BOOT_GRACE_SECONDS, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_CURATOR_REVIEW_MAX_TOKENS, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_MAX_OPS_PER_PLAN, DEFAULT_MEMORY_CHAR_LIMIT, DEFAULT_MIN_IDLE_HOURS, DEFAULT_MUTATION_CAP, DEFAULT_REVIEW_CONTEXT_MESSAGES, DEFAULT_REVIEW_MEMORY_INTERVAL, DEFAULT_REVIEW_MESSAGE_CHARS, DEFAULT_REVIEW_SKILL_INTERVAL, DEFAULT_REVIEW_TIMEOUT_MS, 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, LOCK_BODY_RE, LOCK_SUFFIX, LOW_QUALITY_THRESHOLD, MAINTAIN_OUTPUT_INSTRUCTION, 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, PATTERN_OVERLAP, 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, THREAT_EXEMPT_HINT, advanceReview, allowRowCollisions, appendEvolutionEvent, applyCuratorFields, applyCuratorLifecycleFields, applyCuratorMetaFields, assessStructureHealth, authoringFeedback, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpUse, bumpView, clampedNumber, composePresetComposition, computeDedupGroups, computeDriftSignals, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, contentHash, createGateSet, duplicateHeadings, emptyRecord, evaluateThreat, eventsFile, evolutionEventPayloadIssue, evolutionHome, evolutionIoAdapter, evolutionRoot, findDriftSignal, foldCuratorFields, foldTurn, frontmatterBlock, frontmatterYamlUnsafeValues, getRecord, isProcessAlive, latestActivityAt, lifecycleCandidate, listEventArchives, loadMutations, loadSuppressedNames, loadUsage, makeSerialQueue, markAgentCreated, markerEntryName, memoryRoot, missingSupportPointers, mutateUsage, mutationsFile, narrowNameMatches, nodeEvolutionIo, normalizeFrontmatter, normalizeUsageRecord, observeEvent, overlongLines, parseCuratorNominations, parseEvolutionEvents, parseFrontmatter, pendingSelfCleanup, readEvolutionEvents, readEvolutionTimeline, recordMutation, redactSecrets, relatedSkillNames, renameWithRetry, renderCuratorReportMarkdown, resolveOrigins, resolveRootConfig, resolveSkillsRoot, retainEventArchives, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, suppressedFile, transactIo, updateSuppressedNames, usageFile, usageObserved, validateFrontmatter, validateRestructureTarget, verifyPromptBundle, writeDurableTmp, yamlPlainScalarNeedsQuotes };
5984
+ 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_BOOT_GRACE_SECONDS, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_CURATOR_REVIEW_MAX_TOKENS, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_MAX_OPS_PER_PLAN, DEFAULT_MEMORY_CHAR_LIMIT, DEFAULT_MIN_IDLE_HOURS, DEFAULT_MUTATION_CAP, DEFAULT_REVIEW_CONTEXT_MESSAGES, DEFAULT_REVIEW_MEMORY_INTERVAL, DEFAULT_REVIEW_MESSAGE_CHARS, DEFAULT_REVIEW_SKILL_INTERVAL, DEFAULT_REVIEW_TIMEOUT_MS, 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, LOCK_BODY_RE, LOCK_SUFFIX, LOW_QUALITY_THRESHOLD, MAINTAIN_OUTPUT_INSTRUCTION, 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, PATTERN_OVERLAP, 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, THREAT_EXEMPTION_HINT, advanceReview, allowRowCollisions, appendEvolutionEvent, applyCuratorFields, applyCuratorLifecycleFields, applyCuratorMetaFields, assessStructureHealth, authoringFeedback, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpUse, bumpView, clampedNumber, composePresetComposition, computeDedupGroups, computeDriftSignals, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, contentHash, createGateSet, duplicateHeadings, emptyRecord, evaluateThreat, eventsFile, evolutionEventPayloadIssue, evolutionHome, evolutionIoAdapter, evolutionRoot, findDriftSignal, foldCuratorFields, foldTurn, frontmatterBlock, frontmatterYamlUnsafeValues, getRecord, isProcessAlive, latestActivityAt, lifecycleCandidate, listEventArchives, loadMutations, loadSuppressedNames, loadUsage, makeSerialQueue, markAgentCreated, markerEntryName, memoryRoot, missingSupportPointers, mutateUsage, mutationsFile, narrowNameMatches, nodeEvolutionIo, normalizeFrontmatter, normalizeUsageRecord, observeEvent, overlongLines, parseCuratorNominations, parseEvolutionEvents, parseFrontmatter, parseLockBody, pendingSelfCleanup, readEvolutionEvents, readEvolutionTimeline, recordMutation, redactSecrets, relatedSkillNames, renameWithRetry, renderCuratorReportMarkdown, resolveOrigins, resolveRootConfig, resolveSkillsRoot, retainEventArchives, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, suppressedFile, transactIo, updateSuppressedNames, usageFile, usageObserved, validateFrontmatter, validateRestructureTarget, verifyPromptBundle, writeDurableTmp, yamlPlainScalarNeedsQuotes };
@@ -21,6 +21,15 @@ export interface EvolutionReviewScheduledEvent {
21
21
  toolCalls: number;
22
22
  userChars: number;
23
23
  assistantChars: number;
24
+ /** V24-15 (v24): which delivery channel actually sent the review. The
25
+ * emission point was previously covered on only two of the four delivery
26
+ * paths (subagent success + completion inject), so a consumer on the
27
+ * default inject-mode deployment would have silently missed every cadence
28
+ * review. Every delivery path now emits with its channel:
29
+ * `'subagent'` (spawned review run), `'inject'` (prompt injected into the
30
+ * parent — direct, fallback, or deferred-drain), `'completion'`
31
+ * (completion-trigger prompt, direct or deferred-drain). */
32
+ channel?: 'subagent' | 'inject' | 'completion' | undefined;
24
33
  }
25
34
  export interface EvolutionPlanAppliedEvent {
26
35
  /** Owning session (payload v2): process events carry no session envelope. */
package/lib/types/io.d.ts CHANGED
@@ -45,11 +45,17 @@ export interface EvolutionIoLike {
45
45
  * milliseconds since epoch, or `null` when unknown (unsupported backend,
46
46
  * missing path, stat failure). Intended as a cheap invalidation stamp for a
47
47
  * cached directory listing; a backend without it keeps event-driven
48
- * invalidation only. V9-07 (0.3.51): as of this release NO in-tree consumer
49
- * calls itskill-catalog invalidation is event-driven
50
- * (`evolution/skill-mutated` / `evolution/skills-refresh`). The probe stays
51
- * as a backend contract extension point; document it here before wiring a
52
- * consumer.
48
+ * invalidation only. v20 correction: the former "NO in-tree consumer" note
49
+ * (V9-07, 0.3.51) went stale there are now FOUR in-tree consumers, and a
50
+ * custom backend that omits `mtime` degrades them silently (every call
51
+ * site is optional-call + null-fallback, so omission stays legal):
52
+ * - evolution-skill-catalog: root-mtime stamp on the summaries cache —
53
+ * the second, out-of-band invalidation signal next to the
54
+ * `evolution/skill-mutated` / `evolution/skills-refresh` events;
55
+ * - evolution-curator: run-report recency ordering (2 call sites);
56
+ * - evolution-commands: `.bak` freshness probe in the preset installer.
57
+ * With `mtime` absent, catalog invalidation degrades to purely
58
+ * event-driven. Register new consumers here (the seam contract).
53
59
  */
54
60
  mtime?(this: void, path: string): Promise<number | null>;
55
61
  }
@@ -84,6 +90,18 @@ export declare const pendingSelfCleanup: Map<string, string>;
84
90
  * rethrows with a pointer at the usual causes instead of a bare errno.
85
91
  * `fn` is the rename primitive, injectable for deterministic tests.
86
92
  *
93
+ * v22 (LOCK-5) documented platform limit: POSIX rename-over-existing is
94
+ * atomic for concurrent READERS, but Windows MoveFileExW(REPLACE_EXISTING)
95
+ * can make the target briefly invisible (ENOENT) while the replacement is in
96
+ * flight. The retry budget above covers the WRITE side only — a LOCKLESS
97
+ * reader (`readText`/`readJson` on a path it does not hold the lock for) can
98
+ * observe that window and see "missing" for a file that was just committed.
99
+ * Verified harmless today: every lockless-read consumer treats the transient
100
+ * miss as self-healing state (no consumer persists a read of null into a
101
+ * write), and distinguishing that ENOENT from a genuinely absent file in
102
+ * `readText` would tax every ordinary missing-file probe. Revisit only if a
103
+ * consumer appears that must never transiently miss.
104
+ *
87
105
  * @param tmp - the source path to rename.
88
106
  * @param target - the destination path.
89
107
  * @param fn - the rename primitive (defaults to `node:fs/promises.rename`).
@@ -119,8 +137,15 @@ export declare function isProcessAlive(pid: number): boolean;
119
137
  export declare const LOCK_SUFFIX = ".lock";
120
138
  /** Writer-lock body shape: a decimal pid, a colon, then the claim token. A
121
139
  * torn body (no parsable pid) still matches the `\\d+:` prefix rule only when
122
- * the pid part is intact, which is what the takeover probe needs. */
140
+ * the pid part is intact, which is what the takeover probe needs. The capture
141
+ * group feeds `parseLockBody` (v20 A-1: skill-store's sweepers consume this
142
+ * helper instead of re-inlining a third regex copy — F-17 single-source). */
123
143
  export declare const LOCK_BODY_RE: RegExp;
144
+ /** Parse a writer-lock body into its holder pid. `null` when the body does
145
+ * not have the `pid:token` shape at all (e.g. a user support file named
146
+ * `*.lock`) — callers leave such files alone. A shape-matching body always
147
+ * yields a number (possibly `0`, which `isProcessAlive` treats as dead). */
148
+ export declare function parseLockBody(body: string): number | null;
124
149
  /**
125
150
  * Build the Node IO backend. `lockAttempts` scales the write-lock retry budget
126
151
  * (attempts × 50ms); the default 40 (~2s, rc.69) covers production contention,
@@ -77,6 +77,13 @@ export interface SnapshotManifest {
77
77
  createdAt: string;
78
78
  /** Active skill names at snapshot time. */
79
79
  skills: string[];
80
+ /** V25-05 (v25): skills that were SKIPPED by the write-lock probe at
81
+ * snapshot time (a live byte-writer held them) — they are NOT in the
82
+ * snapshot directory and are NOT restored by a whole-tree restore, which
83
+ * clears the live tree and copies back only `skills`. Consumers must
84
+ * surface this list; restore reports it in its result message. Absent
85
+ * (empty) on pre-0.3.67 manifests. */
86
+ skipped: string[];
80
87
  /** Co-copied sidecar file names (usage/suppression). */
81
88
  sidecars: string[];
82
89
  /** Whether `.archive/` was co-copied; absent on legacy manifests (do not touch archive on restore). */
@@ -474,6 +481,17 @@ export declare class SkillLibrary {
474
481
  private writeSupportFileCore;
475
482
  removeSupportFile(rawName: string, filePath: string, origin?: WriteOrigin): Promise<SkillActionResult>;
476
483
  private removeSupportFileCore;
484
+ /**
485
+ * v23 (ML-1): `.archive` retention. Archived skills are recoverable history,
486
+ * but nothing bounded their growth — the curator auto-archives idle skills,
487
+ * consolidation and manual deletes take the same path, and every snapshot
488
+ * copies the whole `.archive` (keep-5 retention amplifies it ×6). Entries
489
+ * older than this many days are pruned at snapshot time. Generous by
490
+ * design: a year-old auto-archive is effectively dead recoverability.
491
+ * Backends without the mtime probe skip pruning (no false deletes on
492
+ * unknown age).
493
+ */
494
+ private pruneExpiredArchives;
477
495
  /**
478
496
  * Snapshot the recoverable skills state: active tree, usage/suppression
479
497
  * sidecars, `.archive/` and caller-supplied extras. `extras` are opaque
@@ -482,6 +500,14 @@ export declare class SkillLibrary {
482
500
  */
483
501
  snapshotAll(reason?: string, extras?: SnapshotExtra[]): Promise<string>;
484
502
  /** Read and normalize a snapshot manifest; null when the file is missing or unparsable. */
503
+ /**
504
+ * V26-03 (v25/v26): sanitize the manifest's `skipped` list before it can
505
+ * reach a user-visible restore message. Entries must pass the same name
506
+ * gate as snapshot entries (a corrupted or hand-edited manifest cannot
507
+ * inject arbitrary text into the result), bounded to 50 entries of at most
508
+ * 64 chars each (the name-rule maximum — real skill names always fit).
509
+ */
510
+ private sanitizeSkippedNames;
485
511
  readSnapshotManifest(path: string): Promise<SnapshotManifest | null>;
486
512
  /** Keep only the newest N snapshots (Hermes keep=5 parity); older ones are removed outright. */
487
513
  private retainSnapshots;
@@ -61,14 +61,14 @@ export declare function scanMemoryThreats(text: string, maxScanChars?: number, o
61
61
  /** User-facing block message for skill content writes. */
62
62
  export declare function scanContentThreats(text: string, maxScanChars?: number, options?: ScanOptions): string | null;
63
63
  /**
64
- * V10-03 (P2-18): suffix the SkillLibrary/MemoryStore write gates append to a
65
- * block message the hit label is already embedded by scanContentThreats /
66
- * scanMemoryThreats, this names the deployable self-heal path so the model
67
- * (or operator) can allowlist a known-benign label. P2-4 (v14): the
68
- * evolution-threat guard channel now carries `threatExemptLabels` too, so its
69
- * block message (which embeds the same exemption sentence) is accurate there
70
- * as well; this suffix stays store-side because the guard returns the scan
71
- * message verbatim.
64
+ * WD2 (0.3.56): the shared tail of every user-facing threat block — names the
65
+ * deployable self-heal path so the model (or operator) can allowlist a
66
+ * known-benign label. v20 (B-2) single source: the scan builders embed this
67
+ * constant verbatim; do NOT re-word it per call site (the three former copies
68
+ * memory inline, content inline, and the dead `THREAT_EXEMPT_HINT` export,
69
+ * whose docblock still claimed a store-side append that memory-store/skill-store
70
+ * had already removed had drifted apart). The evolution-threat guard channel
71
+ * returns the scan message verbatim, so the hint rides along there too.
72
72
  */
73
- export declare const THREAT_EXEMPT_HINT = " If this is a legitimate false positive, the deployment can allow its label via the threatExemptLabels store option.";
73
+ export declare const THREAT_EXEMPTION_HINT = " A deployment that needs a specific label can exempt it via threatExemptLabels (see the README env/dial reference).";
74
74
  //# sourceMappingURL=threats.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.65",
4
+ "version": "0.3.67",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },