@lmzhen/dsh-evolution-core 0.3.62 → 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
@@ -534,7 +534,9 @@ function normalizeUsageRecord(record) {
534
534
  pinned: bool(raw.pinned, base.pinned),
535
535
  archived_at: nullableTimestamp(raw.archived_at) ? raw.archived_at : base.archived_at,
536
536
  quality_score: typeof raw.quality_score === "number" && Number.isFinite(raw.quality_score) ? raw.quality_score : void 0,
537
- 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
538
540
  };
539
541
  }
540
542
  /** Parse a raw usage sidecar; malformed content reads as empty (best-effort telemetry). */
@@ -560,11 +562,14 @@ async function loadUsage(root, io = nodeEvolutionIo()) {
560
562
  */
561
563
  async function mutateUsage(root, io, task) {
562
564
  await transactIo(io, usageFile(root), async (current) => {
565
+ let shapePreserved = false;
563
566
  if (current !== null) try {
564
- JSON.parse(current);
567
+ const probe = JSON.parse(current);
568
+ if (probe === null || Array.isArray(probe) || typeof probe !== "object") shapePreserved = true;
565
569
  } catch {
566
570
  return current;
567
571
  }
572
+ if (shapePreserved) return current;
568
573
  const map = parseUsage(current);
569
574
  await task(map);
570
575
  return JSON.stringify(Object.fromEntries(map.entries()), null, 2);
@@ -593,6 +598,9 @@ function applyCuratorLifecycleFields(disk, curated) {
593
598
  * Copy the recomputed meta pair (quality_score/quality_warn + the
594
599
  * marker-mirrored pin flag) — refreshed tree-wide each run by design, so a
595
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.
596
604
  */
597
605
  function applyCuratorMetaFields(disk, curated) {
598
606
  disk.quality_score = curated.quality_score;
@@ -1018,8 +1026,9 @@ function computeScopeView(usage, config, protectedNames, gates) {
1018
1026
  if (record.pinned || bundled || suppressed || protectedNames?.has(name) === true || isBuiltin) protectedSet.add(name);
1019
1027
  if (lifecycleCandidate(name, record, config, bundled, gateSet, protectedNames)) {
1020
1028
  managed.push(name);
1021
- if (record.state === "stale" || record.quality_warn === true) watched.push(name);
1022
- 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);
1023
1032
  }
1024
1033
  }
1025
1034
  return {
@@ -1046,7 +1055,7 @@ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Da
1046
1055
  const age = daysSince(null, record.created_at, now.getTime());
1047
1056
  if (record.use_count === 0 && age < config.staleAfterDays) continue;
1048
1057
  const idle = daysSince(latestActivityAt(record), record.created_at, now.getTime());
1049
- const qualityWarn = record.quality_warn === true;
1058
+ const qualityWarn = record.quality_warn === true || record.feedback_warn === true;
1050
1059
  const staleAfterDays = qualityWarn && config.qualityWarnStaleAfterDays !== void 0 ? config.qualityWarnStaleAfterDays : config.staleAfterDays;
1051
1060
  if (record.state === "active") {
1052
1061
  if (idle >= config.archiveAfterDays) {
@@ -1061,7 +1070,8 @@ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Da
1061
1070
  result.archive.push(name);
1062
1071
  } else if (idle >= staleAfterDays) {
1063
1072
  record.state = "stale";
1064
- 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`;
1065
1075
  result.transitions.push({
1066
1076
  name,
1067
1077
  from: "active",
@@ -2092,12 +2102,12 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_
2092
2102
  if (ZERO_WIDTH_CHARS.test(text) && !excluded.has("unicode_zero_width")) findings.push({
2093
2103
  label: "unicode_zero_width",
2094
2104
  category: "unicode_obfuscation",
2095
- scope
2105
+ scope: "all"
2096
2106
  });
2097
2107
  if (BIDI_CHARS.test(text) && !excluded.has("unicode_bidi_override")) findings.push({
2098
2108
  label: "unicode_bidi_override",
2099
2109
  category: "unicode_obfuscation",
2100
- scope
2110
+ scope: "all"
2101
2111
  });
2102
2112
  const normalized = text.normalize("NFKC");
2103
2113
  const windows = [];
@@ -2397,7 +2407,13 @@ var MemoryStore = class {
2397
2407
  async addCore(target, facts, raw) {
2398
2408
  const content = facts.trim();
2399
2409
  if (!content) return {
2400
- 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
+ },
2401
2417
  write: null
2402
2418
  };
2403
2419
  if (this.driftFromRaw(target, raw)) {
@@ -2737,25 +2753,31 @@ function contentHash(content) {
2737
2753
  function parseMutationRecords(raw) {
2738
2754
  if (raw === null) return [];
2739
2755
  try {
2740
- const parsed = JSON.parse(raw);
2741
- 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));
2742
2757
  } catch {
2743
2758
  return [];
2744
2759
  }
2745
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
+ }
2746
2767
  async function loadMutations(root, io = nodeEvolutionIo()) {
2747
2768
  return parseMutationRecords(await io.readText(mutationsFile(root)));
2748
2769
  }
2749
2770
  /** Append one record, trim to `cap`, and write atomically (versioned shape). */
2750
2771
  async function recordMutation(root, io, record, cap = 500) {
2751
2772
  await transactIo(io, mutationsFile(root), (current) => {
2773
+ let parsed = [];
2752
2774
  if (current !== null) try {
2753
- JSON.parse(current);
2775
+ parsed = JSON.parse(current);
2754
2776
  } catch {
2755
2777
  console.warn(`mutation audit record dropped: ${mutationsFile(root)} is malformed and was not overwritten`);
2756
2778
  return current;
2757
2779
  }
2758
- const existing = parseMutationRecords(current);
2780
+ const existing = recordsFromParsed(parsed);
2759
2781
  existing.push(record);
2760
2782
  const trimmed = existing.length > cap ? existing.slice(existing.length - cap) : existing;
2761
2783
  return JSON.stringify({
@@ -3693,7 +3715,9 @@ const SUPPORT_FILE_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
3693
3715
  /** C-18: win32 reserves these stems with ANY extension (`nul.md` hits the
3694
3716
  * NUL device), and they are fully inside the charset above — so the reserved
3695
3717
  * set is checked on the first-dot prefix as well; the charset close alone
3696
- * 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. */
3697
3721
  const WIN32_RESERVED_DEVICE_NAMES = new Set([
3698
3722
  "con",
3699
3723
  "prn",
@@ -3726,6 +3750,7 @@ function validateSupportPath(filePath) {
3726
3750
  if (parts.length < 2) return "Provide a file name, not just a directory.";
3727
3751
  for (const part of parts.slice(1)) {
3728
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.`;
3729
3754
  const stem = part.split(".")[0]?.toLowerCase() ?? "";
3730
3755
  if (WIN32_RESERVED_DEVICE_NAMES.has(stem)) return `Unsupported file name "${part}" — a Windows reserved device name.`;
3731
3756
  }
@@ -4023,9 +4048,10 @@ var SkillLibrary = class {
4023
4048
  * are the deliberate exceptions — their names come from `listNames()`, i.e.
4024
4049
  * from the tree itself, never from caller input.
4025
4050
  */
4026
- badName(name) {
4051
+ badName(name, opts = {}) {
4027
4052
  const normalized = name.trim();
4028
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.`;
4029
4055
  return null;
4030
4056
  }
4031
4057
  /**
@@ -4045,7 +4071,7 @@ var SkillLibrary = class {
4045
4071
  }
4046
4072
  async deleteProtection(rawName, options = {}) {
4047
4073
  const name = rawName.trim();
4048
- const badName = this.badName(name);
4074
+ const badName = this.badName(name, { allowReserved: true });
4049
4075
  if (badName) return badName;
4050
4076
  const dir = this.dirOf(name);
4051
4077
  const markers = options.allowBundled ? ["hub-installed", "pinned"] : [
@@ -4247,7 +4273,21 @@ var SkillLibrary = class {
4247
4273
  message: `Skill "${normalized}" is protected (${protection}).`
4248
4274
  };
4249
4275
  const onDisk = finalContent.trimEnd() + "\n";
4250
- 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
+ };
4251
4291
  if (origin !== "foreground") await this.io.writeText(markerPath(dir, "hermes-managed"), "");
4252
4292
  await this.audit(normalized, "create", null, onDisk, "created");
4253
4293
  this.notifyMutation({
@@ -4267,13 +4307,13 @@ var SkillLibrary = class {
4267
4307
  return await this.serial(() => this.updateCore(name, content, origin));
4268
4308
  }
4269
4309
  async updateCore(name, content, origin) {
4270
- const dir = this.dirOf(name);
4271
- const path = join(dir, "SKILL.md");
4272
4310
  const badName = this.badName(name);
4273
4311
  if (badName) return {
4274
4312
  ok: false,
4275
4313
  message: badName
4276
4314
  };
4315
+ const dir = this.dirOf(name);
4316
+ const path = join(dir, "SKILL.md");
4277
4317
  const protection = await this.writeProtection(name, origin);
4278
4318
  if (protection) return {
4279
4319
  ok: false,
@@ -4493,9 +4533,113 @@ var SkillLibrary = class {
4493
4533
  };
4494
4534
  });
4495
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
+ }
4496
4640
  async archive(rawName, options = {}) {
4497
4641
  const name = rawName.trim();
4498
- const badName = this.badName(name);
4642
+ const badName = this.badName(name, { allowReserved: true });
4499
4643
  if (badName) return {
4500
4644
  ok: false,
4501
4645
  message: badName
@@ -4516,6 +4660,11 @@ var SkillLibrary = class {
4516
4660
  ok: false,
4517
4661
  message: "absorbed_into cannot be the skill being archived (cannot absorb into itself)."
4518
4662
  };
4663
+ const intoBad = this.badName(options.absorbedInto.trim());
4664
+ if (intoBad) return {
4665
+ ok: false,
4666
+ message: `absorbed_into: ${intoBad}`
4667
+ };
4519
4668
  if (!await this.io.readText(join(this.dirOf(options.absorbedInto), "SKILL.md"))) return {
4520
4669
  ok: false,
4521
4670
  message: `absorbed_into="${options.absorbedInto}" does not exist.`
@@ -4534,35 +4683,15 @@ var SkillLibrary = class {
4534
4683
  message: `Skill "${name}" is a symlink; refusing to archive it.`
4535
4684
  };
4536
4685
  }
4537
- try {
4538
- await this.io.rename(dir, dest);
4539
- } catch {
4540
- try {
4541
- await this.io.copy(dir, dest);
4542
- } catch (copyError) {
4543
- return {
4544
- ok: false,
4545
- message: `Skill "${name}" archive fell back to copy but failed (${copyError instanceof Error ? copyError.message : String(copyError)}); the skill stays where it is.`
4546
- };
4547
- }
4548
- try {
4549
- await this.io.remove(dir);
4550
- } catch (error) {
4551
- const reason = error instanceof Error ? error.message : String(error);
4552
- try {
4553
- await this.io.remove(dest);
4554
- return {
4555
- ok: false,
4556
- message: `Archive copy succeeded but the source could not be removed (${reason}); the copied archive was rolled back.`
4557
- };
4558
- } catch {
4559
- return {
4560
- ok: false,
4561
- 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.`
4562
- };
4563
- }
4564
- }
4565
- }
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
+ };
4566
4695
  const reason = options.reason ?? (options.absorbedInto ? `Consolidated into ${options.absorbedInto}` : "Archived by self-evolution curator");
4567
4696
  try {
4568
4697
  await this.io.writeText(join(dest, ".archive-reason"), `${(/* @__PURE__ */ new Date()).toISOString()}: ${reason}\n`);
@@ -4875,7 +5004,7 @@ var SkillLibrary = class {
4875
5004
  */
4876
5005
  async applyTreeChange(plan) {
4877
5006
  const name = plan.name.trim();
4878
- const badName = this.badName(name);
5007
+ const badName = this.badName(name, { allowReserved: true });
4879
5008
  if (badName) return {
4880
5009
  ok: false,
4881
5010
  message: badName
@@ -4959,7 +5088,7 @@ var SkillLibrary = class {
4959
5088
  */
4960
5089
  async restoreFromArchive(rawName) {
4961
5090
  const name = rawName.trim();
4962
- const bad = this.badName(name);
5091
+ const bad = this.badName(name, { allowReserved: true });
4963
5092
  if (bad) return {
4964
5093
  ok: false,
4965
5094
  message: bad
@@ -4996,20 +5125,18 @@ var SkillLibrary = class {
4996
5125
  message: `Archived entry "${chosen}" is a symlink; refusing to restore it.`
4997
5126
  };
4998
5127
  }
4999
- try {
5000
- await this.io.rename(source, dest);
5001
- } catch {
5002
- try {
5003
- await this.io.copy(source, dest);
5004
- await this.io.remove(source);
5005
- } catch (error) {
5006
- return {
5007
- ok: false,
5008
- message: `Restore of "${name}" from .archive failed: ${error instanceof Error ? error.message : String(error)}`
5009
- };
5010
- }
5011
- }
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);
5012
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}`);
5013
5140
  this.notifyMutation({
5014
5141
  action: "restore",
5015
5142
  name,
@@ -5026,12 +5153,12 @@ var SkillLibrary = class {
5026
5153
  return await this.serial(() => this.writeSupportFileCore(name, filePath, content, origin));
5027
5154
  }
5028
5155
  async writeSupportFileCore(name, filePath, content, origin) {
5029
- const dir = this.dirOf(name);
5030
5156
  const badName = this.badName(name);
5031
5157
  if (badName) return {
5032
5158
  ok: false,
5033
5159
  message: badName
5034
5160
  };
5161
+ const dir = this.dirOf(name);
5035
5162
  if (!await this.io.exists(join(dir, "SKILL.md"))) return {
5036
5163
  ok: false,
5037
5164
  message: `Skill "${name}" not found.`
@@ -5120,6 +5247,10 @@ var SkillLibrary = class {
5120
5247
  message: `File "${filePath}" not found in skill "${name}".`
5121
5248
  };
5122
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
+ };
5123
5254
  await this.io.remove(target);
5124
5255
  await this.audit(name, "remove_file", before, null, `removed ${filePath}`);
5125
5256
  this.notifyMutation({
@@ -5320,7 +5451,11 @@ var SkillLibrary = class {
5320
5451
  await this.io.copy(join(snapshotPath, ".archive"), archiveRoot);
5321
5452
  } else if (manifest.hasArchive === false) await this.io.remove(archiveRoot);
5322
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
+ }
5323
5458
  }
5324
5459
  };
5325
5460
  //#endregion
5326
- 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, 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 };
@@ -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;
@@ -339,6 +346,47 @@ export declare class SkillLibrary {
339
346
  private updateCore;
340
347
  patch(rawName: string, oldString: string, newString: string, filePath?: string, replaceAll?: boolean, origin?: WriteOrigin): Promise<SkillActionResult>;
341
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;
342
390
  archive(rawName: string, options?: ArchiveOptions): Promise<SkillActionResult>;
343
391
  /**
344
392
  * Merge the bodies of `sources` into `target` and archive the sources with
@@ -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
  /**
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.62",
4
+ "version": "0.3.63",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },