@lmzhen/dsh-evolution-core 0.3.63 → 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/README.md CHANGED
@@ -29,16 +29,17 @@ Single-file writes (`update`, `patch`, `writeSupportFile`)
29
29
  additionally run the read and the write inside `transactIo` when a caller
30
30
  injects a `transact` into the constructor — that is the cross-process lock, so
31
31
  two processes sharing `DSH_HOME` cannot interleave their RMW on one file.
32
- `create` writes a new file and `archive`/`consolidate` already own a two-phase
33
- commit, so they deliberately stay outside the serial chain.
34
-
35
- **0.3.46 residual (documented, per G2.5 precedent):** the low-frequency
36
- single-file entry points `create`, `archive`, `removeSupportFile` and
37
- `setPinned` still perform an unlocked read→write (their per-file read is not
38
- inside the serial/transact task). The race needs a same-process concurrent
39
- mutator on the SAME skill file, which the serialized entry points above make
40
- unlikely; the exposure is acknowledged and not locked (收益不抵锁面扩大 —
41
- adding locks to four low-frequency entry points is not worth the surface).
32
+ `create` is INSIDE the serial chain and, when a transact backend is bound, its
33
+ exists check runs inside the same per-file transact (v18); `archive`/`consolidate`
34
+ are rename-based two-phase paths and stay outside the single-file serial chain.
35
+
36
+ **v18 residual (updated):** `create`, `removeSupportFile` and `setPinned` now
37
+ run on the serial chain (`removeSupportFile`'s delete also goes through the
38
+ per-file transact), so the remaining single-file residual is the protection
39
+ TOCTOU (a marker check outside the transact) and the multi-file two-phase
40
+ paths `archive`/`consolidate`/`restructure`. The race needs a concurrent
41
+ mutator on the SAME skill file/package; the exposure is acknowledged and the
42
+ protection check is the next candidate (see the v18 optimization plan, E-5).
42
43
 
43
44
  When the backend provides `transact` (nodeEvolutionIo and the io adapter do),
44
45
  the constructor binds it BY DEFAULT since 0.3.27 — the single-file entry points
@@ -47,8 +48,9 @@ the constructor binds it BY DEFAULT since 0.3.27 — the single-file entry point
47
48
  instantiation, so same-file concurrent writes from different processes no
48
49
  longer resolve to last-writer-wins there. An explicit `transact` argument
49
50
  overrides the default binding. The two-phase paths deliberately stay outside
50
- that lock: `create` (exists probe + write) can still double-pass the probe
51
- across processes, `archive`/`consolidate` are rename-based with best-effort
51
+ that lock: `create`'s exists probe runs inside the transact when a transact
52
+ backend is bound (v18), so only a transact-less custom backend can still
53
+ double-pass the probe across processes; `archive`/`consolidate` are rename-based with best-effort
52
54
  rollback (an archive loser's rollback surfaces the raw failure when the source
53
55
  vanished), and `restructure`'s multi-file swap can expose an interleaved tree
54
56
  to a concurrent reader. These residual windows are documented rather than
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 {
@@ -566,6 +589,11 @@ async function mutateUsage(root, io, task) {
566
589
  if (current !== null) try {
567
590
  const probe = JSON.parse(current);
568
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
+ }
569
597
  } catch {
570
598
  return current;
571
599
  }
@@ -617,7 +645,8 @@ function applyCuratorMetaFields(disk, curated) {
617
645
  * transitioned — a concurrent curator run's archive/restore is never reverted
618
646
  * by a stale snapshot; without it both pairs apply everywhere.
619
647
  */
620
- function foldCuratorFields(disk, curated, stateOwned) {
648
+ function foldCuratorFields(disk, curated, stateOwned, runStartStates) {
649
+ const skipped = [];
621
650
  for (const [name, record] of curated) {
622
651
  const diskRecord = disk.get(name);
623
652
  if (!diskRecord) {
@@ -625,8 +654,16 @@ function foldCuratorFields(disk, curated, stateOwned) {
625
654
  continue;
626
655
  }
627
656
  applyCuratorMetaFields(diskRecord, record);
628
- 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
+ }
629
665
  }
666
+ return skipped;
630
667
  }
631
668
  /** Whole-file usage write (V6-37, 0.3.37): this is the ONE path that bypasses
632
669
  * the malformed-defense and the transact lock — prefer `mutateUsage` for any
@@ -757,8 +794,14 @@ async function updateSuppressedNames(root, io, task) {
757
794
  * threshold, which are intentionally left where they are used.
758
795
  * @module @lmzhen/dsh-evolution-core
759
796
  */
760
- /** Skill frontmatter `name` validated for the file name (lowercase + hyphen). */
761
- 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]+)*$/;
762
805
  /** Allowed skill support-file subdirectories (path-traversal boundary). */
763
806
  const SUPPORT_DIRS = [
764
807
  "references",
@@ -796,7 +839,10 @@ const DEFAULT_MEMORY_CHAR_LIMIT = 2200;
796
839
  const DEFAULT_USER_CHAR_LIMIT = 1375;
797
840
  /** Consolidation-failure backoff cap, shared by MemoryStore and memory-files' Config default. */
798
841
  const DEFAULT_CONSOLIDATION_FAILURES = 3;
799
- 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;
800
846
  /** P3-19 (v14): defaults that were written twice (schema `.default()` AND the
801
847
  * clamp fallback literal) now have one home per value. */
802
848
  const DEFAULT_REVIEW_TIMEOUT_MS = 12e4;
@@ -1040,7 +1086,10 @@ function computeScopeView(usage, config, protectedNames, gates) {
1040
1086
  };
1041
1087
  }
1042
1088
  function daysSince(iso, created, now) {
1043
- 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;
1044
1093
  }
1045
1094
  function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Date(), gates, protectedNames) {
1046
1095
  const result = {
@@ -1053,9 +1102,9 @@ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Da
1053
1102
  for (const [name, record] of usage) {
1054
1103
  if (!lifecycleCandidate(name, record, config, config.bundledNames?.has(name) === true, gateSet, protectedNames)) continue;
1055
1104
  const age = daysSince(null, record.created_at, now.getTime());
1056
- if (record.use_count === 0 && age < config.staleAfterDays) continue;
1057
- const idle = daysSince(latestActivityAt(record), record.created_at, now.getTime());
1058
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;
1107
+ const idle = daysSince(latestActivityAt(record), record.created_at, now.getTime());
1059
1108
  const staleAfterDays = qualityWarn && config.qualityWarnStaleAfterDays !== void 0 ? config.qualityWarnStaleAfterDays : config.staleAfterDays;
1060
1109
  if (record.state === "active") {
1061
1110
  if (idle >= config.archiveAfterDays) {
@@ -1148,6 +1197,28 @@ const EVENT_ARCHIVE_RE = /^events-(\d+)\.json$/;
1148
1197
  function eventsFile(home) {
1149
1198
  return join(home, "evolution", "events.json");
1150
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
+ }
1151
1222
  function isEventRecord(event) {
1152
1223
  const seq = event?.seq;
1153
1224
  return typeof event === "object" && event !== null && typeof seq === "number" && Number.isFinite(seq);
@@ -1220,6 +1291,8 @@ async function listEventArchives(io, path) {
1220
1291
  * event can never shadow an archived one in the seq-deduped timeline.
1221
1292
  */
1222
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}`);
1223
1296
  let assigned = 0;
1224
1297
  let refuseMessage = "";
1225
1298
  let parsedBody = null;
@@ -1265,7 +1338,7 @@ async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE
1265
1338
  * one-event rotate would archive everything and restart seqs at 1).
1266
1339
  */
1267
1340
  async function rotateIfDue(io, path, events, rotateAt) {
1268
- if (rotateAt < 2 || events.length < rotateAt) return events;
1341
+ if (!Number.isFinite(rotateAt) || rotateAt < 2 || events.length < rotateAt) return events;
1269
1342
  const mid = Math.ceil(events.length / 2);
1270
1343
  const head = events.slice(0, mid);
1271
1344
  const tail = events.slice(mid);
@@ -1919,7 +1992,7 @@ const PATTERNS = [
1919
1992
  label: "disregard_rules",
1920
1993
  category: "prompt_injection",
1921
1994
  scope: "all",
1922
- 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")
1923
1996
  },
1924
1997
  {
1925
1998
  label: "system_prompt_override",
@@ -2072,7 +2145,7 @@ const PATTERNS = [
2072
2145
  regex: /-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----/
2073
2146
  }
2074
2147
  ];
2075
- 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");
2076
2149
  const BIDI_CHARS = /[\u202a-\u202e\u2066-\u2069]/;
2077
2150
  const SCOPE_ORDER = {
2078
2151
  all: 1,
@@ -2262,12 +2335,17 @@ var MemoryStore = class {
2262
2335
  /** V10-03 (P2-18): the strict-scan write gate. A block message names the hit
2263
2336
  * label (scanMemoryThreats already embeds it) plus the self-heal hint. */
2264
2337
  memoryThreatBlock(text) {
2265
- const threat = scanMemoryThreats(text, void 0, this.threatScanOptions());
2266
- return threat === null ? null : threat + THREAT_EXEMPT_HINT;
2338
+ return scanMemoryThreats(text, void 0, this.threatScanOptions());
2267
2339
  }
2268
2340
  limitFor(target) {
2269
2341
  return target === "memory" ? this.memoryLimit : this.userLimit;
2270
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
+ }
2271
2349
  /**
2272
2350
  * Read-guard probe: `{ size, limit }` when the on-disk file exceeds
2273
2351
  * `limit * READ_GUARD_FACTOR` bytes, `null` when it is absent, unknown
@@ -2452,7 +2530,7 @@ var MemoryStore = class {
2452
2530
  write: null
2453
2531
  };
2454
2532
  const entries = [...new Set(normalizeEntries(raw))];
2455
- if (entries.some((entry) => stripDatePrefix(entry) === content)) {
2533
+ if (entries.some((entry) => this.dedupeKey(entry) === content)) {
2456
2534
  this.resetFailures();
2457
2535
  return {
2458
2536
  result: {
@@ -2574,7 +2652,7 @@ var MemoryStore = class {
2574
2652
  },
2575
2653
  write: null
2576
2654
  };
2577
- if (!working.some((entry) => stripDatePrefix(entry) === body)) working.push(entryBody);
2655
+ if (!working.some((entry) => this.dedupeKey(entry) === body)) working.push(entryBody);
2578
2656
  continue;
2579
2657
  }
2580
2658
  const rawAction = op.action;
@@ -2777,6 +2855,13 @@ async function recordMutation(root, io, record, cap = 500) {
2777
2855
  console.warn(`mutation audit record dropped: ${mutationsFile(root)} is malformed and was not overwritten`);
2778
2856
  return current;
2779
2857
  }
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
+ }
2780
2865
  const existing = recordsFromParsed(parsed);
2781
2866
  existing.push(record);
2782
2867
  const trimmed = existing.length > cap ? existing.slice(existing.length - cap) : existing;
@@ -2897,7 +2982,9 @@ function clamp01(value) {
2897
2982
  return Math.max(0, Math.min(1, value));
2898
2983
  }
2899
2984
  function daysBetween(from, now) {
2900
- 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);
2901
2988
  }
2902
2989
  function computeQualityScores(input) {
2903
2990
  const now = input.now ?? /* @__PURE__ */ new Date();
@@ -2906,9 +2993,9 @@ function computeQualityScores(input) {
2906
2993
  const ageDays = Math.max(1, daysBetween(record.created_at, now));
2907
2994
  const idleDays = daysBetween(latestActivityAt(record) ?? record.created_at, now);
2908
2995
  const patchCount = record.patch_count;
2909
- const useCount = record.use_count;
2910
- const usageFrequency = clamp01(useCount / ageDays);
2911
- 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);
2912
2999
  const recency = idleDays < 30 ? 1 : clamp01(1 - (idleDays - 30) / 150);
2913
3000
  const references = clamp01((input.referenceCounts?.get(name) ?? 0) / 3);
2914
3001
  const mutationMaturity = patchCount === 0 ? .3 : patchCount === 1 ? .4 : clamp01((patchCount - 1) / Math.max(1, ageDays / 30));
@@ -3053,7 +3140,7 @@ const SECRET_PATTERNS = [
3053
3140
  ["jwt", /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g],
3054
3141
  ["bearer credential", /Bearer[\s]+[a-z0-9._~+/=\-]{16,}/gi]
3055
3142
  ];
3056
- 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");
3057
3144
  /**
3058
3145
  * Mask credential-shaped text before it crosses a session boundary.
3059
3146
  * @param text - the text about to be sent to a model outside this session.
@@ -3062,7 +3149,7 @@ const INLINE_ASSIGNMENT_PATTERN = /* @__PURE__ */ new RegExp("((?:\\b|[\\w-]+[_\
3062
3149
  function redactSecrets(text) {
3063
3150
  let out = text;
3064
3151
  for (const [, pattern] of SECRET_PATTERNS) out = out.replace(pattern, "<redacted>");
3065
- 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>`);
3066
3153
  return out;
3067
3154
  }
3068
3155
  //#endregion
@@ -3150,6 +3237,22 @@ const CORRECTION_PATTERNS = [
3150
3237
  /remember\s+(?:this|that|to)/i
3151
3238
  ];
3152
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
+ }
3153
3256
  /** Fold one session event into the current turn observation. */
3154
3257
  function observeEvent(signal, event) {
3155
3258
  const data = event.data;
@@ -3157,7 +3260,7 @@ function observeEvent(signal, event) {
3157
3260
  if (event.type === "user/message") {
3158
3261
  const content = data.content;
3159
3262
  if (!Array.isArray(content)) return;
3160
- const text = content.map((block) => block.type === "text" ? block.text ?? "" : "").join(" ");
3263
+ const text = content.map(textOfBlock).join(" ");
3161
3264
  signal.userChars += text.length;
3162
3265
  if (CORRECTION_PATTERNS.some((pattern) => pattern.test(text))) signal.memorySignal = true;
3163
3266
  if (FIX_PATTERNS.some((pattern) => pattern.test(text))) signal.skillSignal = true;
@@ -3166,7 +3269,7 @@ function observeEvent(signal, event) {
3166
3269
  if (event.type === "assistant/message") {
3167
3270
  const message = data.message;
3168
3271
  if (!message || !Array.isArray(message.content)) return;
3169
- const text = message.content.map((block) => block.type === "text" ? block.text ?? "" : "").join(" ");
3272
+ const text = message.content.map(textOfBlock).join(" ");
3170
3273
  signal.assistantChars += text.length;
3171
3274
  return;
3172
3275
  }
@@ -3408,8 +3511,12 @@ const MAX_RESTRUCTURE_MOVES = 5;
3408
3511
  * dots) used to pass here while every later patch/write/remove on it was
3409
3512
  * refused as traversal (an orphan file the user could not touch). */
3410
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._-]*$/;
3411
3518
  /** Extra file name carried inside a snapshot's `extras/` directory. */
3412
- const SNAPSHOT_EXTRA_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
3519
+ const SNAPSHOT_EXTRA_NAME_RE = SUPPORT_ENTRY_NAME_RE;
3413
3520
  function skillsRoot(env = process.env) {
3414
3521
  return join(evolutionRoot(env), "skills");
3415
3522
  }
@@ -3423,6 +3530,29 @@ function skillsRoot(env = process.env) {
3423
3530
  function resolveSkillsRoot(config = {}) {
3424
3531
  return (config.root ?? "").trim() || skillsRoot();
3425
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
+ }
3426
3556
  /**
3427
3557
  * Map a requesting session onto the two origin surfaces (rc.44 plan M2-2.3):
3428
3558
  * the APPROVAL surface treats every delegated subagent as the autonomous
@@ -3459,6 +3589,15 @@ function skillDir(root, name) {
3459
3589
  function markerEntryName(marker) {
3460
3590
  return `.${marker}`;
3461
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
+ ];
3462
3601
  function markerPath(dir, marker) {
3463
3602
  return join(dir, markerEntryName(marker));
3464
3603
  }
@@ -3711,7 +3850,7 @@ async function listNames(root, io) {
3711
3850
  * `[a-z0-9._-]`) — drive-colon / odd-character / uppercase names can no
3712
3851
  * longer reach the filesystem through writeSupportFile / patch /
3713
3852
  * removeSupportFile. */
3714
- const SUPPORT_FILE_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
3853
+ const SUPPORT_FILE_NAME_RE = SUPPORT_ENTRY_NAME_RE;
3715
3854
  /** C-18: win32 reserves these stems with ANY extension (`nul.md` hits the
3716
3855
  * NUL device), and they are fully inside the charset above — so the reserved
3717
3856
  * set is checked on the first-dot prefix as well; the charset close alone
@@ -3742,6 +3881,16 @@ const WIN32_RESERVED_DEVICE_NAMES = new Set([
3742
3881
  "lpt8",
3743
3882
  "lpt9"
3744
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
+ }
3745
3894
  function validateSupportPath(filePath) {
3746
3895
  const normalized = filePath.replace(/\\/g, "/");
3747
3896
  if (normalized.includes("..")) return "Path traversal is not allowed.";
@@ -3751,6 +3900,7 @@ function validateSupportPath(filePath) {
3751
3900
  for (const part of parts.slice(1)) {
3752
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).`;
3753
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.`;
3754
3904
  const stem = part.split(".")[0]?.toLowerCase() ?? "";
3755
3905
  if (WIN32_RESERVED_DEVICE_NAMES.has(stem)) return `Unsupported file name "${part}" — a Windows reserved device name.`;
3756
3906
  }
@@ -3958,11 +4108,23 @@ var SkillLibrary = class {
3958
4108
  outcome = o;
3959
4109
  return o.write ?? current ?? null;
3960
4110
  };
3961
- 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
+ }
3962
4119
  else {
3963
4120
  const current = await this.io.readText(path);
3964
4121
  const next = await run(current);
3965
- 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
+ }
3966
4128
  }
3967
4129
  const o = outcome;
3968
4130
  if (o === void 0 || typeof o !== "object" || !Object.prototype.hasOwnProperty.call(o, "write")) return {
@@ -3971,7 +4133,10 @@ var SkillLibrary = class {
3971
4133
  };
3972
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);
3973
4135
  if (o.write !== null && o.event) this.notifyMutation(o.event);
3974
- 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
+ };
3975
4140
  }
3976
4141
  /** Notify the mutation observer after a successful write; observers must never fail the mutation. */
3977
4142
  notifyMutation(event) {
@@ -3988,8 +4153,7 @@ var SkillLibrary = class {
3988
4153
  * label (scanContentThreats already embeds it) plus the self-heal hint, so a
3989
4154
  * false-positive rewrite direction is actionable instead of a dead end. */
3990
4155
  contentThreatBlock(content) {
3991
- const threat = scanContentThreats(content, void 0, this.threatScanOptions());
3992
- return threat === null ? null : threat + THREAT_EXEMPT_HINT;
4156
+ return scanContentThreats(content, void 0, this.threatScanOptions());
3993
4157
  }
3994
4158
  async list() {
3995
4159
  const summaries = [];
@@ -3998,20 +4162,42 @@ var SkillLibrary = class {
3998
4162
  const md = await this.io.readText(join(dir, "SKILL.md"));
3999
4163
  if (md === null) continue;
4000
4164
  const parsed = parseFrontmatter(md);
4001
- let entries = [];
4165
+ let entries = null;
4002
4166
  try {
4003
4167
  entries = await this.io.list(dir);
4004
- } catch {}
4005
- const has = (marker) => entries.includes(markerEntryName(marker));
4006
- 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;
4007
4186
  const parsedDescription = parsed?.frontmatter.description;
4187
+ const parsedWhenToUse = parsed?.frontmatter.whenToUse;
4008
4188
  summaries.push({
4009
4189
  name,
4010
4190
  description: typeof parsedDescription === "string" ? parsedDescription : "",
4011
4191
  path: dir,
4012
4192
  protectedBy,
4013
- managed: has("hermes-managed"),
4014
- 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 } : {}
4015
4201
  });
4016
4202
  }
4017
4203
  return summaries;
@@ -4193,6 +4379,9 @@ var SkillLibrary = class {
4193
4379
  * marker write is the only state change; content is untouched.
4194
4380
  */
4195
4381
  async setPinned(name, pinned, origin = "foreground") {
4382
+ return await this.serial(() => this.setPinnedCore(name, pinned, origin));
4383
+ }
4384
+ async setPinnedCore(name, pinned, origin) {
4196
4385
  const normalized = name.trim();
4197
4386
  const bad = this.badName(normalized);
4198
4387
  if (bad) return {
@@ -4275,15 +4464,26 @@ var SkillLibrary = class {
4275
4464
  const onDisk = finalContent.trimEnd() + "\n";
4276
4465
  const createPath = join(dir, "SKILL.md");
4277
4466
  let existsAtCommit = false;
4467
+ let taskRan = false;
4278
4468
  if (this.transact) await this.transact(this.io, createPath, (current) => {
4469
+ taskRan = true;
4279
4470
  if (current !== null) {
4280
4471
  existsAtCommit = true;
4281
4472
  return current;
4282
4473
  }
4283
4474
  return onDisk;
4284
4475
  });
4285
- else if (await this.io.exists(createPath)) existsAtCommit = true;
4286
- else await this.io.writeText(createPath, onDisk);
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
+ };
4287
4487
  if (existsAtCommit) return {
4288
4488
  ok: false,
4289
4489
  message: `Skill "${normalized}" already exists.`
@@ -4583,11 +4783,7 @@ var SkillLibrary = class {
4583
4783
  * CRASHED writer also refuses — correct: inspect, don't archive.
4584
4784
  */
4585
4785
  async hasWriteLock(dir) {
4586
- const markerLocks = [
4587
- join(dir, "SKILL.md.lock"),
4588
- join(dir, ".pinned.lock"),
4589
- join(dir, ".hermes-managed.lock")
4590
- ];
4786
+ const markerLocks = MARKER_LOCK_NAMES.map((name) => join(dir, name));
4591
4787
  for (const lock of markerLocks) if (await this.isWriterLock(lock)) return true;
4592
4788
  for (const supportDir of SUPPORT_DIRS) {
4593
4789
  let entries;
@@ -4609,17 +4805,25 @@ var SkillLibrary = class {
4609
4805
  * or be swept as residue — the v16 first cut matched on suffix alone,
4610
4806
  * which permanently refused archiving and deleted user content on restore. */
4611
4807
  async isWriterLock(lockPath) {
4612
- const body = await this.io.readText(lockPath).catch(() => null);
4808
+ let body;
4809
+ try {
4810
+ body = await this.io.readText(lockPath);
4811
+ } catch {
4812
+ return true;
4813
+ }
4613
4814
  if (body === null) return false;
4614
- return /^\d+:[0-9a-f]*$/.test(body.trim());
4815
+ return LOCK_BODY_RE.test(body.trim());
4615
4816
  }
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. */
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. */
4621
4823
  async deleteStrandedLocks(dir) {
4622
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"));
4623
4827
  for (const supportDir of SUPPORT_DIRS) {
4624
4828
  let entries = [];
4625
4829
  try {
@@ -4631,10 +4835,33 @@ var SkillLibrary = class {
4631
4835
  }
4632
4836
  }
4633
4837
  /** Remove `lockPath` only when its body has the writer-lock `pid:token`
4634
- * shape; anything else (a user support file) is left untouched. */
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. */
4635
4840
  async sweepLockIfStranded(lockPath) {
4636
4841
  const body = await this.io.readText(lockPath).catch(() => null);
4637
- if (body === null || !/^\d+:[0-9a-f]*$/.test(body.trim())) return;
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`);
4638
4865
  await this.io.remove(lockPath).catch(() => {});
4639
4866
  }
4640
4867
  async archive(rawName, options = {}) {
@@ -4921,9 +5148,10 @@ var SkillLibrary = class {
4921
5148
  ok: false,
4922
5149
  message: "Every restructure move needs a non-empty heading."
4923
5150
  };
4924
- if (!RESTRUCTURE_TARGET_RE.test(move.toFile)) return {
5151
+ const targetIssue = validateRestructureTarget(move.toFile);
5152
+ if (targetIssue) return {
4925
5153
  ok: false,
4926
- message: `toFile must be references/<topic>.md (got "${move.toFile}").`
5154
+ message: targetIssue
4927
5155
  };
4928
5156
  }
4929
5157
  const dir = this.dirOf(name);
@@ -5020,16 +5248,17 @@ var SkillLibrary = class {
5020
5248
  ok: false,
5021
5249
  message: `Skill "${name}" is protected (${protection}).`
5022
5250
  };
5023
- for (const precondition of plan.preconditions ?? []) {
5024
- const issue = await precondition({ dir });
5025
- if (issue) return {
5026
- ok: false,
5027
- message: issue
5028
- };
5029
- }
5030
5251
  const landing = [];
5031
5252
  for (const write of plan.writes) {
5032
- 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
+ }
5033
5262
  if (Buffer.byteLength(write.content, "utf8") > this.limits.maxSkillFileBytes) return {
5034
5263
  ok: false,
5035
5264
  message: `Write exceeds ${this.limits.maxSkillFileBytes} bytes: ${write.target}`
@@ -5045,18 +5274,16 @@ var SkillLibrary = class {
5045
5274
  previous
5046
5275
  });
5047
5276
  }
5048
- const semantic = plan.validate?.({
5049
- dir,
5050
- currentMd: md
5051
- }) ?? null;
5052
- if (semantic) return {
5053
- ok: false,
5054
- message: semantic
5055
- };
5056
5277
  const written = [];
5278
+ let durabilityWarning = "";
5057
5279
  try {
5058
5280
  for (const entry of landing) {
5059
- 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
+ }
5060
5287
  written.push({
5061
5288
  target: entry.target,
5062
5289
  previous: entry.previous
@@ -5077,7 +5304,7 @@ var SkillLibrary = class {
5077
5304
  });
5078
5305
  return {
5079
5306
  ok: true,
5080
- 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})`,
5081
5308
  path: dir
5082
5309
  };
5083
5310
  }
@@ -5093,9 +5320,10 @@ var SkillLibrary = class {
5093
5320
  ok: false,
5094
5321
  message: bad
5095
5322
  };
5096
- 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 {
5097
5325
  ok: false,
5098
- 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.`
5099
5327
  };
5100
5328
  const archiveRoot = join(this.root, ".archive");
5101
5329
  let entries;
@@ -5118,7 +5346,6 @@ var SkillLibrary = class {
5118
5346
  message: `Skill "${name}" is not in .archive.`
5119
5347
  };
5120
5348
  const source = join(archiveRoot, chosen);
5121
- const dest = this.dirOf(name);
5122
5349
  if (this.io.isSymlink) {
5123
5350
  if (await this.io.isSymlink(source) === true) return {
5124
5351
  ok: false,
@@ -5251,7 +5478,8 @@ var SkillLibrary = class {
5251
5478
  ok: false,
5252
5479
  message: `"${filePath}" is not a readable regular file — remove the files inside it one by one.`
5253
5480
  };
5254
- await this.io.remove(target);
5481
+ if (this.transact) await this.transact(this.io, target, () => null);
5482
+ else await this.io.remove(target);
5255
5483
  await this.audit(name, "remove_file", before, null, `removed ${filePath}`);
5256
5484
  this.notifyMutation({
5257
5485
  action: "remove_file",
@@ -5278,9 +5506,10 @@ var SkillLibrary = class {
5278
5506
  while (await this.io.exists(dest)) dest = join(backupRoot, `skills-${stamp}-${Math.random().toString(36).slice(2, 8)}`);
5279
5507
  try {
5280
5508
  const names = await listNames(this.root, this.io);
5281
- await Promise.all(names.map(async (name) => {
5509
+ const copyFailure = (await Promise.allSettled(names.map(async (name) => {
5282
5510
  await this.io.copy(this.dirOf(name), join(dest, name));
5283
- }));
5511
+ }))).find((result) => result.status === "rejected");
5512
+ if (copyFailure) throw copyFailure.reason;
5284
5513
  const sidecars = [];
5285
5514
  for (const sidecar of [usageFile(this.root), suppressedFile(this.root)]) if (await this.io.exists(sidecar)) {
5286
5515
  const name = basename(sidecar);
@@ -5295,9 +5524,10 @@ var SkillLibrary = class {
5295
5524
  }
5296
5525
  const validExtras = extras.filter((extra) => SNAPSHOT_EXTRA_NAME_RE.test(extra.name));
5297
5526
  const extraNames = validExtras.map((extra) => extra.name);
5298
- await Promise.all(validExtras.map(async (extra) => {
5527
+ const extraFailure = (await Promise.allSettled(validExtras.map(async (extra) => {
5299
5528
  await this.io.writeText(join(dest, "extras", extra.name), extra.content);
5300
- }));
5529
+ }))).find((result) => result.status === "rejected");
5530
+ if (extraFailure) throw extraFailure.reason;
5301
5531
  await this.io.writeText(join(dest, "manifest.json"), JSON.stringify({
5302
5532
  reason,
5303
5533
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -5319,10 +5549,11 @@ var SkillLibrary = class {
5319
5549
  if (raw === null) return null;
5320
5550
  try {
5321
5551
  const manifest = JSON.parse(raw);
5552
+ if (!Array.isArray(manifest.skills)) return null;
5322
5553
  return {
5323
5554
  reason: typeof manifest.reason === "string" ? manifest.reason : "",
5324
5555
  createdAt: typeof manifest.createdAt === "string" ? manifest.createdAt : "",
5325
- skills: Array.isArray(manifest.skills) ? manifest.skills : [],
5556
+ skills: manifest.skills,
5326
5557
  sidecars: Array.isArray(manifest.sidecars) ? manifest.sidecars : [],
5327
5558
  ...typeof manifest.hasArchive === "boolean" ? { hasArchive: manifest.hasArchive } : {},
5328
5559
  extras: Array.isArray(manifest.extras) ? manifest.extras : []
@@ -5357,6 +5588,7 @@ var SkillLibrary = class {
5357
5588
  reason: manifest.reason
5358
5589
  });
5359
5590
  }
5591
+ out.sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || "") || b.path.localeCompare(a.path));
5360
5592
  return out;
5361
5593
  }
5362
5594
  /**
@@ -5427,6 +5659,15 @@ var SkillLibrary = class {
5427
5659
  * restoreLatestSnapshot so a failed restore can roll itself back (E-13).
5428
5660
  */
5429
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
+ }
5430
5671
  let rootEntries;
5431
5672
  try {
5432
5673
  rootEntries = await this.io.list(this.root);
@@ -5435,13 +5676,24 @@ var SkillLibrary = class {
5435
5676
  }
5436
5677
  for (const entry of rootEntries) {
5437
5678
  if (entry === ".archive" || entry === ".backups" || entry === ".mutations.json" || entry === ".curator-suppressed.json") continue;
5438
- 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
+ }
5439
5688
  }
5440
- const manifest = await this.readSnapshotManifest(snapshotPath);
5441
- if (manifest === null) for (const entry of await this.io.list(snapshotPath)) {
5442
- if (entry === "manifest.json" || entry === "extras") continue;
5443
- 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));
5444
5695
  }
5696
+ if (manifest === null) throw new Error(`snapshot ${snapshotPath} has no readable manifest.json; refusing to restore`);
5445
5697
  else {
5446
5698
  for (const name of manifest.skills) await this.io.copy(join(snapshotPath, name), join(this.root, name));
5447
5699
  for (const sidecar of manifest.sidecars) await this.io.copy(join(snapshotPath, sidecar), join(this.root, sidecar));
@@ -5458,4 +5710,4 @@ var SkillLibrary = class {
5458
5710
  }
5459
5711
  };
5460
5712
  //#endregion
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 };
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 };
@@ -19,7 +19,13 @@
19
19
  * threshold, which are intentionally left where they are used.
20
20
  * @module @lmzhen/dsh-evolution-core
21
21
  */
22
- /** Skill frontmatter `name` validated for the file name (lowercase + hyphen). */
22
+ /** Skill frontmatter `name` validated for the file name (lowercase + hyphen).
23
+ * 计划 B-4 (v18): tightened to the UPSTREAM `SKILL_NAME` shape
24
+ * (`/^[a-z0-9]+(?:-[a-z0-9]+)*$/`, packages/skill/skill/src/index.ts:20). The
25
+ * old form admitted trailing/consecutive hyphens, which upstream
26
+ * `validateCandidate` throws on — and that throw aborts the WHOLE `ctx.skills`
27
+ * collection. The catalog provider still filters such legacy tree entries so
28
+ * an existing tree cannot break a session. */
23
29
  export declare const SKILL_NAME_RE: RegExp;
24
30
  /** Allowed skill support-file subdirectories (path-traversal boundary). */
25
31
  export declare const SUPPORT_DIRS: readonly ["references", "templates", "scripts", "assets"];
@@ -53,6 +59,9 @@ export declare const DEFAULT_MEMORY_CHAR_LIMIT = 2200;
53
59
  export declare const DEFAULT_USER_CHAR_LIMIT = 1375;
54
60
  /** Consolidation-failure backoff cap, shared by MemoryStore and memory-files' Config default. */
55
61
  export declare const DEFAULT_CONSOLIDATION_FAILURES = 3;
62
+ /** F-20 (v18): the authored-body budget and the hard ceiling are the same
63
+ * number today. Derive it so a future divergence is one edit, not two names
64
+ * that silently disagree. */
56
65
  export declare const DEFAULT_SKILL_CONTENT_CHARS = 100000;
57
66
  /** P3-19 (v14): defaults that were written twice (schema `.default()` AND the
58
67
  * clamp fallback literal) now have one home per value. */
@@ -12,10 +12,12 @@
12
12
  * these events off `session.append`). Plan-outcome durability lives in the
13
13
  * evolution-activity store, not the session log.
14
14
  */
15
+ import type { ReviewKind } from './signals.ts';
15
16
  export interface EvolutionReviewScheduledEvent {
16
17
  /** Owning session (payload v2): process events carry no session envelope. */
17
18
  sessionId: string;
18
- kind: 'memory' | 'skill' | 'combined';
19
+ /** F-20 (v18): single definition point `ReviewKind` in signals.ts. */
20
+ kind: ReviewKind;
19
21
  toolCalls: number;
20
22
  userChars: number;
21
23
  assistantChars: number;
@@ -68,6 +68,17 @@ export interface EvolutionEvent {
68
68
  } | undefined;
69
69
  }
70
70
  export declare function eventsFile(home: string): string;
71
+ /** I-5 (v18): one lightweight description of the durable event payload
72
+ * contract. The log is a FILE boundary (a host, a script or an older version
73
+ * can write it), so `appendEvolutionEvent` refuses a record no consumer can
74
+ * fold instead of persisting it and failing silently later. The process event
75
+ * bus stays unvalidated — that is a typed same-process boundary.
76
+ * @param event - the candidate event record.
77
+ * @returns a human-readable issue, or null when the record is well-formed.
78
+ */
79
+ export declare function evolutionEventPayloadIssue(event: {
80
+ type?: unknown;
81
+ } & Partial<EvolutionEvent>): string | null;
71
82
  export declare function parseEvolutionEvents(raw: string | null): EvolutionEvent[];
72
83
  /**
73
84
  * List the numeric archives under the log's directory, sorted ascending by
package/lib/types/io.d.ts CHANGED
@@ -106,6 +106,21 @@ export declare function renameWithRetry(tmp: string, target: string, fn?: (from:
106
106
  * failing `handle.sync()` deterministically.
107
107
  */
108
108
  export declare function writeDurableTmp(target: string, content: string, openImpl?: typeof open): Promise<string>;
109
+ /**
110
+ * True when the pid is alive (EPERM = alive but unowned; ESRCH = gone).
111
+ * V18 single source: the node backend's lock takeover and SkillLibrary's
112
+ * stranded-lock sweep must use the same liveness rule.
113
+ */
114
+ export declare function isProcessAlive(pid: number): boolean;
115
+ /** F-17 (v18): the write-lock protocol is a cross-module contract — the lock
116
+ * file is `<target>.lock` and its body is `<pid>:<token>`. This module creates
117
+ * them (`withWriteLock`) and `skill-store`'s probes/sweepers parse them, so both
118
+ * consume these two constants instead of repeating the literals. */
119
+ export declare const LOCK_SUFFIX = ".lock";
120
+ /** Writer-lock body shape: a decimal pid, a colon, then the claim token. A
121
+ * torn body (no parsable pid) still matches the `\\d+:` prefix rule only when
122
+ * the pid part is intact, which is what the takeover probe needs. */
123
+ export declare const LOCK_BODY_RE: RegExp;
109
124
  /**
110
125
  * Build the Node IO backend. `lockAttempts` scales the write-lock retry budget
111
126
  * (attempts × 50ms); the default 40 (~2s, rc.69) covers production contention,
@@ -57,6 +57,10 @@ export declare class MemoryStore {
57
57
  * label (scanMemoryThreats already embeds it) plus the self-heal hint. */
58
58
  private memoryThreatBlock;
59
59
  limitFor(target: MemoryTarget): number;
60
+ /** P2-1 (v18): the generated date prefix participates in duplicate detection
61
+ * only when THIS store writes it. With addDatePrefix=false a fact's own
62
+ * leading `## YYYY-MM-DD\n` is content, not a generated prefix. */
63
+ private dedupeKey;
60
64
  /**
61
65
  * Read-guard probe: `{ size, limit }` when the on-disk file exceeds
62
66
  * `limit * READ_GUARD_FACTOR` bytes, `null` when it is absent, unknown
@@ -11,9 +11,9 @@
11
11
  */
12
12
  import type { UsageMap } from './usage.ts';
13
13
  export interface QualityFactors {
14
- /** 0.25 — use_count per day of age, capped at 1. */
14
+ /** 0.25 — skill LOADS per day of age (view_count + use_count), capped at 1. */
15
15
  usageFrequency: number;
16
- /** 0.20 — 1 − patch/use (zero use = stable). */
16
+ /** 0.20 — 1 − patch/load (zero loads = stable). */
17
17
  stability: number;
18
18
  /** 0.20 — 1 under 30 idle days, linear decay to 0 at 180. */
19
19
  recency: number;
@@ -22,8 +22,15 @@ export interface SkillSummary {
22
22
  description: string;
23
23
  path: string;
24
24
  protectedBy: string | null;
25
+ /** A1-17 (v18): the marker probe itself failed (EACCES/EIO), so "no marker"
26
+ * cannot be told apart from "directory unreadable". Consumers must treat this
27
+ * as protected, never as unprotected. */
28
+ protectionUnknown: boolean;
25
29
  managed: boolean;
26
- archived: boolean;
30
+ /** E-11 (v18): the frontmatter `whenToUse` routing hint, published so the
31
+ * platform catalog keeps it while this provider shadows the upstream
32
+ * filesystem provider. Absent when the frontmatter has none. */
33
+ whenToUse?: string;
27
34
  }
28
35
  export interface SkillActionResult {
29
36
  ok: boolean;
@@ -99,6 +106,21 @@ export declare function skillsRoot(env?: NodeJS.ProcessEnv): string;
99
106
  export declare function resolveSkillsRoot(config?: {
100
107
  root?: string | undefined;
101
108
  }): string;
109
+ /** E-7 (v18): every family row reads ONE root key. `root` is canonical;
110
+ * `skillsRoot` is a deprecated alias honoured only while `root` is empty (so a
111
+ * deployment that sets both keeps the canonical one) and removed after 0.3.65.
112
+ * Callers log their own deprecation warning.
113
+ * @param config - the raw plugin config, carrying `root` and/or `skillsRoot`.
114
+ * @returns the effective root (empty when neither key is set) and whether the
115
+ * deprecated alias supplied it.
116
+ */
117
+ export declare function resolveRootConfig(config?: {
118
+ root?: string | undefined;
119
+ skillsRoot?: string | undefined;
120
+ }): {
121
+ root: string;
122
+ usedDeprecatedAlias: boolean;
123
+ };
102
124
  /**
103
125
  * Map a requesting session onto the two origin surfaces (rc.44 plan M2-2.3):
104
126
  * the APPROVAL surface treats every delegated subagent as the autonomous
@@ -228,13 +250,11 @@ export interface AuthoringFeedback {
228
250
  * truncated or route-poor instead of silently shipping it.
229
251
  */
230
252
  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>;
253
+ /** A1-6 (v18): the regex alone admits `references/nul.md` (a Windows device
254
+ * stem), which the support-file layer refuses. Restructure must use the same
255
+ * rule, or it creates an orphan the later patch/write/remove paths refuse.
256
+ * Single source shared with the plan validator. */
257
+ export declare function validateRestructureTarget(filePath: string): string | null;
238
258
  export declare class SkillLibrary {
239
259
  readonly root: string;
240
260
  readonly limits: SkillLimits;
@@ -340,6 +360,7 @@ export declare class SkillLibrary {
340
360
  * marker write is the only state change; content is untouched.
341
361
  */
342
362
  setPinned(name: string, pinned: boolean, origin?: WriteOrigin): Promise<SkillActionResult>;
363
+ private setPinnedCore;
343
364
  create(name: string, content: string, origin?: WriteOrigin): Promise<SkillActionResult>;
344
365
  private createCore;
345
366
  update(rawName: string, content: string, origin?: WriteOrigin): Promise<SkillActionResult>;
@@ -378,15 +399,25 @@ export declare class SkillLibrary {
378
399
  * or be swept as residue — the v16 first cut matched on suffix alone,
379
400
  * which permanently refused archiving and deleted user content on restore. */
380
401
  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. */
402
+ /** P2 (v16): best-effort removal of lock residue inside a RESTORED tree.
403
+ * A1-2/A1-7 (v18): the sweep now covers the marker locks the probe checks
404
+ * (`SKILL.md.lock`/`.pinned.lock`/`.hermes-managed.lock`) and only removes
405
+ * a lock whose holder pid is NOT alive a live writer's lock is never
406
+ * stolen by the sweep. A dead-pid residue would otherwise permanently
407
+ * refuse archive/restore. */
386
408
  private deleteStrandedLocks;
387
409
  /** Remove `lockPath` only when its body has the writer-lock `pid:token`
388
- * shape; anything else (a user support file) is left untouched. */
410
+ * shape AND the holder pid is not alive; anything else (a user support file
411
+ * or a live writer's lock) is left untouched. */
389
412
  private sweepLockIfStranded;
413
+ /** A1-4 (v18): a manifest-declared name is copied with `join(root, name)`;
414
+ * only a single, non-traversing path component is safe. Dotfiles
415
+ * (`.usage.json`) stay allowed — sidecars are legitimately dot-prefixed. */
416
+ private safeSnapshotEntryName;
417
+ /** A1-7 (v18): a root-level lock whose holder is alive must refuse the
418
+ * restore; a dead residue is swept so a crashed writer cannot block
419
+ * recovery. A non-lock body shape is left alone (user file). */
420
+ private refuseLiveLockOrSweep;
390
421
  archive(rawName: string, options?: ArchiveOptions): Promise<SkillActionResult>;
391
422
  /**
392
423
  * Merge the bodies of `sources` into `target` and archive the sources with
@@ -85,7 +85,7 @@ export declare function applyCuratorMetaFields(disk: UsageRecord, curated: Usage
85
85
  * transitioned — a concurrent curator run's archive/restore is never reverted
86
86
  * by a stale snapshot; without it both pairs apply everywhere.
87
87
  */
88
- export declare function foldCuratorFields(disk: UsageMap, curated: UsageMap, stateOwned?: ReadonlySet<string>): void;
88
+ export declare function foldCuratorFields(disk: UsageMap, curated: UsageMap, stateOwned?: ReadonlySet<string>, runStartStates?: ReadonlyMap<string, string>): string[];
89
89
  /** Whole-file usage write (V6-37, 0.3.37): this is the ONE path that bypasses
90
90
  * the malformed-defense and the transact lock — prefer `mutateUsage` for any
91
91
  * read-modify-write so a concurrent writer cannot lose its update and a
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.63",
4
+ "version": "0.3.64",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },