@lmzhen/dsh-evolution-core 0.3.64 → 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(() => {});
@@ -393,7 +426,7 @@ function nodeEvolutionIo(lockAttempts = 40) {
393
426
  } catch {}
394
427
  continue;
395
428
  }
396
- if (name.includes(".corrupt")) {
429
+ if (/\.corrupt(\.\d+)?$/.test(name)) {
397
430
  const corruptPath = join(dir, name);
398
431
  try {
399
432
  const st = await stat(corruptPath);
@@ -576,29 +609,47 @@ function parseUsage(raw) {
576
609
  async function loadUsage(root, io = nodeEvolutionIo()) {
577
610
  return parseUsage(await io.readText(usageFile(root)));
578
611
  }
579
- /**
580
- * Atomic read-modify-write on the usage sidecar (rc.50 P2-2): `task` receives
581
- * the map parsed from the current on-disk state and may mutate it; the result
582
- * is persisted inside the same transact so a second process sharing DSH_HOME
583
- * cannot interleave its RMW and lose a counter update. Callers keep their own
584
- * single-process serialize chain as the second layer.
585
- */
586
- async function mutateUsage(root, io, task) {
612
+ async function mutateUsage(root, io, task, options = {}) {
587
613
  await transactIo(io, usageFile(root), async (current) => {
588
614
  let shapePreserved = false;
589
- if (current !== null) try {
590
- const probe = JSON.parse(current);
591
- if (probe === null || Array.isArray(probe) || typeof probe !== "object") shapePreserved = true;
592
- else {
593
- const record = probe;
594
- if (Object.values(record).some((value) => value === null || typeof value !== "object" || Array.isArray(value))) shapePreserved = true;
595
- if (typeof record.version === "number" && record.version > 1) shapePreserved = true;
615
+ let recovered = null;
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;
636
+ const isMalformed = (value) => value === null || typeof value !== "object" || Array.isArray(value);
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)) {
641
+ const bad = Object.keys(record).filter((key) => isMalformed(record[key]));
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
+ }
646
+ recovered = JSON.stringify(Object.fromEntries(Object.entries(record).filter(([, value]) => !isMalformed(value))));
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`);
648
+ }
596
649
  }
597
- } catch {
598
- return current;
599
650
  }
600
651
  if (shapePreserved) return current;
601
- const map = parseUsage(current);
652
+ const map = parseUsage(recovered ?? current);
602
653
  await task(map);
603
654
  return JSON.stringify(Object.fromEntries(map.entries()), null, 2);
604
655
  });
@@ -1314,7 +1365,7 @@ async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE
1314
1365
  }
1315
1366
  const events = await rotateIfDue(io, path, v1EventRecords(parsedBody), rotateAt);
1316
1367
  let maxSeq = events.reduce((max, entry) => Math.max(max, entry.seq), 0);
1317
- 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));
1318
1369
  const record = {
1319
1370
  ...event,
1320
1371
  seq: maxSeq + 1,
@@ -1976,9 +2027,16 @@ function clampedNumber(value, fallback, opts) {
1976
2027
  * Threat scanning for agent-authored memory and skill content.
1977
2028
  *
1978
2029
  * Ported as a small, dependency-free subset of Hermes Agent's
1979
- * `tools/threat_patterns.py` + hermes-claw `threats.ts`. The policy is the
1980
- * load-bearing part: ANY in-scope hit blocks. Severity and category are
1981
- * metadata for diagnostics only.
2030
+ * `tools/threat_patterns.py` + hermes-claw `threats.ts`.
2031
+ *
2032
+ * Policy (P1-1, v19): a finding either BLOCKS the write or only REPORTS.
2033
+ * Blocking is reserved for shapes with no legitimate use in stored knowledge
2034
+ * (prompt-injection phrasing, credential exfiltration, the invisible-character
2035
+ * smuggling core). Typography and presentation characters that are legitimate
2036
+ * in ordinary prose — variation selectors (❤️), zero-width non-joiner, soft
2037
+ * hyphen from PDF paste, Arabic letter mark, Mongolian vowel separator — are
2038
+ * REPORT findings: they stay visible to operators and tests but never reject a
2039
+ * write. Blocking them turned every emoji into a security event.
1982
2040
  */
1983
2041
  const FILLER = String.raw`(?:\w+\s+){0,8}`;
1984
2042
  const PATTERNS = [
@@ -2064,19 +2122,19 @@ const PATTERNS = [
2064
2122
  label: "send_to_url",
2065
2123
  category: "exfiltration",
2066
2124
  scope: "strict",
2067
- 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
2068
2126
  },
2069
2127
  {
2070
2128
  label: "exfil_curl",
2071
2129
  category: "exfiltration",
2072
2130
  scope: "all",
2073
- 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
2074
2132
  },
2075
2133
  {
2076
2134
  label: "exfil_wget",
2077
2135
  category: "exfiltration",
2078
2136
  scope: "all",
2079
- 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
2080
2138
  },
2081
2139
  {
2082
2140
  label: "read_secrets",
@@ -2142,10 +2200,42 @@ const PATTERNS = [
2142
2200
  label: "private_key_block",
2143
2201
  category: "hardcoded_secrets",
2144
2202
  scope: "all",
2145
- 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)/
2146
2234
  }
2147
2235
  ];
2148
- const ZERO_WIDTH_CHARS = new RegExp(`[\\u00ad\\u034f\\u061c\\u180e\\u200b\\u200c\\u200d\\u2060\\u2061\\u2062\\u2063\\u2064\\u206a-\\u206f\\ufeff\\ufe00-\\ufe0f]|\\u{e0000}-\\u{e007f}`, "u");
2236
+ const ZERO_WIDTH_CHARS = new RegExp(`[\\u034f\\u200b\\u2060\\u2061\\u2062\\u2063\\u2064\\u206a-\\u206f\\ufeff\\u{e0000}-\\u{e007f}]`, "u");
2237
+ const ZWJ_OUTSIDE_EMOJI = /(?<!\p{Extended_Pictographic})\u200d(?!\p{Extended_Pictographic})/u;
2238
+ const TYPOGRAPHY_CHARS = /[\u00ad\u061c\u180e\u200c\ufe00-\ufe0f]/;
2149
2239
  const BIDI_CHARS = /[\u202a-\u202e\u2066-\u2069]/;
2150
2240
  const SCOPE_ORDER = {
2151
2241
  all: 1,
@@ -2172,22 +2262,30 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_
2172
2262
  const windowSize = clampedNumber(maxScanChars, 65536, { min: 4097 });
2173
2263
  const findings = [];
2174
2264
  const excluded = new Set(options.excludeLabels ?? []);
2175
- if (ZERO_WIDTH_CHARS.test(text) && !excluded.has("unicode_zero_width")) findings.push({
2265
+ if ((ZERO_WIDTH_CHARS.test(text) || ZWJ_OUTSIDE_EMOJI.test(text)) && !excluded.has("unicode_zero_width")) findings.push({
2176
2266
  label: "unicode_zero_width",
2177
2267
  category: "unicode_obfuscation",
2178
2268
  scope: "all"
2179
2269
  });
2270
+ if (TYPOGRAPHY_CHARS.test(text) && !excluded.has("unicode_typography")) findings.push({
2271
+ label: "unicode_typography",
2272
+ category: "unicode_obfuscation",
2273
+ scope: "all",
2274
+ severity: "report"
2275
+ });
2180
2276
  if (BIDI_CHARS.test(text) && !excluded.has("unicode_bidi_override")) findings.push({
2181
2277
  label: "unicode_bidi_override",
2182
2278
  category: "unicode_obfuscation",
2183
2279
  scope: "all"
2184
2280
  });
2185
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, "")];
2186
2284
  const windows = [];
2187
- if (normalized.length <= windowSize) windows.push(normalized);
2285
+ for (const patternText of patternTexts) if (patternText.length <= windowSize) windows.push(patternText);
2188
2286
  else {
2189
2287
  const step = Math.max(Math.floor(windowSize / 2), 1);
2190
- 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));
2191
2289
  }
2192
2290
  const seen = /* @__PURE__ */ new Set();
2193
2291
  for (const window of windows) for (const pattern of PATTERNS) {
@@ -2205,11 +2303,13 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_
2205
2303
  }
2206
2304
  return findings;
2207
2305
  }
2208
- /** Blocking policy: any hit blocks. `severity` is deliberately not a gate. */
2306
+ /** Blocking policy (P1-1, v19): a finding blocks unless it is explicitly
2307
+ * `report`-only. Pattern findings carry no severity and therefore block as
2308
+ * before. */
2209
2309
  function evaluateThreat(text, scope = "strict", maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
2210
2310
  const findings = scanThreats(text, scope, maxScanChars, options);
2211
2311
  return {
2212
- blocked: findings.length > 0,
2312
+ blocked: findings.some((finding) => finding.severity !== "report"),
2213
2313
  findings
2214
2314
  };
2215
2315
  }
@@ -2218,27 +2318,26 @@ function scanMemoryThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTIONS
2218
2318
  const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars, options);
2219
2319
  if (!blocked) return null;
2220
2320
  const pattern = findings.find((f) => f.category !== "unicode_obfuscation");
2221
- const exemptionHint = " A deployment that needs a specific label can exempt it via threatExemptLabels (see the README env/dial reference).";
2222
- if (pattern) return `Blocked by security scan (${pattern.label}). Rephrase without instruction-like language.${exemptionHint}`;
2223
- 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}`;
2224
2323
  }
2225
2324
  /** User-facing block message for skill content writes. */
2226
2325
  function scanContentThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
2227
2326
  const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars, options);
2228
2327
  if (!blocked) return null;
2229
- 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}`;
2230
2329
  }
2231
2330
  /**
2232
- * V10-03 (P2-18): suffix the SkillLibrary/MemoryStore write gates append to a
2233
- * block message the hit label is already embedded by scanContentThreats /
2234
- * scanMemoryThreats, this names the deployable self-heal path so the model
2235
- * (or operator) can allowlist a known-benign label. P2-4 (v14): the
2236
- * evolution-threat guard channel now carries `threatExemptLabels` too, so its
2237
- * block message (which embeds the same exemption sentence) is accurate there
2238
- * as well; this suffix stays store-side because the guard returns the scan
2239
- * 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.
2240
2339
  */
2241
- 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).";
2242
2341
  //#endregion
2243
2342
  //#region lib/types/memory-store.js
2244
2343
  /**
@@ -2937,7 +3036,7 @@ function injectCatalogDescriptionCap(composition) {
2937
3036
  const next = lines[j] ?? "";
2938
3037
  if (next.trim() === "") break;
2939
3038
  if (!/^\s/.test(next)) break;
2940
- if (/^\s+config:(\s|$)/.test(next)) hasConfig = true;
3039
+ if (/^ {2}config:(\s|$)/.test(next)) hasConfig = true;
2941
3040
  end = j;
2942
3041
  }
2943
3042
  if (hasConfig) continue;
@@ -3138,9 +3237,14 @@ const SECRET_PATTERNS = [
3138
3237
  ["gitlab token", /glpat-[A-Za-z0-9_-]{16,}/g],
3139
3238
  ["slack token", /xox[baprs]-[A-Za-z0-9-]{10,}/g],
3140
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],
3141
3244
  ["bearer credential", /Bearer[\s]+[a-z0-9._~+/=\-]{16,}/gi]
3142
3245
  ];
3143
- const INLINE_ASSIGNMENT_PATTERN = /* @__PURE__ */ new RegExp("(^|[^\\w-])([\\w-]{0,64}[_\\-])?((?:token|api[_-]?key|secret|password|passwd)(?:[_\\-][\\w-]{0,64})?)\\b([\\s]*[:=][\\s]*)(?:\"([^\"\\r\\n]*)\"|'([^'\\r\\n]*)'|([^\\r\\n]+))", "gi");
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;
3144
3248
  /**
3145
3249
  * Mask credential-shaped text before it crosses a session boundary.
3146
3250
  * @param text - the text about to be sent to a model outside this session.
@@ -3149,7 +3253,12 @@ const INLINE_ASSIGNMENT_PATTERN = /* @__PURE__ */ new RegExp("(^|[^\\w-])([\\w-]
3149
3253
  function redactSecrets(text) {
3150
3254
  let out = text;
3151
3255
  for (const [, pattern] of SECRET_PATTERNS) out = out.replace(pattern, "<redacted>");
3256
+ out = out.replace(URL_CREDENTIALS_PATTERN, (_match, lead) => `${lead ?? ""}<redacted>@`);
3152
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");
3153
3262
  return out;
3154
3263
  }
3155
3264
  //#endregion
@@ -3598,6 +3707,8 @@ const MARKER_LOCK_NAMES = [
3598
3707
  `.pinned${LOCK_SUFFIX}`,
3599
3708
  `.hermes-managed${LOCK_SUFFIX}`
3600
3709
  ];
3710
+ /** v23 (ML-1): `.archive` retention window (see pruneExpiredArchives). */
3711
+ const ARCHIVE_RETENTION_DAYS = 365;
3601
3712
  function markerPath(dir, marker) {
3602
3713
  return join(dir, markerEntryName(marker));
3603
3714
  }
@@ -3836,6 +3947,13 @@ function authoringFeedback(frontmatter) {
3836
3947
  lines
3837
3948
  };
3838
3949
  }
3950
+ /** A1-15 (v18) / P2-2 (v19): the io layer marks an error `committed: true` when
3951
+ * the rename landed and only the directory fsync failed. Every single-file
3952
+ * writer must treat that as "written, durability unconfirmed" — never as a
3953
+ * plain failure (which a caller would retry, or a two-phase caller roll back). */
3954
+ function isCommittedOnly(error) {
3955
+ return error?.committed === true;
3956
+ }
3839
3957
  async function listNames(root, io) {
3840
3958
  const entries = await io.list(root);
3841
3959
  const names = [];
@@ -4392,6 +4510,7 @@ var SkillLibrary = class {
4392
4510
  ok: false,
4393
4511
  message: "Only the foreground (user or the main agent) may pin or unpin skills."
4394
4512
  };
4513
+ let durabilityWarning = "";
4395
4514
  const dir = this.dirOf(normalized);
4396
4515
  const marker = markerPath(dir, "pinned");
4397
4516
  const existing = await this.io.exists(marker);
@@ -4409,12 +4528,26 @@ var SkillLibrary = class {
4409
4528
  ok: false,
4410
4529
  message: `Skill "${normalized}" not found.`
4411
4530
  };
4412
- if (pinned) await this.io.writeText(marker, "");
4413
- 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);
4414
4547
  await this.audit(normalized, pinned ? "pin" : "unpin", null, null, pinned ? "pinned" : "unpinned");
4415
4548
  return {
4416
4549
  ok: true,
4417
- message: pinned ? `Skill "${normalized}" pinned: protected from deletion, background review, and the lifecycle.` : `Skill "${normalized}" unpinned.`,
4550
+ message: `${pinned ? `Skill "${normalized}" pinned: protected from deletion, background review, and the lifecycle.` : `Skill "${normalized}" unpinned.`}${durabilityWarning === "" ? "" : ` (warning: the write landed but the directory fsync failed — durability unconfirmed: ${durabilityWarning})`}`,
4418
4551
  path: dir
4419
4552
  };
4420
4553
  }
@@ -4456,6 +4589,10 @@ var SkillLibrary = class {
4456
4589
  ok: false,
4457
4590
  message: `Skill "${normalized}" already exists.`
4458
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
+ };
4459
4596
  const protection = await this.writeProtection(normalized, origin);
4460
4597
  if (protection) return {
4461
4598
  ok: false,
@@ -4465,20 +4602,31 @@ var SkillLibrary = class {
4465
4602
  const createPath = join(dir, "SKILL.md");
4466
4603
  let existsAtCommit = false;
4467
4604
  let taskRan = false;
4468
- if (this.transact) await this.transact(this.io, createPath, (current) => {
4469
- taskRan = true;
4470
- if (current !== null) {
4471
- existsAtCommit = true;
4472
- return current;
4473
- }
4474
- return onDisk;
4475
- });
4605
+ let createDurabilityWarning = "";
4606
+ if (this.transact) try {
4607
+ await this.transact(this.io, createPath, (current) => {
4608
+ taskRan = true;
4609
+ if (current !== null) {
4610
+ existsAtCommit = true;
4611
+ return current;
4612
+ }
4613
+ return onDisk;
4614
+ });
4615
+ } catch (error) {
4616
+ if (!isCommittedOnly(error)) throw error;
4617
+ createDurabilityWarning = error instanceof Error ? error.message : String(error);
4618
+ }
4476
4619
  else if (await this.io.exists(createPath)) {
4477
4620
  taskRan = true;
4478
4621
  existsAtCommit = true;
4479
4622
  } else {
4480
4623
  taskRan = true;
4481
- await this.io.writeText(createPath, onDisk);
4624
+ try {
4625
+ await this.io.writeText(createPath, onDisk);
4626
+ } catch (error) {
4627
+ if (!isCommittedOnly(error)) throw error;
4628
+ createDurabilityWarning = error instanceof Error ? error.message : String(error);
4629
+ }
4482
4630
  }
4483
4631
  if (!taskRan) return {
4484
4632
  ok: false,
@@ -4488,7 +4636,17 @@ var SkillLibrary = class {
4488
4636
  ok: false,
4489
4637
  message: `Skill "${normalized}" already exists.`
4490
4638
  };
4491
- 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
+ }
4492
4650
  await this.audit(normalized, "create", null, onDisk, "created");
4493
4651
  this.notifyMutation({
4494
4652
  action: "create",
@@ -4497,7 +4655,7 @@ var SkillLibrary = class {
4497
4655
  });
4498
4656
  return {
4499
4657
  ok: true,
4500
- message: `Skill "${normalized}" created.`,
4658
+ message: `Skill "${normalized}" created.${createDurabilityWarning === "" ? "" : ` (warning: the write landed but the directory fsync failed — durability unconfirmed: ${createDurabilityWarning})`}`,
4501
4659
  path: dir,
4502
4660
  ...norm.changed ? { normalizedFrontmatterFields: norm.fields } : {}
4503
4661
  };
@@ -4821,9 +4979,7 @@ var SkillLibrary = class {
4821
4979
  * stolen by the sweep. A dead-pid residue would otherwise permanently
4822
4980
  * refuse archive/restore. */
4823
4981
  async deleteStrandedLocks(dir) {
4824
- await this.sweepLockIfStranded(join(dir, "SKILL.md.lock"));
4825
- await this.sweepLockIfStranded(join(dir, ".pinned.lock"));
4826
- await this.sweepLockIfStranded(join(dir, ".hermes-managed.lock"));
4982
+ for (const markerLock of MARKER_LOCK_NAMES) await this.sweepLockIfStranded(join(dir, markerLock));
4827
4983
  for (const supportDir of SUPPORT_DIRS) {
4828
4984
  let entries = [];
4829
4985
  try {
@@ -4840,27 +4996,33 @@ var SkillLibrary = class {
4840
4996
  async sweepLockIfStranded(lockPath) {
4841
4997
  const body = await this.io.readText(lockPath).catch(() => null);
4842
4998
  if (body === null) return;
4843
- const match = /^(\d+):[0-9a-f]*$/.exec(body.trim());
4844
- if (match === null) return;
4845
- const pid = Number(match[1]);
4999
+ const pid = parseLockBody(body);
5000
+ if (pid === null) return;
4846
5001
  if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid)) return;
4847
5002
  await this.io.remove(lockPath).catch(() => {});
4848
5003
  }
4849
5004
  /** A1-4 (v18): a manifest-declared name is copied with `join(root, name)`;
4850
5005
  * only a single, non-traversing path component is safe. Dotfiles
4851
- * (`.usage.json`) stay allowed — sidecars are legitimately dot-prefixed. */
5006
+ * (`.usage.json`) stay allowed — sidecars are legitimately dot-prefixed.
5007
+ * P2-4 (v19): a non-string entry (`skills: [123]`) is refused structurally
5008
+ * instead of throwing `name.includes is not a function`. */
4852
5009
  safeSnapshotEntryName(name) {
5010
+ if (typeof name !== "string") return false;
4853
5011
  return name !== "" && name !== "." && name !== ".." && !name.includes("/") && !name.includes("\\") && basename(name) === name;
4854
5012
  }
4855
5013
  /** A1-7 (v18): a root-level lock whose holder is alive must refuse the
4856
5014
  * restore; a dead residue is swept so a crashed writer cannot block
4857
5015
  * recovery. A non-lock body shape is left alone (user file). */
4858
5016
  async refuseLiveLockOrSweep(lockPath, label) {
4859
- const body = await this.io.readText(lockPath).catch(() => null);
5017
+ let body;
5018
+ try {
5019
+ body = await this.io.readText(lockPath);
5020
+ } catch (error) {
5021
+ throw new Error(`snapshot restore refused: cannot verify ${label} (${error instanceof Error ? error.message : String(error)}); a live writer may hold it`);
5022
+ }
4860
5023
  if (body === null) return;
4861
- const match = /^(\d+):[0-9a-f]*$/.exec(body.trim());
4862
- if (match === null) return;
4863
- const pid = Number(match[1]);
5024
+ const pid = parseLockBody(body);
5025
+ if (pid === null) return;
4864
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`);
4865
5027
  await this.io.remove(lockPath).catch(() => {});
4866
5028
  }
@@ -5279,7 +5441,17 @@ var SkillLibrary = class {
5279
5441
  try {
5280
5442
  for (const entry of landing) {
5281
5443
  try {
5282
- 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);
5283
5455
  } catch (error) {
5284
5456
  if (error?.committed !== true) throw error;
5285
5457
  durabilityWarning = error instanceof Error ? error.message : String(error);
@@ -5494,12 +5666,41 @@ var SkillLibrary = class {
5494
5666
  };
5495
5667
  }
5496
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
+ /**
5497
5697
  * Snapshot the recoverable skills state: active tree, usage/suppression
5498
5698
  * sidecars, `.archive/` and caller-supplied extras. `extras` are opaque
5499
5699
  * side files the Snapshot owner cares about (curator state); they are
5500
5700
  * listed in the manifest and only those names are ever read back.
5501
5701
  */
5502
5702
  async snapshotAll(reason = "pre-mutation", extras = []) {
5703
+ await this.pruneExpiredArchives();
5503
5704
  const backupRoot = join(this.root, ".backups");
5504
5705
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
5505
5706
  let dest = join(backupRoot, `skills-${stamp}`);
@@ -5581,7 +5782,14 @@ var SkillLibrary = class {
5581
5782
  for (const name of entries.sort().reverse()) {
5582
5783
  if (!name.startsWith("skills-")) continue;
5583
5784
  const manifest = await this.readSnapshotManifest(join(backupRoot, name));
5584
- 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
+ }
5585
5793
  out.push({
5586
5794
  path: join(backupRoot, name),
5587
5795
  createdAt: manifest.createdAt,
@@ -5660,13 +5868,19 @@ var SkillLibrary = class {
5660
5868
  */
5661
5869
  async restoreSnapshotIntoRoot(snapshotPath) {
5662
5870
  const manifest = await this.readSnapshotManifest(snapshotPath);
5663
- if (manifest === null) {
5664
- if (await this.io.exists(join(snapshotPath, "manifest.json"))) throw new Error(`snapshot ${snapshotPath} has an unreadable manifest.json; refusing to clear the active tree`);
5665
- } else {
5666
- for (const name of [...manifest.skills, ...manifest.sidecars]) if (!this.safeSnapshotEntryName(name)) throw new Error(`snapshot ${snapshotPath} declares an unsafe entry name ${JSON.stringify(name)}; refusing to restore`);
5667
- if (manifest.skills.length === 0) {
5668
- if ((await this.io.list(snapshotPath)).some((entry) => entry !== "manifest.json" && entry !== "extras" && entry !== ".archive")) throw new Error(`snapshot ${snapshotPath} declares no skills but contains entries; refusing to clear the active tree`);
5669
- }
5871
+ if (manifest === null) throw new Error(await this.io.exists(join(snapshotPath, "manifest.json")) ? `snapshot ${snapshotPath} has an unreadable manifest.json; refusing to clear the active tree` : `snapshot ${snapshotPath} has no readable manifest.json; refusing to restore`);
5872
+ for (const name of [...manifest.skills, ...manifest.sidecars]) if (!this.safeSnapshotEntryName(name)) throw new Error(`snapshot ${snapshotPath} declares an unsafe entry name ${JSON.stringify(name)}; refusing to restore`);
5873
+ if (manifest.skills.length === 0) {
5874
+ const snapshotEntries = await this.io.list(snapshotPath);
5875
+ const declared = new Set([
5876
+ "manifest.json",
5877
+ "extras",
5878
+ ".archive",
5879
+ ...manifest.skills,
5880
+ ...manifest.sidecars
5881
+ ]);
5882
+ const undeclared = snapshotEntries.filter((entry) => !declared.has(entry));
5883
+ if (undeclared.length > 0) throw new Error(`snapshot ${snapshotPath} declares no skills but contains undeclared entries (${undeclared.join(", ")}); refusing to clear the active tree`);
5670
5884
  }
5671
5885
  let rootEntries;
5672
5886
  try {
@@ -5686,17 +5900,16 @@ var SkillLibrary = class {
5686
5900
  if (await this.hasWriteLock(dir)) throw new Error(`snapshot restore refused: skill "${entry}" is being written (write lock present); retry once the write completes`);
5687
5901
  }
5688
5902
  }
5689
- const restoresSuppressed = manifest !== null && manifest.sidecars.includes(".curator-suppressed.json");
5903
+ const restoresSuppressed = manifest.sidecars.includes(".curator-suppressed.json");
5690
5904
  for (const entry of rootEntries) {
5691
5905
  if (entry === ".archive" || entry === ".backups" || entry === ".mutations.json") continue;
5692
5906
  if (entry === ".curator-suppressed.json" && restoresSuppressed) continue;
5693
5907
  if (entry.endsWith(".lock") || entry.endsWith(`.lock.next`)) continue;
5694
5908
  await this.io.remove(join(this.root, entry));
5695
5909
  }
5696
- if (manifest === null) throw new Error(`snapshot ${snapshotPath} has no readable manifest.json; refusing to restore`);
5697
- else {
5698
- for (const name of manifest.skills) await this.io.copy(join(snapshotPath, name), join(this.root, name));
5699
- for (const sidecar of manifest.sidecars) await this.io.copy(join(snapshotPath, sidecar), join(this.root, sidecar));
5910
+ for (const name of manifest.skills) await this.io.copy(join(snapshotPath, name), join(this.root, name));
5911
+ for (const sidecar of manifest.sidecars) await this.io.copy(join(snapshotPath, sidecar), join(this.root, sidecar));
5912
+ {
5700
5913
  const archiveRoot = join(this.root, ".archive");
5701
5914
  if (manifest.hasArchive === true) {
5702
5915
  await this.io.remove(archiveRoot);
@@ -5710,4 +5923,4 @@ var SkillLibrary = class {
5710
5923
  }
5711
5924
  };
5712
5925
  //#endregion
5713
- 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,
@@ -412,7 +412,9 @@ export declare class SkillLibrary {
412
412
  private sweepLockIfStranded;
413
413
  /** A1-4 (v18): a manifest-declared name is copied with `join(root, name)`;
414
414
  * only a single, non-traversing path component is safe. Dotfiles
415
- * (`.usage.json`) stay allowed — sidecars are legitimately dot-prefixed. */
415
+ * (`.usage.json`) stay allowed — sidecars are legitimately dot-prefixed.
416
+ * P2-4 (v19): a non-string entry (`skills: [123]`) is refused structurally
417
+ * instead of throwing `name.includes is not a function`. */
416
418
  private safeSnapshotEntryName;
417
419
  /** A1-7 (v18): a root-level lock whose holder is alive must refuse the
418
420
  * restore; a dead residue is swept so a crashed writer cannot block
@@ -472,6 +474,17 @@ export declare class SkillLibrary {
472
474
  private writeSupportFileCore;
473
475
  removeSupportFile(rawName: string, filePath: string, origin?: WriteOrigin): Promise<SkillActionResult>;
474
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;
475
488
  /**
476
489
  * Snapshot the recoverable skills state: active tree, usage/suppression
477
490
  * sidecars, `.archive/` and caller-supplied extras. `extras` are opaque
@@ -2,15 +2,25 @@
2
2
  * Threat scanning for agent-authored memory and skill content.
3
3
  *
4
4
  * Ported as a small, dependency-free subset of Hermes Agent's
5
- * `tools/threat_patterns.py` + hermes-claw `threats.ts`. The policy is the
6
- * load-bearing part: ANY in-scope hit blocks. Severity and category are
7
- * metadata for diagnostics only.
5
+ * `tools/threat_patterns.py` + hermes-claw `threats.ts`.
6
+ *
7
+ * Policy (P1-1, v19): a finding either BLOCKS the write or only REPORTS.
8
+ * Blocking is reserved for shapes with no legitimate use in stored knowledge
9
+ * (prompt-injection phrasing, credential exfiltration, the invisible-character
10
+ * smuggling core). Typography and presentation characters that are legitimate
11
+ * in ordinary prose — variation selectors (❤️), zero-width non-joiner, soft
12
+ * hyphen from PDF paste, Arabic letter mark, Mongolian vowel separator — are
13
+ * REPORT findings: they stay visible to operators and tests but never reject a
14
+ * write. Blocking them turned every emoji into a security event.
8
15
  */
9
16
  export type ThreatScope = 'all' | 'context' | 'strict';
10
17
  export interface ThreatFinding {
11
18
  label: string;
12
19
  category: string;
13
20
  scope: ThreatScope;
21
+ /** P1-1 (v19): `block` (default) refuses the write; `report` is an audit
22
+ * trail entry only. Absent means `block`. */
23
+ severity?: 'block' | 'report';
14
24
  }
15
25
  /**
16
26
  * Optional scan controls. Default behavior (`options` omitted) is unchanged:
@@ -39,7 +49,9 @@ export declare const PATTERN_OVERLAP = 4096;
39
49
  * characters (skill files may run to 100,000) is no longer a blind zone.
40
50
  */
41
51
  export declare function scanThreats(text: string, scope?: ThreatScope, maxScanChars?: number, options?: ScanOptions): ThreatFinding[];
42
- /** Blocking policy: any hit blocks. `severity` is deliberately not a gate. */
52
+ /** Blocking policy (P1-1, v19): a finding blocks unless it is explicitly
53
+ * `report`-only. Pattern findings carry no severity and therefore block as
54
+ * before. */
43
55
  export declare function evaluateThreat(text: string, scope?: ThreatScope, maxScanChars?: number, options?: ScanOptions): {
44
56
  blocked: boolean;
45
57
  findings: ThreatFinding[];
@@ -49,14 +61,14 @@ export declare function scanMemoryThreats(text: string, maxScanChars?: number, o
49
61
  /** User-facing block message for skill content writes. */
50
62
  export declare function scanContentThreats(text: string, maxScanChars?: number, options?: ScanOptions): string | null;
51
63
  /**
52
- * V10-03 (P2-18): suffix the SkillLibrary/MemoryStore write gates append to a
53
- * block message the hit label is already embedded by scanContentThreats /
54
- * scanMemoryThreats, this names the deployable self-heal path so the model
55
- * (or operator) can allowlist a known-benign label. P2-4 (v14): the
56
- * evolution-threat guard channel now carries `threatExemptLabels` too, so its
57
- * block message (which embeds the same exemption sentence) is accurate there
58
- * as well; this suffix stays store-side because the guard returns the scan
59
- * 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.
60
72
  */
61
- 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).";
62
74
  //# sourceMappingURL=threats.d.ts.map
@@ -52,7 +52,13 @@ export declare function loadUsage(root: string, io?: EvolutionIoLike): Promise<U
52
52
  * cannot interleave its RMW and lose a counter update. Callers keep their own
53
53
  * single-process serialize chain as the second layer.
54
54
  */
55
- export declare function mutateUsage(root: string, io: EvolutionIoLike, task: (map: UsageMap) => void | Promise<void>): Promise<void>;
55
+ export interface UsageMutateOptions {
56
+ /** P2-9 (v19): called when malformed entries had to be quarantined before the
57
+ * task could run. The guard preserves bytes AND keeps the facility working;
58
+ * this callback is how that stays observable. */
59
+ onQuarantine?: ((message: string) => void) | undefined;
60
+ }
61
+ export declare function mutateUsage(root: string, io: EvolutionIoLike, task: (map: UsageMap) => void | Promise<void>, options?: UsageMutateOptions): Promise<void>;
56
62
  /**
57
63
  * Curator-owned usage fields (rc.67 K-2): the curator writes ONLY this set —
58
64
  * lifecycle state, archive stamp, the six-factor quality pair, and the
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.64",
4
+ "version": "0.3.66",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },