@lmzhen/dsh-evolution-core 0.3.62 → 0.3.64

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
@@ -156,12 +156,41 @@ async function fsyncDirectory(path) {
156
156
  async function commitTmp(tmp, target) {
157
157
  try {
158
158
  await renameWithRetry(tmp, target);
159
- await fsyncDirectory(dirname(target));
160
159
  } catch (error) {
161
160
  await rm(tmp, { force: true }).catch(() => {});
162
161
  throw error;
163
162
  }
163
+ try {
164
+ await fsyncDirectory(dirname(target));
165
+ } catch (error) {
166
+ throw Object.assign(/* @__PURE__ */ new Error(`commitTmp: ${target} was renamed but the directory fsync failed: ${error instanceof Error ? error.message : String(error)}`), {
167
+ committed: true,
168
+ cause: error
169
+ });
170
+ }
171
+ }
172
+ /**
173
+ * True when the pid is alive (EPERM = alive but unowned; ESRCH = gone).
174
+ * V18 single source: the node backend's lock takeover and SkillLibrary's
175
+ * stranded-lock sweep must use the same liveness rule.
176
+ */
177
+ function isProcessAlive(pid) {
178
+ try {
179
+ process.kill(pid, 0);
180
+ return true;
181
+ } catch (error) {
182
+ return error?.code === "EPERM";
183
+ }
164
184
  }
185
+ /** F-17 (v18): the write-lock protocol is a cross-module contract — the lock
186
+ * file is `<target>.lock` and its body is `<pid>:<token>`. This module creates
187
+ * them (`withWriteLock`) and `skill-store`'s probes/sweepers parse them, so both
188
+ * consume these two constants instead of repeating the literals. */
189
+ const LOCK_SUFFIX = ".lock";
190
+ /** Writer-lock body shape: a decimal pid, a colon, then the claim token. A
191
+ * torn body (no parsable pid) still matches the `\\d+:` prefix rule only when
192
+ * the pid part is intact, which is what the takeover probe needs. */
193
+ const LOCK_BODY_RE = /^\d+:[0-9a-f]*$/;
165
194
  /**
166
195
  * Build the Node IO backend. `lockAttempts` scales the write-lock retry budget
167
196
  * (attempts × 50ms); the default 40 (~2s, rc.69) covers production contention,
@@ -174,15 +203,8 @@ function nodeEvolutionIo(lockAttempts = 40) {
174
203
  const code = error?.code;
175
204
  return code === "ENOENT" || code === "ENOTDIR";
176
205
  };
177
- /** True when the pid is alive (EPERM = alive but unowned; ESRCH = gone). */
178
- const isAlive = (pid) => {
179
- try {
180
- process.kill(pid, 0);
181
- return true;
182
- } catch (error) {
183
- return error?.code === "EPERM";
184
- }
185
- };
206
+ /** True when the pid is alive (single source: `isProcessAlive`). */
207
+ const isAlive = isProcessAlive;
186
208
  /**
187
209
  * V10-07 (P2-1): age threshold for taking over a lock whose body is TORN
188
210
  * (non-empty, but the pid prefix does not parse to a positive integer). 1h:
@@ -238,7 +260,7 @@ function nodeEvolutionIo(lockAttempts = 40) {
238
260
  * forever.
239
261
  */
240
262
  const withWriteLock = async (path, task) => {
241
- const lock = `${path}.lock`;
263
+ const lock = `${path}${LOCK_SUFFIX}`;
242
264
  let myClaim = "";
243
265
  for (let attempt = 0; attempt < lockAttempts; attempt += 1) {
244
266
  let lockHandle = null;
@@ -343,40 +365,41 @@ function nodeEvolutionIo(lockAttempts = 40) {
343
365
  return;
344
366
  }
345
367
  const prefix = `${base}.`;
346
- const lockName = `${base}.lock`;
368
+ const lockName = `${base}${LOCK_SUFFIX}`;
347
369
  const ticketName = `${lockName}.next`;
348
370
  const CORRUPT_SWEEP_AGE_MS = 168 * 36e5;
349
371
  for (const name of entries) {
350
372
  if (!name.startsWith(prefix) || name === lockName) continue;
351
- if (!name.endsWith(".tmp")) {
352
- if (name === ticketName) {
353
- const ticketPath = join(dir, name);
354
- try {
355
- const body = await readFile(ticketPath, "utf8").catch(() => "");
356
- const holder = Number(body.split(":")[0] ?? "");
357
- const st = await stat(ticketPath);
358
- const dead = !Number.isInteger(holder) || holder <= 0 || !isAlive(holder);
359
- const old = Date.now() - st.mtimeMs > 1e3;
360
- if (dead || old) await rm(ticketPath, { force: true });
361
- } catch {}
362
- continue;
363
- }
364
- if (name.includes(".corrupt")) {
365
- const corruptPath = join(dir, name);
366
- try {
367
- const st = await stat(corruptPath);
368
- if (Date.now() - st.mtimeMs > CORRUPT_SWEEP_AGE_MS) await rm(corruptPath, { force: true });
369
- } catch {}
370
- }
373
+ const tmpMatch = /^(.*)\.(\d+)\.([0-9a-f]+)\.tmp$/.exec(name);
374
+ if (tmpMatch !== null && tmpMatch[1] === base) {
375
+ const tmpPath = join(dir, name);
376
+ const holder = Number(tmpMatch[2]);
377
+ try {
378
+ const st = await stat(tmpPath);
379
+ const deadHolder = !Number.isInteger(holder) || holder <= 0 || !isAlive(holder);
380
+ if (holder === process.pid || Date.now() - st.mtimeMs > 36e5 && deadHolder) await rm(tmpPath, { force: true });
381
+ } catch {}
371
382
  continue;
372
383
  }
373
- const tmpPath = join(dir, name);
374
- const holder = Number(name.slice(prefix.length, name.length - 4).split(".")[0] ?? "");
375
- try {
376
- const st = await stat(tmpPath);
377
- const deadHolder = !Number.isInteger(holder) || holder <= 0 || !isAlive(holder);
378
- if (holder === process.pid || Date.now() - st.mtimeMs > 36e5 && deadHolder) await rm(tmpPath, { force: true });
379
- } catch {}
384
+ if (name === ticketName) {
385
+ const ticketPath = join(dir, name);
386
+ try {
387
+ const body = await readFile(ticketPath, "utf8").catch(() => "");
388
+ const holder = Number(body.split(":")[0] ?? "");
389
+ const st = await stat(ticketPath);
390
+ const dead = !Number.isInteger(holder) || holder <= 0 || !isAlive(holder);
391
+ const old = Date.now() - st.mtimeMs > 1e3;
392
+ if (dead || old) await rm(ticketPath, { force: true });
393
+ } catch {}
394
+ continue;
395
+ }
396
+ if (name.includes(".corrupt")) {
397
+ const corruptPath = join(dir, name);
398
+ try {
399
+ const st = await stat(corruptPath);
400
+ if (Date.now() - st.mtimeMs > CORRUPT_SWEEP_AGE_MS) await rm(corruptPath, { force: true });
401
+ } catch {}
402
+ }
380
403
  }
381
404
  };
382
405
  return {
@@ -534,7 +557,9 @@ function normalizeUsageRecord(record) {
534
557
  pinned: bool(raw.pinned, base.pinned),
535
558
  archived_at: nullableTimestamp(raw.archived_at) ? raw.archived_at : base.archived_at,
536
559
  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
560
+ quality_warn: typeof raw.quality_warn === "boolean" ? raw.quality_warn : void 0,
561
+ feedback_score: typeof raw.feedback_score === "number" && Number.isFinite(raw.feedback_score) ? raw.feedback_score : void 0,
562
+ feedback_warn: typeof raw.feedback_warn === "boolean" ? raw.feedback_warn : void 0
538
563
  };
539
564
  }
540
565
  /** Parse a raw usage sidecar; malformed content reads as empty (best-effort telemetry). */
@@ -560,11 +585,19 @@ async function loadUsage(root, io = nodeEvolutionIo()) {
560
585
  */
561
586
  async function mutateUsage(root, io, task) {
562
587
  await transactIo(io, usageFile(root), async (current) => {
588
+ let shapePreserved = false;
563
589
  if (current !== null) try {
564
- JSON.parse(current);
590
+ const probe = JSON.parse(current);
591
+ if (probe === null || Array.isArray(probe) || typeof probe !== "object") shapePreserved = true;
592
+ else {
593
+ const record = probe;
594
+ if (Object.values(record).some((value) => value === null || typeof value !== "object" || Array.isArray(value))) shapePreserved = true;
595
+ if (typeof record.version === "number" && record.version > 1) shapePreserved = true;
596
+ }
565
597
  } catch {
566
598
  return current;
567
599
  }
600
+ if (shapePreserved) return current;
568
601
  const map = parseUsage(current);
569
602
  await task(map);
570
603
  return JSON.stringify(Object.fromEntries(map.entries()), null, 2);
@@ -593,6 +626,9 @@ function applyCuratorLifecycleFields(disk, curated) {
593
626
  * Copy the recomputed meta pair (quality_score/quality_warn + the
594
627
  * marker-mirrored pin flag) — refreshed tree-wide each run by design, so a
595
628
  * concurrent curator run's lifecycle changes are never reverted by them.
629
+ * P1-1 (v15): the feedback pair (`feedback_score`/`feedback_warn`) is
630
+ * deliberately NOT copied — it is feedback-owned (see the field-ownership
631
+ * contract on {@link UsageRecord}) and must survive curator runs untouched.
596
632
  */
597
633
  function applyCuratorMetaFields(disk, curated) {
598
634
  disk.quality_score = curated.quality_score;
@@ -609,7 +645,8 @@ function applyCuratorMetaFields(disk, curated) {
609
645
  * transitioned — a concurrent curator run's archive/restore is never reverted
610
646
  * by a stale snapshot; without it both pairs apply everywhere.
611
647
  */
612
- function foldCuratorFields(disk, curated, stateOwned) {
648
+ function foldCuratorFields(disk, curated, stateOwned, runStartStates) {
649
+ const skipped = [];
613
650
  for (const [name, record] of curated) {
614
651
  const diskRecord = disk.get(name);
615
652
  if (!diskRecord) {
@@ -617,8 +654,16 @@ function foldCuratorFields(disk, curated, stateOwned) {
617
654
  continue;
618
655
  }
619
656
  applyCuratorMetaFields(diskRecord, record);
620
- if (stateOwned === void 0 || stateOwned.has(name)) applyCuratorLifecycleFields(diskRecord, record);
657
+ if (stateOwned === void 0 || stateOwned.has(name)) {
658
+ const expected = runStartStates?.get(name);
659
+ if (expected !== void 0 && diskRecord.state !== expected) {
660
+ skipped.push(name);
661
+ continue;
662
+ }
663
+ applyCuratorLifecycleFields(diskRecord, record);
664
+ }
621
665
  }
666
+ return skipped;
622
667
  }
623
668
  /** Whole-file usage write (V6-37, 0.3.37): this is the ONE path that bypasses
624
669
  * the malformed-defense and the transact lock — prefer `mutateUsage` for any
@@ -749,8 +794,14 @@ async function updateSuppressedNames(root, io, task) {
749
794
  * threshold, which are intentionally left where they are used.
750
795
  * @module @lmzhen/dsh-evolution-core
751
796
  */
752
- /** Skill frontmatter `name` validated for the file name (lowercase + hyphen). */
753
- const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
797
+ /** Skill frontmatter `name` validated for the file name (lowercase + hyphen).
798
+ * 计划 B-4 (v18): tightened to the UPSTREAM `SKILL_NAME` shape
799
+ * (`/^[a-z0-9]+(?:-[a-z0-9]+)*$/`, packages/skill/skill/src/index.ts:20). The
800
+ * old form admitted trailing/consecutive hyphens, which upstream
801
+ * `validateCandidate` throws on — and that throw aborts the WHOLE `ctx.skills`
802
+ * collection. The catalog provider still filters such legacy tree entries so
803
+ * an existing tree cannot break a session. */
804
+ const SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
754
805
  /** Allowed skill support-file subdirectories (path-traversal boundary). */
755
806
  const SUPPORT_DIRS = [
756
807
  "references",
@@ -788,7 +839,10 @@ const DEFAULT_MEMORY_CHAR_LIMIT = 2200;
788
839
  const DEFAULT_USER_CHAR_LIMIT = 1375;
789
840
  /** Consolidation-failure backoff cap, shared by MemoryStore and memory-files' Config default. */
790
841
  const DEFAULT_CONSOLIDATION_FAILURES = 3;
791
- const DEFAULT_SKILL_CONTENT_CHARS = 1e5;
842
+ /** F-20 (v18): the authored-body budget and the hard ceiling are the same
843
+ * number today. Derive it so a future divergence is one edit, not two names
844
+ * that silently disagree. */
845
+ const DEFAULT_SKILL_CONTENT_CHARS = MAX_SKILL_CONTENT_CHARS;
792
846
  /** P3-19 (v14): defaults that were written twice (schema `.default()` AND the
793
847
  * clamp fallback literal) now have one home per value. */
794
848
  const DEFAULT_REVIEW_TIMEOUT_MS = 12e4;
@@ -1018,8 +1072,9 @@ function computeScopeView(usage, config, protectedNames, gates) {
1018
1072
  if (record.pinned || bundled || suppressed || protectedNames?.has(name) === true || isBuiltin) protectedSet.add(name);
1019
1073
  if (lifecycleCandidate(name, record, config, bundled, gateSet, protectedNames)) {
1020
1074
  managed.push(name);
1021
- if (record.state === "stale" || record.quality_warn === true) watched.push(name);
1022
- if (record.quality_warn === true) qualityWarned.push(name);
1075
+ const warned = record.quality_warn === true || record.feedback_warn === true;
1076
+ if (record.state === "stale" || warned) watched.push(name);
1077
+ if (warned) qualityWarned.push(name);
1023
1078
  }
1024
1079
  }
1025
1080
  return {
@@ -1031,7 +1086,10 @@ function computeScopeView(usage, config, protectedNames, gates) {
1031
1086
  };
1032
1087
  }
1033
1088
  function daysSince(iso, created, now) {
1034
- return (now - new Date(iso ?? created).getTime()) / 864e5;
1089
+ const anchor = iso ?? created;
1090
+ const t = Date.parse(anchor);
1091
+ if (!Number.isFinite(t)) return 0;
1092
+ return (now - t) / 864e5;
1035
1093
  }
1036
1094
  function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Date(), gates, protectedNames) {
1037
1095
  const result = {
@@ -1044,9 +1102,9 @@ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Da
1044
1102
  for (const [name, record] of usage) {
1045
1103
  if (!lifecycleCandidate(name, record, config, config.bundledNames?.has(name) === true, gateSet, protectedNames)) continue;
1046
1104
  const age = daysSince(null, record.created_at, now.getTime());
1047
- if (record.use_count === 0 && age < config.staleAfterDays) continue;
1105
+ const qualityWarn = record.quality_warn === true || record.feedback_warn === true;
1106
+ if (record.use_count + record.view_count === 0 && !qualityWarn && age < config.staleAfterDays) continue;
1048
1107
  const idle = daysSince(latestActivityAt(record), record.created_at, now.getTime());
1049
- const qualityWarn = record.quality_warn === true;
1050
1108
  const staleAfterDays = qualityWarn && config.qualityWarnStaleAfterDays !== void 0 ? config.qualityWarnStaleAfterDays : config.staleAfterDays;
1051
1109
  if (record.state === "active") {
1052
1110
  if (idle >= config.archiveAfterDays) {
@@ -1061,7 +1119,8 @@ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Da
1061
1119
  result.archive.push(name);
1062
1120
  } else if (idle >= staleAfterDays) {
1063
1121
  record.state = "stale";
1064
- const reason = qualityWarn ? `idle ${Math.round(idle)}d >= quality-warn stale ${staleAfterDays}d` : `idle ${Math.round(idle)}d >= ${staleAfterDays}d`;
1122
+ const warnSource = record.feedback_warn === true ? "feedback-warn stale" : "quality-warn stale";
1123
+ const reason = qualityWarn ? `idle ${Math.round(idle)}d >= ${warnSource} ${staleAfterDays}d` : `idle ${Math.round(idle)}d >= ${staleAfterDays}d`;
1065
1124
  result.transitions.push({
1066
1125
  name,
1067
1126
  from: "active",
@@ -1138,6 +1197,28 @@ const EVENT_ARCHIVE_RE = /^events-(\d+)\.json$/;
1138
1197
  function eventsFile(home) {
1139
1198
  return join(home, "evolution", "events.json");
1140
1199
  }
1200
+ /** I-5 (v18): one lightweight description of the durable event payload
1201
+ * contract. The log is a FILE boundary (a host, a script or an older version
1202
+ * can write it), so `appendEvolutionEvent` refuses a record no consumer can
1203
+ * fold instead of persisting it and failing silently later. The process event
1204
+ * bus stays unvalidated — that is a typed same-process boundary.
1205
+ * @param event - the candidate event record.
1206
+ * @returns a human-readable issue, or null when the record is well-formed.
1207
+ */
1208
+ function evolutionEventPayloadIssue(event) {
1209
+ const type = event.type;
1210
+ if (typeof type !== "string") return `unknown event type "${String(type)}"`;
1211
+ switch (type) {
1212
+ case "feedback":
1213
+ if (event.kind !== "skill" && event.kind !== "session") return "feedback event requires kind skill|session";
1214
+ if (event.rating !== "positive" && event.rating !== "negative") return "feedback event requires rating positive|negative";
1215
+ return null;
1216
+ case "maintain": return typeof event.runId === "string" ? null : "maintain event requires runId";
1217
+ case "learn":
1218
+ case "usage": return null;
1219
+ default: return `unknown event type "${type}"`;
1220
+ }
1221
+ }
1141
1222
  function isEventRecord(event) {
1142
1223
  const seq = event?.seq;
1143
1224
  return typeof event === "object" && event !== null && typeof seq === "number" && Number.isFinite(seq);
@@ -1210,6 +1291,8 @@ async function listEventArchives(io, path) {
1210
1291
  * event can never shadow an archived one in the seq-deduped timeline.
1211
1292
  */
1212
1293
  async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE_AT) {
1294
+ const issue = evolutionEventPayloadIssue(event);
1295
+ if (issue !== null) throw new Error(`evolution event refused: ${issue}`);
1213
1296
  let assigned = 0;
1214
1297
  let refuseMessage = "";
1215
1298
  let parsedBody = null;
@@ -1255,7 +1338,7 @@ async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE
1255
1338
  * one-event rotate would archive everything and restart seqs at 1).
1256
1339
  */
1257
1340
  async function rotateIfDue(io, path, events, rotateAt) {
1258
- if (rotateAt < 2 || events.length < rotateAt) return events;
1341
+ if (!Number.isFinite(rotateAt) || rotateAt < 2 || events.length < rotateAt) return events;
1259
1342
  const mid = Math.ceil(events.length / 2);
1260
1343
  const head = events.slice(0, mid);
1261
1344
  const tail = events.slice(mid);
@@ -1909,7 +1992,7 @@ const PATTERNS = [
1909
1992
  label: "disregard_rules",
1910
1993
  category: "prompt_injection",
1911
1994
  scope: "all",
1912
- regex: /disregard\s+(?:your|all|any)\s+(?:instructions|rules|guidelines)/i
1995
+ regex: new RegExp(String.raw`disregard\s+${FILLER}(?:your|all|any)\s+${FILLER}(?:instructions|rules|guidelines)`, "i")
1913
1996
  },
1914
1997
  {
1915
1998
  label: "system_prompt_override",
@@ -2062,7 +2145,7 @@ const PATTERNS = [
2062
2145
  regex: /-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----/
2063
2146
  }
2064
2147
  ];
2065
- const ZERO_WIDTH_CHARS = /[\u200b\u200c\u200d\u2060\u2062\u2063\u2064\ufeff]/;
2148
+ const ZERO_WIDTH_CHARS = new RegExp(`[\\u00ad\\u034f\\u061c\\u180e\\u200b\\u200c\\u200d\\u2060\\u2061\\u2062\\u2063\\u2064\\u206a-\\u206f\\ufeff\\ufe00-\\ufe0f]|\\u{e0000}-\\u{e007f}`, "u");
2066
2149
  const BIDI_CHARS = /[\u202a-\u202e\u2066-\u2069]/;
2067
2150
  const SCOPE_ORDER = {
2068
2151
  all: 1,
@@ -2092,12 +2175,12 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_
2092
2175
  if (ZERO_WIDTH_CHARS.test(text) && !excluded.has("unicode_zero_width")) findings.push({
2093
2176
  label: "unicode_zero_width",
2094
2177
  category: "unicode_obfuscation",
2095
- scope
2178
+ scope: "all"
2096
2179
  });
2097
2180
  if (BIDI_CHARS.test(text) && !excluded.has("unicode_bidi_override")) findings.push({
2098
2181
  label: "unicode_bidi_override",
2099
2182
  category: "unicode_obfuscation",
2100
- scope
2183
+ scope: "all"
2101
2184
  });
2102
2185
  const normalized = text.normalize("NFKC");
2103
2186
  const windows = [];
@@ -2252,12 +2335,17 @@ var MemoryStore = class {
2252
2335
  /** V10-03 (P2-18): the strict-scan write gate. A block message names the hit
2253
2336
  * label (scanMemoryThreats already embeds it) plus the self-heal hint. */
2254
2337
  memoryThreatBlock(text) {
2255
- const threat = scanMemoryThreats(text, void 0, this.threatScanOptions());
2256
- return threat === null ? null : threat + THREAT_EXEMPT_HINT;
2338
+ return scanMemoryThreats(text, void 0, this.threatScanOptions());
2257
2339
  }
2258
2340
  limitFor(target) {
2259
2341
  return target === "memory" ? this.memoryLimit : this.userLimit;
2260
2342
  }
2343
+ /** P2-1 (v18): the generated date prefix participates in duplicate detection
2344
+ * only when THIS store writes it. With addDatePrefix=false a fact's own
2345
+ * leading `## YYYY-MM-DD\n` is content, not a generated prefix. */
2346
+ dedupeKey(entry) {
2347
+ return this.addDatePrefix ? stripDatePrefix(entry) : entry;
2348
+ }
2261
2349
  /**
2262
2350
  * Read-guard probe: `{ size, limit }` when the on-disk file exceeds
2263
2351
  * `limit * READ_GUARD_FACTOR` bytes, `null` when it is absent, unknown
@@ -2397,7 +2485,13 @@ var MemoryStore = class {
2397
2485
  async addCore(target, facts, raw) {
2398
2486
  const content = facts.trim();
2399
2487
  if (!content) return {
2400
- result: this.failure(target, "Content cannot be empty.", []),
2488
+ result: {
2489
+ ok: false,
2490
+ message: "Content cannot be empty.",
2491
+ entries: [],
2492
+ chars: 0,
2493
+ limit: this.limitFor(target)
2494
+ },
2401
2495
  write: null
2402
2496
  };
2403
2497
  if (this.driftFromRaw(target, raw)) {
@@ -2436,7 +2530,7 @@ var MemoryStore = class {
2436
2530
  write: null
2437
2531
  };
2438
2532
  const entries = [...new Set(normalizeEntries(raw))];
2439
- if (entries.some((entry) => stripDatePrefix(entry) === content)) {
2533
+ if (entries.some((entry) => this.dedupeKey(entry) === content)) {
2440
2534
  this.resetFailures();
2441
2535
  return {
2442
2536
  result: {
@@ -2558,7 +2652,7 @@ var MemoryStore = class {
2558
2652
  },
2559
2653
  write: null
2560
2654
  };
2561
- if (!working.some((entry) => stripDatePrefix(entry) === body)) working.push(entryBody);
2655
+ if (!working.some((entry) => this.dedupeKey(entry) === body)) working.push(entryBody);
2562
2656
  continue;
2563
2657
  }
2564
2658
  const rawAction = op.action;
@@ -2737,25 +2831,38 @@ function contentHash(content) {
2737
2831
  function parseMutationRecords(raw) {
2738
2832
  if (raw === null) return [];
2739
2833
  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");
2834
+ return recordsFromParsed(JSON.parse(raw));
2742
2835
  } catch {
2743
2836
  return [];
2744
2837
  }
2745
2838
  }
2839
+ /** Field-level shape guard shared by the parse and the recordMutation write
2840
+ * path (P3/v15: the guard's JSON.parse and this parse used to run twice over
2841
+ * the same bytes inside one transact). */
2842
+ function recordsFromParsed(parsed) {
2843
+ 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");
2844
+ }
2746
2845
  async function loadMutations(root, io = nodeEvolutionIo()) {
2747
2846
  return parseMutationRecords(await io.readText(mutationsFile(root)));
2748
2847
  }
2749
2848
  /** Append one record, trim to `cap`, and write atomically (versioned shape). */
2750
2849
  async function recordMutation(root, io, record, cap = 500) {
2751
2850
  await transactIo(io, mutationsFile(root), (current) => {
2851
+ let parsed = [];
2752
2852
  if (current !== null) try {
2753
- JSON.parse(current);
2853
+ parsed = JSON.parse(current);
2754
2854
  } catch {
2755
2855
  console.warn(`mutation audit record dropped: ${mutationsFile(root)} is malformed and was not overwritten`);
2756
2856
  return current;
2757
2857
  }
2758
- const existing = parseMutationRecords(current);
2858
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
2859
+ const version = parsed.version;
2860
+ if (typeof version === "number" && version > 1) {
2861
+ console.warn(`mutation audit record dropped: ${mutationsFile(root)} declares version ${version} (newer than 1); not overwritten`);
2862
+ return current;
2863
+ }
2864
+ }
2865
+ const existing = recordsFromParsed(parsed);
2759
2866
  existing.push(record);
2760
2867
  const trimmed = existing.length > cap ? existing.slice(existing.length - cap) : existing;
2761
2868
  return JSON.stringify({
@@ -2875,7 +2982,9 @@ function clamp01(value) {
2875
2982
  return Math.max(0, Math.min(1, value));
2876
2983
  }
2877
2984
  function daysBetween(from, now) {
2878
- return Math.max(0, (now.getTime() - new Date(from).getTime()) / 864e5);
2985
+ const t = Date.parse(from);
2986
+ if (!Number.isFinite(t)) return 0;
2987
+ return Math.max(0, (now.getTime() - t) / 864e5);
2879
2988
  }
2880
2989
  function computeQualityScores(input) {
2881
2990
  const now = input.now ?? /* @__PURE__ */ new Date();
@@ -2884,9 +2993,9 @@ function computeQualityScores(input) {
2884
2993
  const ageDays = Math.max(1, daysBetween(record.created_at, now));
2885
2994
  const idleDays = daysBetween(latestActivityAt(record) ?? record.created_at, now);
2886
2995
  const patchCount = record.patch_count;
2887
- const useCount = record.use_count;
2888
- const usageFrequency = clamp01(useCount / ageDays);
2889
- const stability = useCount === 0 ? 1 : clamp01(1 - patchCount / useCount);
2996
+ const loadCount = record.use_count + record.view_count;
2997
+ const usageFrequency = clamp01(loadCount / ageDays);
2998
+ const stability = loadCount === 0 ? 1 : clamp01(1 - patchCount / loadCount);
2890
2999
  const recency = idleDays < 30 ? 1 : clamp01(1 - (idleDays - 30) / 150);
2891
3000
  const references = clamp01((input.referenceCounts?.get(name) ?? 0) / 3);
2892
3001
  const mutationMaturity = patchCount === 0 ? .3 : patchCount === 1 ? .4 : clamp01((patchCount - 1) / Math.max(1, ageDays / 30));
@@ -3031,7 +3140,7 @@ const SECRET_PATTERNS = [
3031
3140
  ["jwt", /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g],
3032
3141
  ["bearer credential", /Bearer[\s]+[a-z0-9._~+/=\-]{16,}/gi]
3033
3142
  ];
3034
- const INLINE_ASSIGNMENT_PATTERN = /* @__PURE__ */ new RegExp("((?:\\b|[\\w-]+[_\\-])(?:token|api[_-]?key|secret|password|passwd)(?:[_\\-][\\w-]+)?\\b[\\s]*[:=][\\s]*[\"']?)([A-Z0-9._~+/=\\-]{12,})", "gi");
3143
+ const INLINE_ASSIGNMENT_PATTERN = /* @__PURE__ */ new RegExp("(^|[^\\w-])([\\w-]{0,64}[_\\-])?((?:token|api[_-]?key|secret|password|passwd)(?:[_\\-][\\w-]{0,64})?)\\b([\\s]*[:=][\\s]*)(?:\"([^\"\\r\\n]*)\"|'([^'\\r\\n]*)'|([^\\r\\n]+))", "gi");
3035
3144
  /**
3036
3145
  * Mask credential-shaped text before it crosses a session boundary.
3037
3146
  * @param text - the text about to be sent to a model outside this session.
@@ -3040,7 +3149,7 @@ const INLINE_ASSIGNMENT_PATTERN = /* @__PURE__ */ new RegExp("((?:\\b|[\\w-]+[_\
3040
3149
  function redactSecrets(text) {
3041
3150
  let out = text;
3042
3151
  for (const [, pattern] of SECRET_PATTERNS) out = out.replace(pattern, "<redacted>");
3043
- out = out.replace(INLINE_ASSIGNMENT_PATTERN, (_match, p1) => `${p1 ?? ""}<redacted>`);
3152
+ out = out.replace(INLINE_ASSIGNMENT_PATTERN, (_match, lead, prefix, key, separator) => `${lead ?? ""}${prefix ?? ""}${key ?? ""}${separator ?? ""}<redacted>`);
3044
3153
  return out;
3045
3154
  }
3046
3155
  //#endregion
@@ -3128,6 +3237,22 @@ const CORRECTION_PATTERNS = [
3128
3237
  /remember\s+(?:this|that|to)/i
3129
3238
  ];
3130
3239
  const FIX_PATTERNS = [/worked after|fixed by|the fix was|root cause/i, /retry(?:ing)? worked|workaround/i];
3240
+ /** Text of one persisted content block, or `''` for any other shape.
3241
+ *
3242
+ * Content blocks cross the durable session-log boundary, so their runtime
3243
+ * shape is `unknown` even where the static event type promises
3244
+ * `{ type, text }`: a persisted `content: [null]` (A2-7, v18) used to throw a
3245
+ * TypeError here and the review catch swallowed the whole turn's remaining
3246
+ * signals. Keeping the guard in one helper also keeps the branches free of
3247
+ * conditions the static type already excludes.
3248
+ * @param block - one element of a persisted message `content` array.
3249
+ * @returns the block's text when it is a text block, otherwise an empty string.
3250
+ */
3251
+ function textOfBlock(block) {
3252
+ if (block === null || typeof block !== "object") return "";
3253
+ const candidate = block;
3254
+ return candidate.type === "text" && typeof candidate.text === "string" ? candidate.text : "";
3255
+ }
3131
3256
  /** Fold one session event into the current turn observation. */
3132
3257
  function observeEvent(signal, event) {
3133
3258
  const data = event.data;
@@ -3135,7 +3260,7 @@ function observeEvent(signal, event) {
3135
3260
  if (event.type === "user/message") {
3136
3261
  const content = data.content;
3137
3262
  if (!Array.isArray(content)) return;
3138
- const text = content.map((block) => block.type === "text" ? block.text ?? "" : "").join(" ");
3263
+ const text = content.map(textOfBlock).join(" ");
3139
3264
  signal.userChars += text.length;
3140
3265
  if (CORRECTION_PATTERNS.some((pattern) => pattern.test(text))) signal.memorySignal = true;
3141
3266
  if (FIX_PATTERNS.some((pattern) => pattern.test(text))) signal.skillSignal = true;
@@ -3144,7 +3269,7 @@ function observeEvent(signal, event) {
3144
3269
  if (event.type === "assistant/message") {
3145
3270
  const message = data.message;
3146
3271
  if (!message || !Array.isArray(message.content)) return;
3147
- const text = message.content.map((block) => block.type === "text" ? block.text ?? "" : "").join(" ");
3272
+ const text = message.content.map(textOfBlock).join(" ");
3148
3273
  signal.assistantChars += text.length;
3149
3274
  return;
3150
3275
  }
@@ -3386,8 +3511,12 @@ const MAX_RESTRUCTURE_MOVES = 5;
3386
3511
  * dots) used to pass here while every later patch/write/remove on it was
3387
3512
  * refused as traversal (an orphan file the user could not touch). */
3388
3513
  const RESTRUCTURE_TARGET_RE = /^references\/[a-z0-9](?!.*\.\.)[a-z0-9._-]*\.md$/;
3514
+ /** F-20 (v18): the character rule shared by support-file names and snapshot
3515
+ * `extras/` entry names. The two exported names used to carry the same literal
3516
+ * independently; both now derive from this one. */
3517
+ const SUPPORT_ENTRY_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
3389
3518
  /** Extra file name carried inside a snapshot's `extras/` directory. */
3390
- const SNAPSHOT_EXTRA_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
3519
+ const SNAPSHOT_EXTRA_NAME_RE = SUPPORT_ENTRY_NAME_RE;
3391
3520
  function skillsRoot(env = process.env) {
3392
3521
  return join(evolutionRoot(env), "skills");
3393
3522
  }
@@ -3401,6 +3530,29 @@ function skillsRoot(env = process.env) {
3401
3530
  function resolveSkillsRoot(config = {}) {
3402
3531
  return (config.root ?? "").trim() || skillsRoot();
3403
3532
  }
3533
+ /** E-7 (v18): every family row reads ONE root key. `root` is canonical;
3534
+ * `skillsRoot` is a deprecated alias honoured only while `root` is empty (so a
3535
+ * deployment that sets both keeps the canonical one) and removed after 0.3.65.
3536
+ * Callers log their own deprecation warning.
3537
+ * @param config - the raw plugin config, carrying `root` and/or `skillsRoot`.
3538
+ * @returns the effective root (empty when neither key is set) and whether the
3539
+ * deprecated alias supplied it.
3540
+ */
3541
+ function resolveRootConfig(config = {}) {
3542
+ const root = (config.root ?? "").trim();
3543
+ if (root !== "") return {
3544
+ root,
3545
+ usedDeprecatedAlias: false
3546
+ };
3547
+ const alias = (config.skillsRoot ?? "").trim();
3548
+ return alias === "" ? {
3549
+ root: "",
3550
+ usedDeprecatedAlias: false
3551
+ } : {
3552
+ root: alias,
3553
+ usedDeprecatedAlias: true
3554
+ };
3555
+ }
3404
3556
  /**
3405
3557
  * Map a requesting session onto the two origin surfaces (rc.44 plan M2-2.3):
3406
3558
  * the APPROVAL surface treats every delegated subagent as the autonomous
@@ -3437,6 +3589,15 @@ function skillDir(root, name) {
3437
3589
  function markerEntryName(marker) {
3438
3590
  return `.${marker}`;
3439
3591
  }
3592
+ /** F-17 (v18): the root-level lock files a DESTRUCTIVE MOVER must treat as an
3593
+ * active writer (skill body + the two marker writers). Single source with
3594
+ * `markerEntryName`/`LOCK_SUFFIX` so a renamed marker cannot silently drop out
3595
+ * of the ghost-writer probe. */
3596
+ const MARKER_LOCK_NAMES = [
3597
+ `SKILL.md${LOCK_SUFFIX}`,
3598
+ `.pinned${LOCK_SUFFIX}`,
3599
+ `.hermes-managed${LOCK_SUFFIX}`
3600
+ ];
3440
3601
  function markerPath(dir, marker) {
3441
3602
  return join(dir, markerEntryName(marker));
3442
3603
  }
@@ -3689,11 +3850,13 @@ async function listNames(root, io) {
3689
3850
  * `[a-z0-9._-]`) — drive-colon / odd-character / uppercase names can no
3690
3851
  * longer reach the filesystem through writeSupportFile / patch /
3691
3852
  * removeSupportFile. */
3692
- const SUPPORT_FILE_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
3853
+ const SUPPORT_FILE_NAME_RE = SUPPORT_ENTRY_NAME_RE;
3693
3854
  /** C-18: win32 reserves these stems with ANY extension (`nul.md` hits the
3694
3855
  * NUL device), and they are fully inside the charset above — so the reserved
3695
3856
  * set is checked on the first-dot prefix as well; the charset close alone
3696
- * cannot refuse them. */
3857
+ * cannot refuse them. Exported single source: `badName` (skill directories,
3858
+ * P2-11/v15) and `validateSupportPath` (support-file stems, C-18) both
3859
+ * consume this one set — a third copy would drift. */
3697
3860
  const WIN32_RESERVED_DEVICE_NAMES = new Set([
3698
3861
  "con",
3699
3862
  "prn",
@@ -3718,6 +3881,16 @@ const WIN32_RESERVED_DEVICE_NAMES = new Set([
3718
3881
  "lpt8",
3719
3882
  "lpt9"
3720
3883
  ]);
3884
+ /** A1-6 (v18): the regex alone admits `references/nul.md` (a Windows device
3885
+ * stem), which the support-file layer refuses. Restructure must use the same
3886
+ * rule, or it creates an orphan the later patch/write/remove paths refuse.
3887
+ * Single source shared with the plan validator. */
3888
+ function validateRestructureTarget(filePath) {
3889
+ if (!RESTRUCTURE_TARGET_RE.test(filePath)) return `toFile must be references/<topic>.md (got "${filePath}").`;
3890
+ const stem = filePath.slice(filePath.lastIndexOf("/") + 1).split(".")[0]?.toLowerCase() ?? "";
3891
+ if (WIN32_RESERVED_DEVICE_NAMES.has(stem)) return `toFile "${filePath}" uses a Windows reserved device name.`;
3892
+ return null;
3893
+ }
3721
3894
  function validateSupportPath(filePath) {
3722
3895
  const normalized = filePath.replace(/\\/g, "/");
3723
3896
  if (normalized.includes("..")) return "Path traversal is not allowed.";
@@ -3726,6 +3899,8 @@ function validateSupportPath(filePath) {
3726
3899
  if (parts.length < 2) return "Provide a file name, not just a directory.";
3727
3900
  for (const part of parts.slice(1)) {
3728
3901
  if (!SUPPORT_FILE_NAME_RE.test(part)) return `Unsupported file name "${part}" — use lowercase letters, digits, dots, hyphens, and underscores (leading letter or digit).`;
3902
+ if (part.toLowerCase().endsWith(".lock")) return `Unsupported file name "${part}" — the .lock suffix is reserved for the writer-lock protocol.`;
3903
+ if (part.toLowerCase().endsWith(".corrupt") || part.toLowerCase().endsWith(".tmp")) return `Unsupported file name "${part}" — the .corrupt/.tmp suffixes are reserved for the state/IO protocols.`;
3729
3904
  const stem = part.split(".")[0]?.toLowerCase() ?? "";
3730
3905
  if (WIN32_RESERVED_DEVICE_NAMES.has(stem)) return `Unsupported file name "${part}" — a Windows reserved device name.`;
3731
3906
  }
@@ -3933,11 +4108,23 @@ var SkillLibrary = class {
3933
4108
  outcome = o;
3934
4109
  return o.write ?? current ?? null;
3935
4110
  };
3936
- if (this.transact) await this.transact(this.io, path, run);
4111
+ let durabilityWarning = "";
4112
+ const committedOnly = (error) => error?.committed === true;
4113
+ if (this.transact) try {
4114
+ await this.transact(this.io, path, run);
4115
+ } catch (error) {
4116
+ if (!committedOnly(error)) throw error;
4117
+ durabilityWarning = error instanceof Error ? error.message : String(error);
4118
+ }
3937
4119
  else {
3938
4120
  const current = await this.io.readText(path);
3939
4121
  const next = await run(current);
3940
- if (next !== null && next !== current) await this.io.writeText(path, next);
4122
+ if (next !== null && next !== current) try {
4123
+ await this.io.writeText(path, next);
4124
+ } catch (error) {
4125
+ if (!committedOnly(error)) throw error;
4126
+ durabilityWarning = error instanceof Error ? error.message : String(error);
4127
+ }
3941
4128
  }
3942
4129
  const o = outcome;
3943
4130
  if (o === void 0 || typeof o !== "object" || !Object.prototype.hasOwnProperty.call(o, "write")) return {
@@ -3946,7 +4133,10 @@ var SkillLibrary = class {
3946
4133
  };
3947
4134
  if (o.write !== null && o.audit) await this.audit(o.audit.skillName, o.audit.action, o.audit.before, o.audit.after, o.audit.summary);
3948
4135
  if (o.write !== null && o.event) this.notifyMutation(o.event);
3949
- return o.result;
4136
+ return durabilityWarning === "" || !o.result.ok ? o.result : {
4137
+ ...o.result,
4138
+ message: `${o.result.message} (warning: the write landed but the directory fsync failed — durability unconfirmed: ${durabilityWarning})`
4139
+ };
3950
4140
  }
3951
4141
  /** Notify the mutation observer after a successful write; observers must never fail the mutation. */
3952
4142
  notifyMutation(event) {
@@ -3963,8 +4153,7 @@ var SkillLibrary = class {
3963
4153
  * label (scanContentThreats already embeds it) plus the self-heal hint, so a
3964
4154
  * false-positive rewrite direction is actionable instead of a dead end. */
3965
4155
  contentThreatBlock(content) {
3966
- const threat = scanContentThreats(content, void 0, this.threatScanOptions());
3967
- return threat === null ? null : threat + THREAT_EXEMPT_HINT;
4156
+ return scanContentThreats(content, void 0, this.threatScanOptions());
3968
4157
  }
3969
4158
  async list() {
3970
4159
  const summaries = [];
@@ -3973,20 +4162,42 @@ var SkillLibrary = class {
3973
4162
  const md = await this.io.readText(join(dir, "SKILL.md"));
3974
4163
  if (md === null) continue;
3975
4164
  const parsed = parseFrontmatter(md);
3976
- let entries = [];
4165
+ let entries = null;
3977
4166
  try {
3978
4167
  entries = await this.io.list(dir);
3979
- } catch {}
3980
- const has = (marker) => entries.includes(markerEntryName(marker));
3981
- const protectedBy = has("bundled") ? "bundled" : has("hub-installed") ? "hub-installed" : has("pinned") ? "pinned" : null;
4168
+ } catch {
4169
+ entries = null;
4170
+ }
4171
+ const probeMarker = async (marker) => {
4172
+ if (entries !== null) return entries.includes(markerEntryName(marker));
4173
+ try {
4174
+ return await this.io.exists(join(dir, markerEntryName(marker)));
4175
+ } catch {
4176
+ return null;
4177
+ }
4178
+ };
4179
+ const [bundled, hubInstalled, pinned, hermesManaged] = await Promise.all([
4180
+ probeMarker("bundled"),
4181
+ probeMarker("hub-installed"),
4182
+ probeMarker("pinned"),
4183
+ probeMarker("hermes-managed")
4184
+ ]);
4185
+ const protectedBy = bundled === true ? "bundled" : hubInstalled === true ? "hub-installed" : pinned === true ? "pinned" : null;
3982
4186
  const parsedDescription = parsed?.frontmatter.description;
4187
+ const parsedWhenToUse = parsed?.frontmatter.whenToUse;
3983
4188
  summaries.push({
3984
4189
  name,
3985
4190
  description: typeof parsedDescription === "string" ? parsedDescription : "",
3986
4191
  path: dir,
3987
4192
  protectedBy,
3988
- managed: has("hermes-managed"),
3989
- archived: false
4193
+ protectionUnknown: [
4194
+ bundled,
4195
+ hubInstalled,
4196
+ pinned,
4197
+ hermesManaged
4198
+ ].some((value) => value === null),
4199
+ managed: hermesManaged === true,
4200
+ ...typeof parsedWhenToUse === "string" && parsedWhenToUse.trim() !== "" ? { whenToUse: parsedWhenToUse } : {}
3990
4201
  });
3991
4202
  }
3992
4203
  return summaries;
@@ -4023,9 +4234,10 @@ var SkillLibrary = class {
4023
4234
  * are the deliberate exceptions — their names come from `listNames()`, i.e.
4024
4235
  * from the tree itself, never from caller input.
4025
4236
  */
4026
- badName(name) {
4237
+ badName(name, opts = {}) {
4027
4238
  const normalized = name.trim();
4028
4239
  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}).`;
4240
+ 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
4241
  return null;
4030
4242
  }
4031
4243
  /**
@@ -4045,7 +4257,7 @@ var SkillLibrary = class {
4045
4257
  }
4046
4258
  async deleteProtection(rawName, options = {}) {
4047
4259
  const name = rawName.trim();
4048
- const badName = this.badName(name);
4260
+ const badName = this.badName(name, { allowReserved: true });
4049
4261
  if (badName) return badName;
4050
4262
  const dir = this.dirOf(name);
4051
4263
  const markers = options.allowBundled ? ["hub-installed", "pinned"] : [
@@ -4167,6 +4379,9 @@ var SkillLibrary = class {
4167
4379
  * marker write is the only state change; content is untouched.
4168
4380
  */
4169
4381
  async setPinned(name, pinned, origin = "foreground") {
4382
+ return await this.serial(() => this.setPinnedCore(name, pinned, origin));
4383
+ }
4384
+ async setPinnedCore(name, pinned, origin) {
4170
4385
  const normalized = name.trim();
4171
4386
  const bad = this.badName(normalized);
4172
4387
  if (bad) return {
@@ -4247,7 +4462,32 @@ var SkillLibrary = class {
4247
4462
  message: `Skill "${normalized}" is protected (${protection}).`
4248
4463
  };
4249
4464
  const onDisk = finalContent.trimEnd() + "\n";
4250
- await this.io.writeText(join(dir, "SKILL.md"), onDisk);
4465
+ const createPath = join(dir, "SKILL.md");
4466
+ let existsAtCommit = false;
4467
+ let taskRan = false;
4468
+ if (this.transact) await this.transact(this.io, createPath, (current) => {
4469
+ taskRan = true;
4470
+ if (current !== null) {
4471
+ existsAtCommit = true;
4472
+ return current;
4473
+ }
4474
+ return onDisk;
4475
+ });
4476
+ else if (await this.io.exists(createPath)) {
4477
+ taskRan = true;
4478
+ existsAtCommit = true;
4479
+ } else {
4480
+ taskRan = true;
4481
+ await this.io.writeText(createPath, onDisk);
4482
+ }
4483
+ if (!taskRan) return {
4484
+ ok: false,
4485
+ message: "internal error: the create transaction did not invoke the task; no file was written"
4486
+ };
4487
+ if (existsAtCommit) return {
4488
+ ok: false,
4489
+ message: `Skill "${normalized}" already exists.`
4490
+ };
4251
4491
  if (origin !== "foreground") await this.io.writeText(markerPath(dir, "hermes-managed"), "");
4252
4492
  await this.audit(normalized, "create", null, onDisk, "created");
4253
4493
  this.notifyMutation({
@@ -4267,13 +4507,13 @@ var SkillLibrary = class {
4267
4507
  return await this.serial(() => this.updateCore(name, content, origin));
4268
4508
  }
4269
4509
  async updateCore(name, content, origin) {
4270
- const dir = this.dirOf(name);
4271
- const path = join(dir, "SKILL.md");
4272
4510
  const badName = this.badName(name);
4273
4511
  if (badName) return {
4274
4512
  ok: false,
4275
4513
  message: badName
4276
4514
  };
4515
+ const dir = this.dirOf(name);
4516
+ const path = join(dir, "SKILL.md");
4277
4517
  const protection = await this.writeProtection(name, origin);
4278
4518
  if (protection) return {
4279
4519
  ok: false,
@@ -4493,9 +4733,140 @@ var SkillLibrary = class {
4493
4733
  };
4494
4734
  });
4495
4735
  }
4736
+ /**
4737
+ * P2-9 (v15): the destructive directory move shared by archive and
4738
+ * restoreFromArchive — rename first, copy+remove fallback when the backend
4739
+ * cannot rename across media (V5-35), with the E-14 rollback when the
4740
+ * fallback's source removal fails. Returns a failure MESSAGE on a failed
4741
+ * move (caller wraps into a structured result) or undefined on success.
4742
+ */
4743
+ async moveDir(dir, dest) {
4744
+ try {
4745
+ await this.io.rename(dir, dest);
4746
+ return;
4747
+ } catch {
4748
+ if (await this.io.exists(dest)) return "the destination appeared mid-move (concurrent create or restore); refusing to merge — inspect both trees";
4749
+ try {
4750
+ await this.io.copy(dir, dest);
4751
+ } catch (copyError) {
4752
+ return `the move fell back to copy but failed (${copyError instanceof Error ? copyError.message : String(copyError)}); the tree stays where it is`;
4753
+ }
4754
+ try {
4755
+ await this.io.remove(dir);
4756
+ return;
4757
+ } catch (error) {
4758
+ const reason = error instanceof Error ? error.message : String(error);
4759
+ try {
4760
+ await this.io.remove(dest);
4761
+ return `the copy succeeded but the source could not be removed (${reason}); the copied tree was rolled back`;
4762
+ } catch {
4763
+ 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`;
4764
+ }
4765
+ }
4766
+ }
4767
+ }
4768
+ /**
4769
+ * P2 (v16): the write-lock probe for the DESTRUCTIVE MOVERS (archive /
4770
+ * restoreFromArchive). A byte-writer mid-flight is the ghost-generator —
4771
+ * after the move its transact commit re-creates `<dir>/…` (mkdir
4772
+ * recursive) and the tree ends half-archived. The signal is the writer's
4773
+ * own lock file, and its PLACEMENT (inside the moved directory) is why the
4774
+ * mover must PROBE-and-REFUSE instead of acquiring it: an acquired lock
4775
+ * would be renamed into `.archive` with the tree, stranding a phantom live
4776
+ * lock (the v16 audit proved the probe→rename TOCTOU does exactly that,
4777
+ * and restore would later move the residue back into the live root).
4778
+ * Coverage: `SKILL.md.lock` (update/patch of the body) plus one level of
4779
+ * each support dir (write_file's lock sits next to its file). Residual:
4780
+ * NESTED support-subdir locks and the probe→rename TOCTOU itself remain
4781
+ * fail-safe (renameWithRetry rides the write out; the writer's locked
4782
+ * re-read refuses on the moved-away file), and a residue `.lock` from a
4783
+ * CRASHED writer also refuses — correct: inspect, don't archive.
4784
+ */
4785
+ async hasWriteLock(dir) {
4786
+ const markerLocks = MARKER_LOCK_NAMES.map((name) => join(dir, name));
4787
+ for (const lock of markerLocks) if (await this.isWriterLock(lock)) return true;
4788
+ for (const supportDir of SUPPORT_DIRS) {
4789
+ let entries;
4790
+ try {
4791
+ entries = await this.io.list(join(dir, supportDir));
4792
+ } catch {
4793
+ return true;
4794
+ }
4795
+ for (const entry of entries) {
4796
+ if (!entry.endsWith(".lock")) continue;
4797
+ if (await this.isWriterLock(join(dir, supportDir, entry))) return true;
4798
+ }
4799
+ }
4800
+ return false;
4801
+ }
4802
+ /** P2 (v17): a file only counts as a writer lock when its body has the
4803
+ * `pid:token` shape the io layer writes. User support files legitimately
4804
+ * named `*.lock` (allowed by SUPPORT_FILE_NAME_RE) must not trip the probe
4805
+ * or be swept as residue — the v16 first cut matched on suffix alone,
4806
+ * which permanently refused archiving and deleted user content on restore. */
4807
+ async isWriterLock(lockPath) {
4808
+ let body;
4809
+ try {
4810
+ body = await this.io.readText(lockPath);
4811
+ } catch {
4812
+ return true;
4813
+ }
4814
+ if (body === null) return false;
4815
+ return LOCK_BODY_RE.test(body.trim());
4816
+ }
4817
+ /** P2 (v16): best-effort removal of lock residue inside a RESTORED tree.
4818
+ * A1-2/A1-7 (v18): the sweep now covers the marker locks the probe checks
4819
+ * (`SKILL.md.lock`/`.pinned.lock`/`.hermes-managed.lock`) and only removes
4820
+ * a lock whose holder pid is NOT alive — a live writer's lock is never
4821
+ * stolen by the sweep. A dead-pid residue would otherwise permanently
4822
+ * refuse archive/restore. */
4823
+ async deleteStrandedLocks(dir) {
4824
+ await this.sweepLockIfStranded(join(dir, "SKILL.md.lock"));
4825
+ await this.sweepLockIfStranded(join(dir, ".pinned.lock"));
4826
+ await this.sweepLockIfStranded(join(dir, ".hermes-managed.lock"));
4827
+ for (const supportDir of SUPPORT_DIRS) {
4828
+ let entries = [];
4829
+ try {
4830
+ entries = await this.io.list(join(dir, supportDir));
4831
+ } catch {
4832
+ continue;
4833
+ }
4834
+ for (const entry of entries) if (entry.endsWith(".lock")) await this.sweepLockIfStranded(join(dir, supportDir, entry));
4835
+ }
4836
+ }
4837
+ /** Remove `lockPath` only when its body has the writer-lock `pid:token`
4838
+ * shape AND the holder pid is not alive; anything else (a user support file
4839
+ * or a live writer's lock) is left untouched. */
4840
+ async sweepLockIfStranded(lockPath) {
4841
+ const body = await this.io.readText(lockPath).catch(() => null);
4842
+ if (body === null) return;
4843
+ const match = /^(\d+):[0-9a-f]*$/.exec(body.trim());
4844
+ if (match === null) return;
4845
+ const pid = Number(match[1]);
4846
+ if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid)) return;
4847
+ await this.io.remove(lockPath).catch(() => {});
4848
+ }
4849
+ /** A1-4 (v18): a manifest-declared name is copied with `join(root, name)`;
4850
+ * only a single, non-traversing path component is safe. Dotfiles
4851
+ * (`.usage.json`) stay allowed — sidecars are legitimately dot-prefixed. */
4852
+ safeSnapshotEntryName(name) {
4853
+ return name !== "" && name !== "." && name !== ".." && !name.includes("/") && !name.includes("\\") && basename(name) === name;
4854
+ }
4855
+ /** A1-7 (v18): a root-level lock whose holder is alive must refuse the
4856
+ * restore; a dead residue is swept so a crashed writer cannot block
4857
+ * recovery. A non-lock body shape is left alone (user file). */
4858
+ async refuseLiveLockOrSweep(lockPath, label) {
4859
+ const body = await this.io.readText(lockPath).catch(() => null);
4860
+ if (body === null) return;
4861
+ const match = /^(\d+):[0-9a-f]*$/.exec(body.trim());
4862
+ if (match === null) return;
4863
+ const pid = Number(match[1]);
4864
+ if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid)) throw new Error(`snapshot restore refused: ${label} is being written (write lock present); retry once the write completes`);
4865
+ await this.io.remove(lockPath).catch(() => {});
4866
+ }
4496
4867
  async archive(rawName, options = {}) {
4497
4868
  const name = rawName.trim();
4498
- const badName = this.badName(name);
4869
+ const badName = this.badName(name, { allowReserved: true });
4499
4870
  if (badName) return {
4500
4871
  ok: false,
4501
4872
  message: badName
@@ -4516,6 +4887,11 @@ var SkillLibrary = class {
4516
4887
  ok: false,
4517
4888
  message: "absorbed_into cannot be the skill being archived (cannot absorb into itself)."
4518
4889
  };
4890
+ const intoBad = this.badName(options.absorbedInto.trim());
4891
+ if (intoBad) return {
4892
+ ok: false,
4893
+ message: `absorbed_into: ${intoBad}`
4894
+ };
4519
4895
  if (!await this.io.readText(join(this.dirOf(options.absorbedInto), "SKILL.md"))) return {
4520
4896
  ok: false,
4521
4897
  message: `absorbed_into="${options.absorbedInto}" does not exist.`
@@ -4534,35 +4910,15 @@ var SkillLibrary = class {
4534
4910
  message: `Skill "${name}" is a symlink; refusing to archive it.`
4535
4911
  };
4536
4912
  }
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
- }
4913
+ if (await this.hasWriteLock(dir)) return {
4914
+ ok: false,
4915
+ message: `Skill "${name}" is being written (write lock present); retry archiving once the write completes.`
4916
+ };
4917
+ const moveFailure = await this.moveDir(dir, dest);
4918
+ if (moveFailure !== void 0) return {
4919
+ ok: false,
4920
+ message: `Skill "${name}" archive failed: ${moveFailure}.`
4921
+ };
4566
4922
  const reason = options.reason ?? (options.absorbedInto ? `Consolidated into ${options.absorbedInto}` : "Archived by self-evolution curator");
4567
4923
  try {
4568
4924
  await this.io.writeText(join(dest, ".archive-reason"), `${(/* @__PURE__ */ new Date()).toISOString()}: ${reason}\n`);
@@ -4792,9 +5148,10 @@ var SkillLibrary = class {
4792
5148
  ok: false,
4793
5149
  message: "Every restructure move needs a non-empty heading."
4794
5150
  };
4795
- if (!RESTRUCTURE_TARGET_RE.test(move.toFile)) return {
5151
+ const targetIssue = validateRestructureTarget(move.toFile);
5152
+ if (targetIssue) return {
4796
5153
  ok: false,
4797
- message: `toFile must be references/<topic>.md (got "${move.toFile}").`
5154
+ message: targetIssue
4798
5155
  };
4799
5156
  }
4800
5157
  const dir = this.dirOf(name);
@@ -4875,7 +5232,7 @@ var SkillLibrary = class {
4875
5232
  */
4876
5233
  async applyTreeChange(plan) {
4877
5234
  const name = plan.name.trim();
4878
- const badName = this.badName(name);
5235
+ const badName = this.badName(name, { allowReserved: true });
4879
5236
  if (badName) return {
4880
5237
  ok: false,
4881
5238
  message: badName
@@ -4891,16 +5248,17 @@ var SkillLibrary = class {
4891
5248
  ok: false,
4892
5249
  message: `Skill "${name}" is protected (${protection}).`
4893
5250
  };
4894
- for (const precondition of plan.preconditions ?? []) {
4895
- const issue = await precondition({ dir });
4896
- if (issue) return {
4897
- ok: false,
4898
- message: issue
4899
- };
4900
- }
4901
5251
  const landing = [];
4902
5252
  for (const write of plan.writes) {
4903
- const previous = await this.io.readText(write.target).catch(() => null);
5253
+ let previous;
5254
+ try {
5255
+ previous = await this.io.readText(write.target);
5256
+ } catch (error) {
5257
+ return {
5258
+ ok: false,
5259
+ message: `Tree change refused: cannot safely pre-read ${write.target} (${error instanceof Error ? error.message : String(error)}); no writes were performed`
5260
+ };
5261
+ }
4904
5262
  if (Buffer.byteLength(write.content, "utf8") > this.limits.maxSkillFileBytes) return {
4905
5263
  ok: false,
4906
5264
  message: `Write exceeds ${this.limits.maxSkillFileBytes} bytes: ${write.target}`
@@ -4916,18 +5274,16 @@ var SkillLibrary = class {
4916
5274
  previous
4917
5275
  });
4918
5276
  }
4919
- const semantic = plan.validate?.({
4920
- dir,
4921
- currentMd: md
4922
- }) ?? null;
4923
- if (semantic) return {
4924
- ok: false,
4925
- message: semantic
4926
- };
4927
5277
  const written = [];
5278
+ let durabilityWarning = "";
4928
5279
  try {
4929
5280
  for (const entry of landing) {
4930
- await this.io.writeText(entry.target, entry.content);
5281
+ try {
5282
+ await this.io.writeText(entry.target, entry.content);
5283
+ } catch (error) {
5284
+ if (error?.committed !== true) throw error;
5285
+ durabilityWarning = error instanceof Error ? error.message : String(error);
5286
+ }
4931
5287
  written.push({
4932
5288
  target: entry.target,
4933
5289
  previous: entry.previous
@@ -4948,7 +5304,7 @@ var SkillLibrary = class {
4948
5304
  });
4949
5305
  return {
4950
5306
  ok: true,
4951
- message: `${plan.eventAction} "${name}" succeeded.`,
5307
+ message: durabilityWarning === "" ? `${plan.eventAction} "${name}" succeeded.` : `${plan.eventAction} "${name}" succeeded (warning: the write landed but the directory fsync failed — durability unconfirmed: ${durabilityWarning})`,
4952
5308
  path: dir
4953
5309
  };
4954
5310
  }
@@ -4959,14 +5315,15 @@ var SkillLibrary = class {
4959
5315
  */
4960
5316
  async restoreFromArchive(rawName) {
4961
5317
  const name = rawName.trim();
4962
- const bad = this.badName(name);
5318
+ const bad = this.badName(name, { allowReserved: true });
4963
5319
  if (bad) return {
4964
5320
  ok: false,
4965
5321
  message: bad
4966
5322
  };
4967
- if (await this.io.exists(join(this.dirOf(name), "SKILL.md"))) return {
5323
+ const dest = this.dirOf(name);
5324
+ if (await this.io.exists(dest)) return {
4968
5325
  ok: false,
4969
- message: `Skill "${name}" already exists in the active root; refusing to overwrite.`
5326
+ message: await this.io.exists(join(dest, "SKILL.md")) ? `Skill "${name}" already exists in the active root; refusing to overwrite.` : `Skill directory "${name}" already exists in the active root but carries no SKILL.md; remove or repair it before restoring.`
4970
5327
  };
4971
5328
  const archiveRoot = join(this.root, ".archive");
4972
5329
  let entries;
@@ -4989,27 +5346,24 @@ var SkillLibrary = class {
4989
5346
  message: `Skill "${name}" is not in .archive.`
4990
5347
  };
4991
5348
  const source = join(archiveRoot, chosen);
4992
- const dest = this.dirOf(name);
4993
5349
  if (this.io.isSymlink) {
4994
5350
  if (await this.io.isSymlink(source) === true) return {
4995
5351
  ok: false,
4996
5352
  message: `Archived entry "${chosen}" is a symlink; refusing to restore it.`
4997
5353
  };
4998
5354
  }
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
- }
5355
+ if (await this.hasWriteLock(dest)) return {
5356
+ ok: false,
5357
+ message: `Skill "${name}" is being written (write lock present); retry restoring once the write completes.`
5358
+ };
5359
+ const moveFailure = await this.moveDir(source, dest);
5360
+ if (moveFailure !== void 0) return {
5361
+ ok: false,
5362
+ message: `Restore of "${name}" from .archive failed: ${moveFailure}`
5363
+ };
5364
+ await this.deleteStrandedLocks(dest);
5012
5365
  if (await this.io.exists(join(dest, ".archive-reason"))) await this.io.remove(join(dest, ".archive-reason"));
5366
+ await this.audit(name, "restore", null, await this.io.readText(join(dest, "SKILL.md")).catch(() => null), `restored from ${source}`);
5013
5367
  this.notifyMutation({
5014
5368
  action: "restore",
5015
5369
  name,
@@ -5026,12 +5380,12 @@ var SkillLibrary = class {
5026
5380
  return await this.serial(() => this.writeSupportFileCore(name, filePath, content, origin));
5027
5381
  }
5028
5382
  async writeSupportFileCore(name, filePath, content, origin) {
5029
- const dir = this.dirOf(name);
5030
5383
  const badName = this.badName(name);
5031
5384
  if (badName) return {
5032
5385
  ok: false,
5033
5386
  message: badName
5034
5387
  };
5388
+ const dir = this.dirOf(name);
5035
5389
  if (!await this.io.exists(join(dir, "SKILL.md"))) return {
5036
5390
  ok: false,
5037
5391
  message: `Skill "${name}" not found.`
@@ -5120,7 +5474,12 @@ var SkillLibrary = class {
5120
5474
  message: `File "${filePath}" not found in skill "${name}".`
5121
5475
  };
5122
5476
  const before = await this.io.readText(target).catch(() => null);
5123
- await this.io.remove(target);
5477
+ if (before === null) return {
5478
+ ok: false,
5479
+ message: `"${filePath}" is not a readable regular file — remove the files inside it one by one.`
5480
+ };
5481
+ if (this.transact) await this.transact(this.io, target, () => null);
5482
+ else await this.io.remove(target);
5124
5483
  await this.audit(name, "remove_file", before, null, `removed ${filePath}`);
5125
5484
  this.notifyMutation({
5126
5485
  action: "remove_file",
@@ -5147,9 +5506,10 @@ var SkillLibrary = class {
5147
5506
  while (await this.io.exists(dest)) dest = join(backupRoot, `skills-${stamp}-${Math.random().toString(36).slice(2, 8)}`);
5148
5507
  try {
5149
5508
  const names = await listNames(this.root, this.io);
5150
- await Promise.all(names.map(async (name) => {
5509
+ const copyFailure = (await Promise.allSettled(names.map(async (name) => {
5151
5510
  await this.io.copy(this.dirOf(name), join(dest, name));
5152
- }));
5511
+ }))).find((result) => result.status === "rejected");
5512
+ if (copyFailure) throw copyFailure.reason;
5153
5513
  const sidecars = [];
5154
5514
  for (const sidecar of [usageFile(this.root), suppressedFile(this.root)]) if (await this.io.exists(sidecar)) {
5155
5515
  const name = basename(sidecar);
@@ -5164,9 +5524,10 @@ var SkillLibrary = class {
5164
5524
  }
5165
5525
  const validExtras = extras.filter((extra) => SNAPSHOT_EXTRA_NAME_RE.test(extra.name));
5166
5526
  const extraNames = validExtras.map((extra) => extra.name);
5167
- await Promise.all(validExtras.map(async (extra) => {
5527
+ const extraFailure = (await Promise.allSettled(validExtras.map(async (extra) => {
5168
5528
  await this.io.writeText(join(dest, "extras", extra.name), extra.content);
5169
- }));
5529
+ }))).find((result) => result.status === "rejected");
5530
+ if (extraFailure) throw extraFailure.reason;
5170
5531
  await this.io.writeText(join(dest, "manifest.json"), JSON.stringify({
5171
5532
  reason,
5172
5533
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -5188,10 +5549,11 @@ var SkillLibrary = class {
5188
5549
  if (raw === null) return null;
5189
5550
  try {
5190
5551
  const manifest = JSON.parse(raw);
5552
+ if (!Array.isArray(manifest.skills)) return null;
5191
5553
  return {
5192
5554
  reason: typeof manifest.reason === "string" ? manifest.reason : "",
5193
5555
  createdAt: typeof manifest.createdAt === "string" ? manifest.createdAt : "",
5194
- skills: Array.isArray(manifest.skills) ? manifest.skills : [],
5556
+ skills: manifest.skills,
5195
5557
  sidecars: Array.isArray(manifest.sidecars) ? manifest.sidecars : [],
5196
5558
  ...typeof manifest.hasArchive === "boolean" ? { hasArchive: manifest.hasArchive } : {},
5197
5559
  extras: Array.isArray(manifest.extras) ? manifest.extras : []
@@ -5226,6 +5588,7 @@ var SkillLibrary = class {
5226
5588
  reason: manifest.reason
5227
5589
  });
5228
5590
  }
5591
+ out.sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || "") || b.path.localeCompare(a.path));
5229
5592
  return out;
5230
5593
  }
5231
5594
  /**
@@ -5296,6 +5659,15 @@ var SkillLibrary = class {
5296
5659
  * restoreLatestSnapshot so a failed restore can roll itself back (E-13).
5297
5660
  */
5298
5661
  async restoreSnapshotIntoRoot(snapshotPath) {
5662
+ const manifest = await this.readSnapshotManifest(snapshotPath);
5663
+ if (manifest === null) {
5664
+ if (await this.io.exists(join(snapshotPath, "manifest.json"))) throw new Error(`snapshot ${snapshotPath} has an unreadable manifest.json; refusing to clear the active tree`);
5665
+ } else {
5666
+ for (const name of [...manifest.skills, ...manifest.sidecars]) if (!this.safeSnapshotEntryName(name)) throw new Error(`snapshot ${snapshotPath} declares an unsafe entry name ${JSON.stringify(name)}; refusing to restore`);
5667
+ if (manifest.skills.length === 0) {
5668
+ if ((await this.io.list(snapshotPath)).some((entry) => entry !== "manifest.json" && entry !== "extras" && entry !== ".archive")) throw new Error(`snapshot ${snapshotPath} declares no skills but contains entries; refusing to clear the active tree`);
5669
+ }
5670
+ }
5299
5671
  let rootEntries;
5300
5672
  try {
5301
5673
  rootEntries = await this.io.list(this.root);
@@ -5304,13 +5676,24 @@ var SkillLibrary = class {
5304
5676
  }
5305
5677
  for (const entry of rootEntries) {
5306
5678
  if (entry === ".archive" || entry === ".backups" || entry === ".mutations.json" || entry === ".curator-suppressed.json") continue;
5307
- await this.io.remove(join(this.root, entry));
5679
+ if (entry.endsWith(".lock") || entry.endsWith(`.lock.next`)) {
5680
+ await this.refuseLiveLockOrSweep(join(this.root, entry), entry);
5681
+ continue;
5682
+ }
5683
+ const dir = join(this.root, entry);
5684
+ if (await this.io.exists(join(dir, "SKILL.md"))) {
5685
+ await this.deleteStrandedLocks(dir);
5686
+ if (await this.hasWriteLock(dir)) throw new Error(`snapshot restore refused: skill "${entry}" is being written (write lock present); retry once the write completes`);
5687
+ }
5308
5688
  }
5309
- const manifest = await this.readSnapshotManifest(snapshotPath);
5310
- if (manifest === null) for (const entry of await this.io.list(snapshotPath)) {
5311
- if (entry === "manifest.json" || entry === "extras") continue;
5312
- await this.io.copy(join(snapshotPath, entry), join(this.root, entry));
5689
+ const restoresSuppressed = manifest !== null && manifest.sidecars.includes(".curator-suppressed.json");
5690
+ for (const entry of rootEntries) {
5691
+ if (entry === ".archive" || entry === ".backups" || entry === ".mutations.json") continue;
5692
+ if (entry === ".curator-suppressed.json" && restoresSuppressed) continue;
5693
+ if (entry.endsWith(".lock") || entry.endsWith(`.lock.next`)) continue;
5694
+ await this.io.remove(join(this.root, entry));
5313
5695
  }
5696
+ if (manifest === null) throw new Error(`snapshot ${snapshotPath} has no readable manifest.json; refusing to restore`);
5314
5697
  else {
5315
5698
  for (const name of manifest.skills) await this.io.copy(join(snapshotPath, name), join(this.root, name));
5316
5699
  for (const sidecar of manifest.sidecars) await this.io.copy(join(snapshotPath, sidecar), join(this.root, sidecar));
@@ -5320,7 +5703,11 @@ var SkillLibrary = class {
5320
5703
  await this.io.copy(join(snapshotPath, ".archive"), archiveRoot);
5321
5704
  } else if (manifest.hasArchive === false) await this.io.remove(archiveRoot);
5322
5705
  }
5706
+ for (const entry of await this.io.list(this.root)) {
5707
+ if (entry.startsWith(".")) continue;
5708
+ await this.deleteStrandedLocks(join(this.root, entry));
5709
+ }
5323
5710
  }
5324
5711
  };
5325
5712
  //#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 };
5713
+ export { AUTHORING_DESCRIPTION_BAR, COMBINED_REVIEW_PLAN_PROMPT, COMBINED_REVIEW_PROMPT, COMPLETION_SKILL_REVIEW_PROMPT, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_CONSOLIDATION_FAILURES, DEFAULT_CURATOR_BOOT_GRACE_SECONDS, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_CURATOR_REVIEW_MAX_TOKENS, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_MAX_OPS_PER_PLAN, DEFAULT_MEMORY_CHAR_LIMIT, DEFAULT_MIN_IDLE_HOURS, DEFAULT_MUTATION_CAP, DEFAULT_REVIEW_CONTEXT_MESSAGES, DEFAULT_REVIEW_MEMORY_INTERVAL, DEFAULT_REVIEW_MESSAGE_CHARS, DEFAULT_REVIEW_SKILL_INTERVAL, DEFAULT_REVIEW_TIMEOUT_MS, DEFAULT_SKILL_CONTENT_CHARS, DEFAULT_SKILL_LIMITS, DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS, DEFAULT_SKILL_REVIEW_TRIGGER, DEFAULT_STALE_AFTER_DAYS, DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS, DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS, DEFAULT_SUBSTANTIVE_MIN_USER_CHARS, DEFAULT_USER_CHAR_LIMIT, DRIFT_MAX_LINE_CHARS, DRIFT_SIGNALS_VERSION, DRIFT_SIGNAL_NOUNS, DSH_AUTHORING_STANDARDS, ENTRY_DELIMITER, EVENT_ARCHIVE_PREFIX, EVENT_LOG_RETAIN_ARCHIVES, EVENT_LOG_ROTATE_AT, EVENT_LOG_VERSION, EVOLUTION_WRITE_TOOLS, EvolutionGateSet, FORBIDDEN_CONTROL_KEYS, HEALTH_STAMP_RE, LOCK_BODY_RE, LOCK_SUFFIX, LOW_QUALITY_THRESHOLD, MAINTAIN_OUTPUT_INSTRUCTION, MAINTAIN_PROMPT, MAX_DESCRIPTION_LENGTH, MAX_RESTRUCTURE_MOVES, MAX_SKILL_CONTENT_CHARS, MAX_SKILL_FILE_BYTES, MAX_SKILL_NAME_LENGTH, MEMORY_REVIEW_PROMPT, MIN_STAMP_BODY_CHARS, MUTATIONS_FILE_VERSION, MemoryStore, PATTERN_OVERLAP, PROMPT_BUNDLE, PROMPT_BUNDLE_ID, PROMPT_BUNDLE_VERSION, PROTECTED_BUILTIN_SKILLS, QUALITY_WEIGHTS, RESTRUCTURE_TARGET_RE, SKILLS_GUIDANCE, SKILL_NAME_RE, SKILL_REVIEW_PLAN_PROMPT, SKILL_REVIEW_PROMPT, SNAPSHOT_EXTRA_NAME_RE, SUPPORT_DIRS, SUPPRESSED_FILE_VERSION, SkillLibrary, THREAT_EXEMPT_HINT, advanceReview, allowRowCollisions, appendEvolutionEvent, applyCuratorFields, applyCuratorLifecycleFields, applyCuratorMetaFields, assessStructureHealth, authoringFeedback, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpUse, bumpView, clampedNumber, composePresetComposition, computeDedupGroups, computeDriftSignals, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, contentHash, createGateSet, duplicateHeadings, emptyRecord, evaluateThreat, eventsFile, evolutionEventPayloadIssue, evolutionHome, evolutionIoAdapter, evolutionRoot, findDriftSignal, foldCuratorFields, foldTurn, frontmatterBlock, frontmatterYamlUnsafeValues, getRecord, isProcessAlive, latestActivityAt, lifecycleCandidate, listEventArchives, loadMutations, loadSuppressedNames, loadUsage, makeSerialQueue, markAgentCreated, markerEntryName, memoryRoot, missingSupportPointers, mutateUsage, mutationsFile, narrowNameMatches, nodeEvolutionIo, normalizeFrontmatter, normalizeUsageRecord, observeEvent, overlongLines, parseCuratorNominations, parseEvolutionEvents, parseFrontmatter, pendingSelfCleanup, readEvolutionEvents, readEvolutionTimeline, recordMutation, redactSecrets, relatedSkillNames, renameWithRetry, renderCuratorReportMarkdown, resolveOrigins, resolveRootConfig, resolveSkillsRoot, retainEventArchives, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, suppressedFile, transactIo, updateSuppressedNames, usageFile, usageObserved, validateFrontmatter, validateRestructureTarget, verifyPromptBundle, writeDurableTmp, yamlPlainScalarNeedsQuotes };