@lmzhen/dsh-evolution-core 0.3.65 → 0.3.66

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);
@@ -1315,7 +1365,7 @@ async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE
1315
1365
  }
1316
1366
  const events = await rotateIfDue(io, path, v1EventRecords(parsedBody), rotateAt);
1317
1367
  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));
1368
+ for (const name of await listEventArchives(io, path)) maxSeq = Math.max(maxSeq, Number.parseInt(name.slice(7, name.length - 5), 10));
1319
1369
  const record = {
1320
1370
  ...event,
1321
1371
  seq: maxSeq + 1,
@@ -2072,19 +2122,19 @@ const PATTERNS = [
2072
2122
  label: "send_to_url",
2073
2123
  category: "exfiltration",
2074
2124
  scope: "strict",
2075
- regex: /(?:send|post|upload|transmit)\s+[^\n]{0,512}\s+(?:to|at)\s+https?:\/\//i
2125
+ regex: /(?:send|post|upload|transmit)\s+[\s\S]{0,512}?\s+(?:to|at)\s+https?:\/\//i
2076
2126
  },
2077
2127
  {
2078
2128
  label: "exfil_curl",
2079
2129
  category: "exfiltration",
2080
2130
  scope: "all",
2081
- regex: /\bcurl\s+[^\n]{0,512}\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)/i
2131
+ regex: /\bcurl\s+[\s\S]{0,512}?\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)/i
2082
2132
  },
2083
2133
  {
2084
2134
  label: "exfil_wget",
2085
2135
  category: "exfiltration",
2086
2136
  scope: "all",
2087
- regex: /\bwget\s+[^\n]{0,512}\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)/i
2137
+ regex: /\bwget\s+[\s\S]{0,512}?\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)/i
2088
2138
  },
2089
2139
  {
2090
2140
  label: "read_secrets",
@@ -2150,7 +2200,37 @@ const PATTERNS = [
2150
2200
  label: "private_key_block",
2151
2201
  category: "hardcoded_secrets",
2152
2202
  scope: "all",
2153
- regex: /-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----/
2203
+ regex: /-----BEGIN\s+(?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED|PGP)\s+)?PRIVATE\s+KEY(?:\s+BLOCK)?-----/
2204
+ },
2205
+ {
2206
+ label: "prompt_injection_ignore_zh",
2207
+ category: "prompt_injection",
2208
+ scope: "all",
2209
+ regex: /(?:忽略|无视|抛开)[\s\S]{0,8}(?:之前|以上|上面|先前|以前)?[\s\S]{0,8}(?:所有|全部|任何)?[\s\S]{0,4}(?:指令|规则|设定|约束)/
2210
+ },
2211
+ {
2212
+ label: "deception_hide_zh",
2213
+ category: "deception",
2214
+ scope: "all",
2215
+ regex: /(?:不要|别|勿)(?:告诉|告知|透露)(?:给)?[\s\S]{0,6}(?:用户|任何人|主人|开发者)/
2216
+ },
2217
+ {
2218
+ label: "system_prompt_leak_zh",
2219
+ category: "deception",
2220
+ scope: "context",
2221
+ regex: /(?:泄露|输出|打印|透露|导出)[\s\S]{0,10}系统提示|系统提示[\s\S]{0,10}(?:泄露|透露|发送|导出)/
2222
+ },
2223
+ {
2224
+ label: "context_exfil_zh",
2225
+ category: "exfiltration",
2226
+ scope: "strict",
2227
+ regex: /(?:对话记录|聊天记录|全部上下文|完整上下文)[\s\S]{0,30}(?:发送|上传|传输|外传|泄露)[\s\S]{0,30}(?:到|至|给)/
2228
+ },
2229
+ {
2230
+ label: "secret_exfil_zh",
2231
+ category: "exfiltration",
2232
+ scope: "strict",
2233
+ regex: /(?:密钥|凭据|口令|密码|环境变量)[\s\S]{0,30}(?:发送|上传|传输|外传|泄露)[\s\S]{0,30}(?:到|至)\s*(?:https?:\/\/|[\w.-]+\.(?:com|net|org|io|cn|dev|xyz|ru)\b)/
2154
2234
  }
2155
2235
  ];
2156
2236
  const ZERO_WIDTH_CHARS = new RegExp(`[\\u034f\\u200b\\u2060\\u2061\\u2062\\u2063\\u2064\\u206a-\\u206f\\ufeff\\u{e0000}-\\u{e007f}]`, "u");
@@ -2199,11 +2279,13 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_
2199
2279
  scope: "all"
2200
2280
  });
2201
2281
  const normalized = text.normalize("NFKC");
2282
+ const SPACE_SPLITTERS = /[\u00ad\u061c\u180e\u200c\ufe00-\ufe0f]/gu;
2283
+ const patternTexts = [normalized.replace(SPACE_SPLITTERS, " "), normalized.replace(SPACE_SPLITTERS, "")];
2202
2284
  const windows = [];
2203
- if (normalized.length <= windowSize) windows.push(normalized);
2285
+ for (const patternText of patternTexts) if (patternText.length <= windowSize) windows.push(patternText);
2204
2286
  else {
2205
2287
  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));
2288
+ for (let start = 0; start < patternText.length; start += step) windows.push(patternText.slice(start, start + windowSize));
2207
2289
  }
2208
2290
  const seen = /* @__PURE__ */ new Set();
2209
2291
  for (const window of windows) for (const pattern of PATTERNS) {
@@ -2236,27 +2318,26 @@ function scanMemoryThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTIONS
2236
2318
  const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars, options);
2237
2319
  if (!blocked) return null;
2238
2320
  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}`;
2321
+ if (pattern) return `Blocked by security scan (${pattern.label}). Rephrase without instruction-like language.${THREAT_EXEMPTION_HINT}`;
2322
+ return `Blocked by security scan: invisible or potentially malicious Unicode detected.${THREAT_EXEMPTION_HINT}`;
2242
2323
  }
2243
2324
  /** User-facing block message for skill content writes. */
2244
2325
  function scanContentThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
2245
2326
  const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars, options);
2246
2327
  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).`;
2328
+ return `Blocked by security scan (${findings[0]?.label ?? "unknown"}). This content appears to contain potentially malicious instructions.${THREAT_EXEMPTION_HINT}`;
2248
2329
  }
2249
2330
  /**
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.
2331
+ * WD2 (0.3.56): the shared tail of every user-facing threat block — names the
2332
+ * deployable self-heal path so the model (or operator) can allowlist a
2333
+ * known-benign label. v20 (B-2) single source: the scan builders embed this
2334
+ * constant verbatim; do NOT re-word it per call site (the three former copies
2335
+ * memory inline, content inline, and the dead `THREAT_EXEMPT_HINT` export,
2336
+ * whose docblock still claimed a store-side append that memory-store/skill-store
2337
+ * had already removed had drifted apart). The evolution-threat guard channel
2338
+ * returns the scan message verbatim, so the hint rides along there too.
2258
2339
  */
2259
- const THREAT_EXEMPT_HINT = " If this is a legitimate false positive, the deployment can allow its label via the threatExemptLabels store option.";
2340
+ const THREAT_EXEMPTION_HINT = " A deployment that needs a specific label can exempt it via threatExemptLabels (see the README env/dial reference).";
2260
2341
  //#endregion
2261
2342
  //#region lib/types/memory-store.js
2262
2343
  /**
@@ -2955,7 +3036,7 @@ function injectCatalogDescriptionCap(composition) {
2955
3036
  const next = lines[j] ?? "";
2956
3037
  if (next.trim() === "") break;
2957
3038
  if (!/^\s/.test(next)) break;
2958
- if (/^\s+config:(\s|$)/.test(next)) hasConfig = true;
3039
+ if (/^ {2}config:(\s|$)/.test(next)) hasConfig = true;
2959
3040
  end = j;
2960
3041
  }
2961
3042
  if (hasConfig) continue;
@@ -3156,9 +3237,14 @@ const SECRET_PATTERNS = [
3156
3237
  ["gitlab token", /glpat-[A-Za-z0-9_-]{16,}/g],
3157
3238
  ["slack token", /xox[baprs]-[A-Za-z0-9-]{10,}/g],
3158
3239
  ["jwt", /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g],
3240
+ ["npm token", /npm_[A-Za-z0-9]{20,}/g],
3241
+ ["stripe key", /[sr]k_(?:live|test)_[A-Za-z0-9]{16,}/g],
3242
+ ["github fine-grained token", /github_pat_[A-Za-z0-9_]{20,}/g],
3243
+ ["google api key", /AIza[0-9A-Za-z_-]{30,}/g],
3159
3244
  ["bearer credential", /Bearer[\s]+[a-z0-9._~+/=\-]{16,}/gi]
3160
3245
  ];
3161
3246
  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");
3247
+ const URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s/@]+)@/gi;
3162
3248
  /**
3163
3249
  * Mask credential-shaped text before it crosses a session boundary.
3164
3250
  * @param text - the text about to be sent to a model outside this session.
@@ -3167,7 +3253,12 @@ const INLINE_ASSIGNMENT_PATTERN = /* @__PURE__ */ new RegExp("(^|[^\\w-])([\\w-]
3167
3253
  function redactSecrets(text) {
3168
3254
  let out = text;
3169
3255
  for (const [, pattern] of SECRET_PATTERNS) out = out.replace(pattern, "<redacted>");
3256
+ out = out.replace(URL_CREDENTIALS_PATTERN, (_match, lead) => `${lead ?? ""}<redacted>@`);
3170
3257
  out = out.replace(INLINE_ASSIGNMENT_PATTERN, (_match, lead, prefix, key, separator) => `${lead ?? ""}${prefix ?? ""}${key ?? ""}${separator ?? ""}<redacted>`);
3258
+ out = out.split("\n").map((line) => {
3259
+ if (!/<redacted>/.test(line) && !/\baws\b|\bAKIA\b|\bsecret\b/i.test(line)) return line;
3260
+ return line.replace(/(?<![A-Za-z0-9/+=])[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])/g, "<redacted>");
3261
+ }).join("\n");
3171
3262
  return out;
3172
3263
  }
3173
3264
  //#endregion
@@ -3616,6 +3707,8 @@ const MARKER_LOCK_NAMES = [
3616
3707
  `.pinned${LOCK_SUFFIX}`,
3617
3708
  `.hermes-managed${LOCK_SUFFIX}`
3618
3709
  ];
3710
+ /** v23 (ML-1): `.archive` retention window (see pruneExpiredArchives). */
3711
+ const ARCHIVE_RETENTION_DAYS = 365;
3619
3712
  function markerPath(dir, marker) {
3620
3713
  return join(dir, markerEntryName(marker));
3621
3714
  }
@@ -4435,13 +4528,22 @@ var SkillLibrary = class {
4435
4528
  ok: false,
4436
4529
  message: `Skill "${normalized}" not found.`
4437
4530
  };
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);
4531
+ if (pinned) {
4532
+ try {
4533
+ await this.io.writeText(marker, "");
4534
+ } catch (error) {
4535
+ if (!isCommittedOnly(error)) throw error;
4536
+ durabilityWarning = error instanceof Error ? error.message : String(error);
4537
+ }
4538
+ if (!await this.io.exists(join(dir, "SKILL.md"))) {
4539
+ await this.io.remove(marker).catch(() => {});
4540
+ if ((await this.io.list(dir).catch(() => [])).length === 0) await this.io.remove(dir).catch(() => {});
4541
+ return {
4542
+ ok: false,
4543
+ message: `Skill "${normalized}" was archived concurrently while pinning; the partial marker was removed — retry after the mover settles.`
4544
+ };
4545
+ }
4546
+ } else await this.io.remove(marker);
4445
4547
  await this.audit(normalized, pinned ? "pin" : "unpin", null, null, pinned ? "pinned" : "unpinned");
4446
4548
  return {
4447
4549
  ok: true,
@@ -4487,6 +4589,10 @@ var SkillLibrary = class {
4487
4589
  ok: false,
4488
4590
  message: `Skill "${normalized}" already exists.`
4489
4591
  };
4592
+ for (const entry of await this.io.list(this.root).catch(() => [])) if (typeof entry === "string" && entry !== normalized && entry.toLowerCase() === normalized.toLowerCase()) return {
4593
+ ok: false,
4594
+ message: `Skill "${normalized}" collides with the existing case-variant directory "${entry}" (skill names are lowercase-only); rename one of them.`
4595
+ };
4490
4596
  const protection = await this.writeProtection(normalized, origin);
4491
4597
  if (protection) return {
4492
4598
  ok: false,
@@ -4530,7 +4636,17 @@ var SkillLibrary = class {
4530
4636
  ok: false,
4531
4637
  message: `Skill "${normalized}" already exists.`
4532
4638
  };
4533
- if (origin !== "foreground") await this.io.writeText(markerPath(dir, "hermes-managed"), "");
4639
+ if (origin !== "foreground") {
4640
+ await this.io.writeText(markerPath(dir, "hermes-managed"), "");
4641
+ if (!await this.io.exists(createPath)) {
4642
+ await this.io.remove(markerPath(dir, "hermes-managed")).catch(() => {});
4643
+ if ((await this.io.list(dir).catch(() => [])).length === 0) await this.io.remove(dir).catch(() => {});
4644
+ return {
4645
+ ok: false,
4646
+ message: `Skill "${normalized}" was archived concurrently while being created; the partial marker was removed — retry once the mover settles.`
4647
+ };
4648
+ }
4649
+ }
4534
4650
  await this.audit(normalized, "create", null, onDisk, "created");
4535
4651
  this.notifyMutation({
4536
4652
  action: "create",
@@ -4863,9 +4979,7 @@ var SkillLibrary = class {
4863
4979
  * stolen by the sweep. A dead-pid residue would otherwise permanently
4864
4980
  * refuse archive/restore. */
4865
4981
  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"));
4982
+ for (const markerLock of MARKER_LOCK_NAMES) await this.sweepLockIfStranded(join(dir, markerLock));
4869
4983
  for (const supportDir of SUPPORT_DIRS) {
4870
4984
  let entries = [];
4871
4985
  try {
@@ -4882,9 +4996,8 @@ var SkillLibrary = class {
4882
4996
  async sweepLockIfStranded(lockPath) {
4883
4997
  const body = await this.io.readText(lockPath).catch(() => null);
4884
4998
  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]);
4999
+ const pid = parseLockBody(body);
5000
+ if (pid === null) return;
4888
5001
  if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid)) return;
4889
5002
  await this.io.remove(lockPath).catch(() => {});
4890
5003
  }
@@ -4908,9 +5021,8 @@ var SkillLibrary = class {
4908
5021
  throw new Error(`snapshot restore refused: cannot verify ${label} (${error instanceof Error ? error.message : String(error)}); a live writer may hold it`);
4909
5022
  }
4910
5023
  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]);
5024
+ const pid = parseLockBody(body);
5025
+ if (pid === null) return;
4914
5026
  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
5027
  await this.io.remove(lockPath).catch(() => {});
4916
5028
  }
@@ -5329,7 +5441,17 @@ var SkillLibrary = class {
5329
5441
  try {
5330
5442
  for (const entry of landing) {
5331
5443
  try {
5332
- await this.io.writeText(entry.target, entry.content);
5444
+ if (this.transact) {
5445
+ const drift = { seen: false };
5446
+ await this.transact(this.io, entry.target, (current) => {
5447
+ if (current !== entry.previous) {
5448
+ drift.seen = true;
5449
+ return current;
5450
+ }
5451
+ return entry.content;
5452
+ });
5453
+ 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`);
5454
+ } else await this.io.writeText(entry.target, entry.content);
5333
5455
  } catch (error) {
5334
5456
  if (error?.committed !== true) throw error;
5335
5457
  durabilityWarning = error instanceof Error ? error.message : String(error);
@@ -5544,12 +5666,41 @@ var SkillLibrary = class {
5544
5666
  };
5545
5667
  }
5546
5668
  /**
5669
+ * v23 (ML-1): `.archive` retention. Archived skills are recoverable history,
5670
+ * but nothing bounded their growth — the curator auto-archives idle skills,
5671
+ * consolidation and manual deletes take the same path, and every snapshot
5672
+ * copies the whole `.archive` (keep-5 retention amplifies it ×6). Entries
5673
+ * older than this many days are pruned at snapshot time. Generous by
5674
+ * design: a year-old auto-archive is effectively dead recoverability.
5675
+ * Backends without the mtime probe skip pruning (no false deletes on
5676
+ * unknown age).
5677
+ */
5678
+ async pruneExpiredArchives() {
5679
+ const archiveRoot = join(this.root, ".archive");
5680
+ if (!this.io.mtime) return;
5681
+ let entries = [];
5682
+ try {
5683
+ entries = await this.io.list(archiveRoot);
5684
+ } catch {
5685
+ return;
5686
+ }
5687
+ const cutoff = Date.now() - ARCHIVE_RETENTION_DAYS * 864e5;
5688
+ for (const entry of entries) {
5689
+ const entryPath = join(archiveRoot, entry);
5690
+ const mtime = await this.io.mtime(entryPath).catch(() => null);
5691
+ if (mtime === null || mtime > cutoff) continue;
5692
+ await this.io.remove(entryPath).catch(() => {});
5693
+ console.warn(`skill-store: pruned archived skill "${entry}" (older than ${ARCHIVE_RETENTION_DAYS} days; recoverable from snapshots until they rotate)`);
5694
+ }
5695
+ }
5696
+ /**
5547
5697
  * Snapshot the recoverable skills state: active tree, usage/suppression
5548
5698
  * sidecars, `.archive/` and caller-supplied extras. `extras` are opaque
5549
5699
  * side files the Snapshot owner cares about (curator state); they are
5550
5700
  * listed in the manifest and only those names are ever read back.
5551
5701
  */
5552
5702
  async snapshotAll(reason = "pre-mutation", extras = []) {
5703
+ await this.pruneExpiredArchives();
5553
5704
  const backupRoot = join(this.root, ".backups");
5554
5705
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
5555
5706
  let dest = join(backupRoot, `skills-${stamp}`);
@@ -5631,7 +5782,14 @@ var SkillLibrary = class {
5631
5782
  for (const name of entries.sort().reverse()) {
5632
5783
  if (!name.startsWith("skills-")) continue;
5633
5784
  const manifest = await this.readSnapshotManifest(join(backupRoot, name));
5634
- if (manifest === null) continue;
5785
+ if (manifest === null) {
5786
+ out.push({
5787
+ path: join(backupRoot, name),
5788
+ createdAt: "",
5789
+ reason: "unreadable or missing manifest (orphan snapshot)"
5790
+ });
5791
+ continue;
5792
+ }
5635
5793
  out.push({
5636
5794
  path: join(backupRoot, name),
5637
5795
  createdAt: manifest.createdAt,
@@ -5765,4 +5923,4 @@ var SkillLibrary = class {
5765
5923
  }
5766
5924
  };
5767
5925
  //#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 };
5926
+ 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 };
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,
@@ -474,6 +474,17 @@ export declare class SkillLibrary {
474
474
  private writeSupportFileCore;
475
475
  removeSupportFile(rawName: string, filePath: string, origin?: WriteOrigin): Promise<SkillActionResult>;
476
476
  private removeSupportFileCore;
477
+ /**
478
+ * v23 (ML-1): `.archive` retention. Archived skills are recoverable history,
479
+ * but nothing bounded their growth — the curator auto-archives idle skills,
480
+ * consolidation and manual deletes take the same path, and every snapshot
481
+ * copies the whole `.archive` (keep-5 retention amplifies it ×6). Entries
482
+ * older than this many days are pruned at snapshot time. Generous by
483
+ * design: a year-old auto-archive is effectively dead recoverability.
484
+ * Backends without the mtime probe skip pruning (no false deletes on
485
+ * unknown age).
486
+ */
487
+ private pruneExpiredArchives;
477
488
  /**
478
489
  * Snapshot the recoverable skills state: active tree, usage/suppression
479
490
  * sidecars, `.archive/` and caller-supplied extras. `extras` are opaque
@@ -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.66",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },