@lmzhen/dsh-evolution-core 0.3.61 → 0.3.63

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
@@ -117,7 +117,7 @@ async function renameWithRetry(tmp, target, fn = rename) {
117
117
  async function writeDurableTmp(target, content, openImpl = open) {
118
118
  const tmp = `${target}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
119
119
  try {
120
- const handle = await openImpl(tmp, "wx");
120
+ const handle = await openImpl(tmp, "wx", process.platform === "win32" ? void 0 : await stat(target).then((value) => value.mode & 511).catch(() => void 0));
121
121
  try {
122
122
  await handle.writeFile(content, "utf8");
123
123
  await handle.sync();
@@ -312,8 +312,16 @@ function nodeEvolutionIo(lockAttempts = 40) {
312
312
  }).catch(async () => {
313
313
  const body = await readFile(lock, "utf8").catch(() => "");
314
314
  if (pendingSelfCleanup.size >= 64) {
315
- const oldest = pendingSelfCleanup.keys().next().value;
316
- if (oldest !== void 0) pendingSelfCleanup.delete(oldest);
315
+ let droppable;
316
+ for (const candidate of pendingSelfCleanup.keys()) {
317
+ if (candidate === lock) continue;
318
+ if (await readFile(candidate, "utf8").then(() => false, () => true)) {
319
+ droppable = candidate;
320
+ break;
321
+ }
322
+ }
323
+ const victim = droppable ?? pendingSelfCleanup.keys().next().value;
324
+ if (victim !== void 0) pendingSelfCleanup.delete(victim);
317
325
  }
318
326
  pendingSelfCleanup.set(lock, body);
319
327
  });
@@ -452,8 +460,9 @@ function nodeEvolutionIo(lockAttempts = 40) {
452
460
  async isSymlink(path) {
453
461
  try {
454
462
  return (await lstat(path)).isSymbolicLink();
455
- } catch {
456
- return null;
463
+ } catch (error) {
464
+ if (isMissing(error)) return null;
465
+ throw error;
457
466
  }
458
467
  },
459
468
  async mtime(path) {
@@ -525,7 +534,9 @@ function normalizeUsageRecord(record) {
525
534
  pinned: bool(raw.pinned, base.pinned),
526
535
  archived_at: nullableTimestamp(raw.archived_at) ? raw.archived_at : base.archived_at,
527
536
  quality_score: typeof raw.quality_score === "number" && Number.isFinite(raw.quality_score) ? raw.quality_score : void 0,
528
- quality_warn: typeof raw.quality_warn === "boolean" ? raw.quality_warn : void 0
537
+ quality_warn: typeof raw.quality_warn === "boolean" ? raw.quality_warn : void 0,
538
+ feedback_score: typeof raw.feedback_score === "number" && Number.isFinite(raw.feedback_score) ? raw.feedback_score : void 0,
539
+ feedback_warn: typeof raw.feedback_warn === "boolean" ? raw.feedback_warn : void 0
529
540
  };
530
541
  }
531
542
  /** Parse a raw usage sidecar; malformed content reads as empty (best-effort telemetry). */
@@ -551,11 +562,14 @@ async function loadUsage(root, io = nodeEvolutionIo()) {
551
562
  */
552
563
  async function mutateUsage(root, io, task) {
553
564
  await transactIo(io, usageFile(root), async (current) => {
565
+ let shapePreserved = false;
554
566
  if (current !== null) try {
555
- JSON.parse(current);
567
+ const probe = JSON.parse(current);
568
+ if (probe === null || Array.isArray(probe) || typeof probe !== "object") shapePreserved = true;
556
569
  } catch {
557
570
  return current;
558
571
  }
572
+ if (shapePreserved) return current;
559
573
  const map = parseUsage(current);
560
574
  await task(map);
561
575
  return JSON.stringify(Object.fromEntries(map.entries()), null, 2);
@@ -584,6 +598,9 @@ function applyCuratorLifecycleFields(disk, curated) {
584
598
  * Copy the recomputed meta pair (quality_score/quality_warn + the
585
599
  * marker-mirrored pin flag) — refreshed tree-wide each run by design, so a
586
600
  * concurrent curator run's lifecycle changes are never reverted by them.
601
+ * P1-1 (v15): the feedback pair (`feedback_score`/`feedback_warn`) is
602
+ * deliberately NOT copied — it is feedback-owned (see the field-ownership
603
+ * contract on {@link UsageRecord}) and must survive curator runs untouched.
587
604
  */
588
605
  function applyCuratorMetaFields(disk, curated) {
589
606
  disk.quality_score = curated.quality_score;
@@ -614,7 +631,9 @@ function foldCuratorFields(disk, curated, stateOwned) {
614
631
  /** Whole-file usage write (V6-37, 0.3.37): this is the ONE path that bypasses
615
632
  * the malformed-defense and the transact lock — prefer `mutateUsage` for any
616
633
  * read-modify-write so a concurrent writer cannot lose its update and a
617
- * malformed sidecar stays recoverable. Kept for fixture/test seeding. */
634
+ * malformed sidecar stays recoverable. Kept for fixture/test seeding.
635
+ * @internal P3-16 (v14): no production caller (verified by grep); exported for
636
+ * the family's tests only. Do not use it to write the sidecar in new code. */
618
637
  async function saveUsage(root, map, io = nodeEvolutionIo()) {
619
638
  const obj = Object.fromEntries(map.entries());
620
639
  await io.writeText(usageFile(root), JSON.stringify(obj, null, 2));
@@ -778,6 +797,13 @@ const DEFAULT_USER_CHAR_LIMIT = 1375;
778
797
  /** Consolidation-failure backoff cap, shared by MemoryStore and memory-files' Config default. */
779
798
  const DEFAULT_CONSOLIDATION_FAILURES = 3;
780
799
  const DEFAULT_SKILL_CONTENT_CHARS = 1e5;
800
+ /** P3-19 (v14): defaults that were written twice (schema `.default()` AND the
801
+ * clamp fallback literal) now have one home per value. */
802
+ const DEFAULT_REVIEW_TIMEOUT_MS = 12e4;
803
+ const DEFAULT_REVIEW_CONTEXT_MESSAGES = 60;
804
+ const DEFAULT_REVIEW_MESSAGE_CHARS = 2e3;
805
+ const DEFAULT_CURATOR_BOOT_GRACE_SECONDS = 10;
806
+ const DEFAULT_CURATOR_REVIEW_MAX_TOKENS = 2048;
781
807
  /** 0.3.17 (S3.10, T-1): control-plane fields a model-facing write call may
782
808
  * never carry — single source for plan-validator, evolution-policy and the
783
809
  * threat scanner (they used to each hardcode the list). */
@@ -1000,8 +1026,9 @@ function computeScopeView(usage, config, protectedNames, gates) {
1000
1026
  if (record.pinned || bundled || suppressed || protectedNames?.has(name) === true || isBuiltin) protectedSet.add(name);
1001
1027
  if (lifecycleCandidate(name, record, config, bundled, gateSet, protectedNames)) {
1002
1028
  managed.push(name);
1003
- if (record.state === "stale" || record.quality_warn === true) watched.push(name);
1004
- if (record.quality_warn === true) qualityWarned.push(name);
1029
+ const warned = record.quality_warn === true || record.feedback_warn === true;
1030
+ if (record.state === "stale" || warned) watched.push(name);
1031
+ if (warned) qualityWarned.push(name);
1005
1032
  }
1006
1033
  }
1007
1034
  return {
@@ -1028,7 +1055,7 @@ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Da
1028
1055
  const age = daysSince(null, record.created_at, now.getTime());
1029
1056
  if (record.use_count === 0 && age < config.staleAfterDays) continue;
1030
1057
  const idle = daysSince(latestActivityAt(record), record.created_at, now.getTime());
1031
- const qualityWarn = record.quality_warn === true;
1058
+ const qualityWarn = record.quality_warn === true || record.feedback_warn === true;
1032
1059
  const staleAfterDays = qualityWarn && config.qualityWarnStaleAfterDays !== void 0 ? config.qualityWarnStaleAfterDays : config.staleAfterDays;
1033
1060
  if (record.state === "active") {
1034
1061
  if (idle >= config.archiveAfterDays) {
@@ -1043,7 +1070,8 @@ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Da
1043
1070
  result.archive.push(name);
1044
1071
  } else if (idle >= staleAfterDays) {
1045
1072
  record.state = "stale";
1046
- const reason = qualityWarn ? `idle ${Math.round(idle)}d >= quality-warn stale ${staleAfterDays}d` : `idle ${Math.round(idle)}d >= ${staleAfterDays}d`;
1073
+ const warnSource = record.feedback_warn === true ? "feedback-warn stale" : "quality-warn stale";
1074
+ const reason = qualityWarn ? `idle ${Math.round(idle)}d >= ${warnSource} ${staleAfterDays}d` : `idle ${Math.round(idle)}d >= ${staleAfterDays}d`;
1047
1075
  result.transitions.push({
1048
1076
  name,
1049
1077
  from: "active",
@@ -1339,10 +1367,14 @@ async function readEvolutionTimeline(io, path) {
1339
1367
  * reads inside patch YAML `!!js` expressions (session-query path/openAt) stay
1340
1368
  * in the profile config evaluation — they are NOT migrated (they are resolved
1341
1369
  * at cordis config time, not plugin time) but are documented in the README
1342
- * env table.
1370
+ * env table. `EVOLUTION_SCOPE` is read by the source installers only
1371
+ * (`packages/scripts/install-layered.mjs`, `packages/test-support/row-contract.ts`),
1372
+ * never by plugin runtime code.
1373
+ *
1374
+ * v14 P3-1: the former `EVOLUTION_ENV_KEYS` export was deleted — nothing read
1375
+ * it, so the "generated env reference" it claimed to source was never
1376
+ * generated (the README table is maintained by hand and now lists every key).
1343
1377
  */
1344
- /** Plugin-side DSH_EVOLUTION_* keys (config-layer keys are documented separately). */
1345
- const EVOLUTION_ENV_KEYS = ["DSH_EVOLUTION_ALLOW_ROW_COLLISIONS"];
1346
1378
  const ALLOW_ROW_COLLISIONS = "1";
1347
1379
  /** N-5 escape: `DSH_EVOLUTION_ALLOW_ROW_COLLISIONS=1` downgrades a delta-row
1348
1380
  * collision from fail-loud to warn+keep-both. Any other value (including an
@@ -1977,7 +2009,7 @@ const PATTERNS = [
1977
2009
  label: "read_secrets",
1978
2010
  category: "exfiltration",
1979
2011
  scope: "all",
1980
- regex: /\bcat\s+[^\n]{0,512}(?:\.env|credentials|\.netrc|\.pgpass|\.npmrc|\.pypirc)/i
2012
+ regex: /\bcat\s+[^\n]{0,512}(?:\.env(?!\w)|(?:\bcredentials\b)|\.netrc|\.pgpass|\.npmrc|\.pypirc)/i
1981
2013
  },
1982
2014
  {
1983
2015
  label: "ssh_backdoor",
@@ -2027,6 +2059,12 @@ const PATTERNS = [
2027
2059
  scope: "strict",
2028
2060
  regex: /(?:api[_-]?key|token|secret|password)\s*[=:]\s*["'][a-z0-9+/=_-]{20,}["']/i
2029
2061
  },
2062
+ {
2063
+ label: "jwt_like_secret",
2064
+ category: "hardcoded_secrets",
2065
+ scope: "strict",
2066
+ regex: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/
2067
+ },
2030
2068
  {
2031
2069
  label: "private_key_block",
2032
2070
  category: "hardcoded_secrets",
@@ -2060,15 +2098,16 @@ const PATTERN_OVERLAP = 4096;
2060
2098
  function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
2061
2099
  const windowSize = clampedNumber(maxScanChars, 65536, { min: 4097 });
2062
2100
  const findings = [];
2063
- if (ZERO_WIDTH_CHARS.test(text)) findings.push({
2101
+ const excluded = new Set(options.excludeLabels ?? []);
2102
+ if (ZERO_WIDTH_CHARS.test(text) && !excluded.has("unicode_zero_width")) findings.push({
2064
2103
  label: "unicode_zero_width",
2065
2104
  category: "unicode_obfuscation",
2066
- scope
2105
+ scope: "all"
2067
2106
  });
2068
- if (BIDI_CHARS.test(text)) findings.push({
2107
+ if (BIDI_CHARS.test(text) && !excluded.has("unicode_bidi_override")) findings.push({
2069
2108
  label: "unicode_bidi_override",
2070
2109
  category: "unicode_obfuscation",
2071
- scope
2110
+ scope: "all"
2072
2111
  });
2073
2112
  const normalized = text.normalize("NFKC");
2074
2113
  const windows = [];
@@ -2077,7 +2116,6 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_
2077
2116
  const step = Math.max(Math.floor(windowSize / 2), 1);
2078
2117
  for (let start = 0; start < normalized.length; start += step) windows.push(normalized.slice(start, start + windowSize));
2079
2118
  }
2080
- const excluded = new Set(options.excludeLabels ?? []);
2081
2119
  const seen = /* @__PURE__ */ new Set();
2082
2120
  for (const window of windows) for (const pattern of PATTERNS) {
2083
2121
  if (SCOPE_ORDER[pattern.scope] > SCOPE_ORDER[scope]) continue;
@@ -2121,9 +2159,11 @@ function scanContentThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTION
2121
2159
  * V10-03 (P2-18): suffix the SkillLibrary/MemoryStore write gates append to a
2122
2160
  * block message — the hit label is already embedded by scanContentThreats /
2123
2161
  * scanMemoryThreats, this names the deployable self-heal path so the model
2124
- * (or operator) can allowlist a known-benign label. The evolution-threat tool
2125
- * channel deliberately does NOT append it: that channel has no
2126
- * threatExemptLabels option to advertise.
2162
+ * (or operator) can allowlist a known-benign label. P2-4 (v14): the
2163
+ * evolution-threat guard channel now carries `threatExemptLabels` too, so its
2164
+ * block message (which embeds the same exemption sentence) is accurate there
2165
+ * as well; this suffix stays store-side because the guard returns the scan
2166
+ * message verbatim.
2127
2167
  */
2128
2168
  const THREAT_EXEMPT_HINT = " If this is a legitimate false positive, the deployment can allow its label via the threatExemptLabels store option.";
2129
2169
  //#endregion
@@ -2300,11 +2340,14 @@ var MemoryStore = class {
2300
2340
  async backupFile(target) {
2301
2341
  const path = fileFor(this.root, target);
2302
2342
  const backup = `${path}.bak`;
2343
+ const staging = `${backup}.${process.pid}.${Date.now().toString(36)}.tmp`;
2303
2344
  try {
2304
- await this.io.remove(backup).catch(() => {});
2305
- await this.io.copy(path, backup);
2345
+ await this.io.remove(staging).catch(() => {});
2346
+ await this.io.copy(path, staging);
2347
+ await this.io.rename(staging, backup);
2306
2348
  return backup;
2307
2349
  } catch {
2350
+ await this.io.remove(staging).catch(() => {});
2308
2351
  return null;
2309
2352
  }
2310
2353
  }
@@ -2364,7 +2407,13 @@ var MemoryStore = class {
2364
2407
  async addCore(target, facts, raw) {
2365
2408
  const content = facts.trim();
2366
2409
  if (!content) return {
2367
- result: this.failure(target, "Content cannot be empty.", []),
2410
+ result: {
2411
+ ok: false,
2412
+ message: "Content cannot be empty.",
2413
+ entries: [],
2414
+ chars: 0,
2415
+ limit: this.limitFor(target)
2416
+ },
2368
2417
  write: null
2369
2418
  };
2370
2419
  if (this.driftFromRaw(target, raw)) {
@@ -2650,7 +2699,7 @@ var MemoryStore = class {
2650
2699
  const note = safe.length === entries.length ? "" : ` (${entries.length - safe.length} threat-matched entries filtered)`;
2651
2700
  const usage = limit > 0 ? ` [${Math.min(100, Math.floor(body.length * 100 / limit))}% — ${body.length}/${limit} chars]` : "";
2652
2701
  parts.push(`## ${label} (${safe.length} entries)${usage}${note}\n${body}`);
2653
- }
2702
+ } else if (entries.length > 0) parts.push(`## ${label} — ${entries.length} entries withheld by the security scan; none injected`);
2654
2703
  }
2655
2704
  return parts.join("\n\n");
2656
2705
  }
@@ -2704,25 +2753,31 @@ function contentHash(content) {
2704
2753
  function parseMutationRecords(raw) {
2705
2754
  if (raw === null) return [];
2706
2755
  try {
2707
- const parsed = JSON.parse(raw);
2708
- return (Array.isArray(parsed) ? parsed : typeof parsed === "object" && parsed !== null && Array.isArray(parsed.records) ? parsed.records : []).filter((entry) => typeof entry === "object" && entry !== null && typeof entry.skillName === "string" && typeof entry.action === "string" && typeof entry.at === "string");
2756
+ return recordsFromParsed(JSON.parse(raw));
2709
2757
  } catch {
2710
2758
  return [];
2711
2759
  }
2712
2760
  }
2761
+ /** Field-level shape guard shared by the parse and the recordMutation write
2762
+ * path (P3/v15: the guard's JSON.parse and this parse used to run twice over
2763
+ * the same bytes inside one transact). */
2764
+ function recordsFromParsed(parsed) {
2765
+ return (Array.isArray(parsed) ? parsed : typeof parsed === "object" && parsed !== null && Array.isArray(parsed.records) ? parsed.records : []).filter((entry) => typeof entry === "object" && entry !== null && typeof entry.skillName === "string" && typeof entry.action === "string" && typeof entry.at === "string");
2766
+ }
2713
2767
  async function loadMutations(root, io = nodeEvolutionIo()) {
2714
2768
  return parseMutationRecords(await io.readText(mutationsFile(root)));
2715
2769
  }
2716
2770
  /** Append one record, trim to `cap`, and write atomically (versioned shape). */
2717
2771
  async function recordMutation(root, io, record, cap = 500) {
2718
2772
  await transactIo(io, mutationsFile(root), (current) => {
2773
+ let parsed = [];
2719
2774
  if (current !== null) try {
2720
- JSON.parse(current);
2775
+ parsed = JSON.parse(current);
2721
2776
  } catch {
2722
2777
  console.warn(`mutation audit record dropped: ${mutationsFile(root)} is malformed and was not overwritten`);
2723
2778
  return current;
2724
2779
  }
2725
- const existing = parseMutationRecords(current);
2780
+ const existing = recordsFromParsed(parsed);
2726
2781
  existing.push(record);
2727
2782
  const trimmed = existing.length > cap ? existing.slice(existing.length - cap) : existing;
2728
2783
  return JSON.stringify({
@@ -3660,7 +3715,9 @@ const SUPPORT_FILE_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
3660
3715
  /** C-18: win32 reserves these stems with ANY extension (`nul.md` hits the
3661
3716
  * NUL device), and they are fully inside the charset above — so the reserved
3662
3717
  * set is checked on the first-dot prefix as well; the charset close alone
3663
- * cannot refuse them. */
3718
+ * cannot refuse them. Exported single source: `badName` (skill directories,
3719
+ * P2-11/v15) and `validateSupportPath` (support-file stems, C-18) both
3720
+ * consume this one set — a third copy would drift. */
3664
3721
  const WIN32_RESERVED_DEVICE_NAMES = new Set([
3665
3722
  "con",
3666
3723
  "prn",
@@ -3693,6 +3750,7 @@ function validateSupportPath(filePath) {
3693
3750
  if (parts.length < 2) return "Provide a file name, not just a directory.";
3694
3751
  for (const part of parts.slice(1)) {
3695
3752
  if (!SUPPORT_FILE_NAME_RE.test(part)) return `Unsupported file name "${part}" — use lowercase letters, digits, dots, hyphens, and underscores (leading letter or digit).`;
3753
+ if (part.toLowerCase().endsWith(".lock")) return `Unsupported file name "${part}" — the .lock suffix is reserved for the writer-lock protocol.`;
3696
3754
  const stem = part.split(".")[0]?.toLowerCase() ?? "";
3697
3755
  if (WIN32_RESERVED_DEVICE_NAMES.has(stem)) return `Unsupported file name "${part}" — a Windows reserved device name.`;
3698
3756
  }
@@ -3982,14 +4040,30 @@ var SkillLibrary = class {
3982
4040
  dirOf(name) {
3983
4041
  return skillDir(this.root, name.trim());
3984
4042
  }
3985
- /** Name-format guard shared by every path-building mutator/reader. */
3986
- badName(name) {
4043
+ /**
4044
+ * Name-format guard for every path-building entry point (P1-1/v14 closed the
4045
+ * one gap: `patch`). Write paths, protection probes and support-file
4046
+ * enumeration all call it before `dirOf`, so no directory path is ever built
4047
+ * from a name that could escape the skills root. `list()` and `snapshotAll()`
4048
+ * are the deliberate exceptions — their names come from `listNames()`, i.e.
4049
+ * from the tree itself, never from caller input.
4050
+ */
4051
+ badName(name, opts = {}) {
3987
4052
  const normalized = name.trim();
3988
4053
  if (!SKILL_NAME_RE.test(normalized) || normalized.length > this.limits.maxNameLength) return `Invalid skill name "${normalized}". Use lowercase letters, digits, and hyphens (<= ${this.limits.maxNameLength}).`;
4054
+ if (!opts.allowReserved && WIN32_RESERVED_DEVICE_NAMES.has(normalized)) return `"${normalized}" is a Windows reserved device name and cannot be used as a skill name.`;
3989
4055
  return null;
3990
4056
  }
4057
+ /**
4058
+ * Refusal reason for a write to `rawName`, or null when the write may
4059
+ * proceed: a protection marker on the directory, or an invalid name
4060
+ * (P1-1/v14 — the name guard lives HERE as well as at every entry point, so
4061
+ * no caller can build a path from an unvalidated name).
4062
+ */
3991
4063
  async writeProtection(rawName, origin = "foreground") {
3992
4064
  const name = rawName.trim();
4065
+ const badName = this.badName(name);
4066
+ if (badName) return badName;
3993
4067
  const dir = this.dirOf(name);
3994
4068
  for (const marker of ["bundled", "hub-installed"]) if (await this.io.exists(markerPath(dir, marker))) return marker;
3995
4069
  if (origin === "background_review" && await this.io.exists(markerPath(dir, "pinned"))) return "pinned";
@@ -3997,6 +4071,8 @@ var SkillLibrary = class {
3997
4071
  }
3998
4072
  async deleteProtection(rawName, options = {}) {
3999
4073
  const name = rawName.trim();
4074
+ const badName = this.badName(name, { allowReserved: true });
4075
+ if (badName) return badName;
4000
4076
  const dir = this.dirOf(name);
4001
4077
  const markers = options.allowBundled ? ["hub-installed", "pinned"] : [
4002
4078
  "bundled",
@@ -4008,6 +4084,7 @@ var SkillLibrary = class {
4008
4084
  }
4009
4085
  async isManaged(rawName) {
4010
4086
  const name = rawName.trim();
4087
+ if (this.badName(name) !== null) return false;
4011
4088
  const dir = this.dirOf(name);
4012
4089
  return await this.io.exists(markerPath(dir, "hermes-managed"));
4013
4090
  }
@@ -4051,7 +4128,9 @@ var SkillLibrary = class {
4051
4128
  * Used by the maintenance enrichment (011 §7) and probe reads.
4052
4129
  */
4053
4130
  async listSupportFiles(rawName) {
4054
- const dir = this.dirOf(rawName);
4131
+ const name = rawName.trim();
4132
+ if (this.badName(name) !== null) return [];
4133
+ const dir = this.dirOf(name);
4055
4134
  let entries;
4056
4135
  try {
4057
4136
  entries = await this.io.list(dir);
@@ -4194,7 +4273,21 @@ var SkillLibrary = class {
4194
4273
  message: `Skill "${normalized}" is protected (${protection}).`
4195
4274
  };
4196
4275
  const onDisk = finalContent.trimEnd() + "\n";
4197
- await this.io.writeText(join(dir, "SKILL.md"), onDisk);
4276
+ const createPath = join(dir, "SKILL.md");
4277
+ let existsAtCommit = false;
4278
+ if (this.transact) await this.transact(this.io, createPath, (current) => {
4279
+ if (current !== null) {
4280
+ existsAtCommit = true;
4281
+ return current;
4282
+ }
4283
+ return onDisk;
4284
+ });
4285
+ else if (await this.io.exists(createPath)) existsAtCommit = true;
4286
+ else await this.io.writeText(createPath, onDisk);
4287
+ if (existsAtCommit) return {
4288
+ ok: false,
4289
+ message: `Skill "${normalized}" already exists.`
4290
+ };
4198
4291
  if (origin !== "foreground") await this.io.writeText(markerPath(dir, "hermes-managed"), "");
4199
4292
  await this.audit(normalized, "create", null, onDisk, "created");
4200
4293
  this.notifyMutation({
@@ -4214,13 +4307,13 @@ var SkillLibrary = class {
4214
4307
  return await this.serial(() => this.updateCore(name, content, origin));
4215
4308
  }
4216
4309
  async updateCore(name, content, origin) {
4217
- const dir = this.dirOf(name);
4218
- const path = join(dir, "SKILL.md");
4219
4310
  const badName = this.badName(name);
4220
4311
  if (badName) return {
4221
4312
  ok: false,
4222
4313
  message: badName
4223
4314
  };
4315
+ const dir = this.dirOf(name);
4316
+ const path = join(dir, "SKILL.md");
4224
4317
  const protection = await this.writeProtection(name, origin);
4225
4318
  if (protection) return {
4226
4319
  ok: false,
@@ -4295,6 +4388,11 @@ var SkillLibrary = class {
4295
4388
  return await this.serial(() => this.patchCore(name, oldString, newString, filePath, replaceAll, origin));
4296
4389
  }
4297
4390
  async patchCore(name, oldString, newString, filePath, replaceAll, origin) {
4391
+ const badName = this.badName(name);
4392
+ if (badName) return {
4393
+ ok: false,
4394
+ message: badName
4395
+ };
4298
4396
  const dir = this.dirOf(name);
4299
4397
  const skillMd = join(dir, "SKILL.md");
4300
4398
  if (!await this.io.exists(skillMd)) return {
@@ -4333,6 +4431,13 @@ var SkillLibrary = class {
4333
4431
  },
4334
4432
  write: null
4335
4433
  };
4434
+ if (oldString === "" || trimPatternBoundaries(oldString) === "") return {
4435
+ result: {
4436
+ ok: false,
4437
+ message: `old_string is empty (or whitespace-only) — a patch of "${name}/${patchLabel}" needs an anchor; use update for a full rewrite.`
4438
+ },
4439
+ write: null
4440
+ };
4336
4441
  const patched = fuzzyPatch(md, oldString, newString, replaceAll);
4337
4442
  if (patched === null) return {
4338
4443
  result: {
@@ -4428,9 +4533,113 @@ var SkillLibrary = class {
4428
4533
  };
4429
4534
  });
4430
4535
  }
4536
+ /**
4537
+ * P2-9 (v15): the destructive directory move shared by archive and
4538
+ * restoreFromArchive — rename first, copy+remove fallback when the backend
4539
+ * cannot rename across media (V5-35), with the E-14 rollback when the
4540
+ * fallback's source removal fails. Returns a failure MESSAGE on a failed
4541
+ * move (caller wraps into a structured result) or undefined on success.
4542
+ */
4543
+ async moveDir(dir, dest) {
4544
+ try {
4545
+ await this.io.rename(dir, dest);
4546
+ return;
4547
+ } catch {
4548
+ if (await this.io.exists(dest)) return "the destination appeared mid-move (concurrent create or restore); refusing to merge — inspect both trees";
4549
+ try {
4550
+ await this.io.copy(dir, dest);
4551
+ } catch (copyError) {
4552
+ return `the move fell back to copy but failed (${copyError instanceof Error ? copyError.message : String(copyError)}); the tree stays where it is`;
4553
+ }
4554
+ try {
4555
+ await this.io.remove(dir);
4556
+ return;
4557
+ } catch (error) {
4558
+ const reason = error instanceof Error ? error.message : String(error);
4559
+ try {
4560
+ await this.io.remove(dest);
4561
+ return `the copy succeeded but the source could not be removed (${reason}); the copied tree was rolled back`;
4562
+ } catch {
4563
+ return `the copy succeeded but the source could not be removed (${reason}) and the copied tree could not be rolled back — the tree now exists in BOTH locations; clean up manually`;
4564
+ }
4565
+ }
4566
+ }
4567
+ }
4568
+ /**
4569
+ * P2 (v16): the write-lock probe for the DESTRUCTIVE MOVERS (archive /
4570
+ * restoreFromArchive). A byte-writer mid-flight is the ghost-generator —
4571
+ * after the move its transact commit re-creates `<dir>/…` (mkdir
4572
+ * recursive) and the tree ends half-archived. The signal is the writer's
4573
+ * own lock file, and its PLACEMENT (inside the moved directory) is why the
4574
+ * mover must PROBE-and-REFUSE instead of acquiring it: an acquired lock
4575
+ * would be renamed into `.archive` with the tree, stranding a phantom live
4576
+ * lock (the v16 audit proved the probe→rename TOCTOU does exactly that,
4577
+ * and restore would later move the residue back into the live root).
4578
+ * Coverage: `SKILL.md.lock` (update/patch of the body) plus one level of
4579
+ * each support dir (write_file's lock sits next to its file). Residual:
4580
+ * NESTED support-subdir locks and the probe→rename TOCTOU itself remain
4581
+ * fail-safe (renameWithRetry rides the write out; the writer's locked
4582
+ * re-read refuses on the moved-away file), and a residue `.lock` from a
4583
+ * CRASHED writer also refuses — correct: inspect, don't archive.
4584
+ */
4585
+ async hasWriteLock(dir) {
4586
+ const markerLocks = [
4587
+ join(dir, "SKILL.md.lock"),
4588
+ join(dir, ".pinned.lock"),
4589
+ join(dir, ".hermes-managed.lock")
4590
+ ];
4591
+ for (const lock of markerLocks) if (await this.isWriterLock(lock)) return true;
4592
+ for (const supportDir of SUPPORT_DIRS) {
4593
+ let entries;
4594
+ try {
4595
+ entries = await this.io.list(join(dir, supportDir));
4596
+ } catch {
4597
+ return true;
4598
+ }
4599
+ for (const entry of entries) {
4600
+ if (!entry.endsWith(".lock")) continue;
4601
+ if (await this.isWriterLock(join(dir, supportDir, entry))) return true;
4602
+ }
4603
+ }
4604
+ return false;
4605
+ }
4606
+ /** P2 (v17): a file only counts as a writer lock when its body has the
4607
+ * `pid:token` shape the io layer writes. User support files legitimately
4608
+ * named `*.lock` (allowed by SUPPORT_FILE_NAME_RE) must not trip the probe
4609
+ * or be swept as residue — the v16 first cut matched on suffix alone,
4610
+ * which permanently refused archiving and deleted user content on restore. */
4611
+ async isWriterLock(lockPath) {
4612
+ const body = await this.io.readText(lockPath).catch(() => null);
4613
+ if (body === null) return false;
4614
+ return /^\d+:[0-9a-f]*$/.test(body.trim());
4615
+ }
4616
+ /** P2 (v16): best-effort removal of lock residue inside a RESTORED tree —
4617
+ * a `.lock` that a pre-restore crash or TOCTOU stranded in `.archive`
4618
+ * cannot have a live writer (restore refuses when the live root is
4619
+ * locked), and if left in place its body (a live pid on a single-host
4620
+ * deployment) structurally closes the writer's self-heal path. */
4621
+ async deleteStrandedLocks(dir) {
4622
+ await this.sweepLockIfStranded(join(dir, "SKILL.md.lock"));
4623
+ for (const supportDir of SUPPORT_DIRS) {
4624
+ let entries = [];
4625
+ try {
4626
+ entries = await this.io.list(join(dir, supportDir));
4627
+ } catch {
4628
+ continue;
4629
+ }
4630
+ for (const entry of entries) if (entry.endsWith(".lock")) await this.sweepLockIfStranded(join(dir, supportDir, entry));
4631
+ }
4632
+ }
4633
+ /** Remove `lockPath` only when its body has the writer-lock `pid:token`
4634
+ * shape; anything else (a user support file) is left untouched. */
4635
+ async sweepLockIfStranded(lockPath) {
4636
+ const body = await this.io.readText(lockPath).catch(() => null);
4637
+ if (body === null || !/^\d+:[0-9a-f]*$/.test(body.trim())) return;
4638
+ await this.io.remove(lockPath).catch(() => {});
4639
+ }
4431
4640
  async archive(rawName, options = {}) {
4432
4641
  const name = rawName.trim();
4433
- const badName = this.badName(name);
4642
+ const badName = this.badName(name, { allowReserved: true });
4434
4643
  if (badName) return {
4435
4644
  ok: false,
4436
4645
  message: badName
@@ -4451,6 +4660,11 @@ var SkillLibrary = class {
4451
4660
  ok: false,
4452
4661
  message: "absorbed_into cannot be the skill being archived (cannot absorb into itself)."
4453
4662
  };
4663
+ const intoBad = this.badName(options.absorbedInto.trim());
4664
+ if (intoBad) return {
4665
+ ok: false,
4666
+ message: `absorbed_into: ${intoBad}`
4667
+ };
4454
4668
  if (!await this.io.readText(join(this.dirOf(options.absorbedInto), "SKILL.md"))) return {
4455
4669
  ok: false,
4456
4670
  message: `absorbed_into="${options.absorbedInto}" does not exist.`
@@ -4469,37 +4683,19 @@ var SkillLibrary = class {
4469
4683
  message: `Skill "${name}" is a symlink; refusing to archive it.`
4470
4684
  };
4471
4685
  }
4472
- try {
4473
- await this.io.rename(dir, dest);
4474
- } catch {
4475
- try {
4476
- await this.io.copy(dir, dest);
4477
- } catch (copyError) {
4478
- return {
4479
- ok: false,
4480
- message: `Skill "${name}" archive fell back to copy but failed (${copyError instanceof Error ? copyError.message : String(copyError)}); the skill stays where it is.`
4481
- };
4482
- }
4483
- try {
4484
- await this.io.remove(dir);
4485
- } catch (error) {
4486
- const reason = error instanceof Error ? error.message : String(error);
4487
- try {
4488
- await this.io.remove(dest);
4489
- return {
4490
- ok: false,
4491
- message: `Archive copy succeeded but the source could not be removed (${reason}); the copied archive was rolled back.`
4492
- };
4493
- } catch {
4494
- return {
4495
- ok: false,
4496
- message: `Archive copy succeeded but the source could not be removed (${reason}) and the archive copy could not be rolled back — the skill now exists in BOTH the active root and .archive; clean up manually.`
4497
- };
4498
- }
4499
- }
4500
- }
4686
+ if (await this.hasWriteLock(dir)) return {
4687
+ ok: false,
4688
+ message: `Skill "${name}" is being written (write lock present); retry archiving once the write completes.`
4689
+ };
4690
+ const moveFailure = await this.moveDir(dir, dest);
4691
+ if (moveFailure !== void 0) return {
4692
+ ok: false,
4693
+ message: `Skill "${name}" archive failed: ${moveFailure}.`
4694
+ };
4501
4695
  const reason = options.reason ?? (options.absorbedInto ? `Consolidated into ${options.absorbedInto}` : "Archived by self-evolution curator");
4502
- await this.io.writeText(join(dest, ".archive-reason"), `${(/* @__PURE__ */ new Date()).toISOString()}: ${reason}\n`);
4696
+ try {
4697
+ await this.io.writeText(join(dest, ".archive-reason"), `${(/* @__PURE__ */ new Date()).toISOString()}: ${reason}\n`);
4698
+ } catch {}
4503
4699
  await this.audit(name, "archive", md, null, reason);
4504
4700
  this.notifyMutation({
4505
4701
  action: "archive",
@@ -4808,7 +5004,7 @@ var SkillLibrary = class {
4808
5004
  */
4809
5005
  async applyTreeChange(plan) {
4810
5006
  const name = plan.name.trim();
4811
- const badName = this.badName(name);
5007
+ const badName = this.badName(name, { allowReserved: true });
4812
5008
  if (badName) return {
4813
5009
  ok: false,
4814
5010
  message: badName
@@ -4892,7 +5088,7 @@ var SkillLibrary = class {
4892
5088
  */
4893
5089
  async restoreFromArchive(rawName) {
4894
5090
  const name = rawName.trim();
4895
- const bad = this.badName(name);
5091
+ const bad = this.badName(name, { allowReserved: true });
4896
5092
  if (bad) return {
4897
5093
  ok: false,
4898
5094
  message: bad
@@ -4929,20 +5125,18 @@ var SkillLibrary = class {
4929
5125
  message: `Archived entry "${chosen}" is a symlink; refusing to restore it.`
4930
5126
  };
4931
5127
  }
4932
- try {
4933
- await this.io.rename(source, dest);
4934
- } catch {
4935
- try {
4936
- await this.io.copy(source, dest);
4937
- await this.io.remove(source);
4938
- } catch (error) {
4939
- return {
4940
- ok: false,
4941
- message: `Restore of "${name}" from .archive failed: ${error instanceof Error ? error.message : String(error)}`
4942
- };
4943
- }
4944
- }
5128
+ if (await this.hasWriteLock(dest)) return {
5129
+ ok: false,
5130
+ message: `Skill "${name}" is being written (write lock present); retry restoring once the write completes.`
5131
+ };
5132
+ const moveFailure = await this.moveDir(source, dest);
5133
+ if (moveFailure !== void 0) return {
5134
+ ok: false,
5135
+ message: `Restore of "${name}" from .archive failed: ${moveFailure}`
5136
+ };
5137
+ await this.deleteStrandedLocks(dest);
4945
5138
  if (await this.io.exists(join(dest, ".archive-reason"))) await this.io.remove(join(dest, ".archive-reason"));
5139
+ await this.audit(name, "restore", null, await this.io.readText(join(dest, "SKILL.md")).catch(() => null), `restored from ${source}`);
4946
5140
  this.notifyMutation({
4947
5141
  action: "restore",
4948
5142
  name,
@@ -4959,12 +5153,12 @@ var SkillLibrary = class {
4959
5153
  return await this.serial(() => this.writeSupportFileCore(name, filePath, content, origin));
4960
5154
  }
4961
5155
  async writeSupportFileCore(name, filePath, content, origin) {
4962
- const dir = this.dirOf(name);
4963
5156
  const badName = this.badName(name);
4964
5157
  if (badName) return {
4965
5158
  ok: false,
4966
5159
  message: badName
4967
5160
  };
5161
+ const dir = this.dirOf(name);
4968
5162
  if (!await this.io.exists(join(dir, "SKILL.md"))) return {
4969
5163
  ok: false,
4970
5164
  message: `Skill "${name}" not found.`
@@ -5053,6 +5247,10 @@ var SkillLibrary = class {
5053
5247
  message: `File "${filePath}" not found in skill "${name}".`
5054
5248
  };
5055
5249
  const before = await this.io.readText(target).catch(() => null);
5250
+ if (before === null) return {
5251
+ ok: false,
5252
+ message: `"${filePath}" is not a readable regular file — remove the files inside it one by one.`
5253
+ };
5056
5254
  await this.io.remove(target);
5057
5255
  await this.audit(name, "remove_file", before, null, `removed ${filePath}`);
5058
5256
  this.notifyMutation({
@@ -5253,7 +5451,11 @@ var SkillLibrary = class {
5253
5451
  await this.io.copy(join(snapshotPath, ".archive"), archiveRoot);
5254
5452
  } else if (manifest.hasArchive === false) await this.io.remove(archiveRoot);
5255
5453
  }
5454
+ for (const entry of await this.io.list(this.root)) {
5455
+ if (entry.startsWith(".")) continue;
5456
+ await this.deleteStrandedLocks(join(this.root, entry));
5457
+ }
5256
5458
  }
5257
5459
  };
5258
5460
  //#endregion
5259
- export { AUTHORING_DESCRIPTION_BAR, COMBINED_REVIEW_PLAN_PROMPT, COMBINED_REVIEW_PROMPT, COMPLETION_SKILL_REVIEW_PROMPT, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_CONSOLIDATION_FAILURES, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_MAX_OPS_PER_PLAN, DEFAULT_MEMORY_CHAR_LIMIT, DEFAULT_MIN_IDLE_HOURS, DEFAULT_MUTATION_CAP, DEFAULT_REVIEW_MEMORY_INTERVAL, DEFAULT_REVIEW_SKILL_INTERVAL, DEFAULT_SKILL_CONTENT_CHARS, DEFAULT_SKILL_LIMITS, DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS, DEFAULT_SKILL_REVIEW_TRIGGER, DEFAULT_STALE_AFTER_DAYS, DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS, DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS, DEFAULT_SUBSTANTIVE_MIN_USER_CHARS, DEFAULT_USER_CHAR_LIMIT, DRIFT_MAX_LINE_CHARS, DRIFT_SIGNALS_VERSION, DRIFT_SIGNAL_NOUNS, DSH_AUTHORING_STANDARDS, ENTRY_DELIMITER, EVENT_ARCHIVE_PREFIX, EVENT_LOG_RETAIN_ARCHIVES, EVENT_LOG_ROTATE_AT, EVENT_LOG_VERSION, EVOLUTION_ENV_KEYS, EVOLUTION_WRITE_TOOLS, EvolutionGateSet, FORBIDDEN_CONTROL_KEYS, HEALTH_STAMP_RE, 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, evolutionHome, evolutionIoAdapter, evolutionRoot, findDriftSignal, foldCuratorFields, foldTurn, frontmatterBlock, frontmatterYamlUnsafeValues, getRecord, 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, resolveSkillsRoot, retainEventArchives, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, suppressedFile, transactIo, updateSuppressedNames, usageFile, usageObserved, validateFrontmatter, verifyPromptBundle, writeDurableTmp, yamlPlainScalarNeedsQuotes };
5461
+ 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, 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, WIN32_RESERVED_DEVICE_NAMES, 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, evolutionHome, evolutionIoAdapter, evolutionRoot, findDriftSignal, foldCuratorFields, foldTurn, frontmatterBlock, frontmatterYamlUnsafeValues, getRecord, 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, resolveSkillsRoot, retainEventArchives, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, suppressedFile, transactIo, updateSuppressedNames, usageFile, usageObserved, validateFrontmatter, verifyPromptBundle, writeDurableTmp, yamlPlainScalarNeedsQuotes };
@@ -54,6 +54,13 @@ export declare const DEFAULT_USER_CHAR_LIMIT = 1375;
54
54
  /** Consolidation-failure backoff cap, shared by MemoryStore and memory-files' Config default. */
55
55
  export declare const DEFAULT_CONSOLIDATION_FAILURES = 3;
56
56
  export declare const DEFAULT_SKILL_CONTENT_CHARS = 100000;
57
+ /** P3-19 (v14): defaults that were written twice (schema `.default()` AND the
58
+ * clamp fallback literal) now have one home per value. */
59
+ export declare const DEFAULT_REVIEW_TIMEOUT_MS = 120000;
60
+ export declare const DEFAULT_REVIEW_CONTEXT_MESSAGES = 60;
61
+ export declare const DEFAULT_REVIEW_MESSAGE_CHARS = 2000;
62
+ export declare const DEFAULT_CURATOR_BOOT_GRACE_SECONDS = 10;
63
+ export declare const DEFAULT_CURATOR_REVIEW_MAX_TOKENS = 2048;
57
64
  /** 0.3.17 (S3.10, T-1): control-plane fields a model-facing write call may
58
65
  * never carry — single source for plan-validator, evolution-policy and the
59
66
  * threat scanner (they used to each hardcode the list). */
@@ -7,10 +7,14 @@
7
7
  * reads inside patch YAML `!!js` expressions (session-query path/openAt) stay
8
8
  * in the profile config evaluation — they are NOT migrated (they are resolved
9
9
  * at cordis config time, not plugin time) but are documented in the README
10
- * env table.
10
+ * env table. `EVOLUTION_SCOPE` is read by the source installers only
11
+ * (`packages/scripts/install-layered.mjs`, `packages/test-support/row-contract.ts`),
12
+ * never by plugin runtime code.
13
+ *
14
+ * v14 P3-1: the former `EVOLUTION_ENV_KEYS` export was deleted — nothing read
15
+ * it, so the "generated env reference" it claimed to source was never
16
+ * generated (the README table is maintained by hand and now lists every key).
11
17
  */
12
- /** Plugin-side DSH_EVOLUTION_* keys (config-layer keys are documented separately). */
13
- export declare const EVOLUTION_ENV_KEYS: readonly ["DSH_EVOLUTION_ALLOW_ROW_COLLISIONS"];
14
18
  /** N-5 escape: `DSH_EVOLUTION_ALLOW_ROW_COLLISIONS=1` downgrades a delta-row
15
19
  * collision from fail-loud to warn+keep-both. Any other value (including an
16
20
  * EMPTY/whitespace string — a set-but-unset variable) keeps the fail-loud. */
@@ -47,7 +47,13 @@ export declare const HEALTH_STAMP_RE: RegExp;
47
47
  */
48
48
  export declare const MIN_STAMP_BODY_CHARS = 2000;
49
49
  export type SkillHealthVerdict = 'healthy' | 'warn' | 'needs-restructure';
50
- /** Facts a caller already has; assessors never do IO. */
50
+ /** Facts a caller already has; assessors never do IO.
51
+ *
52
+ * `bodyChars`/`bodyText` are the WHOLE `SKILL.md` text as read from disk
53
+ * (frontmatter included), matching both callers: `SkillLibrary.assessHealth`
54
+ * and `snapshotFromLibrary` feed `read()` verbatim. The field names predate
55
+ * that convention; the thresholds are calibrated against the whole file, so a
56
+ * caller must not strip frontmatter before measuring. */
51
57
  export interface SkillHealthSnapshot {
52
58
  skillName: string;
53
59
  bodyChars: number;
@@ -228,6 +228,13 @@ export interface AuthoringFeedback {
228
228
  * truncated or route-poor instead of silently shipping it.
229
229
  */
230
230
  export declare function authoringFeedback(frontmatter: Frontmatter): AuthoringFeedback;
231
+ /** C-18: win32 reserves these stems with ANY extension (`nul.md` hits the
232
+ * NUL device), and they are fully inside the charset above — so the reserved
233
+ * set is checked on the first-dot prefix as well; the charset close alone
234
+ * cannot refuse them. Exported single source: `badName` (skill directories,
235
+ * P2-11/v15) and `validateSupportPath` (support-file stems, C-18) both
236
+ * consume this one set — a third copy would drift. */
237
+ export declare const WIN32_RESERVED_DEVICE_NAMES: ReadonlySet<string>;
231
238
  export declare class SkillLibrary {
232
239
  readonly root: string;
233
240
  readonly limits: SkillLimits;
@@ -280,8 +287,21 @@ export declare class SkillLibrary {
280
287
 
281
288
  */
282
289
  private dirOf;
283
- /** Name-format guard shared by every path-building mutator/reader. */
290
+ /**
291
+ * Name-format guard for every path-building entry point (P1-1/v14 closed the
292
+ * one gap: `patch`). Write paths, protection probes and support-file
293
+ * enumeration all call it before `dirOf`, so no directory path is ever built
294
+ * from a name that could escape the skills root. `list()` and `snapshotAll()`
295
+ * are the deliberate exceptions — their names come from `listNames()`, i.e.
296
+ * from the tree itself, never from caller input.
297
+ */
284
298
  private badName;
299
+ /**
300
+ * Refusal reason for a write to `rawName`, or null when the write may
301
+ * proceed: a protection marker on the directory, or an invalid name
302
+ * (P1-1/v14 — the name guard lives HERE as well as at every entry point, so
303
+ * no caller can build a path from an unvalidated name).
304
+ */
285
305
  writeProtection(rawName: string, origin?: WriteOrigin): Promise<string | null>;
286
306
  deleteProtection(rawName: string, options?: {
287
307
  allowBundled?: boolean | undefined;
@@ -326,6 +346,47 @@ export declare class SkillLibrary {
326
346
  private updateCore;
327
347
  patch(rawName: string, oldString: string, newString: string, filePath?: string, replaceAll?: boolean, origin?: WriteOrigin): Promise<SkillActionResult>;
328
348
  private patchCore;
349
+ /**
350
+ * P2-9 (v15): the destructive directory move shared by archive and
351
+ * restoreFromArchive — rename first, copy+remove fallback when the backend
352
+ * cannot rename across media (V5-35), with the E-14 rollback when the
353
+ * fallback's source removal fails. Returns a failure MESSAGE on a failed
354
+ * move (caller wraps into a structured result) or undefined on success.
355
+ */
356
+ private moveDir;
357
+ /**
358
+ * P2 (v16): the write-lock probe for the DESTRUCTIVE MOVERS (archive /
359
+ * restoreFromArchive). A byte-writer mid-flight is the ghost-generator —
360
+ * after the move its transact commit re-creates `<dir>/…` (mkdir
361
+ * recursive) and the tree ends half-archived. The signal is the writer's
362
+ * own lock file, and its PLACEMENT (inside the moved directory) is why the
363
+ * mover must PROBE-and-REFUSE instead of acquiring it: an acquired lock
364
+ * would be renamed into `.archive` with the tree, stranding a phantom live
365
+ * lock (the v16 audit proved the probe→rename TOCTOU does exactly that,
366
+ * and restore would later move the residue back into the live root).
367
+ * Coverage: `SKILL.md.lock` (update/patch of the body) plus one level of
368
+ * each support dir (write_file's lock sits next to its file). Residual:
369
+ * NESTED support-subdir locks and the probe→rename TOCTOU itself remain
370
+ * fail-safe (renameWithRetry rides the write out; the writer's locked
371
+ * re-read refuses on the moved-away file), and a residue `.lock` from a
372
+ * CRASHED writer also refuses — correct: inspect, don't archive.
373
+ */
374
+ private hasWriteLock;
375
+ /** P2 (v17): a file only counts as a writer lock when its body has the
376
+ * `pid:token` shape the io layer writes. User support files legitimately
377
+ * named `*.lock` (allowed by SUPPORT_FILE_NAME_RE) must not trip the probe
378
+ * or be swept as residue — the v16 first cut matched on suffix alone,
379
+ * which permanently refused archiving and deleted user content on restore. */
380
+ private isWriterLock;
381
+ /** P2 (v16): best-effort removal of lock residue inside a RESTORED tree —
382
+ * a `.lock` that a pre-restore crash or TOCTOU stranded in `.archive`
383
+ * cannot have a live writer (restore refuses when the live root is
384
+ * locked), and if left in place its body (a live pid on a single-host
385
+ * deployment) structurally closes the writer's self-heal path. */
386
+ private deleteStrandedLocks;
387
+ /** Remove `lockPath` only when its body has the writer-lock `pid:token`
388
+ * shape; anything else (a user support file) is left untouched. */
389
+ private sweepLockIfStranded;
329
390
  archive(rawName: string, options?: ArchiveOptions): Promise<SkillActionResult>;
330
391
  /**
331
392
  * Merge the bodies of `sources` into `target` and archive the sources with
@@ -52,9 +52,11 @@ export declare function scanContentThreats(text: string, maxScanChars?: number,
52
52
  * V10-03 (P2-18): suffix the SkillLibrary/MemoryStore write gates append to a
53
53
  * block message — the hit label is already embedded by scanContentThreats /
54
54
  * scanMemoryThreats, this names the deployable self-heal path so the model
55
- * (or operator) can allowlist a known-benign label. The evolution-threat tool
56
- * channel deliberately does NOT append it: that channel has no
57
- * threatExemptLabels option to advertise.
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.
58
60
  */
59
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.";
60
62
  //# sourceMappingURL=threats.d.ts.map
@@ -18,6 +18,16 @@ export interface UsageRecord {
18
18
  archived_at: string | null;
19
19
  quality_score?: number | undefined;
20
20
  quality_warn?: boolean | undefined;
21
+ /** P1-1 (v15): feedback-owned quality signal. Field ownership contract —
22
+ * `quality_score`/`quality_warn` are written ONLY by the curator's
23
+ * six-factor `scoreTree`; `feedback_score`/`feedback_warn` are written ONLY
24
+ * by the feedback channel (`SkillUsageRegistry.setFeedbackQuality`);
25
+ * `foldCuratorFields` refreshes the quality_* pair tree-wide and must never
26
+ * touch feedback_*. The lifecycle engine and the scope view read the UNION
27
+ * of both warn flags, which is what makes negative feedback decision-
28
+ * relevant again. */
29
+ feedback_score?: number | undefined;
30
+ feedback_warn?: boolean | undefined;
21
31
  }
22
32
  export type UsageMap = Map<string, UsageRecord>;
23
33
  export declare function usageFile(root: string): string;
@@ -60,6 +70,9 @@ export declare function applyCuratorLifecycleFields(disk: UsageRecord, curated:
60
70
  * Copy the recomputed meta pair (quality_score/quality_warn + the
61
71
  * marker-mirrored pin flag) — refreshed tree-wide each run by design, so a
62
72
  * concurrent curator run's lifecycle changes are never reverted by them.
73
+ * P1-1 (v15): the feedback pair (`feedback_score`/`feedback_warn`) is
74
+ * deliberately NOT copied — it is feedback-owned (see the field-ownership
75
+ * contract on {@link UsageRecord}) and must survive curator runs untouched.
63
76
  */
64
77
  export declare function applyCuratorMetaFields(disk: UsageRecord, curated: UsageRecord): void;
65
78
  /**
@@ -76,7 +89,9 @@ export declare function foldCuratorFields(disk: UsageMap, curated: UsageMap, sta
76
89
  /** Whole-file usage write (V6-37, 0.3.37): this is the ONE path that bypasses
77
90
  * the malformed-defense and the transact lock — prefer `mutateUsage` for any
78
91
  * read-modify-write so a concurrent writer cannot lose its update and a
79
- * malformed sidecar stays recoverable. Kept for fixture/test seeding. */
92
+ * malformed sidecar stays recoverable. Kept for fixture/test seeding.
93
+ * @internal P3-16 (v14): no production caller (verified by grep); exported for
94
+ * the family's tests only. Do not use it to write the sidecar in new code. */
80
95
  export declare function saveUsage(root: string, map: UsageMap, io?: EvolutionIoLike): Promise<void>;
81
96
  export declare function getRecord(map: UsageMap, name: string): UsageRecord;
82
97
  export declare function bumpView(map: UsageMap, name: string, when?: Date): void;
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.61",
4
+ "version": "0.3.63",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },