@lmzhen/dsh-evolution-core 0.3.15 → 0.3.17

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
@@ -64,45 +64,81 @@ function nodeEvolutionIo() {
64
64
  };
65
65
  /**
66
66
  * Cross-process write lock (claw `withFileLock` parity): an O_EXCL lock file
67
- * guards the atomic write. A >5s-old lock is taken over ONLY after probing
67
+ * guards the atomic write. A >1s-old lock is taken over ONLY after probing
68
68
  * the holder pid it carries (rc.66): a LIVE holder is never stolen, so a
69
- * slow writer no longer loses its lock to a peer at the 5s mark (the
69
+ * slow writer no longer loses its lock to a peer at the threshold (the
70
70
  * takeover is the only best-effort surface; the retry budget fails loud —
71
71
  * rc.65 — instead of ever proceeding unlocked). Budget = 40 * 50ms (~2s,
72
72
  * rc.69): 8-writer contention bursts on a loaded CI runner exceed 10
73
73
  * attempts (500ms), and a fail-loud throw was observed instead of a clean
74
74
  * serialization.
75
+ * 0.3.17 (E-8): acquisition and the task are now SEPARATE try blocks — a
76
+ * task error (win32 rename/EBUSY surfaces as EPERM) used to be mistaken for
77
+ * lock contention, retried up to 40x and finally reported as
78
+ * "could not acquire write lock" while the real cause was hidden.
79
+ * 0.3.17 (E-8a): the takeover threshold (1000ms) now fits INSIDE the ~2s
80
+ * retry budget (budget >= 2 x threshold), so a dead holder's lock is
81
+ * actually recoverable within one budget instead of being arithmetically
82
+ * unreachable.
75
83
  */
76
84
  const withWriteLock = async (path, task) => {
77
85
  const lock = `${path}.lock`;
78
- for (let attempt = 0; attempt < 40; attempt += 1) try {
79
- await writeFile(lock, String(process.pid), { flag: "wx" });
86
+ for (let attempt = 0; attempt < 40; attempt += 1) {
80
87
  try {
81
- return await task();
82
- } finally {
83
- await rm(lock, { force: true }).catch(() => {});
84
- }
85
- } catch (error) {
86
- const code = error?.code;
87
- if (code !== "EEXIST" && code !== "EPERM") throw error;
88
- try {
89
- const st = await stat(lock);
90
- if (Date.now() - st.mtimeMs > 5e3) {
91
- const holder = Number(await readFile(lock, "utf8").catch(() => ""));
92
- if (!(Number.isInteger(holder) && holder > 0 && isAlive(holder))) {
93
- try {
94
- await rm(lock, { force: true });
95
- } catch {}
96
- continue;
88
+ await writeFile(lock, String(process.pid), { flag: "wx" });
89
+ } catch (error) {
90
+ const code = error?.code;
91
+ if (code !== "EEXIST" && code !== "EPERM") throw error;
92
+ try {
93
+ const st = await stat(lock);
94
+ if (Date.now() - st.mtimeMs > 1e3) {
95
+ const holder = Number(await readFile(lock, "utf8").catch(() => ""));
96
+ if (!(Number.isInteger(holder) && holder > 0 && isAlive(holder))) {
97
+ try {
98
+ await rm(lock, { force: true });
99
+ } catch {}
100
+ continue;
101
+ }
97
102
  }
103
+ } catch {
104
+ continue;
98
105
  }
99
- } catch {
106
+ await new Promise((resolve) => setTimeout(resolve, 50));
100
107
  continue;
101
108
  }
102
- await new Promise((resolve) => setTimeout(resolve, 50));
109
+ try {
110
+ return await task();
111
+ } finally {
112
+ await rm(lock, { force: true }).catch(() => {});
113
+ }
103
114
  }
104
115
  throw new Error(`could not acquire write lock for ${path} after 40 attempts`);
105
116
  };
117
+ /** 0.3.17 (E-8b): sweep tmp files a crashed writer left behind — same
118
+ * `<target>.<pid>.<rand>.tmp` shape, older than 1h AND held by a dead pid.
119
+ * Lazy: only the directory a write is about to touch gets swept, once per
120
+ * write, inside the write lock. */
121
+ const sweepStaleTmps = async (path) => {
122
+ const dir = dirname(path);
123
+ const base = basename(path);
124
+ let entries;
125
+ try {
126
+ entries = await readdir(dir);
127
+ } catch {
128
+ return;
129
+ }
130
+ const prefix = `${base}.`;
131
+ for (const name of entries) {
132
+ if (!name.startsWith(prefix) || !name.endsWith(".tmp")) continue;
133
+ const tmpPath = join(dir, name);
134
+ const holder = Number(name.slice(prefix.length, name.length - 4).split(".")[0] ?? "");
135
+ try {
136
+ const st = await stat(tmpPath);
137
+ const deadHolder = !Number.isInteger(holder) || holder <= 0 || !isAlive(holder);
138
+ if (Date.now() - st.mtimeMs > 36e5 && deadHolder) await rm(tmpPath, { force: true });
139
+ } catch {}
140
+ }
141
+ };
106
142
  return {
107
143
  async readText(path) {
108
144
  try {
@@ -115,6 +151,7 @@ function nodeEvolutionIo() {
115
151
  async writeText(path, content) {
116
152
  await mkdir(dirname(path), { recursive: true });
117
153
  await withWriteLock(path, async () => {
154
+ await sweepStaleTmps(path);
118
155
  const tmp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
119
156
  await writeFile(tmp, content, "utf8");
120
157
  await rename(tmp, path);
@@ -123,6 +160,7 @@ function nodeEvolutionIo() {
123
160
  async transact(path, task) {
124
161
  await mkdir(dirname(path), { recursive: true });
125
162
  await withWriteLock(path, async () => {
163
+ await sweepStaleTmps(path);
126
164
  let current;
127
165
  try {
128
166
  current = await readFile(path, "utf8");
@@ -370,9 +408,9 @@ function latestActivityAt(record) {
370
408
  record.last_used_at,
371
409
  record.last_viewed_at,
372
410
  record.last_patched_at
373
- ].filter((value) => typeof value === "string");
411
+ ].filter((value) => typeof value === "string" && Number.isFinite(Date.parse(value)));
374
412
  if (values.length === 0) return null;
375
- return values.sort().reverse()[0] ?? null;
413
+ return values.reduce((latest, value) => Date.parse(value) > Date.parse(latest) ? value : latest);
376
414
  }
377
415
  /**
378
416
  * Whether the library has ANY observed read evidence (C observation window):
@@ -494,6 +532,25 @@ const DEFAULT_USER_CHAR_LIMIT = 1375;
494
532
  /** Consolidation-failure backoff cap, shared by MemoryStore and memory-files' Config default. */
495
533
  const DEFAULT_CONSOLIDATION_FAILURES = 3;
496
534
  const DEFAULT_SKILL_CONTENT_CHARS = 1e5;
535
+ /** 0.3.17 (S3.10, T-1): control-plane fields a model-facing write call may
536
+ * never carry — single source for plan-validator, evolution-policy and the
537
+ * threat scanner (they used to each hardcode the list). */
538
+ const FORBIDDEN_CONTROL_KEYS = [
539
+ "policy",
540
+ "threshold",
541
+ "prompt_hash",
542
+ "model_route",
543
+ "evolution_config"
544
+ ];
545
+ /** 0.3.17 (S3.10): the model-facing write tools the policy guard and threat
546
+ * scanner cover. */
547
+ const EVOLUTION_WRITE_TOOLS = ["memory", "skill_manage"];
548
+ /** Hermes authoring quality bar for descriptions (the 60-char Rule). The
549
+ * platform's own index limit stays in validateFrontmatter; this bar is the
550
+ * target the authoring standard names, enforced as ADVISORY feedback.
551
+ * 0.3.16 (T-4): moved here from skill-store.ts so drift-signals (pure, no IO)
552
+ * can reference it without importing the skill-store module. */
553
+ const AUTHORING_DESCRIPTION_BAR = 60;
497
554
  //#endregion
498
555
  //#region lib/types/gates.js
499
556
  /**
@@ -999,8 +1056,8 @@ async function readEvolutionTimeline(io, path) {
999
1056
  * changes semantically: the bundle digest is the fail-closed signal for
1000
1057
  * review workers, so a stale id across deployments must be distinguishable.
1001
1058
  */
1002
- const PROMPT_BUNDLE_ID = "dsh-evolution@13";
1003
1059
  const PROMPT_BUNDLE_VERSION = 13;
1060
+ const PROMPT_BUNDLE_ID = `dsh-evolution@13`;
1004
1061
  const MEMORY_REVIEW_PROMPT = `[Auto-review — Memory]
1005
1062
  Review the conversation above and consider saving to memory if appropriate.
1006
1063
 
@@ -1264,8 +1321,8 @@ const SKILL_REVIEW_PLAN_PROMPT = `${SKILL_REVIEW_PROMPT}${PLAN_CHANNEL_NOTE}`;
1264
1321
  /** Subagent-channel variant of the combined review (M-2). */
1265
1322
  const COMBINED_REVIEW_PLAN_PROMPT = `${COMBINED_REVIEW_PROMPT}${PLAN_CHANNEL_NOTE}`;
1266
1323
  function reviewPrompt(kind, channel = "agent") {
1267
- if (kind === "memory") return MEMORY_REVIEW_PROMPT;
1268
1324
  if (channel === "plan") return kind === "skill" ? SKILL_REVIEW_PLAN_PROMPT : COMBINED_REVIEW_PLAN_PROMPT;
1325
+ if (kind === "memory") return MEMORY_REVIEW_PROMPT;
1269
1326
  if (kind === "skill") return SKILL_REVIEW_PROMPT;
1270
1327
  return COMBINED_REVIEW_PROMPT;
1271
1328
  }
@@ -1297,7 +1354,7 @@ const PROMPT_BUNDLE = createPromptBundle({
1297
1354
  skillsGuidance: SKILLS_GUIDANCE
1298
1355
  });
1299
1356
  function verifyPromptBundle(bundle = PROMPT_BUNDLE) {
1300
- if (bundle.id !== "dsh-evolution@13" || bundle.version !== 13) return false;
1357
+ if (bundle.id !== PROMPT_BUNDLE_ID || bundle.version !== 13) return false;
1301
1358
  const canonical = JSON.stringify({
1302
1359
  id: PROMPT_BUNDLE_ID,
1303
1360
  version: 13,
@@ -1510,7 +1567,7 @@ const PATTERNS = [
1510
1567
  label: "hermes_env",
1511
1568
  category: "persistence",
1512
1569
  scope: "strict",
1513
- regex: /\$?HOME\/\.hermes|~\/\.hermes|\.hermes\/\.env/i
1570
+ regex: /\$?HOME\/\.hermes|~\/\.hermes|\.hermes\/\.env|%USERPROFILE%[\\/]\.hermes/i
1514
1571
  },
1515
1572
  {
1516
1573
  label: "c2_node_registration",
@@ -1557,9 +1614,16 @@ const SCOPE_ORDER = {
1557
1614
  strict: 3
1558
1615
  };
1559
1616
  const NO_SCAN_OPTIONS = {};
1617
+ /** Window overlap for the full-coverage scan: far larger than the longest
1618
+ * pattern span (~530 chars: `curl [^\n]{0,512} ...`), so a match straddling a
1619
+ * window boundary is fully inside at least one window (E-12, 0.3.16). */
1620
+ const PATTERN_OVERLAP = 4096;
1560
1621
  /**
1561
1622
  * Scan text at `scope`. Patterns are cumulative: `strict` includes all scopes.
1562
1623
  * `options.excludeLabels` removes matching patterns without changing `scope`.
1624
+ * `maxScanChars` is the WINDOW SIZE, not a total cap (E-12, 0.3.16): the whole
1625
+ * text is always scanned in overlapping windows, so content beyond 65,536
1626
+ * characters (skill files may run to 100,000) is no longer a blind zone.
1563
1627
  */
1564
1628
  function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
1565
1629
  const findings = [];
@@ -1573,12 +1637,23 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_
1573
1637
  category: "unicode_obfuscation",
1574
1638
  scope
1575
1639
  });
1576
- const normalized = text.normalize("NFKC").slice(0, maxScanChars);
1640
+ const normalized = text.normalize("NFKC");
1641
+ const windows = [];
1642
+ if (normalized.length <= maxScanChars) windows.push(normalized);
1643
+ else {
1644
+ const step = Math.max(1, maxScanChars - PATTERN_OVERLAP);
1645
+ for (let start = 0; start < normalized.length; start += step) windows.push(normalized.slice(start, start + maxScanChars));
1646
+ }
1577
1647
  const excluded = new Set(options.excludeLabels ?? []);
1578
- for (const pattern of PATTERNS) {
1648
+ const seen = /* @__PURE__ */ new Set();
1649
+ for (const window of windows) for (const pattern of PATTERNS) {
1579
1650
  if (SCOPE_ORDER[pattern.scope] > SCOPE_ORDER[scope]) continue;
1580
1651
  if (excluded.has(pattern.label)) continue;
1581
- if (pattern.regex.test(normalized)) findings.push({
1652
+ if (!pattern.regex.test(window)) continue;
1653
+ const key = `${pattern.label}|${pattern.scope}`;
1654
+ if (seen.has(key)) continue;
1655
+ seen.add(key);
1656
+ findings.push({
1582
1657
  label: pattern.label,
1583
1658
  category: pattern.category,
1584
1659
  scope: pattern.scope
@@ -1648,7 +1723,7 @@ function previewEntries(entries) {
1648
1723
  return `\n\nCurrent entries (preview):\n${shown.join("\n")}${more}`;
1649
1724
  }
1650
1725
  function memoryRoot(env = process.env) {
1651
- return join(env.DSH_HOME ?? join(homedir(), ".dsh"), "memories");
1726
+ return join(env.DSH_HOME || join(homedir(), ".dsh"), "memories");
1652
1727
  }
1653
1728
  function fileFor(root, target) {
1654
1729
  return join(root, target === "memory" ? "MEMORY.md" : "USER.md");
@@ -1703,9 +1778,6 @@ var MemoryStore = class {
1703
1778
  const raw = await this.io.readText(fileFor(this.root, target));
1704
1779
  return raw === null ? [] : [...new Set(normalizeEntries(raw))];
1705
1780
  }
1706
- async write(target, entries) {
1707
- await this.io.writeText(fileFor(this.root, target), render(entries));
1708
- }
1709
1781
  resetFailures() {
1710
1782
  this.failureCount = 0;
1711
1783
  }
@@ -2101,7 +2173,7 @@ async function loadMutations(root, io = nodeEvolutionIo()) {
2101
2173
  }
2102
2174
  /** Append one record, trim to `cap`, and write atomically (versioned shape). */
2103
2175
  async function recordMutation(root, io, record, cap = 500) {
2104
- await transactIo(io, mutationsFile(root), async (current) => {
2176
+ await transactIo(io, mutationsFile(root), (current) => {
2105
2177
  if (current !== null) try {
2106
2178
  JSON.parse(current);
2107
2179
  } catch {
@@ -2110,10 +2182,10 @@ async function recordMutation(root, io, record, cap = 500) {
2110
2182
  const existing = parseMutationRecords(current);
2111
2183
  existing.push(record);
2112
2184
  const trimmed = existing.length > cap ? existing.slice(existing.length - cap) : existing;
2113
- return Promise.resolve(JSON.stringify({
2185
+ return JSON.stringify({
2114
2186
  version: 1,
2115
2187
  records: trimmed
2116
- }, null, 2));
2188
+ }, null, 2);
2117
2189
  });
2118
2190
  }
2119
2191
  //#endregion
@@ -2248,7 +2320,7 @@ function computeDedupGroups(input) {
2248
2320
  const [ra, rb] = [find(a), find(b)];
2249
2321
  if (ra !== rb) parent.set(rb, ra);
2250
2322
  };
2251
- for (const [hash, bucketNames] of hashes) {
2323
+ for (const [, bucketNames] of hashes) {
2252
2324
  const first = bucketNames[0];
2253
2325
  if (first === void 0 || bucketNames.length === 1) continue;
2254
2326
  for (let index = 1; index < bucketNames.length; index += 1) {
@@ -2326,9 +2398,9 @@ const SECRET_PATTERNS = [
2326
2398
  ["gitlab token", /glpat-[A-Za-z0-9_-]{16,}/g],
2327
2399
  ["slack token", /xox[baprs]-[A-Za-z0-9-]{10,}/g],
2328
2400
  ["jwt", /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g],
2329
- ["bearer credential", /Bearer [A-Za-z0-9._~+/=\-]{16,}/g],
2330
- ["inline assignment", /(\b(?:token|api[_-]?key|secret|password|passwd)\b[\s]*[:=][\s]*["']?)([A-Z0-9._~+/=\-]{12,})/gi]
2401
+ ["bearer credential", /Bearer [A-Za-z0-9._~+/=\-]{16,}/g]
2331
2402
  ];
2403
+ const INLINE_ASSIGNMENT_PATTERN = /(\b(?:token|api[_-]?key|secret|password|passwd)\b[\s]*[:=][\s]*["']?)([A-Z0-9._~+/=\-]{12,})/gi;
2332
2404
  /**
2333
2405
  * Mask credential-shaped text before it crosses a session boundary.
2334
2406
  * @param text - the text about to be sent to a model outside this session.
@@ -2336,10 +2408,29 @@ const SECRET_PATTERNS = [
2336
2408
  */
2337
2409
  function redactSecrets(text) {
2338
2410
  let out = text;
2339
- for (const [, pattern] of SECRET_PATTERNS) out = out.replace(pattern, (_match, p1) => p1 === void 0 ? "<redacted>" : `${p1}<redacted>`);
2411
+ for (const [, pattern] of SECRET_PATTERNS) out = out.replace(pattern, "<redacted>");
2412
+ out = out.replace(INLINE_ASSIGNMENT_PATTERN, (_match, p1) => `${p1 ?? ""}<redacted>`);
2340
2413
  return out;
2341
2414
  }
2342
2415
  //#endregion
2416
+ //#region lib/types/serial.js
2417
+ /**
2418
+ * A process-local serial task queue: each task starts only after the previous
2419
+ * one settles (success or failure), so read-modify-write sequences that share
2420
+ * one file never interleave inside this process. The durable cross-process
2421
+ * serialization layer is the IO backend's transact lock; this chain is the
2422
+ * second layer (0.3.17 S2.8, T-1: the shape was duplicated in state-json and
2423
+ * memory-files — one factory now).
2424
+ */
2425
+ function makeSerialQueue() {
2426
+ let chain = Promise.resolve();
2427
+ return (task) => {
2428
+ const run = chain.then(task, task);
2429
+ chain = run.then(() => void 0, () => void 0);
2430
+ return run;
2431
+ };
2432
+ }
2433
+ //#endregion
2343
2434
  //#region lib/types/skill-health.js
2344
2435
  /**
2345
2436
  * Skill structure-health domain (rc.73 A1, 008 design): a SECOND assessment
@@ -2414,6 +2505,7 @@ const FIX_PATTERNS = [/worked after|fixed by|the fix was|root cause/i, /retry(?:
2414
2505
  /** Fold one session event into the current turn observation. */
2415
2506
  function observeEvent(signal, event) {
2416
2507
  if (event.type === "user/message") {
2508
+ if (!Array.isArray(event.data.content)) return;
2417
2509
  const text = event.data.content.map((block) => block.type === "text" ? block.text : "").join(" ");
2418
2510
  signal.userChars += text.length;
2419
2511
  if (CORRECTION_PATTERNS.some((pattern) => pattern.test(text))) signal.memorySignal = true;
@@ -2473,6 +2565,165 @@ function foldTurn(session, fromSeq) {
2473
2565
  return signal;
2474
2566
  }
2475
2567
  //#endregion
2568
+ //#region lib/types/drift-signals.js
2569
+ /**
2570
+ * Library-level drift signals for the maintenance subagent (design 011).
2571
+ *
2572
+ * Deterministic fact checks over a skill-library snapshot: domain drift
2573
+ * (narrow names, near-duplicate groups, prefix clusters) and layer drift
2574
+ * (log-like bodies, duplicate headings, overlong lines, missing support-file
2575
+ * pointers, description over the authoring bar). Pure functions only — no IO,
2576
+ * no LLM, no services. Thresholds are imported from their owning modules
2577
+ * (skill-health / quality / skill-store), never duplicated.
2578
+ *
2579
+ * Distinct from `signals.ts` — the session-level review signal gate.
2580
+ */
2581
+ /** Physical line length at/above which a body line is reported overlong (011 §4). */
2582
+ const DRIFT_MAX_LINE_CHARS = 1500;
2583
+ /** Signal-set version: bump whenever ids/thresholds change (011 §7 version coupling). */
2584
+ const DRIFT_SIGNALS_VERSION = "1";
2585
+ /** Render-time nouns for the MAINTAIN_PROMPT placeholders (single vocabulary with the facts block). */
2586
+ const DRIFT_SIGNAL_NOUNS = {
2587
+ dedup_group: "近重复组",
2588
+ prefix_cluster: "前缀聚类",
2589
+ stamp_density: "stamp 密度",
2590
+ body_size: "正文体量",
2591
+ dup_heading: "重复标题",
2592
+ overlong_line: "超长行",
2593
+ pointer_missing: "缺失指针",
2594
+ description_chars: "描述长度",
2595
+ narrow_name: "窄名",
2596
+ usage_observed: "使用观察",
2597
+ quality_low: "质量分"
2598
+ };
2599
+ const NARROW_NAME_PATTERNS = [
2600
+ {
2601
+ label: "error-string",
2602
+ re: /^(?:err|error|exception|traceback|warn|fail)(?:[-_][a-z0-9]+)+$/i
2603
+ },
2604
+ {
2605
+ label: "pr-number",
2606
+ re: /^(?:pr|issue)[-_]?\d{2,}$/i
2607
+ },
2608
+ {
2609
+ label: "dated",
2610
+ re: /\d{4}-\d{2}-\d{2}/
2611
+ },
2612
+ {
2613
+ label: "session-verb",
2614
+ re: /^(?:fix|debug|audit|salvage|diagnose|investigate)[-_][a-z0-9-]+$/i
2615
+ }
2616
+ ];
2617
+ /** Detect support files the body never references (by basename or relative path). */
2618
+ function missingSupportPointers(body, supportFiles) {
2619
+ return supportFiles.filter((path) => {
2620
+ const base = path.split("/").pop() ?? path;
2621
+ return base.length > 0 && !body.includes(base) && !body.includes(path);
2622
+ });
2623
+ }
2624
+ /** Duplicate `## heading` occurrences: singleton results default to head of the file. */
2625
+ function duplicateHeadings(body) {
2626
+ const counts = /* @__PURE__ */ new Map();
2627
+ for (const line of body.split("\n")) {
2628
+ const m = /^##\s+(.+)$/.exec(line);
2629
+ if (m?.[1]) {
2630
+ const heading = m[1].trim();
2631
+ if (heading) counts.set(heading, (counts.get(heading) ?? 0) + 1);
2632
+ }
2633
+ }
2634
+ return [...counts.entries()].filter(([, count]) => count > 1).map(([heading, count]) => ({
2635
+ heading,
2636
+ count
2637
+ }));
2638
+ }
2639
+ /** Physical lines over `max` characters: `{ lineNo, chars }`, 1-based line numbers. */
2640
+ function overlongLines(body, max = DRIFT_MAX_LINE_CHARS) {
2641
+ const out = [];
2642
+ const lines = body.split("\n");
2643
+ for (let index = 0; index < lines.length; index += 1) {
2644
+ const length = (lines[index] ?? "").length;
2645
+ if (length > max) out.push({
2646
+ lineNo: index + 1,
2647
+ chars: length
2648
+ });
2649
+ }
2650
+ return out;
2651
+ }
2652
+ /** Narrow-name shapes detected in a skill name (empty = none). */
2653
+ function narrowNameMatches(name) {
2654
+ return NARROW_NAME_PATTERNS.filter(({ re }) => re.test(name)).map(({ label }) => label);
2655
+ }
2656
+ function supportGroupCount(supportFiles) {
2657
+ const groups = /* @__PURE__ */ new Set();
2658
+ for (const path of supportFiles ?? []) {
2659
+ const head = path.split("/")[0];
2660
+ if (head) groups.add(head);
2661
+ }
2662
+ return groups.size;
2663
+ }
2664
+ function sig(id, verdict, value, threshold, detail) {
2665
+ return {
2666
+ id,
2667
+ verdict,
2668
+ value,
2669
+ threshold,
2670
+ detail
2671
+ };
2672
+ }
2673
+ /**
2674
+ * Compute all drift signals for a snapshot. Missing inputs (quality score,
2675
+ * usage window) yield `unknown` — never a fabricated verdict.
2676
+ */
2677
+ function computeDriftSignals(snapshots) {
2678
+ const library = [];
2679
+ const names = snapshots.map((s) => s.name);
2680
+ const dedup = computeDedupGroups({ contents: new Map(snapshots.map((s) => [s.name, s.body])) });
2681
+ library.push(dedup.length === 0 ? sig("dedup_group", "pass", "none", "size >= 2") : sig("dedup_group", "over", dedup.map((group) => group.join(", ")).join(" | "), "size >= 2", `members=${dedup.map((group) => group.join("|")).join(";")}`));
2682
+ const clusters = computePrefixClusters(names);
2683
+ library.push(clusters.length === 0 ? sig("prefix_cluster", "pass", "none", "size >= 2") : sig("prefix_cluster", "over", clusters.map((cluster) => cluster.members.join(", ")).join(" | "), "size >= 2", `key=${clusters.map((cluster) => cluster.key).join("|")}`));
2684
+ const allProvided = snapshots.length > 0 && snapshots.every((s) => s.usageObserved !== null && s.usageObserved !== void 0);
2685
+ library.push(!allProvided ? sig("usage_observed", "unknown", "not-observed", void 0, "usage window status missing") : snapshots.every((s) => s.usageObserved === true) ? sig("usage_observed", "pass", "observed") : sig("usage_observed", "pass", "unobserved"));
2686
+ return {
2687
+ library,
2688
+ skills: snapshots.map((snapshot) => {
2689
+ const signals = [];
2690
+ const body = snapshot.body;
2691
+ const supportFiles = snapshot.supportFiles ?? [];
2692
+ const supportEnumerated = snapshot.supportFiles !== void 0;
2693
+ const density = assessStructureHealth({
2694
+ skillName: snapshot.name,
2695
+ bodyChars: body.length,
2696
+ bodyText: body,
2697
+ supportGroups: supportGroupCount(supportFiles)
2698
+ }, DEFAULT_HEALTH_THRESHOLDS).dims.stampDensityPerKb;
2699
+ signals.push(density === null ? sig("stamp_density", "pass", body.length < 2e3 ? "below-min-body" : "not-assessed") : sig("stamp_density", density >= DEFAULT_HEALTH_THRESHOLDS.stampDensityPerKb ? "over" : "pass", `${density.toFixed(2)}/KB`, `${DEFAULT_HEALTH_THRESHOLDS.stampDensityPerKb}/KB`));
2700
+ signals.push(sig("body_size", body.length >= DEFAULT_HEALTH_THRESHOLDS.softBodyChars ? "over" : "pass", `${body.length}`, `${DEFAULT_HEALTH_THRESHOLDS.softBodyChars}`));
2701
+ const dupes = duplicateHeadings(body);
2702
+ signals.push(dupes.length === 0 ? sig("dup_heading", "pass", "none") : sig("dup_heading", "over", dupes.map((d) => `${d.heading}(${d.count})`).join(", "), "count >= 2"));
2703
+ const long = overlongLines(body);
2704
+ signals.push(long.length === 0 ? sig("overlong_line", "pass", "none") : sig("overlong_line", "over", long.map((l) => `${l.lineNo}:${l.chars}`).join(", "), `${DRIFT_MAX_LINE_CHARS}`));
2705
+ const missing = supportEnumerated ? missingSupportPointers(body, supportFiles) : void 0;
2706
+ signals.push(!supportEnumerated ? sig("pointer_missing", "unknown", "not-enumerated", void 0, "support files not enumerated") : (missing ?? []).length === 0 ? sig("pointer_missing", "pass", "none") : sig("pointer_missing", "over", missing?.join(", ") ?? ""));
2707
+ const narrow = narrowNameMatches(snapshot.name);
2708
+ signals.push(narrow.length === 0 ? sig("narrow_name", "pass", "none") : sig("narrow_name", "over", narrow.join(", "), void 0, `name=${snapshot.name}`));
2709
+ const description = snapshot.description;
2710
+ signals.push(description === void 0 ? sig("description_chars", "unknown", "missing", "60") : sig("description_chars", description.length > 60 ? "over" : "pass", `${description.length}`, `60`));
2711
+ const quality = snapshot.quality;
2712
+ signals.push(quality === null || quality === void 0 ? sig("quality_low", "unknown", "not-assessed") : sig("quality_low", quality < .3 ? "over" : "pass", quality.toFixed(2), `${LOW_QUALITY_THRESHOLD}`));
2713
+ return {
2714
+ name: snapshot.name,
2715
+ signals,
2716
+ ...snapshot.protected !== void 0 && snapshot.protected !== null ? { protected: snapshot.protected } : {},
2717
+ ...snapshot.catalogInvalid !== void 0 ? { catalogInvalid: snapshot.catalogInvalid } : {}
2718
+ };
2719
+ })
2720
+ };
2721
+ }
2722
+ /** Convenience: fetch one signal from an assessment or library list. */
2723
+ function findDriftSignal(signals, id) {
2724
+ return signals.find((signal) => signal.id === id);
2725
+ }
2726
+ //#endregion
2476
2727
  //#region lib/types/skill-store.js
2477
2728
  /**
2478
2729
  * Skill library management for the self-evolution plugin.
@@ -2482,6 +2733,10 @@ function foldTurn(session, fromSeq) {
2482
2733
  * it created unless a `.hermes-managed` marker opts a skill in. Archival is a
2483
2734
  * move to `.archive/` — never a hard delete.
2484
2735
  */
2736
+ /** 0.3.16 (S1.13, T-6): the pointer-line prefix written into a body when a
2737
+ * section is moved to references/ — single literal, both restructure and
2738
+ * append-mode consolidation emit the same discoverability line. */
2739
+ const POINTER_LINE_PREFIX = "> 详见 references/";
2485
2740
  const DEFAULT_SKILL_LIMITS = {
2486
2741
  maxNameLength: 64,
2487
2742
  maxDescriptionLength: MAX_DESCRIPTION_LENGTH,
@@ -2495,7 +2750,7 @@ const RESTRUCTURE_TARGET_RE = /^references\/[a-z0-9][a-z0-9._-]*\.md$/;
2495
2750
  /** Extra file name carried inside a snapshot's `extras/` directory. */
2496
2751
  const SNAPSHOT_EXTRA_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
2497
2752
  function skillsRoot(env = process.env) {
2498
- return join(env.DSH_HOME ?? join(homedir(), ".dsh"), "skills");
2753
+ return join(env.DSH_HOME || join(homedir(), ".dsh"), "skills");
2499
2754
  }
2500
2755
  /**
2501
2756
  * Map a requesting session onto the two origin surfaces (rc.44 plan M2-2.3):
@@ -2583,21 +2838,26 @@ function parseFrontmatter(content) {
2583
2838
  }
2584
2839
  /** YAML plain-scalar hazards that make an UNQUOTED frontmatter value
2585
2840
  * unloadable to the platform catalog (strict YAML parser): `: ` (mapping
2586
- * separator), ` #` (comment start), or a leading YAML indicator. The
2587
- * evolution `parseFrontmatter` is deliberately lenient, so violations
2588
- * silently split family-visibility from platform-visibility (0.3.11
2589
- * inkos-harness case: the description carried "…: " and the catalog dropped
2590
- * the whole skill). Already-quoted values and well-formed flow collections
2591
- * (`[a, b]` / `{a: b}`) are considered safe. This rule is only the FAST
2592
- * PATH the write path re-verifies every rewrite with the real YAML parser
2593
- * (see normalizeFrontmatter), so an incomplete approximation can never
2594
- * corrupt a multiline flow value (P3-4). */
2841
+ * separator), ` #` (comment start), a trailing `:` (a mapping marker),
2842
+ * or a leading YAML indicator. The evolution `parseFrontmatter` is
2843
+ * deliberately lenient, so violations silently split family-visibility from
2844
+ * platform-visibility (0.3.11 inkos-harness case: the description carried
2845
+ * "…: " and the catalog dropped the whole skill). Already-quoted values and
2846
+ * well-formed flow collections (`[a, b]` / `{a: b}`) are considered safe.
2847
+ * 0.3.16 (E-47): null/bool/number-shaped plain scalars are flagged too they
2848
+ * parse as booleans/numbers on the platform while the family keeps the string
2849
+ * (a `description: true` split-brain).
2850
+ * This rule is only the FAST PATH — the write path re-verifies every rewrite
2851
+ * with the real YAML parser (see normalizeFrontmatter), so an incomplete
2852
+ * approximation can never corrupt a multiline flow value (P3-4). */
2595
2853
  function yamlPlainScalarNeedsQuotes(value) {
2596
2854
  if (value.length === 0) return false;
2597
2855
  if (value.length >= 2 && (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'"))) return false;
2598
2856
  if (/^\[.*\]$/.test(value) || /^\{.*\}$/.test(value)) return false;
2599
2857
  if (value.includes(": ")) return true;
2600
2858
  if (value.includes(" #")) return true;
2859
+ if (value.endsWith(":")) return true;
2860
+ if (/^(?:null|true|false|~|[-+]?\d+(?:\.\d+)?)$/i.test(value)) return true;
2601
2861
  if (/^[-?:,[\]{}#&*!|>'\"%@`\s]/.test(value)) return true;
2602
2862
  return false;
2603
2863
  }
@@ -2723,10 +2983,6 @@ function validateFrontmatter(content, expectedName, limits = DEFAULT_SKILL_LIMIT
2723
2983
  if (content.length > limits.maxSkillContentChars) return `SKILL.md content exceeds ${limits.maxSkillContentChars} characters. Consider splitting into a smaller SKILL.md with supporting files.`;
2724
2984
  return null;
2725
2985
  }
2726
- /** Hermes authoring quality bar for descriptions (the 60-char Rule). The
2727
- * platform's own index limit stays in `validateFrontmatter`; this bar is the
2728
- * target the authoring standard names, enforced as ADVISORY feedback. */
2729
- const AUTHORING_DESCRIPTION_BAR = 60;
2730
2986
  /**
2731
2987
  * Advisory authoring feedback (P0): evaluate frontmatter against the
2732
2988
  * authoring bar WITHOUT changing platform validation semantics. The bar is
@@ -2835,7 +3091,7 @@ function fuzzyReplace(content, oldString, newString, replaceAll) {
2835
3091
  }
2836
3092
  function fuzzyPatch(content, oldString, newString, replaceAll = false) {
2837
3093
  if (oldString === "") return null;
2838
- if (content.includes(oldString)) return replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, newString);
3094
+ if (content.includes(oldString)) return replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, () => newString);
2839
3095
  const boundary = trimPatternBoundaries(oldString);
2840
3096
  if (boundary === "") return null;
2841
3097
  if (boundary !== oldString) {
@@ -2900,7 +3156,7 @@ function planRestructureSections(body, moves) {
2900
3156
  for (let i = 0; i < lines.length; i += 1) {
2901
3157
  const span = byStart.get(i);
2902
3158
  if (span) {
2903
- rebuilt.push(`> 详见 references/${span.rel.split("/").at(-1)}`);
3159
+ rebuilt.push(`${POINTER_LINE_PREFIX}${span.rel.split("/").at(-1)}`);
2904
3160
  i = span.end - 1;
2905
3161
  } else rebuilt.push(lines[i] ?? "");
2906
3162
  }
@@ -2957,7 +3213,12 @@ var SkillLibrary = class {
2957
3213
  async read(rawName) {
2958
3214
  const name = rawName.trim();
2959
3215
  if (this.badName(name) !== null) return null;
2960
- return this.io.readText(join(this.dirOf(name), "SKILL.md"));
3216
+ try {
3217
+ return await this.io.readText(join(this.dirOf(name), "SKILL.md"));
3218
+ } catch (error) {
3219
+ if (error?.code === "EISDIR") return null;
3220
+ throw error;
3221
+ }
2961
3222
  }
2962
3223
  /**
2963
3224
 
@@ -3180,7 +3441,7 @@ var SkillLibrary = class {
3180
3441
  this.notifyMutation({
3181
3442
  action: "create",
3182
3443
  name: normalized,
3183
- filePath: dir
3444
+ skillDir: dir
3184
3445
  });
3185
3446
  return {
3186
3447
  ok: true,
@@ -3235,7 +3496,7 @@ var SkillLibrary = class {
3235
3496
  this.notifyMutation({
3236
3497
  action: "update",
3237
3498
  name,
3238
- filePath: dir
3499
+ skillDir: dir
3239
3500
  });
3240
3501
  return {
3241
3502
  ok: true,
@@ -3324,7 +3585,7 @@ var SkillLibrary = class {
3324
3585
  this.notifyMutation({
3325
3586
  action: "patch",
3326
3587
  name,
3327
- filePath: dir
3588
+ skillDir: dir
3328
3589
  });
3329
3590
  return {
3330
3591
  ok: true,
@@ -3374,7 +3635,23 @@ var SkillLibrary = class {
3374
3635
  await this.io.rename(dir, dest);
3375
3636
  } catch {
3376
3637
  await this.io.copy(dir, dest);
3377
- await this.io.remove(dir);
3638
+ try {
3639
+ await this.io.remove(dir);
3640
+ } catch (error) {
3641
+ const reason = error instanceof Error ? error.message : String(error);
3642
+ try {
3643
+ await this.io.remove(dest);
3644
+ return {
3645
+ ok: false,
3646
+ message: `Archive copy succeeded but the source could not be removed (${reason}); the copied archive was rolled back.`
3647
+ };
3648
+ } catch {
3649
+ return {
3650
+ ok: false,
3651
+ 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.`
3652
+ };
3653
+ }
3654
+ }
3378
3655
  }
3379
3656
  const reason = options.reason ?? (options.absorbedInto ? `Consolidated into ${options.absorbedInto}` : "Archived by self-evolution curator");
3380
3657
  await this.io.writeText(join(dest, ".archive-reason"), `${(/* @__PURE__ */ new Date()).toISOString()}: ${reason}\n`);
@@ -3499,7 +3776,7 @@ var SkillLibrary = class {
3499
3776
  content: `<!-- demoted from ${source} at ${(/* @__PURE__ */ new Date()).toISOString()} -->\n${parsed.body.trim()}\n`
3500
3777
  });
3501
3778
  }
3502
- const pointerLines = normalizedSources.map((source) => `\n> 详见 references/${source}.md`).join("");
3779
+ const pointerLines = normalizedSources.map((source) => `\n${POINTER_LINE_PREFIX}${source}.md`).join("");
3503
3780
  const extended = targetMd.trimEnd() + pointerLines + "\n";
3504
3781
  const validation = validateFrontmatter(extended, targetName, this.limits);
3505
3782
  if (validation) return {
@@ -3529,10 +3806,20 @@ var SkillLibrary = class {
3529
3806
  });
3530
3807
  if (!result.ok) throw new Error(result.message);
3531
3808
  } catch (error) {
3532
- for (const source of archived.reverse()) await this.restoreFromArchive(source).catch(() => {});
3809
+ const reason = error instanceof Error ? error.message : String(error);
3810
+ const failedRestores = [];
3811
+ for (const source of archived.reverse()) try {
3812
+ if (!(await this.restoreFromArchive(source)).ok) failedRestores.push(source);
3813
+ } catch {
3814
+ failedRestores.push(source);
3815
+ }
3816
+ if (failedRestores.length > 0) return {
3817
+ ok: false,
3818
+ message: `Consolidation failed (${reason}); rolled back EXCEPT ${failedRestores.join(", ")} — still in .archive, restore them with /evolution skill restore.`
3819
+ };
3533
3820
  return {
3534
3821
  ok: false,
3535
- message: `Consolidation failed and was rolled back: ${error instanceof Error ? error.message : String(error)}`
3822
+ message: `Consolidation failed and was rolled back: ${reason}`
3536
3823
  };
3537
3824
  }
3538
3825
  return {
@@ -3584,20 +3871,20 @@ var SkillLibrary = class {
3584
3871
  ok: false,
3585
3872
  message: `Skill "${name}" not found.`
3586
3873
  };
3587
- const normalized = md.replace(/\r\n/g, "\n");
3588
- const frontmatterEnd = normalized.indexOf("\n---", 3);
3589
- if (frontmatterEnd < 0) return {
3874
+ const block = frontmatterBlock(md);
3875
+ if (!block) return {
3590
3876
  ok: false,
3591
3877
  message: "SKILL.md has no valid frontmatter; refusing to restructure."
3592
3878
  };
3593
- const header = normalized.slice(0, frontmatterEnd + 4);
3594
- const plan = planRestructureSections(normalized.slice(frontmatterEnd + 4), moves);
3879
+ const header = block.lines.slice(0, block.end + 1).join(block.nl);
3880
+ const plan = planRestructureSections(md.slice(header.length).replace(/\r\n/g, "\n"), moves);
3595
3881
  if ("error" in plan) return {
3596
3882
  ok: false,
3597
3883
  message: `Restructure rejected: ${plan.error}`
3598
3884
  };
3599
- const newMd = header + plan.body;
3600
- const newMdCheck = validateFrontmatter(newMd, name, this.limits);
3885
+ const newMd = `${header}${plan.body}`.replace(/\r\n/g, "\n");
3886
+ const finalMd = block.nl === "\r\n" ? newMd.replace(/\n/g, "\r\n") : newMd;
3887
+ const newMdCheck = validateFrontmatter(finalMd, name, this.limits);
3601
3888
  if (newMdCheck) return {
3602
3889
  ok: false,
3603
3890
  message: `Restructure rejected: ${newMdCheck}`
@@ -3629,7 +3916,7 @@ var SkillLibrary = class {
3629
3916
  }
3630
3917
  writes.push({
3631
3918
  target: join(dir, "SKILL.md"),
3632
- content: newMd
3919
+ content: finalMd
3633
3920
  });
3634
3921
  const result = await this.applyTreeChange({
3635
3922
  name,
@@ -3721,11 +4008,11 @@ var SkillLibrary = class {
3721
4008
  message: `Tree change failed and was rolled back: ${error instanceof Error ? error.message : String(error)}`
3722
4009
  };
3723
4010
  }
3724
- await this.audit(name, plan.auditAction, md, landing.find((entry) => entry.target.endsWith("SKILL.md"))?.content ?? md, plan.auditSummary);
4011
+ await this.audit(name, plan.auditAction, md, landing.find((entry) => entry.target.split(/[\\/]/).pop() === "SKILL.md")?.content ?? md, plan.auditSummary);
3725
4012
  this.notifyMutation({
3726
4013
  action: plan.eventAction,
3727
4014
  name,
3728
- filePath: dir
4015
+ skillDir: dir
3729
4016
  });
3730
4017
  return {
3731
4018
  ok: true,
@@ -3758,7 +4045,12 @@ var SkillLibrary = class {
3758
4045
  message: "No skill archive available."
3759
4046
  };
3760
4047
  }
3761
- const chosen = entries.filter((entry) => entry === name || entry.startsWith(`${name}-`)).sort().reverse()[0];
4048
+ const candidates = entries.filter((entry) => entry === name || entry.startsWith(`${name}-`)).sort().reverse();
4049
+ let chosen;
4050
+ for (const candidate of candidates) if (parseFrontmatter(await this.io.readText(join(archiveRoot, candidate, "SKILL.md")).catch(() => null) ?? "")?.frontmatter.name === name) {
4051
+ chosen = candidate;
4052
+ break;
4053
+ }
3762
4054
  if (!chosen) return {
3763
4055
  ok: false,
3764
4056
  message: `Skill "${name}" is not in .archive.`
@@ -3774,14 +4066,21 @@ var SkillLibrary = class {
3774
4066
  try {
3775
4067
  await this.io.rename(source, dest);
3776
4068
  } catch {
3777
- await this.io.copy(source, dest);
3778
- await this.io.remove(source);
4069
+ try {
4070
+ await this.io.copy(source, dest);
4071
+ await this.io.remove(source);
4072
+ } catch (error) {
4073
+ return {
4074
+ ok: false,
4075
+ message: `Restore of "${name}" from .archive failed: ${error instanceof Error ? error.message : String(error)}`
4076
+ };
4077
+ }
3779
4078
  }
3780
4079
  if (await this.io.exists(join(dest, ".archive-reason"))) await this.io.remove(join(dest, ".archive-reason"));
3781
4080
  this.notifyMutation({
3782
4081
  action: "restore",
3783
4082
  name,
3784
- filePath: dest
4083
+ skillDir: dest
3785
4084
  });
3786
4085
  return {
3787
4086
  ok: true,
@@ -3827,7 +4126,8 @@ var SkillLibrary = class {
3827
4126
  this.notifyMutation({
3828
4127
  action: "write_file",
3829
4128
  name,
3830
- filePath: target
4129
+ skillDir: dir,
4130
+ file: target
3831
4131
  });
3832
4132
  return {
3833
4133
  ok: true,
@@ -3868,7 +4168,8 @@ var SkillLibrary = class {
3868
4168
  this.notifyMutation({
3869
4169
  action: "remove_file",
3870
4170
  name,
3871
- filePath: target
4171
+ skillDir: dir,
4172
+ file: target
3872
4173
  });
3873
4174
  return {
3874
4175
  ok: true,
@@ -3996,7 +4297,43 @@ var SkillLibrary = class {
3996
4297
  ok: false,
3997
4298
  message: "No skill snapshot available."
3998
4299
  };
3999
- await this.snapshotAll("pre-rollback", extras);
4300
+ const preRollbackPath = await this.snapshotAll("pre-rollback", extras);
4301
+ try {
4302
+ await this.restoreSnapshotIntoRoot(latest.path);
4303
+ } catch (error) {
4304
+ const reason = error instanceof Error ? error.message : String(error);
4305
+ try {
4306
+ await this.restoreSnapshotIntoRoot(preRollbackPath);
4307
+ return {
4308
+ ok: false,
4309
+ message: `Snapshot restore failed (${reason}); the active tree was rolled back to the pre-rollback snapshot.`
4310
+ };
4311
+ } catch (rollbackError) {
4312
+ return {
4313
+ ok: false,
4314
+ message: `Snapshot restore failed (${reason}) AND pre-rollback restore failed (${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}). Rescue manually from: ${preRollbackPath} (pre-rollback), ${latest.path} (target).`
4315
+ };
4316
+ }
4317
+ }
4318
+ const snapshotExtras = await this.readSnapshotExtras(latest.path);
4319
+ this.notifyMutation({
4320
+ action: "restore",
4321
+ name: "snapshot"
4322
+ });
4323
+ return {
4324
+ ok: true,
4325
+ message: `Restored skill tree from ${latest.path}`,
4326
+ path: latest.path,
4327
+ ...snapshotExtras.length === 0 ? {} : { extras: snapshotExtras }
4328
+ };
4329
+ }
4330
+ /**
4331
+ * Whole-tree replacement from one snapshot path (rc.50 P2-14): every
4332
+ * NON-system entry in the active root is cleared first, then the manifest
4333
+ * drives the repopulation (skills, sidecars, `.archive`). Extracted from
4334
+ * restoreLatestSnapshot so a failed restore can roll itself back (E-13).
4335
+ */
4336
+ async restoreSnapshotIntoRoot(snapshotPath) {
4000
4337
  let rootEntries;
4001
4338
  try {
4002
4339
  rootEntries = await this.io.list(this.root);
@@ -4007,200 +4344,30 @@ var SkillLibrary = class {
4007
4344
  if (entry.startsWith(".")) continue;
4008
4345
  await this.io.remove(join(this.root, entry));
4009
4346
  }
4010
- const manifest = await this.readSnapshotManifest(latest.path);
4011
- if (manifest === null) for (const entry of await this.io.list(latest.path)) {
4347
+ const manifest = await this.readSnapshotManifest(snapshotPath);
4348
+ if (manifest === null) for (const entry of await this.io.list(snapshotPath)) {
4012
4349
  if (entry === "manifest.json" || entry === "extras") continue;
4013
- await this.io.copy(join(latest.path, entry), join(this.root, entry));
4350
+ await this.io.copy(join(snapshotPath, entry), join(this.root, entry));
4014
4351
  }
4015
4352
  else {
4016
- for (const name of manifest.skills) await this.io.copy(join(latest.path, name), join(this.root, name));
4017
- for (const sidecar of manifest.sidecars) await this.io.copy(join(latest.path, sidecar), join(this.root, sidecar));
4353
+ for (const name of manifest.skills) await this.io.copy(join(snapshotPath, name), join(this.root, name));
4354
+ for (const sidecar of manifest.sidecars) await this.io.copy(join(snapshotPath, sidecar), join(this.root, sidecar));
4018
4355
  const archiveRoot = join(this.root, ".archive");
4019
4356
  if (manifest.hasArchive === true) {
4020
4357
  await this.io.remove(archiveRoot);
4021
- await this.io.copy(join(latest.path, ".archive"), archiveRoot);
4358
+ await this.io.copy(join(snapshotPath, ".archive"), archiveRoot);
4022
4359
  } else if (manifest.hasArchive === false) await this.io.remove(archiveRoot);
4023
4360
  }
4024
- const snapshotExtras = await this.readSnapshotExtras(latest.path);
4025
- this.notifyMutation({
4026
- action: "restore",
4027
- name: "snapshot"
4028
- });
4029
- return {
4030
- ok: true,
4031
- message: `Restored skill tree from ${latest.path}`,
4032
- path: latest.path,
4033
- ...snapshotExtras.length === 0 ? {} : { extras: snapshotExtras }
4034
- };
4035
4361
  }
4036
4362
  };
4037
4363
  //#endregion
4038
- //#region lib/types/drift-signals.js
4039
- /**
4040
- * Library-level drift signals for the maintenance subagent (design 011).
4041
- *
4042
- * Deterministic fact checks over a skill-library snapshot: domain drift
4043
- * (narrow names, near-duplicate groups, prefix clusters) and layer drift
4044
- * (log-like bodies, duplicate headings, overlong lines, missing support-file
4045
- * pointers, description over the authoring bar). Pure functions only — no IO,
4046
- * no LLM, no services. Thresholds are imported from their owning modules
4047
- * (skill-health / quality / skill-store), never duplicated.
4048
- *
4049
- * Distinct from `signals.ts` — the session-level review signal gate.
4050
- */
4051
- /** Physical line length at/above which a body line is reported overlong (011 §4). */
4052
- const DRIFT_MAX_LINE_CHARS = 1500;
4053
- /** Signal-set version: bump whenever ids/thresholds change (011 §7 version coupling). */
4054
- const DRIFT_SIGNALS_VERSION = "1";
4055
- /** Render-time nouns for the MAINTAIN_PROMPT placeholders (single vocabulary with the facts block). */
4056
- const DRIFT_SIGNAL_NOUNS = {
4057
- dedup_group: "近重复组",
4058
- prefix_cluster: "前缀聚类",
4059
- stamp_density: "stamp 密度",
4060
- body_size: "正文体量",
4061
- dup_heading: "重复标题",
4062
- overlong_line: "超长行",
4063
- pointer_missing: "缺失指针",
4064
- description_chars: "描述长度",
4065
- narrow_name: "窄名",
4066
- usage_observed: "使用观察",
4067
- quality_low: "质量分"
4068
- };
4069
- const NARROW_NAME_PATTERNS = [
4070
- {
4071
- label: "error-string",
4072
- re: /^(?:err|error|exception|traceback|warn|fail)(?:[-_][a-z0-9]+)+$/i
4073
- },
4074
- {
4075
- label: "pr-number",
4076
- re: /^(?:pr|issue)[-_]?\d{2,}$/i
4077
- },
4078
- {
4079
- label: "dated",
4080
- re: /\d{4}-\d{2}-\d{2}/
4081
- },
4082
- {
4083
- label: "session-verb",
4084
- re: /^(?:fix|debug|audit|salvage|diagnose|investigate)[-_][a-z0-9-]+$/i
4085
- }
4086
- ];
4087
- /** Detect support files the body never references (by basename or relative path). */
4088
- function missingSupportPointers(body, supportFiles) {
4089
- return supportFiles.filter((path) => {
4090
- const base = path.split("/").pop() ?? path;
4091
- return base.length > 0 && !body.includes(base) && !body.includes(path);
4092
- });
4093
- }
4094
- /** Duplicate `## heading` occurrences: singleton results default to head of the file. */
4095
- function duplicateHeadings(body) {
4096
- const counts = /* @__PURE__ */ new Map();
4097
- for (const line of body.split("\n")) {
4098
- const m = /^##\s+(.+)$/.exec(line);
4099
- if (m?.[1]) {
4100
- const heading = m[1].trim();
4101
- if (heading) counts.set(heading, (counts.get(heading) ?? 0) + 1);
4102
- }
4103
- }
4104
- return [...counts.entries()].filter(([, count]) => count > 1).map(([heading, count]) => ({
4105
- heading,
4106
- count
4107
- }));
4108
- }
4109
- /** Physical lines over `max` characters: `{ lineNo, chars }`, 1-based line numbers. */
4110
- function overlongLines(body, max = DRIFT_MAX_LINE_CHARS) {
4111
- const out = [];
4112
- const lines = body.split("\n");
4113
- for (let index = 0; index < lines.length; index += 1) {
4114
- const length = (lines[index] ?? "").length;
4115
- if (length > max) out.push({
4116
- lineNo: index + 1,
4117
- chars: length
4118
- });
4119
- }
4120
- return out;
4121
- }
4122
- /** Narrow-name shapes detected in a skill name (empty = none). */
4123
- function narrowNameMatches(name) {
4124
- return NARROW_NAME_PATTERNS.filter(({ re }) => re.test(name)).map(({ label }) => label);
4125
- }
4126
- function supportGroupCount(supportFiles) {
4127
- const groups = /* @__PURE__ */ new Set();
4128
- for (const path of supportFiles ?? []) {
4129
- const head = path.split("/")[0];
4130
- if (head) groups.add(head);
4131
- }
4132
- return groups.size;
4133
- }
4134
- function sig(id, verdict, value, threshold, detail) {
4135
- return {
4136
- id,
4137
- verdict,
4138
- value,
4139
- threshold,
4140
- detail
4141
- };
4142
- }
4143
- /**
4144
- * Compute all drift signals for a snapshot. Missing inputs (quality score,
4145
- * usage window) yield `unknown` — never a fabricated verdict.
4146
- */
4147
- function computeDriftSignals(snapshots) {
4148
- const library = [];
4149
- const names = snapshots.map((s) => s.name);
4150
- const dedup = computeDedupGroups({ contents: new Map(snapshots.map((s) => [s.name, s.body])) });
4151
- library.push(dedup.length === 0 ? sig("dedup_group", "pass", "none", "size >= 2") : sig("dedup_group", "over", dedup.map((group) => group.join(", ")).join(" | "), "size >= 2", `members=${dedup.map((group) => group.join("|")).join(";")}`));
4152
- const clusters = computePrefixClusters(names);
4153
- library.push(clusters.length === 0 ? sig("prefix_cluster", "pass", "none", "size >= 2") : sig("prefix_cluster", "over", clusters.map((cluster) => cluster.members.join(", ")).join(" | "), "size >= 2", `key=${clusters.map((cluster) => cluster.key).join("|")}`));
4154
- const allProvided = snapshots.length > 0 && snapshots.every((s) => s.usageObserved !== null && s.usageObserved !== void 0);
4155
- library.push(!allProvided ? sig("usage_observed", "unknown", "not-observed", void 0, "usage window status missing") : snapshots.every((s) => s.usageObserved === true) ? sig("usage_observed", "pass", "observed") : sig("usage_observed", "pass", "unobserved"));
4156
- return {
4157
- library,
4158
- skills: snapshots.map((snapshot) => {
4159
- const signals = [];
4160
- const body = snapshot.body;
4161
- const supportFiles = snapshot.supportFiles ?? [];
4162
- const supportEnumerated = snapshot.supportFiles !== void 0;
4163
- const density = assessStructureHealth({
4164
- skillName: snapshot.name,
4165
- bodyChars: body.length,
4166
- bodyText: body,
4167
- supportGroups: supportGroupCount(supportFiles)
4168
- }, DEFAULT_HEALTH_THRESHOLDS).dims.stampDensityPerKb;
4169
- signals.push(density === null ? sig("stamp_density", "pass", body.length < 2e3 ? "below-min-body" : "not-assessed") : sig("stamp_density", density >= DEFAULT_HEALTH_THRESHOLDS.stampDensityPerKb ? "over" : "pass", `${density.toFixed(2)}/KB`, `${DEFAULT_HEALTH_THRESHOLDS.stampDensityPerKb}/KB`));
4170
- signals.push(sig("body_size", body.length >= DEFAULT_HEALTH_THRESHOLDS.softBodyChars ? "over" : "pass", `${body.length}`, `${DEFAULT_HEALTH_THRESHOLDS.softBodyChars}`));
4171
- const dupes = duplicateHeadings(body);
4172
- signals.push(dupes.length === 0 ? sig("dup_heading", "pass", "none") : sig("dup_heading", "over", dupes.map((d) => `${d.heading}(${d.count})`).join(", "), "count >= 2"));
4173
- const long = overlongLines(body);
4174
- signals.push(long.length === 0 ? sig("overlong_line", "pass", "none") : sig("overlong_line", "over", long.map((l) => `${l.lineNo}:${l.chars}`).join(", "), `${DRIFT_MAX_LINE_CHARS}`));
4175
- const missing = supportEnumerated ? missingSupportPointers(body, supportFiles) : void 0;
4176
- signals.push(!supportEnumerated ? sig("pointer_missing", "unknown", "not-enumerated", void 0, "support files not enumerated") : (missing ?? []).length === 0 ? sig("pointer_missing", "pass", "none") : sig("pointer_missing", "over", missing?.join(", ") ?? ""));
4177
- const narrow = narrowNameMatches(snapshot.name);
4178
- signals.push(narrow.length === 0 ? sig("narrow_name", "pass", "none") : sig("narrow_name", "over", narrow.join(", "), void 0, `name=${snapshot.name}`));
4179
- const description = snapshot.description;
4180
- signals.push(description === void 0 ? sig("description_chars", "unknown", "missing", "60") : sig("description_chars", description.length > 60 ? "over" : "pass", `${description.length}`, `60`));
4181
- const quality = snapshot.quality;
4182
- signals.push(quality === null || quality === void 0 ? sig("quality_low", "unknown", "not-assessed") : sig("quality_low", quality < .3 ? "over" : "pass", quality.toFixed(2), `${LOW_QUALITY_THRESHOLD}`));
4183
- return {
4184
- name: snapshot.name,
4185
- signals,
4186
- ...snapshot.protected !== void 0 && snapshot.protected !== null ? { protected: snapshot.protected } : {},
4187
- ...snapshot.catalogInvalid !== void 0 ? { catalogInvalid: snapshot.catalogInvalid } : {}
4188
- };
4189
- })
4190
- };
4191
- }
4192
- /** Convenience: fetch one signal from an assessment or library list. */
4193
- function findDriftSignal(signals, id) {
4194
- return signals.find((signal) => signal.id === id);
4195
- }
4196
- //#endregion
4197
4364
  //#region lib/types/state-store.js
4198
4365
  /**
4199
4366
  * Evolution home path helper: `$DSH_HOME/evolution` for plugin-owned sidecar
4200
4367
  * state (reports, activity store, feedback file, state-domain data).
4201
4368
  */
4202
4369
  function evolutionHome(env = process.env) {
4203
- return join(env.DSH_HOME ?? join(homedir(), ".dsh"), "evolution");
4370
+ return join(env.DSH_HOME || join(homedir(), ".dsh"), "evolution");
4204
4371
  }
4205
4372
  //#endregion
4206
- export { AUTHORING_DESCRIPTION_BAR, COMBINED_REVIEW_PLAN_PROMPT, COMBINED_REVIEW_PROMPT, COMPLETION_SKILL_REVIEW_PROMPT, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_CONSOLIDATION_FAILURES, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_MAX_OPS_PER_PLAN, DEFAULT_MEMORY_CHAR_LIMIT, DEFAULT_MIN_IDLE_HOURS, DEFAULT_MUTATION_CAP, DEFAULT_REVIEW_MEMORY_INTERVAL, DEFAULT_REVIEW_SKILL_INTERVAL, DEFAULT_SKILL_CONTENT_CHARS, DEFAULT_SKILL_LIMITS, DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS, DEFAULT_SKILL_REVIEW_TRIGGER, DEFAULT_STALE_AFTER_DAYS, DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS, DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS, DEFAULT_SUBSTANTIVE_MIN_USER_CHARS, DEFAULT_USER_CHAR_LIMIT, DRIFT_MAX_LINE_CHARS, DRIFT_SIGNALS_VERSION, DRIFT_SIGNAL_NOUNS, DSH_AUTHORING_STANDARDS, ENTRY_DELIMITER, EVENT_ARCHIVE_PREFIX, EVENT_LOG_RETAIN_ARCHIVES, EVENT_LOG_ROTATE_AT, EVENT_LOG_VERSION, EvolutionGateSet, HEALTH_STAMP_RE, LOW_QUALITY_THRESHOLD, MAINTAIN_PROMPT, MAX_DESCRIPTION_LENGTH, MAX_RESTRUCTURE_MOVES, MAX_SKILL_CONTENT_CHARS, MAX_SKILL_FILE_BYTES, MAX_SKILL_NAME_LENGTH, MEMORY_REVIEW_PROMPT, MUTATIONS_FILE_VERSION, MemoryStore, 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, advanceReview, appendEvolutionEvent, applyCuratorFields, applyCuratorLifecycleFields, applyCuratorMetaFields, assessStructureHealth, authoringFeedback, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpUse, bumpView, composePresetComposition, computeDedupGroups, computeDriftSignals, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, contentHash, createGateSet, duplicateHeadings, emptyRecord, evaluateThreat, eventsFile, evolutionHome, evolutionIoAdapter, findDriftSignal, foldCuratorFields, foldTurn, frontmatterBlock, frontmatterYamlUnsafeValues, getRecord, latestActivityAt, lifecycleCandidate, listEventArchives, loadMutations, loadSuppressedNames, loadUsage, markAgentCreated, memoryRoot, missingSupportPointers, mutateUsage, mutationsFile, narrowNameMatches, nodeEvolutionIo, normalizeFrontmatter, normalizeUsageRecord, observeEvent, overlongLines, parseCuratorNominations, parseEvolutionEvents, parseFrontmatter, readEvolutionEvents, readEvolutionTimeline, recordMutation, redactSecrets, relatedSkillNames, renderCuratorReportMarkdown, resolveOrigins, retainEventArchives, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, suppressedFile, transactIo, updateSuppressedNames, usageFile, usageObserved, validateFrontmatter, verifyPromptBundle, yamlPlainScalarNeedsQuotes };
4373
+ export { AUTHORING_DESCRIPTION_BAR, COMBINED_REVIEW_PLAN_PROMPT, COMBINED_REVIEW_PROMPT, COMPLETION_SKILL_REVIEW_PROMPT, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_CONSOLIDATION_FAILURES, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_MAX_OPS_PER_PLAN, DEFAULT_MEMORY_CHAR_LIMIT, DEFAULT_MIN_IDLE_HOURS, DEFAULT_MUTATION_CAP, DEFAULT_REVIEW_MEMORY_INTERVAL, DEFAULT_REVIEW_SKILL_INTERVAL, DEFAULT_SKILL_CONTENT_CHARS, DEFAULT_SKILL_LIMITS, DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS, DEFAULT_SKILL_REVIEW_TRIGGER, DEFAULT_STALE_AFTER_DAYS, DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS, DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS, DEFAULT_SUBSTANTIVE_MIN_USER_CHARS, DEFAULT_USER_CHAR_LIMIT, DRIFT_MAX_LINE_CHARS, DRIFT_SIGNALS_VERSION, DRIFT_SIGNAL_NOUNS, DSH_AUTHORING_STANDARDS, ENTRY_DELIMITER, EVENT_ARCHIVE_PREFIX, EVENT_LOG_RETAIN_ARCHIVES, EVENT_LOG_ROTATE_AT, EVENT_LOG_VERSION, EVOLUTION_WRITE_TOOLS, EvolutionGateSet, FORBIDDEN_CONTROL_KEYS, HEALTH_STAMP_RE, LOW_QUALITY_THRESHOLD, MAINTAIN_PROMPT, MAX_DESCRIPTION_LENGTH, MAX_RESTRUCTURE_MOVES, MAX_SKILL_CONTENT_CHARS, MAX_SKILL_FILE_BYTES, MAX_SKILL_NAME_LENGTH, MEMORY_REVIEW_PROMPT, MUTATIONS_FILE_VERSION, MemoryStore, 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, advanceReview, appendEvolutionEvent, applyCuratorFields, applyCuratorLifecycleFields, applyCuratorMetaFields, assessStructureHealth, authoringFeedback, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpUse, bumpView, composePresetComposition, computeDedupGroups, computeDriftSignals, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, contentHash, createGateSet, duplicateHeadings, emptyRecord, evaluateThreat, eventsFile, evolutionHome, evolutionIoAdapter, findDriftSignal, foldCuratorFields, foldTurn, frontmatterBlock, frontmatterYamlUnsafeValues, getRecord, latestActivityAt, lifecycleCandidate, listEventArchives, loadMutations, loadSuppressedNames, loadUsage, makeSerialQueue, markAgentCreated, memoryRoot, missingSupportPointers, mutateUsage, mutationsFile, narrowNameMatches, nodeEvolutionIo, normalizeFrontmatter, normalizeUsageRecord, observeEvent, overlongLines, parseCuratorNominations, parseEvolutionEvents, parseFrontmatter, readEvolutionEvents, readEvolutionTimeline, recordMutation, redactSecrets, relatedSkillNames, renderCuratorReportMarkdown, resolveOrigins, retainEventArchives, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, suppressedFile, transactIo, updateSuppressedNames, usageFile, usageObserved, validateFrontmatter, verifyPromptBundle, yamlPlainScalarNeedsQuotes };
@@ -50,4 +50,17 @@ export declare const DEFAULT_USER_CHAR_LIMIT = 1375;
50
50
  /** Consolidation-failure backoff cap, shared by MemoryStore and memory-files' Config default. */
51
51
  export declare const DEFAULT_CONSOLIDATION_FAILURES = 3;
52
52
  export declare const DEFAULT_SKILL_CONTENT_CHARS = 100000;
53
+ /** 0.3.17 (S3.10, T-1): control-plane fields a model-facing write call may
54
+ * never carry — single source for plan-validator, evolution-policy and the
55
+ * threat scanner (they used to each hardcode the list). */
56
+ export declare const FORBIDDEN_CONTROL_KEYS: readonly ["policy", "threshold", "prompt_hash", "model_route", "evolution_config"];
57
+ /** 0.3.17 (S3.10): the model-facing write tools the policy guard and threat
58
+ * scanner cover. */
59
+ export declare const EVOLUTION_WRITE_TOOLS: readonly ["memory", "skill_manage"];
60
+ /** Hermes authoring quality bar for descriptions (the 60-char Rule). The
61
+ * platform's own index limit stays in validateFrontmatter; this bar is the
62
+ * target the authoring standard names, enforced as ADVISORY feedback.
63
+ * 0.3.16 (T-4): moved here from skill-store.ts so drift-signals (pure, no IO)
64
+ * can reference it without importing the skill-store module. */
65
+ export declare const AUTHORING_DESCRIPTION_BAR = 60;
53
66
  //# sourceMappingURL=constants.d.ts.map
@@ -35,7 +35,11 @@ export interface EvolutionPlanAppliedEvent {
35
35
  export interface EvolutionSkillMutatedEvent {
36
36
  action: string;
37
37
  name: string;
38
- filePath?: string;
38
+ /** 0.3.16 (E-50): was `filePath` with mixed semantics — skill-directory ops
39
+ * carried the DIRECTORY while file ops (write_file/remove_file) carried the
40
+ * FILE path. Split into explicit fields so a subscriber can distinguish. */
41
+ skillDir?: string;
42
+ file?: string;
39
43
  archivedPath?: string;
40
44
  }
41
45
  declare module '@deepseek-ai/cordis' {
@@ -19,6 +19,7 @@ export * from './preset-composition.ts';
19
19
  export * from './prompts.ts';
20
20
  export * from './quality.ts';
21
21
  export * from './redact.ts';
22
+ export * from './serial.ts';
22
23
  export * from './skill-health.ts';
23
24
  export * from './signals.ts';
24
25
  export * from './drift-signals.ts';
package/lib/types/io.d.ts CHANGED
@@ -26,8 +26,10 @@ export interface EvolutionIoLike {
26
26
  * (`null` when missing) and returns the next content; returning `null`
27
27
  * deletes the file. A backend without it falls back to plain read+write and
28
28
  * the caller keeps its single-process chain as the second layer.
29
+ * 0.3.16: sync returns are allowed (0.3.16 S1.14 X-1 — mutated callers with
30
+ * no await in the task need no Promise residue).
29
31
  */
30
- transact?(this: void, path: string, task: (current: string | null) => Promise<string | null>): Promise<void>;
32
+ transact?(this: void, path: string, task: (current: string | null) => string | null | Promise<string | null>): Promise<void>;
31
33
  /**
32
34
  * Optional symlink probe (G7). `true` = the path is a symlink, `false` = a
33
35
  * real entry, `null` = guard not applicable (backend without the probe or
@@ -40,7 +42,7 @@ export interface EvolutionIoLike {
40
42
  * back to a plain read → task → write/remove sequence (no cross-process lock —
41
43
  * callers keep their single-process serialize chain as the second layer).
42
44
  */
43
- export declare function transactIo(io: EvolutionIoLike, path: string, task: (current: string | null) => Promise<string | null>): Promise<void>;
45
+ export declare function transactIo(io: EvolutionIoLike, path: string, task: (current: string | null) => string | null | Promise<string | null>): Promise<void>;
44
46
  /** Lazy adapter over an IO provider registry, shared by every evolution consumer. */
45
47
  export declare function evolutionIoAdapter(provider: () => EvolutionIoLike): EvolutionIoLike;
46
48
  export declare function nodeEvolutionIo(): EvolutionIoLike;
@@ -46,7 +46,6 @@ export declare class MemoryStore {
46
46
  */
47
47
  private oversizedFile;
48
48
  read(target: MemoryTarget): Promise<string[]>;
49
- write(target: MemoryTarget, entries: string[]): Promise<void>;
50
49
  resetFailures(): void;
51
50
  private failure;
52
51
  /**
@@ -3,8 +3,8 @@
3
3
  * changes semantically: the bundle digest is the fail-closed signal for
4
4
  * review workers, so a stale id across deployments must be distinguishable.
5
5
  */
6
- export declare const PROMPT_BUNDLE_ID = "dsh-evolution@13";
7
6
  export declare const PROMPT_BUNDLE_VERSION = 13;
7
+ export declare const PROMPT_BUNDLE_ID = "dsh-evolution@13";
8
8
  export declare const MEMORY_REVIEW_PROMPT = "[Auto-review \u2014 Memory]\nReview the conversation above and consider saving to memory if appropriate.\n\nFocus on:\n1. Has the user revealed things about themselves \u2014 persona, desires, preferences, or personal details worth remembering?\n2. Has the user expressed expectations about how you should behave, their work style, or ways they want you to operate?\n\nIf something stands out, save it using the memory tool.\nIf nothing is worth saving, just say \"Nothing to save.\" and stop.";
9
9
  export declare const SKILL_REVIEW_PROMPT = "[Auto-review \u2014 Skills]\nReview the conversation above and update the skill library. Be ACTIVE \u2014 most sessions produce at least one skill update, even if small. A pass that does nothing is a missed learning opportunity, not a neutral outcome.\n\nTarget shape of the library: CLASS-LEVEL skills, each with a rich SKILL.md and a references/ directory for session-specific detail. Not a long flat list of narrow one-session-one-skill entries. This shapes HOW you update, not WHETHER you update.\n\nSignals to look for (any one of these warrants action):\n \u2022 User corrected your style, tone, format, legibility, or verbosity. Frustration signals like 'stop doing X', 'this is too verbose', 'don't format like this', 'why are you explaining', 'just give me the answer', 'you always do Y and I hate it', or an explicit 'remember this' are FIRST-CLASS skill signals, not just memory signals. Update the relevant skill(s) to embed the preference so the next session starts already knowing.\n \u2022 User corrected your workflow, approach, or sequence of steps. Encode the correction as a pitfall or explicit step in the skill that governs that class of task.\n \u2022 Non-trivial technique, fix, workaround, debugging path, or tool-usage pattern emerged that a future session would benefit from. Capture it.\n \u2022 A skill that got loaded or consulted this session turned out to be wrong, missing a step, or outdated. Patch it NOW.\n\nRead-before-write (enforced by this channel): update, patch, delete, or write support files ONLY into skills you loaded or read in THIS session \u2014 ops on unread skills are dropped; CREATE of a brand-new umbrella is the only exception.\n\nPreference order \u2014 prefer the earliest action that fits, but do pick one when a signal above fired:\n 1. UPDATE A CURRENTLY-LOADED SKILL. Look back through the conversation for skills the user loaded or you read. If any of them covers the territory of the new learning, PATCH that one first. It is the skill that was in play, so it's the right one to extend.\n 2. UPDATE AN EXISTING UMBRELLA. If no loaded skill fits but an existing class-level skill does, patch it. Add a subsection, a pitfall, or broaden a trigger.\n 3. ADD A SUPPORT FILE under an existing umbrella. Skills can be packaged with three kinds of support files \u2014 use the right directory per kind:\n \u2022 references/<topic>.md \u2014 session-specific detail (error transcripts, reproduction recipes, provider quirks) AND condensed knowledge banks: quoted research, API docs, external authoritative excerpts, or domain notes you found while working on the problem. Write it concise and for the value of the task, not as a full mirror of upstream docs.\n \u2022 templates/<name>.<ext> \u2014 starter files meant to be copied and modified (boilerplate configs, scaffolding, a known-good example the agent can reproduce with modifications).\n \u2022 scripts/<name>.<ext> \u2014 statically re-runnable actions the skill can invoke directly (verification scripts, fixture generators, deterministic probes, anything the agent should run rather than hand-type each time).\n Add support files via skill_manage action=write_file with file_path starting 'references/', 'templates/', or 'scripts/'. The umbrella's SKILL.md should gain a one-line pointer to any new support file so future agents know it exists.\n 4. RESTRUCTURE a loaded skill whose body grew log-like \u2014 rc/sha/date-dense sections, session-detail spirals, or a fat body with no support files. Use skill_manage action=restructure with restructure: [{\"heading\": \"<the exact ## heading text>\", \"to_file\": \"references/<topic>.md\"}] \u2014 the ENTIRE ## section (from that heading to the next heading) moves into the support file and its position becomes a pointer line. The skill's name and directory never change. Only propose headings that exist verbatim in the body; never invent one, and never restructure a healthy small skill.\n 5. CREATE A NEW CLASS-LEVEL UMBRELLA SKILL when no existing skill covers the class. The name MUST be at the class level. The name MUST NOT be a specific PR number, error string, feature codename, library-alone name, or 'fix-X / debug-Y / audit-Z-today' session artifact. If the proposed name only makes sense for today's task, it's wrong \u2014 fall back to (1), (2), or (3).\n\nUser-preference embedding (important): when the user expressed a style/format/workflow preference, the update belongs in the SKILL.md body, not just in memory. Memory captures 'who the user is and what the current situation and state of your operations are'; skills capture 'how to do this class of task for this user'. When they complain about how you handled a task, the skill that governs that task needs to carry the lesson.\n\nIf you notice two existing skills that overlap, note it in your reply \u2014 the background curator handles consolidation at scale.\n\nTwo-tier deposition discipline (DSH addition, same spirit as the umbrella rule): before writing, classify the knowledge:\n \u2022 PATTERN (reusable \u2014 symptom \u2192 mechanism \u2192 fix \u2192 verification, still valuable next session) belongs in the SKILL.md body.\n \u2022 LOG (one-off \u2014 commit SHAs, npm/profile states, what this release changed, this session's process narrative) belongs in a references/ file, never the body. Body density IS reuse rate. Keep new entries tight: a pattern fits in 2-8 physical lines; prefer changing the current-state pointer over appending history.\n\nProtected skills (DO NOT edit these):\n \u2022 Bundled skills (shipped with the platform).\n \u2022 Hub-installed skills (installed from a hub).\nPinned skills are read-only to THIS background review pass \u2014 the pinned write guard refuses background changes, so only the foreground may update or archive them. Foreground and delegated-subagent writes to pinned skills remain allowed.\nIf the only skills that need updating are protected, say 'Nothing to save.' and stop.\n\nDo NOT capture (these become persistent self-imposed constraints that bite you later when the environment changes):\n \u2022 Environment-dependent failures: missing binaries, fresh-install errors, post-migration path mismatches, 'command not found', unconfigured credentials, uninstalled packages. The user can fix these \u2014 they are not durable rules.\n \u2022 Negative claims about tools or features ('browser tools do not work', 'X tool is broken', 'cannot use Y'). These harden into refusals the agent cites against itself for months after the actual problem was fixed.\n \u2022 Session-specific transient errors that resolved before the conversation ended. If retrying worked, the lesson is the retry pattern, not the original failure.\n \u2022 One-off task narratives. A user asking 'summarize today's market' or 'analyze this PR' is not a class of work that warrants a skill.\n\nIf a tool failed because of setup state, capture the FIX (install command, config step, env var to set) under an existing setup or troubleshooting skill \u2014 never 'this tool does not work' as a standalone constraint.\n\n'Nothing to save.' is a real option but should NOT be the default. If the session ran smoothly with no corrections and produced no new technique, just say 'Nothing to save.' and stop. Otherwise, act.";
10
10
  export declare const COMBINED_REVIEW_PROMPT = "[Auto-review]\nReview the conversation above and update two things:\n\n**Memory**: who the user is. Did the user reveal persona, desires, preferences, personal details, or expectations about how you should behave? Save facts about the user and durable preferences with the memory tool.\n\n**Skills**: how to do this class of task. Be ACTIVE \u2014 most sessions produce at least one skill update. A pass that does nothing is a missed learning opportunity, not a neutral outcome.\n\nTarget shape of the skill library: CLASS-LEVEL skills with a rich SKILL.md and a references/ directory for session-specific detail. Not a long flat list of narrow one-session-one-skill entries.\n\nSignals that warrant a skill update (any one is enough):\n \u2022 User corrected your style, tone, format, legibility, verbosity, or approach. Frustration is a FIRST-CLASS skill signal, not just a memory signal. 'stop doing X', 'don't format like this', 'I hate when you Y' \u2014 embed the lesson in the skill that governs that task so the next session starts fixed.\n \u2022 Non-trivial technique, fix, workaround, or debugging path emerged.\n \u2022 A skill that was loaded or consulted turned out wrong, missing, or outdated \u2014 patch it now.\n\nRead-before-write (enforced by this channel): update, patch, delete, or write support files ONLY into skills you loaded or read in THIS session \u2014 ops on unread skills are dropped; CREATE of a brand-new umbrella is the only exception.\n\nPreference order for skills \u2014 pick the earliest that fits:\n 1. UPDATE A CURRENTLY-LOADED SKILL. Check what skills were loaded or read in the conversation. If one of them covers the learning, PATCH it first. It was in play; it's the right place.\n 2. UPDATE AN EXISTING UMBRELLA. Patch it.\n 3. ADD A SUPPORT FILE under an existing umbrella via skill_manage action=write_file. Three kinds: references/<topic>.md for session-specific detail OR condensed knowledge banks (quoted research, API docs excerpts, domain notes) written concise and task-focused; templates/<name>.<ext> for starter files meant to be copied and modified; scripts/<name>.<ext> for statically re-runnable actions (verification, fixture generators, probes). Add a one-line pointer in SKILL.md so future agents find them.\n 4. RESTRUCTURE a loaded skill whose body grew log-like (rc/sha/date-dense sections, session-detail spirals, fat body with no support files) via skill_manage action=restructure with restructure: [{\"heading\": \"<the exact ## heading text>\", \"to_file\": \"references/<topic>.md\"}] \u2014 the ENTIRE ## section moves into the support file and its position becomes a pointer line; the skill's name and directory never change. Only propose headings that exist verbatim in the body.\n 5. CREATE A NEW CLASS-LEVEL UMBRELLA when nothing exists. Name at the class level \u2014 NOT a PR number, error string, codename, library-alone name, or 'fix-X / debug-Y' session artifact. If the name only fits today's task, fall back to (1), (2), or (3).\n\nTwo-tier deposition discipline (DSH addition): classify before writing \u2014 PATTERN (symptom \u2192 mechanism \u2192 fix \u2192 verification) goes in the SKILL.md body; LOG (commit SHAs, npm/profile states, this release's change list, this session's narrative) goes in a references/ file. Body density IS reuse rate; a pattern fits in 2-8 physical lines.\n\nUser-preference embedding: when the user complains about how you handled a task, update the skill that governs that task \u2014 memory alone isn't enough. Memory says 'who the user is and what the current situation and state of your operations are'; skills say 'how to do this class of task for this user'. Both should carry user-preference lessons when relevant.\n\nIf you notice overlapping existing skills, mention it \u2014 the background curator handles consolidation.\n\nProtected skills (DO NOT edit these):\n \u2022 Bundled skills (shipped with the platform).\n \u2022 Hub-installed skills (installed from a hub).\nPinned skills are read-only to THIS background review pass \u2014 the pinned write guard refuses background changes, so only the foreground may update or archive them. Foreground and delegated-subagent writes to pinned skills remain allowed.\nIf the only skills that need updating are protected, say 'Nothing to save.' and stop.\n\nDo NOT capture as skills (these become persistent self-imposed constraints that bite you later when the environment changes):\n \u2022 Environment-dependent failures: missing binaries, fresh-install errors, post-migration path mismatches, 'command not found', unconfigured credentials, uninstalled packages. The user can fix these \u2014 they are not durable rules.\n \u2022 Negative claims about tools or features ('browser tools do not work', 'X tool is broken', 'cannot use Y'). These harden into refusals the agent cites against itself for months after the actual problem was fixed.\n \u2022 Session-specific transient errors that resolved before the conversation ended. If retrying worked, the lesson is the retry pattern, not the original failure.\n \u2022 One-off task narratives. A user asking 'summarize today's market' or 'analyze this PR' is not a class of work that warrants a skill.\n\nIf a tool failed because of setup state, capture the FIX (install command, config step, env var to set) under an existing setup or troubleshooting skill \u2014 never 'this tool does not work' as a standalone constraint.\n\nAct on whichever of the two dimensions has real signal. If genuinely nothing stands out on either, say 'Nothing to save.' and stop \u2014 but don't reach for that conclusion as a default.";
@@ -0,0 +1,10 @@
1
+ /**
2
+ * A process-local serial task queue: each task starts only after the previous
3
+ * one settles (success or failure), so read-modify-write sequences that share
4
+ * one file never interleave inside this process. The durable cross-process
5
+ * serialization layer is the IO backend's transact lock; this chain is the
6
+ * second layer (0.3.17 S2.8, T-1: the shape was duplicated in state-json and
7
+ * memory-files — one factory now).
8
+ */
9
+ export declare function makeSerialQueue(): <T>(task: () => Promise<T>) => Promise<T>;
10
+ //# sourceMappingURL=serial.d.ts.map
@@ -119,15 +119,18 @@ export declare function parseFrontmatter(content: string): {
119
119
  } | null;
120
120
  /** YAML plain-scalar hazards that make an UNQUOTED frontmatter value
121
121
  * unloadable to the platform catalog (strict YAML parser): `: ` (mapping
122
- * separator), ` #` (comment start), or a leading YAML indicator. The
123
- * evolution `parseFrontmatter` is deliberately lenient, so violations
124
- * silently split family-visibility from platform-visibility (0.3.11
125
- * inkos-harness case: the description carried "…: " and the catalog dropped
126
- * the whole skill). Already-quoted values and well-formed flow collections
127
- * (`[a, b]` / `{a: b}`) are considered safe. This rule is only the FAST
128
- * PATH the write path re-verifies every rewrite with the real YAML parser
129
- * (see normalizeFrontmatter), so an incomplete approximation can never
130
- * corrupt a multiline flow value (P3-4). */
122
+ * separator), ` #` (comment start), a trailing `:` (a mapping marker),
123
+ * or a leading YAML indicator. The evolution `parseFrontmatter` is
124
+ * deliberately lenient, so violations silently split family-visibility from
125
+ * platform-visibility (0.3.11 inkos-harness case: the description carried
126
+ * "…: " and the catalog dropped the whole skill). Already-quoted values and
127
+ * well-formed flow collections (`[a, b]` / `{a: b}`) are considered safe.
128
+ * 0.3.16 (E-47): null/bool/number-shaped plain scalars are flagged too they
129
+ * parse as booleans/numbers on the platform while the family keeps the string
130
+ * (a `description: true` split-brain).
131
+ * This rule is only the FAST PATH — the write path re-verifies every rewrite
132
+ * with the real YAML parser (see normalizeFrontmatter), so an incomplete
133
+ * approximation can never corrupt a multiline flow value (P3-4). */
131
134
  export declare function yamlPlainScalarNeedsQuotes(value: string): boolean;
132
135
  /** Raw-line scan of the frontmatter block: entries whose UNQUOTED value is
133
136
  * YAML-unsafe for the strict platform catalog. Operates on the ORIGINAL line
@@ -172,10 +175,9 @@ export declare function normalizeFrontmatter(content: string): FrontmatterNormal
172
175
  */
173
176
  export declare function relatedSkillNames(content: string, exclude?: string): string[];
174
177
  export declare function validateFrontmatter(content: string, expectedName?: string, limits?: SkillLimits): string | null;
175
- /** Hermes authoring quality bar for descriptions (the 60-char Rule). The
176
- * platform's own index limit stays in `validateFrontmatter`; this bar is the
177
- * target the authoring standard names, enforced as ADVISORY feedback. */
178
- export declare const AUTHORING_DESCRIPTION_BAR = 60;
178
+ /** Hermes authoring quality bar for descriptions see constants.ts
179
+ * (0.3.16 T-4 moved the single source there; the public re-export sits behind
180
+ * the package root, which re-exports constants anyway). */
179
181
  export interface AuthoringFeedback {
180
182
  /** Frontmatter description length in characters (0 when absent). */
181
183
  descriptionChars: number;
@@ -341,5 +343,12 @@ export declare class SkillLibrary {
341
343
  restoreLatestSnapshot(extras?: SnapshotExtra[]): Promise<SkillActionResult & {
342
344
  extras?: SnapshotExtra[];
343
345
  }>;
346
+ /**
347
+ * Whole-tree replacement from one snapshot path (rc.50 P2-14): every
348
+ * NON-system entry in the active root is cleared first, then the manifest
349
+ * drives the repopulation (skills, sidecars, `.archive`). Extracted from
350
+ * restoreLatestSnapshot so a failed restore can roll itself back (E-13).
351
+ */
352
+ private restoreSnapshotIntoRoot;
344
353
  }
345
354
  //# sourceMappingURL=skill-store.d.ts.map
@@ -26,6 +26,9 @@ export interface ScanOptions {
26
26
  /**
27
27
  * Scan text at `scope`. Patterns are cumulative: `strict` includes all scopes.
28
28
  * `options.excludeLabels` removes matching patterns without changing `scope`.
29
+ * `maxScanChars` is the WINDOW SIZE, not a total cap (E-12, 0.3.16): the whole
30
+ * text is always scanned in overlapping windows, so content beyond 65,536
31
+ * characters (skill files may run to 100,000) is no longer a blind zone.
29
32
  */
30
33
  export declare function scanThreats(text: string, scope?: ThreatScope, maxScanChars?: number, options?: ScanOptions): ThreatFinding[];
31
34
  /** Blocking policy: any hit blocks. `severity` is deliberately not a gate. */
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.15",
4
+ "version": "0.3.17",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },