@lmzhen/dsh-evolution-core 0.1.0-rc.4 → 0.1.0-rc.41

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
@@ -1,4 +1,4 @@
1
- import { dirname, join } from "node:path";
1
+ import { basename, dirname, join } from "node:path";
2
2
  import { cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { createHash, randomBytes } from "node:crypto";
4
4
  import { homedir } from "node:os";
@@ -20,23 +20,67 @@ function evolutionIoAdapter(provider) {
20
20
  list: (path) => provider().list(path),
21
21
  exists: (path) => provider().exists(path),
22
22
  rename: (path, destination) => provider().rename(path, destination),
23
- copy: (path, destination) => provider().copy(path, destination)
23
+ copy: (path, destination) => provider().copy(path, destination),
24
+ size: (path) => {
25
+ const io = provider();
26
+ return io.size ? io.size(path) : Promise.resolve(null);
27
+ }
24
28
  };
25
29
  }
26
30
  function nodeEvolutionIo() {
31
+ const isMissing = (error) => {
32
+ const code = error?.code;
33
+ return code === "ENOENT" || code === "ENOTDIR";
34
+ };
35
+ /**
36
+ * Cross-process write lock (claw `withFileLock` parity): an O_EXCL lock file
37
+ * guards the atomic write; a >5s-old lock is treated as stale and taken
38
+ * over. After the retry budget the write proceeds unlocked — the lock is a
39
+ * best-effort accommodation for multi-process deployments, never a read of
40
+ * availability.
41
+ */
42
+ const withWriteLock = async (path, task) => {
43
+ const lock = `${path}.lock`;
44
+ for (let attempt = 0; attempt < 10; attempt += 1) try {
45
+ await writeFile(lock, String(process.pid), { flag: "wx" });
46
+ try {
47
+ return await task();
48
+ } finally {
49
+ await rm(lock, { force: true }).catch(() => {});
50
+ }
51
+ } catch (error) {
52
+ if (error?.code !== "EEXIST") throw error;
53
+ try {
54
+ const st = await stat(lock);
55
+ if (Date.now() - st.mtimeMs > 5e3) {
56
+ try {
57
+ await rm(lock, { force: true });
58
+ } catch {}
59
+ continue;
60
+ }
61
+ } catch {
62
+ continue;
63
+ }
64
+ await new Promise((resolve) => setTimeout(resolve, 50));
65
+ }
66
+ return await task();
67
+ };
27
68
  return {
28
69
  async readText(path) {
29
70
  try {
30
71
  return await readFile(path, "utf8");
31
- } catch {
32
- return null;
72
+ } catch (error) {
73
+ if (isMissing(error)) return null;
74
+ throw error;
33
75
  }
34
76
  },
35
77
  async writeText(path, content) {
36
78
  await mkdir(dirname(path), { recursive: true });
37
- const tmp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
38
- await writeFile(tmp, content, "utf8");
39
- await rename(tmp, path);
79
+ await withWriteLock(path, async () => {
80
+ const tmp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
81
+ await writeFile(tmp, content, "utf8");
82
+ await rename(tmp, path);
83
+ });
40
84
  },
41
85
  async remove(path) {
42
86
  await rm(path, {
@@ -55,8 +99,9 @@ function nodeEvolutionIo() {
55
99
  try {
56
100
  await stat(path);
57
101
  return true;
58
- } catch {
59
- return false;
102
+ } catch (error) {
103
+ if (isMissing(error)) return false;
104
+ throw error;
60
105
  }
61
106
  },
62
107
  async rename(path, destination) {
@@ -69,13 +114,17 @@ function nodeEvolutionIo() {
69
114
  recursive: true,
70
115
  force: true
71
116
  });
117
+ },
118
+ async size(path) {
119
+ try {
120
+ return (await stat(path)).size;
121
+ } catch (error) {
122
+ if (isMissing(error)) return null;
123
+ throw error;
124
+ }
72
125
  }
73
126
  };
74
127
  }
75
- /** Absolute path helper kept separate so stores stay platform-correct. */
76
- function childPath(parent, ...parts) {
77
- return join(parent, ...parts);
78
- }
79
128
  //#endregion
80
129
  //#region lib/types/usage.js
81
130
  /**
@@ -155,15 +204,102 @@ function latestActivityAt(record) {
155
204
  if (values.length === 0) return null;
156
205
  return values.sort().reverse()[0] ?? null;
157
206
  }
207
+ /**
208
+ * Curator suppression sidecar: built-in skills the curator has archived stay
209
+ * suppressed across re-seeds, so the lifecycle never fights a re-created
210
+ * bundled skill. Best-effort load/save, mirroring the usage sidecar posture.
211
+ * Versioned shape ({ version, names }) with legacy plain-array compat.
212
+ */
213
+ const SUPPRESSED_FILE_VERSION = 1;
214
+ function suppressedFile(root) {
215
+ return join(root, ".curator-suppressed.json");
216
+ }
217
+ async function loadSuppressedNames(root, io = nodeEvolutionIo()) {
218
+ const raw = await io.readText(suppressedFile(root));
219
+ if (raw === null) return /* @__PURE__ */ new Set();
220
+ try {
221
+ const parsed = JSON.parse(raw);
222
+ const names = Array.isArray(parsed) ? parsed : typeof parsed === "object" && parsed !== null && Array.isArray(parsed.names) ? parsed.names : [];
223
+ return new Set(names.filter((entry) => typeof entry === "string"));
224
+ } catch {
225
+ return /* @__PURE__ */ new Set();
226
+ }
227
+ }
228
+ async function saveSuppressedNames(root, names, io = nodeEvolutionIo()) {
229
+ await io.writeText(suppressedFile(root), JSON.stringify({
230
+ version: 1,
231
+ names: [...names].sort()
232
+ }, null, 2));
233
+ }
234
+ //#endregion
235
+ //#region lib/types/constants.js
236
+ /**
237
+ * Shared constants for the dsh-evolution plugin family.
238
+ *
239
+ * Two classes of value live here, deliberately separated by section so future
240
+ * edits do not blur the semantic boundary:
241
+ *
242
+ * 1. **Fixed protocol/format/security invariants** — changing these breaks an
243
+ * on-disk format, a naming/format contract, a path-security boundary, or a
244
+ * cross-component invariant. They are NOT exposed as deployment config.
245
+ *
246
+ * 2. **Cross-package shared tunable defaults** — the same semantic default is
247
+ * read (with a config override path) by more than one package (e.g.
248
+ * `evolution-policy` and `evolution-curator` both default `staleAfterDays`
249
+ * to 30). Centralizing them here means one authoritative default: a config
250
+ * override still applies per package, but the fallback is single-sourced.
251
+ *
252
+ * Package-private tunables (used by exactly one package) stay in that package,
253
+ * not here — see evolution-replay's `DEFAULT_WEIGHTS` and evolution-feedback's
254
+ * threshold, which are intentionally left where they are used.
255
+ * @module @lmzhen/dsh-evolution-core
256
+ */
257
+ /** Skill frontmatter `name` validated for the file name (lowercase + hyphen). */
258
+ const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
259
+ /** Allowed skill support-file subdirectories (path-traversal boundary). */
260
+ const SUPPORT_DIRS = [
261
+ "references",
262
+ "templates",
263
+ "scripts",
264
+ "assets"
265
+ ];
266
+ /** Delimiter between durable memory entries (on-disk storage format). */
267
+ const ENTRY_DELIMITER = "\n§\n";
268
+ /** Built-in skill names the curator must never lifecycle-manage. */
269
+ const PROTECTED_BUILTIN_SKILLS = new Set(["plan"]);
270
+ const MAX_SKILL_NAME_LENGTH = 64;
271
+ const MAX_DESCRIPTION_LENGTH = 1024;
272
+ const MAX_SKILL_CONTENT_CHARS = 1e5;
273
+ const MAX_SKILL_FILE_BYTES = 1048576;
274
+ const DEFAULT_REVIEW_MEMORY_INTERVAL = 10;
275
+ const DEFAULT_REVIEW_SKILL_INTERVAL = 10;
276
+ /** Skill-review completion channel trigger mode: 'cadence' | 'completion' | 'both'. */
277
+ const DEFAULT_SKILL_REVIEW_TRIGGER = "both";
278
+ /** Cumulative session tool calls before a session counts as "proven long" for the completion channel. */
279
+ const DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS = 20;
280
+ const DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS = 3;
281
+ const DEFAULT_SUBSTANTIVE_MIN_USER_CHARS = 200;
282
+ const DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS = 500;
283
+ const DEFAULT_MAX_OPS_PER_PLAN = 32;
284
+ const DEFAULT_CURATOR_INTERVAL_HOURS = 168;
285
+ const DEFAULT_MIN_IDLE_HOURS = 2;
286
+ const DEFAULT_STALE_AFTER_DAYS = 30;
287
+ const DEFAULT_ARCHIVE_AFTER_DAYS = 90;
288
+ const DEFAULT_MEMORY_CHAR_LIMIT = 2200;
289
+ const DEFAULT_USER_CHAR_LIMIT = 1375;
290
+ const DEFAULT_SKILL_CONTENT_CHARS = 1e5;
158
291
  //#endregion
159
292
  //#region lib/types/curator.js
160
293
  /**
161
294
  * Deterministic skill curator: active → stale → archived transitions.
162
- * Pure function; file moves are performed by SkillLibrary.
295
+ * Pure function with one deliberate side effect: records in the passed
296
+ * `usage` map are MUTATED (state/archived_at) to carry the transition — the
297
+ * caller owns the map and decides whether to clone first (dry-run) or persist
298
+ * after. File moves are performed by SkillLibrary.
163
299
  */
164
- const PROTECTED_BUILTIN_SKILLS = new Set(["plan"]);
165
300
  function buildCuratorRunReport(input) {
166
301
  return {
302
+ schemaVersion: 1,
167
303
  runId: input.runId,
168
304
  startedAt: input.startedAt,
169
305
  finishedAt: input.finishedAt,
@@ -172,7 +308,98 @@ function buildCuratorRunReport(input) {
172
308
  archiveCandidates: [...input.archiveCandidates],
173
309
  archived: [...input.archived],
174
310
  failed: [...input.failed],
175
- ...input.snapshotPath === void 0 ? {} : { snapshotPath: input.snapshotPath }
311
+ ...input.snapshotPath === void 0 ? {} : { snapshotPath: input.snapshotPath },
312
+ ...input.llmReviewEnabled === void 0 ? {} : { llmReviewEnabled: input.llmReviewEnabled }
313
+ };
314
+ }
315
+ const NOMINATION_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
316
+ /**
317
+ * Parse the curator LLM's YAML nomination block (consolidations + prunings).
318
+ * Line-oriented and lenient by design: the LLM output is advisory, every name
319
+ * is re-validated against the tree before any file move happens downstream.
320
+ */
321
+ function parseCuratorNominations(text) {
322
+ const prunings = [];
323
+ const consolidations = [];
324
+ let section = null;
325
+ let currentFrom = "";
326
+ for (const line of text.split("\n")) {
327
+ const consolidated = /^\s*-\s*from:\s*([a-z0-9][a-z0-9-]*)\s*$/.exec(line);
328
+ if (consolidated) {
329
+ section = "consolidations";
330
+ currentFrom = consolidated[1] ?? "";
331
+ continue;
332
+ }
333
+ const into = /^\s*into:\s*([a-z0-9][a-z0-9-]*)\s*$/.exec(line);
334
+ if (into) {
335
+ const intoName = into[1] ?? "";
336
+ if (section === "consolidations" && currentFrom !== "" && currentFrom !== intoName) consolidations.push({
337
+ from: currentFrom,
338
+ into: intoName
339
+ });
340
+ currentFrom = "";
341
+ continue;
342
+ }
343
+ const pruned = /^\s*-\s*name:\s*([a-z0-9][a-z0-9-]*)\s*$/.exec(line);
344
+ if (pruned) {
345
+ section = "prunings";
346
+ const name = pruned[1];
347
+ if (name) prunings.push(name);
348
+ }
349
+ }
350
+ const valid = (name) => NOMINATION_NAME_RE.test(name);
351
+ return {
352
+ prunings: prunings.filter(valid),
353
+ consolidations: consolidations.filter((item) => valid(item.from) && valid(item.into))
354
+ };
355
+ }
356
+ /**
357
+ * The lifecycle-candidate gate, shared by the transition engine and the scope
358
+ * view so the two can never disagree: records failing ANY of these gates are
359
+ * outside the managed scope.
360
+ */
361
+ function lifecycleCandidate(name, record, config, bundled) {
362
+ if (record.pinned) return false;
363
+ if (config.excludeSkillNames?.has(name)) return false;
364
+ if (config.suppressedNames?.has(name)) return false;
365
+ if (config.referencedSkillNames?.has(name)) return false;
366
+ if (!(record.created_by === "agent" || config.manageUnmanaged === true) && !(config.pruneBuiltins === true && bundled)) return false;
367
+ if (PROTECTED_BUILTIN_SKILLS.has(name)) return false;
368
+ if (record.state === "archived") return false;
369
+ return true;
370
+ }
371
+ /**
372
+ * Read-only scope classification, derived from the SAME gate the transition
373
+ * engine uses (`lifecycleCandidate`), so the view always predicts what a
374
+ * curator pass may touch. `protectedNames` carries the marker info the usage
375
+ * records lack (bundled / hub-installed / pinned from `SkillLibrary.list()`).
376
+ */
377
+ function computeScopeView(usage, config, protectedNames) {
378
+ const managed = [];
379
+ const watched = [];
380
+ const qualityWarned = [];
381
+ const exempted = [];
382
+ const protectedSet = /* @__PURE__ */ new Set();
383
+ for (const [name, record] of usage) {
384
+ if (config.excludeSkillNames?.has(name) || config.referencedSkillNames?.has(name)) {
385
+ exempted.push(name);
386
+ continue;
387
+ }
388
+ const bundled = config.bundledNames?.has(name) === true;
389
+ const suppressed = config.suppressedNames?.has(name) === true;
390
+ if (record.pinned || bundled || suppressed || protectedNames?.has(name) === true) protectedSet.add(name);
391
+ if (lifecycleCandidate(name, record, config, bundled)) {
392
+ managed.push(name);
393
+ if (record.state === "stale" || record.quality_warn === true) watched.push(name);
394
+ if (record.quality_warn === true) qualityWarned.push(name);
395
+ }
396
+ }
397
+ return {
398
+ managed: managed.sort(),
399
+ watched: watched.sort(),
400
+ qualityWarned: qualityWarned.sort(),
401
+ exempted: exempted.sort(),
402
+ protected: [...protectedSet].sort()
176
403
  };
177
404
  }
178
405
  function daysSince(iso, created, now) {
@@ -186,11 +413,7 @@ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Da
186
413
  markStale: []
187
414
  };
188
415
  for (const [name, record] of usage) {
189
- if (record.pinned) continue;
190
- if (config.excludeSkillNames?.has(name)) continue;
191
- if (record.created_by !== "agent" && config.manageUnmanaged !== true) continue;
192
- if (PROTECTED_BUILTIN_SKILLS.has(name)) continue;
193
- if (record.state === "archived") continue;
416
+ if (!lifecycleCandidate(name, record, config, config.bundledNames?.has(name) === true)) continue;
194
417
  const age = daysSince(null, record.created_at, now.getTime());
195
418
  if (record.use_count === 0 && age < config.staleAfterDays) continue;
196
419
  const idle = daysSince(latestActivityAt(record), record.created_at, now.getTime());
@@ -242,6 +465,223 @@ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Da
242
465
  return result;
243
466
  }
244
467
  //#endregion
468
+ //#region lib/types/prompts.js
469
+ /**
470
+ * Review and curation prompts adapted from Hermes Agent
471
+ * `agent/background_review.py`, `agent/curator.py`, and
472
+ * `agent/learn_prompt.py`, with tool names translated to the DSH-native
473
+ * catalog (`memory`, `skill_manage`, `skill`, `bash`, `str_replace_editor`).
474
+ *
475
+ * Every prompt is pinned in a versioned bundle. Review workers verify the
476
+ * bundle digest before spending a model call, so a partially-patched
477
+ * deployment fails closed instead of silently running a truncated prompt.
478
+ */
479
+ /**
480
+ * Prompt bundle identity. Bump both id and version whenever a prompt's text
481
+ * changes semantically: the bundle digest is the fail-closed signal for
482
+ * review workers, so a stale id across deployments must be distinguishable.
483
+ */
484
+ const PROMPT_BUNDLE_ID = "dsh-evolution@2";
485
+ const PROMPT_BUNDLE_VERSION = 2;
486
+ const MEMORY_REVIEW_PROMPT = `[Auto-review — Memory]
487
+ Review the conversation above and consider saving to memory if appropriate.
488
+
489
+ Focus on:
490
+ 1. Has the user revealed things about themselves — persona, desires, preferences, or personal details worth remembering?
491
+ 2. Has the user expressed expectations about how you should behave, their work style, or ways they want you to operate?
492
+
493
+ If something stands out, save it using the memory tool.
494
+ If nothing is worth saving, just say "Nothing to save." and stop.`;
495
+ const SKILL_REVIEW_PROMPT = `[Auto-review — Skills]
496
+ Review the conversation above and update the skill library. Be ACTIVE — most sessions produce at least one skill update, even if small.
497
+
498
+ Target shape: CLASS-LEVEL skills with a rich SKILL.md and a references/ directory for session-specific detail. Not a flat list of narrow one-session skills.
499
+
500
+ Signals that warrant action:
501
+ - The user corrected your style, tone, format, verbosity, workflow, or approach.
502
+ - A non-trivial technique, fix, workaround, or debugging path emerged.
503
+ - A loaded skill turned out wrong, missing, or outdated — patch it now.
504
+
505
+ Preference order:
506
+ 1. Patch a skill that was loaded or read this session.
507
+ 2. Patch an existing umbrella skill.
508
+ 3. Add references/, templates/, or scripts/ support under an existing skill.
509
+ 4. Create a new class-level umbrella skill only when nothing fits.
510
+
511
+ Protected skills (bundled/hub-installed) must not be edited. Pinned skills may be patched but not archived.
512
+
513
+ Do NOT capture:
514
+ - Environment-dependent failures (missing binaries, unconfigured credentials).
515
+ - Negative claims about tools ("browser tools do not work").
516
+ - Transient errors that resolved during the session.
517
+ - One-off task narratives.
518
+
519
+ If a tool failed because of setup state, capture the FIX under an existing setup skill — never "this tool does not work" as a standalone constraint.
520
+
521
+ "Nothing to save." is a real option but should NOT be the default.`;
522
+ const COMBINED_REVIEW_PROMPT = `[Auto-review]
523
+ Review the conversation above and update two things.
524
+
525
+ **Memory**: who the user is. Save durable user preferences, personal details, and expectations with the memory tool.
526
+
527
+ **Skills**: how to do this class of task. Be ACTIVE. Follow the same class-level umbrella policy, preference order, protected-skill rules, and do-not-capture list as a skill review.
528
+
529
+ Act on whichever dimension has real signal. If genuinely nothing stands out on either, say "Nothing to save." and stop — but don't reach for that conclusion as a default.`;
530
+ const CURATOR_PROMPT = `You are the skill curator. Maintain a healthy, class-level skill library, not a flat pile of narrow one-session skills.
531
+
532
+ The goal is a LIBRARY OF CLASS-LEVEL INSTRUCTIONS. A skill collection of many narrow skills where each captures one session's specific bug is a FAILURE of the library. An agent searching skills matches on descriptions, not exact names; one broad umbrella with labeled subsections beats five narrow siblings for discoverability.
533
+
534
+ Right target shape: class-level skills with rich SKILL.md + references/, templates/, scripts/ support files for session-specific detail.
535
+
536
+ Hard rules:
537
+ 1. NEVER hard-delete a skill. Archive (moving to .archive/) is the maximum destructive action; archives are recoverable, deletion is not.
538
+ 2. Do not touch bundled, hub-installed, pinned, or scheduled-task-referenced (\`referenced\`) skills. Referenced skills MAY be consolidated into an umbrella, but never simply pruned.
539
+ 3. Do not archive recently-created or never-used skills without strong evidence. "use=0" is NOT evidence either way — it only means the trigger has not come up yet.
540
+ 4. Do NOT reject consolidation on the grounds that "each skill has a distinct trigger". The right bar is: would a human maintainer write this as N separate skills, or one skill with N labeled subsections? When the answer is the latter, merge.
541
+ 5. Judge overlap on CONTENT, not on usage counters.
542
+ 6. Before archiving a merged skill, ensure its unique content was preserved in the umbrella.
543
+
544
+ How to work:
545
+ 1. Scan the candidate list. Identify PREFIX CLUSTERS — skills sharing a first word or domain keyword (expect 10-25 clusters).
546
+ 2. For each cluster with 2+ members, ask "what is the UMBRELLA CLASS these skills serve?" and consolidate:
547
+ a. MERGE INTO AN EXISTING UMBRELLA (patch a labeled section for each sibling's unique insight, then archive the siblings).
548
+ b. CREATE A NEW UMBRELLA SKILL.md covering the shared workflow with short labeled subsections, then archive the absorbed siblings.
549
+ c. DEMOTE session-specific detail to references/, templates/, or scripts/ under the umbrella.
550
+ 3. Keep the umbrella body tight and scannable: exact commands, verbatim paths, ~100-200 lines; never invent flags or APIs.
551
+
552
+ Produce a YAML summary with exactly this shape:
553
+ consolidations:
554
+ - from: <old-skill-name>
555
+ into: <umbrella-skill-name>
556
+ reason: <one short sentence>
557
+ prunings:
558
+ - name: <skill-name>
559
+ reason: <one short sentence>
560
+ Nominate a pruning only when archival is clearly safe (stale AND genuinely obsolete or fully absorbed elsewhere).`;
561
+ const CURATOR_DRY_RUN_BANNER = `═══════════════════════════════════════════════════════════════
562
+ DRY-RUN — REPORT ONLY. DO NOT MUTATE THE SKILL LIBRARY.
563
+ ═══════════════════════════════════════════════════════════════
564
+
565
+ This is a PREVIEW pass. Follow every instruction above EXCEPT:
566
+ • Do NOT call skill_manage with action=create, update, patch, delete, write_file, or remove_file.
567
+ • Do NOT move, copy, or rewrite any file under the skills tree.
568
+
569
+ Your output IS the deliverable: produce the exact same human-readable summary and YAML block you would on a live run, describing the actions you WOULD take. A reviewer will decide whether to approve a live run.
570
+
571
+ If you accidentally take a mutating action, say so explicitly in the summary.`;
572
+ const COMPLETION_SKILL_REVIEW_PROMPT = `[Auto-review — Skills · task complete]
573
+ Your current task now appears complete. Before wrapping up, review the approach and update the skill library via skill_manage.
574
+
575
+ Follow the skills review policy: be ACTIVE, prefer class-level umbrellas, patch skills loaded this session, and capture non-trivial techniques and user corrections. Do NOT capture environment-dependent failures, negative claims about tools, or one-off task narratives.
576
+
577
+ Do NOT modify output files or re-run the task. If you are still mid-task, ignore this.`;
578
+ function reviewPrompt(kind) {
579
+ if (kind === "memory") return MEMORY_REVIEW_PROMPT;
580
+ if (kind === "skill") return SKILL_REVIEW_PROMPT;
581
+ return COMBINED_REVIEW_PROMPT;
582
+ }
583
+ function sha256(text) {
584
+ return createHash("sha256").update(text).digest("hex");
585
+ }
586
+ function createPromptBundle(prompts) {
587
+ const canonical = JSON.stringify({
588
+ id: PROMPT_BUNDLE_ID,
589
+ version: 2,
590
+ prompts: Object.fromEntries(Object.entries(prompts).sort())
591
+ });
592
+ return Object.freeze({
593
+ id: PROMPT_BUNDLE_ID,
594
+ version: 2,
595
+ prompts: Object.freeze({ ...prompts }),
596
+ sha256: sha256(canonical)
597
+ });
598
+ }
599
+ const PROMPT_BUNDLE = createPromptBundle({
600
+ memory: MEMORY_REVIEW_PROMPT,
601
+ skill: SKILL_REVIEW_PROMPT,
602
+ combined: COMBINED_REVIEW_PROMPT,
603
+ curator: CURATOR_PROMPT,
604
+ completion: COMPLETION_SKILL_REVIEW_PROMPT
605
+ });
606
+ function verifyPromptBundle(bundle = PROMPT_BUNDLE) {
607
+ if (bundle.id !== "dsh-evolution@2" || bundle.version !== 2) return false;
608
+ const canonical = JSON.stringify({
609
+ id: PROMPT_BUNDLE_ID,
610
+ version: 2,
611
+ prompts: Object.fromEntries(Object.entries(bundle.prompts).sort())
612
+ });
613
+ return bundle.sha256 === sha256(canonical);
614
+ }
615
+ const DSH_AUTHORING_STANDARDS = `Follow the Hermes skill-authoring standards, translated to DSH tools.
616
+
617
+ Frontmatter:
618
+ - name: lowercase-hyphenated, <=64 chars, no spaces.
619
+ - description: ONE sentence, <=60 characters, ends with a period. State the capability, not the implementation. No marketing words. Do NOT repeat the skill name. Count the characters before saving.
620
+ - version: 0.1.0
621
+ - author: always the literal value "Hermes". NEVER fill it from the environment, git config, or any identity you can probe.
622
+ - platforms: declare [macos], [linux], and/or [windows] only when the skill is genuinely OS-bound; omit for portable skills.
623
+ - metadata.hermes.tags: a few Capitalized, Relevant, Tags.
624
+ - metadata.hermes.related_skills: [a, b] — name sibling skills this one builds on or is referenced by (optional; feeds the quality references factor).
625
+
626
+ Body section order (omit only when empty):
627
+ 1. "# <Human Title>" then a 2-3 sentence intro: what it does, what it does NOT do, key dependency stance.
628
+ 2. "## When to Use" — concrete trigger phrases.
629
+ 3. "## Prerequisites" — exact env vars, install steps, credentials.
630
+ 4. "## How to Run" — canonical invocation framed through DSH tools.
631
+ 5. "## Quick Reference" — flat command/endpoint list.
632
+ 6. "## Procedure" — numbered steps with copy-paste-exact commands.
633
+ 7. "## Pitfalls" — known limits and rate limits.
634
+ 8. "## Verification" — one check proving the skill worked.
635
+
636
+ DSH-tool framing:
637
+ - Reference DSH tools by name in backticks: \`bash\`, \`str_replace_editor\`, \`write\`, \`skill\`, \`skill_manage\`, \`memory\`.
638
+ - Do not name wrapped shell utilities when a DSH tool already covers them.
639
+ - Larger scripts belong under \`scripts/\` (written with \`skill_manage write_file\`) and are referenced from SKILL.md by relative path.
640
+
641
+ Quality bar:
642
+ - Prefer verbatim flags, paths, and APIs from the source. Never invent them.
643
+ - Keep it tight: ~100 lines simple, ~200 complex.
644
+ - No router/index/hub skills that only point at other skills.
645
+ - References go in \`references/\`, templates in \`templates/\`.`;
646
+ //#endregion
647
+ //#region lib/types/learn-prompt.js
648
+ /**
649
+ * Open-ended `/evolution learn` prompt builder.
650
+ *
651
+ * `learn` is open-ended: the user can name anything they can describe — a
652
+ * directory of code, an API doc URL, a workflow they just walked the agent
653
+ * through, or pasted notes. The prompt instructs the live agent to gather the
654
+ * named sources with its existing tools, then author a single SKILL.md via
655
+ * `skill_manage` following `DSH_AUTHORING_STANDARDS`. There is no separate
656
+ * distillation engine and no model-tool footprint.
657
+ */
658
+ /**
659
+ * Build the agent prompt for an open-ended `/evolution learn` request.
660
+ *
661
+ * @param userRequest free-text the user gave after `/evolution learn`; an
662
+ * empty string falls back to "the workflow we just went through".
663
+ * @returns a complete instruction the agent runs as a normal turn.
664
+ */
665
+ function buildLearnPrompt(userRequest) {
666
+ return [
667
+ "[/learn] The user wants you to learn a reusable skill from the request below, and save it.",
668
+ "",
669
+ "THE REQUEST:",
670
+ userRequest.trim() || "the workflow we just went through in this conversation — review the steps taken and distill them into a reusable skill",
671
+ "",
672
+ "The request is open-ended and may mix two kinds of content, in any order: SOURCES to gather (directories, file paths, URLs, \"what we just did\", pasted notes) AND REQUIREMENTS that shape the skill (what to focus on, what to leave out, scope, naming, the angle to take). Treat EVERY part of the request as load-bearing. In particular, prose that comes after a path or link is NOT incidental — it is the user telling you what they want from that source. A request like `<url> focus on the auth flow, skip the deprecated endpoints` means: gather the URL AND honor \"focus on auth, skip deprecated\" as authoring requirements. Never fetch the first source and ignore the rest.",
673
+ "",
674
+ "Do this:",
675
+ "1. Gather every source the user named, using the tools you already have — reads and searches for local files or directories, web access for URLs, this conversation history if they referred to something you just did, and the text they pasted as-is. If the request is ambiguous about scope, make a reasonable choice and note it; do not stall.",
676
+ "2. Author ONE SKILL.md, applying every requirement, focus, and constraint in the request — these govern what the SKILL.md covers and emphasizes, not just which sources you read.",
677
+ "3. Save it with the `skill_manage` tool (action=\"create\"). Pick a sensible category. If the procedure needs a non-trivial script, add it under the skill's `scripts/` with `skill_manage` write_file and reference it by relative path.",
678
+ "",
679
+ DSH_AUTHORING_STANDARDS,
680
+ "",
681
+ "When done, tell the user the skill name, its category, and a one-line summary of what it captured."
682
+ ].join("\n");
683
+ }
684
+ //#endregion
245
685
  //#region lib/types/threats.js
246
686
  /**
247
687
  * Threat scanning for agent-authored memory and skill content.
@@ -417,10 +857,12 @@ const SCOPE_ORDER = {
417
857
  context: 2,
418
858
  strict: 3
419
859
  };
860
+ const NO_SCAN_OPTIONS = {};
420
861
  /**
421
862
  * Scan text at `scope`. Patterns are cumulative: `strict` includes all scopes.
863
+ * `options.excludeLabels` removes matching patterns without changing `scope`.
422
864
  */
423
- function scanThreats(text, scope = "strict", maxScanChars = 65536) {
865
+ function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
424
866
  const findings = [];
425
867
  if (ZERO_WIDTH_CHARS.test(text)) findings.push({
426
868
  label: "unicode_zero_width",
@@ -433,8 +875,10 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536) {
433
875
  scope
434
876
  });
435
877
  const normalized = text.normalize("NFKC").slice(0, maxScanChars);
878
+ const excluded = new Set(options.excludeLabels ?? []);
436
879
  for (const pattern of PATTERNS) {
437
880
  if (SCOPE_ORDER[pattern.scope] > SCOPE_ORDER[scope]) continue;
881
+ if (excluded.has(pattern.label)) continue;
438
882
  if (pattern.regex.test(normalized)) findings.push({
439
883
  label: pattern.label,
440
884
  category: pattern.category,
@@ -444,24 +888,24 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536) {
444
888
  return findings;
445
889
  }
446
890
  /** Blocking policy: any hit blocks. `severity` is deliberately not a gate. */
447
- function evaluateThreat(text, scope = "strict", maxScanChars = 65536) {
448
- const findings = scanThreats(text, scope, maxScanChars);
891
+ function evaluateThreat(text, scope = "strict", maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
892
+ const findings = scanThreats(text, scope, maxScanChars, options);
449
893
  return {
450
894
  blocked: findings.length > 0,
451
895
  findings
452
896
  };
453
897
  }
454
898
  /** User-facing block message for memory writes. */
455
- function scanMemoryThreats(text, maxScanChars = 65536) {
456
- const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars);
899
+ function scanMemoryThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
900
+ const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars, options);
457
901
  if (!blocked) return null;
458
902
  const pattern = findings.find((f) => f.category !== "unicode_obfuscation");
459
903
  if (pattern) return `Blocked by security scan (${pattern.label}). Rephrase without instruction-like language.`;
460
904
  return "Blocked by security scan: invisible or potentially malicious Unicode detected.";
461
905
  }
462
906
  /** User-facing block message for skill content writes. */
463
- function scanContentThreats(text, maxScanChars = 65536) {
464
- const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars);
907
+ function scanContentThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
908
+ const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars, options);
465
909
  if (!blocked) return null;
466
910
  return `Blocked by security scan (${findings[0]?.label ?? "unknown"}). This content appears to contain potentially malicious instructions.`;
467
911
  }
@@ -471,7 +915,13 @@ function scanContentThreats(text, maxScanChars = 65536) {
471
915
  * File-backed durable memory with Hermes-compatible semantics.
472
916
  * Stores are MEMORY.md and USER.md under $DSH_HOME/memories (~/.dsh/memories).
473
917
  */
474
- const ENTRY_DELIMITER = "\n§\n";
918
+ /**
919
+ * Read-guard factor: a memory file larger than this multiple of its target's
920
+ * char limit is treated as externally corrupted and skipped instead of being
921
+ * read whole (aligned with claw `tools/memory.ts` size guard, which uses the
922
+ * same 10× bound around a file that should never exceed the store limit).
923
+ */
924
+ const READ_GUARD_FACTOR = 10;
475
925
  function memoryRoot(env = process.env) {
476
926
  return join(env.DSH_HOME ?? join(homedir(), ".dsh"), "memories");
477
927
  }
@@ -506,7 +956,24 @@ var MemoryStore = class {
506
956
  limitFor(target) {
507
957
  return target === "memory" ? this.memoryLimit : this.userLimit;
508
958
  }
959
+ /**
960
+ * Read-guard probe: `{ size, limit }` when the on-disk file exceeds
961
+ * `limit * READ_GUARD_FACTOR` bytes, `null` when it is absent, unknown
962
+ * (backend without a size probe), under the bound, or the target has no
963
+ * limit configured.
964
+ */
965
+ async oversizedFile(target) {
966
+ const size = await this.io.size?.(fileFor(this.root, target));
967
+ if (size === null || size === void 0) return null;
968
+ const limit = this.limitFor(target);
969
+ if (limit <= 0) return null;
970
+ return size > limit * READ_GUARD_FACTOR ? {
971
+ size,
972
+ limit
973
+ } : null;
974
+ }
509
975
  async read(target) {
976
+ if (await this.oversizedFile(target)) return [];
510
977
  const raw = await this.io.readText(fileFor(this.root, target));
511
978
  return raw === null ? [] : [...new Set(normalizeEntries(raw))];
512
979
  }
@@ -534,7 +1001,67 @@ var MemoryStore = class {
534
1001
  limit: this.limitFor(target)
535
1002
  };
536
1003
  }
1004
+ /**
1005
+ * StorageHint percentage must clamp at 100 like the render header: a drifted
1006
+ * entry can push chars past the limit, and "Storage at 125%" contradicts the
1007
+ * clamped usage indicator.
1008
+ */
1009
+ storageHint(target, chars) {
1010
+ const limit = this.limitFor(target);
1011
+ if (limit <= 0) return "";
1012
+ const percent = Math.min(100, Math.floor(chars * 100 / limit));
1013
+ return percent >= 80 ? ` ⚠️ Storage at ${percent}% (${chars}/${limit} chars).` : "";
1014
+ }
1015
+ /**
1016
+ * Best-effort raw-copy backup of the on-disk file to `<file>.bak.<stamp>`
1017
+ * before a refusal, so an externally modified (or oversized) file stays
1018
+ * recoverable. Copies bytes instead of reading them so a pathologically
1019
+ * large file is never loaded just to back it up. Failure to back up does
1020
+ * not change the refusal semantics.
1021
+ */
1022
+ async backupFile(target) {
1023
+ const path = fileFor(this.root, target);
1024
+ const unique = `${(/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 14)}-${Math.random().toString(36).slice(2, 8)}`;
1025
+ try {
1026
+ await this.io.copy(path, `${path}.bak.${unique}`);
1027
+ return `${path}.bak.${unique}`;
1028
+ } catch {
1029
+ return null;
1030
+ }
1031
+ }
1032
+ /**
1033
+ * Read-guard refusal for write paths. Returns the refusal result when the
1034
+ * target file is oversized, `null` otherwise. The file is skipped for
1035
+ * reading (never loaded), backed up by raw copy, and the model is told to
1036
+ * fix it manually — mirroring the drift refusal so corrupted state is never
1037
+ * silently overwritten.
1038
+ */
1039
+ async oversizedRefusal(target) {
1040
+ const oversized = await this.oversizedFile(target);
1041
+ if (!oversized) return null;
1042
+ const backup = await this.backupFile(target);
1043
+ const suffix = backup ? ` A backup was saved to ${basename(backup)}.` : "";
1044
+ return {
1045
+ ok: false,
1046
+ message: `Memory file is ${oversized.size} bytes (limit ${oversized.limit * READ_GUARD_FACTOR}) — skipping read.${suffix} Fix the file manually, then retry.`,
1047
+ entries: [],
1048
+ chars: 0,
1049
+ limit: this.limitFor(target)
1050
+ };
1051
+ }
537
1052
  async add(target, facts) {
1053
+ const refusal = await this.oversizedRefusal(target);
1054
+ if (refusal) return refusal;
1055
+ if (await this.detectDrift(target)) {
1056
+ const backup = await this.backupFile(target);
1057
+ return {
1058
+ ok: false,
1059
+ message: `External drift detected in memory file.${backup ? ` A backup was saved to ${basename(backup)}.` : ""} Resolve the drift before retrying.`,
1060
+ entries: [],
1061
+ chars: 0,
1062
+ limit: this.limitFor(target)
1063
+ };
1064
+ }
538
1065
  const content = facts.trim();
539
1066
  if (!content) return {
540
1067
  ok: false,
@@ -556,7 +1083,7 @@ var MemoryStore = class {
556
1083
  this.resetFailures();
557
1084
  return {
558
1085
  ok: true,
559
- message: "Entry already exists (no duplicate added).",
1086
+ message: `Entry already exists (no duplicate added).${this.storageHint(target, entries.join(ENTRY_DELIMITER).length)}`,
560
1087
  entries,
561
1088
  chars: entries.join(ENTRY_DELIMITER).length,
562
1089
  limit: this.limitFor(target)
@@ -564,12 +1091,13 @@ var MemoryStore = class {
564
1091
  }
565
1092
  const next = [...entries, this.addDatePrefix ? `## ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}\n${content}` : content];
566
1093
  const total = next.join(ENTRY_DELIMITER).length;
567
- if (total > this.limitFor(target)) return this.failure(target, `Adding this entry would exceed the ${this.limitFor(target)} char limit. Consolidate or remove stale entries, then retry.`, entries);
1094
+ const addLimit = this.limitFor(target);
1095
+ if (addLimit > 0 && total > addLimit) return this.failure(target, `Adding this entry would exceed the ${addLimit} char limit. Consolidate or remove stale entries, then retry.`, entries);
568
1096
  await this.write(target, next);
569
1097
  this.resetFailures();
570
1098
  return {
571
1099
  ok: true,
572
- message: "Entry added.",
1100
+ message: `Entry added.${this.storageHint(target, total)}`,
573
1101
  entries: next,
574
1102
  chars: total,
575
1103
  limit: this.limitFor(target)
@@ -590,6 +1118,8 @@ var MemoryStore = class {
590
1118
  chars: 0,
591
1119
  limit: this.limitFor(target)
592
1120
  };
1121
+ const refusal = await this.oversizedRefusal(target);
1122
+ if (refusal) return refusal;
593
1123
  const content = action === "replace" ? (facts ?? "").trim() : "";
594
1124
  if (action === "replace" && !content) return {
595
1125
  ok: false,
@@ -608,13 +1138,16 @@ var MemoryStore = class {
608
1138
  limit: this.limitFor(target)
609
1139
  };
610
1140
  }
611
- if (await this.detectDrift(target)) return {
612
- ok: false,
613
- message: "External drift detected in memory file. Resolve the drift before retrying.",
614
- entries: [],
615
- chars: 0,
616
- limit: this.limitFor(target)
617
- };
1141
+ if (await this.detectDrift(target)) {
1142
+ const backup = await this.backupFile(target);
1143
+ return {
1144
+ ok: false,
1145
+ message: `External drift detected in memory file.${backup ? ` A backup was saved to ${basename(backup)}.` : ""} Resolve the drift before retrying.`,
1146
+ entries: [],
1147
+ chars: 0,
1148
+ limit: this.limitFor(target)
1149
+ };
1150
+ }
618
1151
  const entries = await this.read(target);
619
1152
  const matches = entries.map((entry, index) => ({
620
1153
  entry,
@@ -633,12 +1166,13 @@ var MemoryStore = class {
633
1166
  if (action === "remove") next.splice(index, 1);
634
1167
  else next[index] = content;
635
1168
  const total = next.join(ENTRY_DELIMITER).length;
636
- if (total > this.limitFor(target)) return this.failure(target, `Resulting memory would exceed the ${this.limitFor(target)} char limit.`, entries);
1169
+ const mutateLimit = this.limitFor(target);
1170
+ if (mutateLimit > 0 && total > mutateLimit) return this.failure(target, `Resulting memory would exceed the ${mutateLimit} char limit.`, entries);
637
1171
  await this.write(target, next);
638
1172
  this.resetFailures();
639
1173
  return {
640
1174
  ok: true,
641
- message: `Entry ${action === "remove" ? "removed" : "replaced"}.`,
1175
+ message: `Entry ${action === "remove" ? "removed" : "replaced"}.${this.storageHint(target, total)}`,
642
1176
  entries: next,
643
1177
  chars: total,
644
1178
  limit: this.limitFor(target)
@@ -652,17 +1186,22 @@ var MemoryStore = class {
652
1186
  chars: 0,
653
1187
  limit: this.limitFor(target)
654
1188
  };
655
- if (await this.detectDrift(target)) return {
656
- ok: false,
657
- message: "External drift detected in memory file. Resolve the drift before retrying.",
658
- entries: [],
659
- chars: 0,
660
- limit: this.limitFor(target)
661
- };
662
- const entries = await this.read(target);
663
- const working = [...entries];
664
- for (const [index, op] of operations.entries()) {
665
- const position = index + 1;
1189
+ const refusal = await this.oversizedRefusal(target);
1190
+ if (refusal) return refusal;
1191
+ if (await this.detectDrift(target)) {
1192
+ const backup = await this.backupFile(target);
1193
+ return {
1194
+ ok: false,
1195
+ message: `External drift detected in memory file.${backup ? ` A backup was saved to ${basename(backup)}.` : ""} Resolve the drift before retrying.`,
1196
+ entries: [],
1197
+ chars: 0,
1198
+ limit: this.limitFor(target)
1199
+ };
1200
+ }
1201
+ const entries = await this.read(target);
1202
+ const working = [...entries];
1203
+ for (const [index, op] of operations.entries()) {
1204
+ const position = index + 1;
666
1205
  if (op.action === "add") {
667
1206
  const body = (op.facts ?? "").trim();
668
1207
  if (!body) return {
@@ -726,12 +1265,13 @@ var MemoryStore = class {
726
1265
  }
727
1266
  }
728
1267
  const total = working.join(ENTRY_DELIMITER).length;
729
- if (total > this.limitFor(target)) return this.failure(target, `Batch result (${total} chars) exceeds the ${this.limitFor(target)} limit. Remove or shorten more entries in the same batch.`, entries);
1268
+ const batchLimit = this.limitFor(target);
1269
+ if (batchLimit > 0 && total > batchLimit) return this.failure(target, `Batch result (${total} chars) exceeds the ${batchLimit} limit. Remove or shorten more entries in the same batch.`, entries);
730
1270
  await this.write(target, working);
731
1271
  this.resetFailures();
732
1272
  return {
733
1273
  ok: true,
734
- message: `Applied ${operations.length} operation(s).`,
1274
+ message: `Applied ${operations.length} operation(s).${this.storageHint(target, total)}`,
735
1275
  entries: working,
736
1276
  chars: total,
737
1277
  limit: this.limitFor(target)
@@ -741,172 +1281,223 @@ var MemoryStore = class {
741
1281
  const memory = await this.read("memory");
742
1282
  const user = await this.read("user");
743
1283
  const parts = [];
744
- for (const [target, entries] of [["Memory", memory], ["User Profile", user]]) {
1284
+ for (const [target, label, entries] of [[
1285
+ "memory",
1286
+ "Memory",
1287
+ memory
1288
+ ], [
1289
+ "user",
1290
+ "User Profile",
1291
+ user
1292
+ ]]) {
1293
+ const oversized = entries.length === 0 ? await this.oversizedFile(target) : null;
1294
+ if (oversized) {
1295
+ parts.push(`## ${label} — file skipped: ${oversized.size} bytes (limit ${oversized.limit * READ_GUARD_FACTOR}); not read`);
1296
+ continue;
1297
+ }
745
1298
  const safe = entries.filter((entry) => !scanMemoryThreats(entry));
746
1299
  if (safe.length > 0) {
747
1300
  const body = safe.join(ENTRY_DELIMITER);
1301
+ const limit = this.limitFor(target);
1302
+ const pct = limit > 0 ? Math.min(100, Math.floor(body.length * 100 / limit)) : 0;
748
1303
  const note = safe.length === entries.length ? "" : ` (${entries.length - safe.length} threat-matched entries filtered)`;
749
- parts.push(`## ${target} (${safe.length} entries)${note}\n${body}`);
1304
+ parts.push(`## ${label} (${safe.length} entries) [${pct}% — ${body.length}/${limit} chars]${note}\n${body}`);
750
1305
  }
751
1306
  }
752
1307
  return parts.join("\n\n");
753
1308
  }
754
- async snapshot() {
755
- const [memory, user] = await Promise.all([this.read("memory"), this.read("user")]);
756
- return {
757
- memory,
758
- user
759
- };
760
- }
761
- async restoreSnapshot(snapshot) {
762
- await this.write("memory", snapshot.memory);
763
- await this.write("user", snapshot.user);
764
- }
1309
+ /**
1310
+ * Detect on-disk drift: true when the file is not in the canonical
1311
+ * `render(normalizeEntries(raw))` form. This catches structural anomalies
1312
+ * the writer would quietly normalize away (empty/`§`-only entries, stray
1313
+ * blank lines, leading/trailing delimiters) that indicate the file was
1314
+ * edited outside MemoryStore. Purely single-canonical content reaches the
1315
+ * same serialization and returns false, so a normal write is never flagged.
1316
+ */
765
1317
  async detectDrift(target) {
1318
+ if (await this.oversizedFile(target)) return true;
766
1319
  const raw = await this.io.readText(fileFor(this.root, target));
767
1320
  if (raw === null) return false;
768
- return normalizeEntries(raw).join(ENTRY_DELIMITER) !== raw.trim();
1321
+ const entries = normalizeEntries(raw);
1322
+ const limit = this.limitFor(target);
1323
+ if (limit > 0 && entries.some((entry) => entry.length > limit)) return true;
1324
+ return render(entries) !== raw;
769
1325
  }
770
1326
  };
771
1327
  //#endregion
772
- //#region lib/types/prompts.js
1328
+ //#region lib/types/mutations.js
773
1329
  /**
774
- * Review and curation prompts adapted from Hermes Agent
775
- * `agent/background_review.py`, `agent/curator.py`, and
776
- * `agent/learn_prompt.py`, with tool names translated to the DSH-native
777
- * catalog (`memory`, `skill_manage`, `skill`, `bash`, `str_replace_editor`).
778
- *
779
- * Every prompt is pinned in a versioned bundle. Review workers verify the
780
- * bundle digest before spending a model call, so a partially-patched
781
- * deployment fails closed instead of silently running a truncated prompt.
1330
+ * Curator/author audit trail: `.mutations.json` records every skill mutation
1331
+ * with before/after content hashes so any automated edit is reviewable and
1332
+ * replayable. Best-effort persistence, mirroring the usage sidecar posture.
1333
+ * @module @lmzhen/dsh-evolution-core
782
1334
  */
783
- const PROMPT_BUNDLE_ID = "dsh-evolution@1";
784
- const MEMORY_REVIEW_PROMPT = `[Auto-review Memory]
785
- Review the conversation above and consider saving to memory if appropriate.
786
-
787
- Focus on:
788
- 1. Has the user revealed things about themselves — persona, desires, preferences, or personal details worth remembering?
789
- 2. Has the user expressed expectations about how you should behave, their work style, or ways they want you to operate?
790
-
791
- If something stands out, save it using the memory tool.
792
- If nothing is worth saving, just say "Nothing to save." and stop.`;
793
- const SKILL_REVIEW_PROMPT = `[Auto-review — Skills]
794
- Review the conversation above and update the skill library. Be ACTIVE — most sessions produce at least one skill update, even if small.
795
-
796
- Target shape: CLASS-LEVEL skills with a rich SKILL.md and a references/ directory for session-specific detail. Not a flat list of narrow one-session skills.
797
-
798
- Signals that warrant action:
799
- - The user corrected your style, tone, format, verbosity, workflow, or approach.
800
- - A non-trivial technique, fix, workaround, or debugging path emerged.
801
- - A loaded skill turned out wrong, missing, or outdated — patch it now.
802
-
803
- Preference order:
804
- 1. Patch a skill that was loaded or read this session.
805
- 2. Patch an existing umbrella skill.
806
- 3. Add references/, templates/, or scripts/ support under an existing skill.
807
- 4. Create a new class-level umbrella skill only when nothing fits.
808
-
809
- Protected skills (bundled/hub-installed) must not be edited. Pinned skills may be patched but not archived.
810
-
811
- Do NOT capture:
812
- - Environment-dependent failures (missing binaries, unconfigured credentials).
813
- - Negative claims about tools ("browser tools do not work").
814
- - Transient errors that resolved during the session.
815
- - One-off task narratives.
816
-
817
- If a tool failed because of setup state, capture the FIX under an existing setup skill — never "this tool does not work" as a standalone constraint.
818
-
819
- "Nothing to save." is a real option but should NOT be the default.`;
820
- const COMBINED_REVIEW_PROMPT = `[Auto-review]
821
- Review the conversation above and update two things.
822
-
823
- **Memory**: who the user is. Save durable user preferences, personal details, and expectations with the memory tool.
824
-
825
- **Skills**: how to do this class of task. Be ACTIVE. Follow the same class-level umbrella policy, preference order, protected-skill rules, and do-not-capture list as a skill review.
826
-
827
- Act on whichever dimension has real signal. If genuinely nothing stands out on either, say "Nothing to save." and stop — but don't reach for that conclusion as a default.`;
828
- const CURATOR_PROMPT = `You are the skill curator. Maintain a healthy, class-level skill library.
829
-
830
- Rules:
831
- 1. NEVER hard-delete a skill. Archive is the maximum destructive action.
832
- 2. Do not touch bundled, hub-installed, or pinned skills.
833
- 3. Do not archive recently-created or never-used skills without strong evidence.
834
- 4. Prefer merging narrow skills into class-level umbrellas.
835
- 5. Before archiving a merged skill, ensure its unique content was preserved.
836
-
837
- Produce a YAML summary:
838
- consolidations:
839
- - from: <old-skill-name>
840
- into: <umbrella-skill-name>
841
- reason: <one short sentence>
842
- prunings:
843
- - name: <skill-name>
844
- reason: <one short sentence>`;
845
- function reviewPrompt(kind) {
846
- if (kind === "memory") return MEMORY_REVIEW_PROMPT;
847
- if (kind === "skill") return SKILL_REVIEW_PROMPT;
848
- return COMBINED_REVIEW_PROMPT;
1335
+ const DEFAULT_MUTATION_CAP = 500;
1336
+ /** Version of the `.mutations.json` file shape; writers always emit the current one. */
1337
+ const MUTATIONS_FILE_VERSION = 1;
1338
+ function mutationsFile(root) {
1339
+ return join(root, ".mutations.json");
849
1340
  }
850
- function sha256(text) {
851
- return createHash("sha256").update(text).digest("hex");
1341
+ function contentHash(content) {
1342
+ return createHash("sha256").update(content).digest("hex");
852
1343
  }
853
- function createPromptBundle(prompts) {
854
- const canonical = JSON.stringify({
855
- id: PROMPT_BUNDLE_ID,
856
- version: 1,
857
- prompts: Object.fromEntries(Object.entries(prompts).sort())
858
- });
859
- return Object.freeze({
860
- id: PROMPT_BUNDLE_ID,
1344
+ async function loadMutations(root, io = nodeEvolutionIo()) {
1345
+ const raw = await io.readText(mutationsFile(root));
1346
+ if (raw === null) return [];
1347
+ try {
1348
+ const parsed = JSON.parse(raw);
1349
+ return (Array.isArray(parsed) ? parsed : typeof parsed === "object" && parsed !== null && Array.isArray(parsed.records) ? parsed.records : []).filter((entry) => typeof entry === "object" && entry !== null && typeof entry.skillName === "string" && typeof entry.action === "string");
1350
+ } catch {
1351
+ return [];
1352
+ }
1353
+ }
1354
+ /** Append one record, trim to `cap`, and write atomically (versioned shape). */
1355
+ async function recordMutation(root, io, record, cap = 500) {
1356
+ const existing = await loadMutations(root, io);
1357
+ existing.push(record);
1358
+ const trimmed = existing.length > cap ? existing.slice(existing.length - cap) : existing;
1359
+ await io.writeText(mutationsFile(root), JSON.stringify({
861
1360
  version: 1,
862
- prompts: Object.freeze({ ...prompts }),
863
- sha256: sha256(canonical)
864
- });
1361
+ records: trimmed
1362
+ }, null, 2));
865
1363
  }
866
- const PROMPT_BUNDLE = createPromptBundle({
867
- memory: MEMORY_REVIEW_PROMPT,
868
- skill: SKILL_REVIEW_PROMPT,
869
- combined: COMBINED_REVIEW_PROMPT,
870
- curator: CURATOR_PROMPT
871
- });
872
- function verifyPromptBundle(bundle = PROMPT_BUNDLE) {
873
- const canonical = JSON.stringify({
874
- id: bundle.id,
875
- version: bundle.version,
876
- prompts: Object.fromEntries(Object.entries(bundle.prompts).sort())
877
- });
878
- return bundle.sha256 === sha256(canonical);
1364
+ //#endregion
1365
+ //#region lib/types/quality.js
1366
+ /**
1367
+ * Quality scoring and near-duplicate detection for the curated skill library.
1368
+ *
1369
+ * Pure functions over data inputs so the scoring policy is unit-testable and
1370
+ * the same math feeds the usage sidecar, the `skill_manage review` surface and
1371
+ * the learning graph. Weights follow the Hermes/hermes-claw six-factor model;
1372
+ * mutation maturity is a documented DSH approximation (single per-month patch
1373
+ * trend ratio replaces the claw timestamp-trend formula, since DSH usage
1374
+ * records only carry the last patched timestamp).
1375
+ * @module @lmzhen/dsh-evolution-core
1376
+ */
1377
+ const QUALITY_WEIGHTS = {
1378
+ usageFrequency: .25,
1379
+ stability: .2,
1380
+ recency: .2,
1381
+ references: .1,
1382
+ mutationMaturity: .2,
1383
+ richness: .05
1384
+ };
1385
+ /** Score below which a skill is flagged for review. */
1386
+ const LOW_QUALITY_THRESHOLD = .3;
1387
+ function clamp01(value) {
1388
+ return Math.max(0, Math.min(1, value));
1389
+ }
1390
+ function daysBetween(from, now) {
1391
+ return Math.max(0, (now.getTime() - new Date(from).getTime()) / 864e5);
1392
+ }
1393
+ function computeQualityScores(input) {
1394
+ const now = input.now ?? /* @__PURE__ */ new Date();
1395
+ const scores = /* @__PURE__ */ new Map();
1396
+ for (const [name, record] of input.usage) {
1397
+ const ageDays = Math.max(1, daysBetween(record.created_at, now));
1398
+ const idleDays = daysBetween(latestActivityAt(record) ?? record.created_at, now);
1399
+ const patchCount = record.patch_count;
1400
+ const useCount = record.use_count;
1401
+ const usageFrequency = clamp01(useCount / ageDays);
1402
+ const stability = useCount === 0 ? 1 : clamp01(1 - patchCount / useCount);
1403
+ const recency = idleDays < 30 ? 1 : clamp01(1 - (idleDays - 30) / 150);
1404
+ const references = clamp01((input.referenceCounts?.get(name) ?? 0) / 3);
1405
+ const mutationMaturity = patchCount === 0 ? .3 : patchCount === 1 ? .4 : clamp01((patchCount - 1) / Math.max(1, ageDays / 30));
1406
+ const richness = clamp01((input.supportDirs?.get(name) ?? 0) * .175);
1407
+ const factors = {
1408
+ usageFrequency,
1409
+ stability,
1410
+ recency,
1411
+ references,
1412
+ mutationMaturity,
1413
+ richness
1414
+ };
1415
+ const score = usageFrequency * QUALITY_WEIGHTS.usageFrequency + stability * QUALITY_WEIGHTS.stability + recency * QUALITY_WEIGHTS.recency + references * QUALITY_WEIGHTS.references + mutationMaturity * QUALITY_WEIGHTS.mutationMaturity + richness * QUALITY_WEIGHTS.richness;
1416
+ scores.set(name, {
1417
+ score,
1418
+ factors,
1419
+ warn: score < LOW_QUALITY_THRESHOLD
1420
+ });
1421
+ }
1422
+ return scores;
1423
+ }
1424
+ function normalize(content) {
1425
+ return content.toLowerCase().replace(/\s+/g, " ").trim();
1426
+ }
1427
+ function contentHash$1(content) {
1428
+ return createHash("sha256").update(normalize(content)).digest("hex");
1429
+ }
1430
+ function tokenize(content) {
1431
+ return new Set(normalize(content).split(/[^a-z0-9\u4e00-\u9fff]+/).filter(Boolean));
1432
+ }
1433
+ function jaccard(a, b) {
1434
+ if (a.size === 0 || b.size === 0) return 0;
1435
+ let intersection = 0;
1436
+ for (const token of a) if (b.has(token)) intersection += 1;
1437
+ return intersection / (a.size + b.size - intersection);
1438
+ }
1439
+ /**
1440
+ * Two-phase near-duplicate clustering: exact normalized-hash groups first,
1441
+ * then token-Jaccard edges at {@link DEDUP_SIMILARITY_THRESHOLD} with a token
1442
+ * ratio guard, union-find across the whole set.
1443
+ */
1444
+ function computeDedupGroups(input) {
1445
+ const threshold = input.threshold ?? .95;
1446
+ const names = [...input.contents.keys()];
1447
+ const hashes = /* @__PURE__ */ new Map();
1448
+ for (const name of names) {
1449
+ const hash = contentHash$1(input.contents.get(name) ?? "");
1450
+ const bucket = hashes.get(hash);
1451
+ if (bucket) bucket.push(name);
1452
+ else hashes.set(hash, [name]);
1453
+ }
1454
+ const parent = /* @__PURE__ */ new Map();
1455
+ const find = (x) => {
1456
+ const root = parent.get(x) ?? x;
1457
+ if (root !== x) parent.set(x, find(root));
1458
+ return parent.get(x) ?? x;
1459
+ };
1460
+ const union = (a, b) => {
1461
+ const [ra, rb] = [find(a), find(b)];
1462
+ if (ra !== rb) parent.set(rb, ra);
1463
+ };
1464
+ for (const [hash, bucketNames] of hashes) {
1465
+ const first = bucketNames[0];
1466
+ if (first === void 0 || bucketNames.length === 1) continue;
1467
+ for (let index = 1; index < bucketNames.length; index += 1) {
1468
+ const peer = bucketNames[index];
1469
+ if (peer) union(first, peer);
1470
+ }
1471
+ }
1472
+ const tokens = /* @__PURE__ */ new Map();
1473
+ const tokenSet = (name) => {
1474
+ let set = tokens.get(name);
1475
+ if (!set) {
1476
+ set = tokenize(input.contents.get(name) ?? "");
1477
+ tokens.set(name, set);
1478
+ }
1479
+ return set;
1480
+ };
1481
+ for (let index = 0; index < names.length; index += 1) {
1482
+ const a = names[index];
1483
+ if (a === void 0) continue;
1484
+ for (let other = index + 1; other < names.length; other += 1) {
1485
+ const b = names[other];
1486
+ if (b === void 0) continue;
1487
+ const [ta, tb] = [tokenSet(a), tokenSet(b)];
1488
+ if (Math.max(ta.size, tb.size) / Math.max(1, Math.min(ta.size, tb.size)) > 5) continue;
1489
+ if (jaccard(ta, tb) >= threshold) union(a, b);
1490
+ }
1491
+ }
1492
+ const groups = /* @__PURE__ */ new Map();
1493
+ for (const name of names) {
1494
+ const root = find(name);
1495
+ const group = groups.get(root);
1496
+ if (group) group.push(name);
1497
+ else groups.set(root, [name]);
1498
+ }
1499
+ return [...groups.values()].filter((group) => group.length > 1);
879
1500
  }
880
- const DSH_AUTHORING_STANDARDS = `Follow the Hermes skill-authoring standards, translated to DSH tools.
881
-
882
- Frontmatter:
883
- - name: lowercase-hyphenated, <=64 chars, no spaces.
884
- - description: ONE sentence, <=60 characters, ends with a period. State the capability, not the implementation. No marketing words. Do NOT repeat the skill name. Count the characters before saving.
885
- - version: 0.1.0
886
- - author: always the literal value "Hermes". NEVER fill it from the environment, git config, or any identity you can probe.
887
- - platforms: declare [macos], [linux], and/or [windows] only when the skill is genuinely OS-bound; omit for portable skills.
888
- - metadata.hermes.tags: a few Capitalized, Relevant, Tags.
889
-
890
- Body section order (omit only when empty):
891
- 1. "# <Human Title>" then a 2-3 sentence intro: what it does, what it does NOT do, key dependency stance.
892
- 2. "## When to Use" — concrete trigger phrases.
893
- 3. "## Prerequisites" — exact env vars, install steps, credentials.
894
- 4. "## How to Run" — canonical invocation framed through DSH tools.
895
- 5. "## Quick Reference" — flat command/endpoint list.
896
- 6. "## Procedure" — numbered steps with copy-paste-exact commands.
897
- 7. "## Pitfalls" — known limits and rate limits.
898
- 8. "## Verification" — one check proving the skill worked.
899
-
900
- DSH-tool framing:
901
- - Reference DSH tools by name in backticks: \`bash\`, \`str_replace_editor\`, \`write\`, \`skill\`, \`skill_manage\`, \`memory\`.
902
- - Do not name wrapped shell utilities when a DSH tool already covers them.
903
- - Larger scripts belong under \`scripts/\` (written with \`skill_manage write_file\`) and are referenced from SKILL.md by relative path.
904
-
905
- Quality bar:
906
- - Prefer verbatim flags, paths, and APIs from the source. Never invent them.
907
- - Keep it tight: ~100 lines simple, ~200 complex.
908
- - No router/index/hub skills that only point at other skills.
909
- - References go in \`references/\`, templates in \`templates/\`.`;
910
1501
  //#endregion
911
1502
  //#region lib/types/signals.js
912
1503
  /**
@@ -994,23 +1585,14 @@ function foldTurn(session, fromSeq) {
994
1585
  * it created unless a `.hermes-managed` marker opts a skill in. Archival is a
995
1586
  * move to `.archive/` — never a hard delete.
996
1587
  */
997
- const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
998
- const MAX_SKILL_NAME_LENGTH = 64;
999
- const MAX_DESCRIPTION_LENGTH = 1024;
1000
- const MAX_SKILL_CONTENT_CHARS = 1e5;
1001
- const MAX_SKILL_FILE_BYTES = 1048576;
1002
1588
  const DEFAULT_SKILL_LIMITS = {
1003
1589
  maxNameLength: 64,
1004
1590
  maxDescriptionLength: MAX_DESCRIPTION_LENGTH,
1005
1591
  maxSkillContentChars: MAX_SKILL_CONTENT_CHARS,
1006
1592
  maxSkillFileBytes: MAX_SKILL_FILE_BYTES
1007
1593
  };
1008
- const SUPPORT_DIRS = [
1009
- "references",
1010
- "templates",
1011
- "scripts",
1012
- "assets"
1013
- ];
1594
+ /** Extra file name carried inside a snapshot's `extras/` directory. */
1595
+ const SNAPSHOT_EXTRA_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
1014
1596
  function skillsRoot(env = process.env) {
1015
1597
  return join(env.DSH_HOME ?? join(homedir(), ".dsh"), "skills");
1016
1598
  }
@@ -1069,12 +1651,89 @@ function validateSupportPath(filePath) {
1069
1651
  if (parts.length < 2) return "Provide a file name, not just a directory.";
1070
1652
  return null;
1071
1653
  }
1654
+ /**
1655
+ * Index of `pattern` inside `content`, treating whitespace runs (spaces/tabs)
1656
+ * as flexible and literal escape sequences (`\n`, `\t`, `\r`) as their real
1657
+ * characters: a PATTERN whitespace run matches any content run of any length
1658
+ * (even empty), while extra whitespace that only exists in the content is not
1659
+ * skipped — the flexibility is one-sided on the pattern, and a backslash-
1660
+ * escaped char in the pattern matches the real char in the content
1661
+ * (model-copy drift). Returns the [start, end) range in the ORIGINAL content
1662
+ * so a patch can replace exactly the matched span and keep every other byte
1663
+ * intact. Returns null when no fuzzy match exists.
1664
+ */
1665
+ function fuzzyIndexOf(content, pattern, from = 0) {
1666
+ const isSpace = (char) => char !== void 0 && /[ \t]/.test(char);
1667
+ const escaped = (char) => {
1668
+ if (char === "n") return "\n";
1669
+ if (char === "t") return " ";
1670
+ if (char === "r") return "\r";
1671
+ return null;
1672
+ };
1673
+ for (let start = from; start < content.length; start += 1) {
1674
+ let contentIndex = start;
1675
+ let patternIndex = 0;
1676
+ while (patternIndex < pattern.length && contentIndex < content.length) {
1677
+ const patternChar = pattern[patternIndex];
1678
+ const contentChar = content[contentIndex];
1679
+ if (isSpace(patternChar)) {
1680
+ while (patternIndex < pattern.length && isSpace(pattern[patternIndex])) patternIndex += 1;
1681
+ while (contentIndex < content.length && isSpace(content[contentIndex])) contentIndex += 1;
1682
+ continue;
1683
+ }
1684
+ const escapedChar = patternChar === "\\" ? escaped(pattern[patternIndex + 1]) : null;
1685
+ if (escapedChar !== null && contentChar === escapedChar) {
1686
+ patternIndex += 2;
1687
+ contentIndex += 1;
1688
+ continue;
1689
+ }
1690
+ if (patternChar === contentChar) {
1691
+ contentIndex += 1;
1692
+ patternIndex += 1;
1693
+ continue;
1694
+ }
1695
+ break;
1696
+ }
1697
+ if (patternIndex === pattern.length) return [start, contentIndex];
1698
+ }
1699
+ return null;
1700
+ }
1701
+ /** Trim leading whitespace of the first line and trailing whitespace of the last line. */
1702
+ function trimPatternBoundaries(pattern) {
1703
+ const from = pattern.search(/\S/);
1704
+ const trimmed = from < 0 ? pattern : pattern.slice(from);
1705
+ const trailing = trimmed.search(/\s+$/);
1706
+ return trailing < 0 ? trimmed : trimmed.slice(0, trailing);
1707
+ }
1708
+ /** Replace only the fuzzy-matched span, preserving all surrounding bytes. */
1709
+ function fuzzyReplace(content, oldString, newString, replaceAll) {
1710
+ let current = content;
1711
+ let scanFrom = 0;
1712
+ for (;;) {
1713
+ const match = fuzzyIndexOf(current, oldString, scanFrom);
1714
+ if (match === null) return current;
1715
+ const [start, end] = match;
1716
+ const next = current.slice(0, start) + newString + current.slice(end);
1717
+ if (!replaceAll) return next;
1718
+ current = next;
1719
+ scanFrom = start + newString.length;
1720
+ }
1721
+ }
1072
1722
  function fuzzyPatch(content, oldString, newString, replaceAll = false) {
1723
+ if (oldString === "") return null;
1073
1724
  if (content.includes(oldString)) return replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, newString);
1074
- const trimmed = content.replaceAll(/[ ]+$/gm, "");
1075
- if (trimmed.includes(oldString)) return trimmed.replace(oldString, newString);
1076
- const whitespace = content.replaceAll(/[ ]+/g, " ");
1077
- if (whitespace.includes(oldString)) return whitespace.replace(oldString, newString);
1725
+ const boundary = trimPatternBoundaries(oldString);
1726
+ if (boundary === "") return null;
1727
+ if (boundary !== oldString) {
1728
+ if (fuzzyIndexOf(content, boundary) !== null) {
1729
+ const patched = fuzzyReplace(content, boundary, newString, replaceAll);
1730
+ return patched === content ? null : patched;
1731
+ }
1732
+ }
1733
+ if (fuzzyIndexOf(content, oldString) !== null) {
1734
+ const patched = fuzzyReplace(content, oldString, newString, replaceAll);
1735
+ return patched === content ? null : patched;
1736
+ }
1078
1737
  return null;
1079
1738
  }
1080
1739
  var SkillLibrary = class {
@@ -1107,27 +1766,126 @@ var SkillLibrary = class {
1107
1766
  return summaries;
1108
1767
  }
1109
1768
  async read(name) {
1769
+ if (this.badName(name) !== null) return null;
1110
1770
  return this.io.readText(join(skillDir(this.root, name), "SKILL.md"));
1111
1771
  }
1112
- async writeProtection(name) {
1772
+ /** Name-format guard shared by every path-building mutator/reader. */
1773
+ badName(name) {
1774
+ const normalized = name.trim();
1775
+ if (!SKILL_NAME_RE.test(normalized) || normalized.length > this.limits.maxNameLength) return `Invalid skill name "${normalized}". Use lowercase letters, digits, and hyphens (<= ${this.limits.maxNameLength}).`;
1776
+ return null;
1777
+ }
1778
+ async writeProtection(name, origin = "foreground") {
1113
1779
  const dir = skillDir(this.root, name);
1114
1780
  for (const marker of ["bundled", "hub-installed"]) if (await this.io.exists(markerPath(dir, marker))) return marker;
1781
+ if (origin === "background_review" && await this.io.exists(markerPath(dir, "pinned"))) return "pinned";
1115
1782
  return null;
1116
1783
  }
1117
- async deleteProtection(name) {
1784
+ async deleteProtection(name, options = {}) {
1118
1785
  const dir = skillDir(this.root, name);
1119
- for (const marker of [
1786
+ const markers = options.allowBundled ? ["hub-installed", "pinned"] : [
1120
1787
  "bundled",
1121
1788
  "hub-installed",
1122
1789
  "pinned"
1123
- ]) if (await this.io.exists(markerPath(dir, marker))) return marker;
1790
+ ];
1791
+ for (const marker of markers) if (await this.io.exists(markerPath(dir, marker))) return marker;
1124
1792
  return null;
1125
1793
  }
1126
1794
  async isManaged(name) {
1127
1795
  const dir = skillDir(this.root, name);
1128
1796
  return await this.io.exists(markerPath(dir, "hermes-managed"));
1129
1797
  }
1130
- async create(name, content, origin) {
1798
+ /** Whether the skill carries the bundled marker (curator prune-builtins eligibility). */
1799
+ async isBundled(name) {
1800
+ if (this.badName(name) !== null) return false;
1801
+ const dir = skillDir(this.root, name);
1802
+ return await this.io.exists(markerPath(dir, "bundled"));
1803
+ }
1804
+ /** Whether the skill carries the pinned marker (the marker is the factual source; usage.pinned mirrors it). */
1805
+ async isPinned(name) {
1806
+ if (this.badName(name) !== null) return false;
1807
+ const dir = skillDir(this.root, name);
1808
+ return await this.io.exists(markerPath(dir, "pinned"));
1809
+ }
1810
+ /** Count non-empty support subdirectories (richness input for quality scoring). */
1811
+ async countSupportDirs(name) {
1812
+ if (this.badName(name) !== null) return 0;
1813
+ const dir = skillDir(this.root, name);
1814
+ let entries;
1815
+ try {
1816
+ entries = await this.io.list(dir);
1817
+ } catch {
1818
+ return 0;
1819
+ }
1820
+ let count = 0;
1821
+ for (const subdir of SUPPORT_DIRS) {
1822
+ if (!entries.includes(subdir)) continue;
1823
+ try {
1824
+ if ((await this.io.list(join(dir, subdir))).some((file) => file !== ".gitkeep")) count += 1;
1825
+ } catch {}
1826
+ }
1827
+ return count;
1828
+ }
1829
+ /** Best-effort audit trail entry; never blocks the mutation. */
1830
+ async audit(skillName, action, before, after, summary) {
1831
+ try {
1832
+ await recordMutation(this.root, this.io, {
1833
+ skillName,
1834
+ action,
1835
+ ...before === null ? {} : { beforeHash: contentHash(before) },
1836
+ ...after === null ? {} : { afterHash: contentHash(after) },
1837
+ summary,
1838
+ at: (/* @__PURE__ */ new Date()).toISOString()
1839
+ });
1840
+ } catch {}
1841
+ }
1842
+ /** Recent mutation audit records (read-only inspection surface). */
1843
+ async listMutations() {
1844
+ return await loadMutations(this.root, this.io);
1845
+ }
1846
+ /**
1847
+ * Pin or unpin a skill (`.pinned` marker). Pinned skills are protected from
1848
+ * deletion, from background-review writes, and from the lifecycle — a
1849
+ * protective mutation, so the autonomous pipeline may never call it. The
1850
+ * marker write is the only state change; content is untouched.
1851
+ */
1852
+ async setPinned(name, pinned, origin = "foreground") {
1853
+ const normalized = name.trim();
1854
+ if (!SKILL_NAME_RE.test(normalized) || normalized.length > this.limits.maxNameLength) return {
1855
+ ok: false,
1856
+ message: `Invalid skill name "${normalized}". Use lowercase letters, digits, and hyphens (<= ${this.limits.maxNameLength}).`
1857
+ };
1858
+ if (origin === "background_review") return {
1859
+ ok: false,
1860
+ message: "Only the foreground (user or the main agent) may pin or unpin skills."
1861
+ };
1862
+ const dir = skillDir(this.root, normalized);
1863
+ const marker = markerPath(dir, "pinned");
1864
+ const existing = await this.io.exists(marker);
1865
+ if (pinned && existing) return {
1866
+ ok: true,
1867
+ message: `Skill "${normalized}" is already pinned.`,
1868
+ path: dir
1869
+ };
1870
+ if (!pinned && !existing) return {
1871
+ ok: true,
1872
+ message: `Skill "${normalized}" is not pinned; nothing to do.`,
1873
+ path: dir
1874
+ };
1875
+ if (!await this.io.exists(join(dir, "SKILL.md"))) return {
1876
+ ok: false,
1877
+ message: `Skill "${normalized}" not found.`
1878
+ };
1879
+ if (pinned) await this.io.writeText(marker, "");
1880
+ else await this.io.remove(marker);
1881
+ await this.audit(normalized, pinned ? "pin" : "unpin", null, null, pinned ? "pinned" : "unpinned");
1882
+ return {
1883
+ ok: true,
1884
+ message: pinned ? `Skill "${normalized}" pinned: protected from deletion, background review, and the lifecycle.` : `Skill "${normalized}" unpinned.`,
1885
+ path: dir
1886
+ };
1887
+ }
1888
+ async create(name, content, origin = "foreground") {
1131
1889
  const normalized = name.trim();
1132
1890
  if (!SKILL_NAME_RE.test(normalized) || normalized.length > this.limits.maxNameLength) return {
1133
1891
  ok: false,
@@ -1149,20 +1907,27 @@ var SkillLibrary = class {
1149
1907
  message: `Skill "${normalized}" already exists.`
1150
1908
  };
1151
1909
  await this.io.writeText(join(dir, "SKILL.md"), content.trimEnd() + "\n");
1152
- if (origin === "background_review") await this.io.writeText(markerPath(dir, "hermes-managed"), "");
1910
+ if (origin !== "foreground") await this.io.writeText(markerPath(dir, "hermes-managed"), "");
1911
+ await this.audit(normalized, "create", null, content, "created");
1153
1912
  return {
1154
1913
  ok: true,
1155
1914
  message: `Skill "${normalized}" created.`,
1156
1915
  path: dir
1157
1916
  };
1158
1917
  }
1159
- async update(name, content) {
1918
+ async update(name, content, origin = "foreground") {
1919
+ const badName = this.badName(name);
1920
+ if (badName) return {
1921
+ ok: false,
1922
+ message: badName
1923
+ };
1160
1924
  const dir = skillDir(this.root, name);
1161
- if (!await this.io.readText(join(dir, "SKILL.md"))) return {
1925
+ const md = await this.io.readText(join(dir, "SKILL.md"));
1926
+ if (!md) return {
1162
1927
  ok: false,
1163
1928
  message: `Skill "${name}" not found.`
1164
1929
  };
1165
- const protection = await this.writeProtection(name);
1930
+ const protection = await this.writeProtection(name, origin);
1166
1931
  if (protection) return {
1167
1932
  ok: false,
1168
1933
  message: `Skill "${name}" is protected (${protection}).`
@@ -1178,20 +1943,26 @@ var SkillLibrary = class {
1178
1943
  message: threat
1179
1944
  };
1180
1945
  await this.io.writeText(join(dir, "SKILL.md"), content.trimEnd() + "\n");
1946
+ await this.audit(name, "update", md, content, "updated");
1181
1947
  return {
1182
1948
  ok: true,
1183
1949
  message: `Skill "${name}" updated.`,
1184
1950
  path: dir
1185
1951
  };
1186
1952
  }
1187
- async patch(name, oldString, newString, filePath = "", replaceAll = false) {
1953
+ async patch(name, oldString, newString, filePath = "", replaceAll = false, origin = "foreground") {
1954
+ const badName = this.badName(name);
1955
+ if (badName) return {
1956
+ ok: false,
1957
+ message: badName
1958
+ };
1188
1959
  const dir = skillDir(this.root, name);
1189
1960
  const skillMd = join(dir, "SKILL.md");
1190
1961
  if (!await this.io.exists(skillMd)) return {
1191
1962
  ok: false,
1192
1963
  message: `Skill "${name}" not found.`
1193
1964
  };
1194
- const protection = await this.writeProtection(name);
1965
+ const protection = await this.writeProtection(name, origin);
1195
1966
  if (protection) return {
1196
1967
  ok: false,
1197
1968
  message: `Skill "${name}" is protected (${protection}).`
@@ -1213,7 +1984,7 @@ var SkillLibrary = class {
1213
1984
  message: `File not found: ${patchLabel}`
1214
1985
  };
1215
1986
  const patched = fuzzyPatch(md, oldString, newString, replaceAll);
1216
- if (!patched) return {
1987
+ if (patched === null) return {
1217
1988
  ok: false,
1218
1989
  message: `Could not find old_string in "${name}/${patchLabel}". Use update for a full rewrite.`
1219
1990
  };
@@ -1238,27 +2009,34 @@ var SkillLibrary = class {
1238
2009
  message: threat
1239
2010
  };
1240
2011
  await this.io.writeText(target, patched.trimEnd() + "\n");
2012
+ await this.audit(name, "patch", md, patched, `patched ${patchLabel}`);
1241
2013
  return {
1242
2014
  ok: true,
1243
2015
  message: `Skill "${name}" patched (${patchLabel}).`,
1244
2016
  path: dir
1245
2017
  };
1246
2018
  }
1247
- async archive(name, absorbedInto = "") {
2019
+ async archive(name, options = {}) {
2020
+ const badName = this.badName(name);
2021
+ if (badName) return {
2022
+ ok: false,
2023
+ message: badName
2024
+ };
1248
2025
  const dir = skillDir(this.root, name);
1249
- if (!await this.io.readText(join(dir, "SKILL.md"))) return {
2026
+ const md = await this.io.readText(join(dir, "SKILL.md"));
2027
+ if (!md) return {
1250
2028
  ok: false,
1251
2029
  message: `Skill "${name}" not found.`
1252
2030
  };
1253
- const protection = await this.deleteProtection(name);
2031
+ const protection = await this.deleteProtection(name, options.allowBundled === void 0 ? {} : { allowBundled: options.allowBundled });
1254
2032
  if (protection) return {
1255
2033
  ok: false,
1256
2034
  message: `Skill "${name}" is protected (${protection}).`
1257
2035
  };
1258
- if (absorbedInto) {
1259
- if (!await this.io.readText(join(skillDir(this.root, absorbedInto), "SKILL.md"))) return {
2036
+ if (options.absorbedInto) {
2037
+ if (!await this.io.readText(join(skillDir(this.root, options.absorbedInto), "SKILL.md"))) return {
1260
2038
  ok: false,
1261
- message: `absorbed_into="${absorbedInto}" does not exist.`
2039
+ message: `absorbed_into="${options.absorbedInto}" does not exist.`
1262
2040
  };
1263
2041
  }
1264
2042
  const archiveRoot = join(this.root, ".archive");
@@ -1270,21 +2048,149 @@ var SkillLibrary = class {
1270
2048
  await this.io.copy(dir, dest);
1271
2049
  await this.io.remove(dir);
1272
2050
  }
1273
- const reason = absorbedInto ? `Consolidated into ${absorbedInto}` : "Archived by self-evolution curator";
2051
+ const reason = options.reason ?? (options.absorbedInto ? `Consolidated into ${options.absorbedInto}` : "Archived by self-evolution curator");
1274
2052
  await this.io.writeText(join(dest, ".archive-reason"), `${(/* @__PURE__ */ new Date()).toISOString()}: ${reason}\n`);
2053
+ await this.audit(name, "archive", md, null, reason);
1275
2054
  return {
1276
2055
  ok: true,
1277
2056
  message: `Skill "${name}" archived to .archive.`,
1278
2057
  path: dest
1279
2058
  };
1280
2059
  }
1281
- async writeSupportFile(name, filePath, content) {
2060
+ /**
2061
+ * Merge the bodies of `sources` into `target` and archive the sources with
2062
+ * an absorbed-into marker. Hermes-style consolidation: overlapping skills
2063
+ * collapse into one, and the originals stay recoverable under `.archive/`.
2064
+ */
2065
+ async consolidate(target, sources, origin = "foreground") {
2066
+ const normalizedSources = [...new Set(sources)].filter((name) => name !== target);
2067
+ if (normalizedSources.length === 0) return {
2068
+ ok: false,
2069
+ message: "Consolidation requires at least one distinct source skill."
2070
+ };
2071
+ for (const name of [target, ...normalizedSources]) if (!SKILL_NAME_RE.test(name)) return {
2072
+ ok: false,
2073
+ message: `Invalid skill name "${name}". Use lowercase letters, digits, and hyphens.`
2074
+ };
2075
+ const targetDir = skillDir(this.root, target);
2076
+ const targetMd = await this.io.readText(join(targetDir, "SKILL.md"));
2077
+ if (!targetMd) return {
2078
+ ok: false,
2079
+ message: `Skill "${target}" not found.`
2080
+ };
2081
+ const targetProtection = await this.writeProtection(target, origin);
2082
+ if (targetProtection) return {
2083
+ ok: false,
2084
+ message: `Skill "${target}" is protected (${targetProtection}).`
2085
+ };
2086
+ const parts = [];
2087
+ for (const source of normalizedSources) {
2088
+ const protection = await this.deleteProtection(source);
2089
+ if (protection) return {
2090
+ ok: false,
2091
+ message: `Skill "${source}" is protected (${protection}).`
2092
+ };
2093
+ const sourceMd = await this.io.readText(join(skillDir(this.root, source), "SKILL.md"));
2094
+ if (!sourceMd) return {
2095
+ ok: false,
2096
+ message: `Skill "${source}" not found.`
2097
+ };
2098
+ const parsed = parseFrontmatter(sourceMd);
2099
+ if (!parsed) return {
2100
+ ok: false,
2101
+ message: `Skill "${source}" has no valid frontmatter; refusing to merge.`
2102
+ };
2103
+ parts.push(`\n<!-- consolidated from ${source} at ${(/* @__PURE__ */ new Date()).toISOString()} -->\n${parsed.body.trim()}`);
2104
+ }
2105
+ const merged = targetMd.trimEnd() + parts.join("\n") + "\n";
2106
+ const validation = validateFrontmatter(merged, target, this.limits);
2107
+ if (validation) return {
2108
+ ok: false,
2109
+ message: `Consolidation rejected: ${validation}`
2110
+ };
2111
+ const threat = scanContentThreats(merged);
2112
+ if (threat) return {
2113
+ ok: false,
2114
+ message: threat
2115
+ };
2116
+ const archived = [];
2117
+ try {
2118
+ for (const source of normalizedSources) {
2119
+ const result = await this.archive(source, { absorbedInto: target });
2120
+ if (!result.ok) return result;
2121
+ archived.push(source);
2122
+ }
2123
+ await this.io.writeText(join(targetDir, "SKILL.md"), merged);
2124
+ } catch (error) {
2125
+ await this.io.writeText(join(targetDir, "SKILL.md"), targetMd).catch(() => {});
2126
+ for (const source of archived.reverse()) await this.restoreFromArchive(source).catch(() => {});
2127
+ return {
2128
+ ok: false,
2129
+ message: `Consolidation failed and was rolled back: ${error instanceof Error ? error.message : String(error)}`
2130
+ };
2131
+ }
2132
+ return {
2133
+ ok: true,
2134
+ message: `Consolidated ${normalizedSources.join(", ")} into "${target}".`,
2135
+ path: targetDir
2136
+ };
2137
+ }
2138
+ /**
2139
+ * Restore one skill from `.archive/` back to the active root. Hermes-style
2140
+ * recoverability: archival never deletes, and this is the control-plane
2141
+ * path back. The `.archive-reason` marker is dropped on restore.
2142
+ */
2143
+ async restoreFromArchive(name) {
2144
+ if (!SKILL_NAME_RE.test(name)) return {
2145
+ ok: false,
2146
+ message: `Invalid skill name "${name}". Use lowercase letters, digits, and hyphens.`
2147
+ };
2148
+ if (await this.io.exists(join(skillDir(this.root, name), "SKILL.md"))) return {
2149
+ ok: false,
2150
+ message: `Skill "${name}" already exists in the active root; refusing to overwrite.`
2151
+ };
2152
+ const archiveRoot = join(this.root, ".archive");
2153
+ let entries;
2154
+ try {
2155
+ entries = await this.io.list(archiveRoot);
2156
+ } catch {
2157
+ return {
2158
+ ok: false,
2159
+ message: "No skill archive available."
2160
+ };
2161
+ }
2162
+ const chosen = entries.filter((entry) => entry === name || entry.startsWith(`${name}-`)).sort().reverse()[0];
2163
+ if (!chosen) return {
2164
+ ok: false,
2165
+ message: `Skill "${name}" is not in .archive.`
2166
+ };
2167
+ const source = join(archiveRoot, chosen);
2168
+ const dest = skillDir(this.root, name);
2169
+ try {
2170
+ await this.io.rename(source, dest);
2171
+ } catch {
2172
+ await this.io.copy(source, dest);
2173
+ await this.io.remove(source);
2174
+ }
2175
+ if (await this.io.exists(join(dest, ".archive-reason"))) await this.io.remove(join(dest, ".archive-reason"));
2176
+ return {
2177
+ ok: true,
2178
+ message: `Skill "${name}" restored from .archive.`,
2179
+ path: dest
2180
+ };
2181
+ }
2182
+ async writeSupportFile(name, filePath, content, origin = "foreground") {
2183
+ const badName = this.badName(name);
2184
+ if (badName) return {
2185
+ ok: false,
2186
+ message: badName
2187
+ };
1282
2188
  const dir = skillDir(this.root, name);
1283
2189
  if (!await this.io.exists(join(dir, "SKILL.md"))) return {
1284
2190
  ok: false,
1285
2191
  message: `Skill "${name}" not found.`
1286
2192
  };
1287
- const protection = await this.writeProtection(name);
2193
+ const protection = await this.writeProtection(name, origin);
1288
2194
  if (protection) return {
1289
2195
  ok: false,
1290
2196
  message: `Skill "${name}" is protected (${protection}).`
@@ -1304,20 +2210,27 @@ var SkillLibrary = class {
1304
2210
  message: threat
1305
2211
  };
1306
2212
  const target = join(dir, ...filePath.replace(/\\/g, "/").split("/").filter(Boolean));
2213
+ const existing = await this.io.readText(target).catch(() => null);
1307
2214
  await this.io.writeText(target, content);
2215
+ await this.audit(name, "write_file", existing, content, `wrote ${filePath}`);
1308
2216
  return {
1309
2217
  ok: true,
1310
2218
  message: `Support file "${filePath}" written to "${name}".`,
1311
2219
  path: target
1312
2220
  };
1313
2221
  }
1314
- async removeSupportFile(name, filePath) {
2222
+ async removeSupportFile(name, filePath, origin = "foreground") {
2223
+ const badName = this.badName(name);
2224
+ if (badName) return {
2225
+ ok: false,
2226
+ message: badName
2227
+ };
1315
2228
  const dir = skillDir(this.root, name);
1316
2229
  if (!await this.io.exists(join(dir, "SKILL.md"))) return {
1317
2230
  ok: false,
1318
2231
  message: `Skill "${name}" not found.`
1319
2232
  };
1320
- const protection = await this.writeProtection(name);
2233
+ const protection = await this.writeProtection(name, origin);
1321
2234
  if (protection) return {
1322
2235
  ok: false,
1323
2236
  message: `Skill "${name}" is protected (${protection}).`
@@ -1332,24 +2245,79 @@ var SkillLibrary = class {
1332
2245
  ok: false,
1333
2246
  message: `File "${filePath}" not found in skill "${name}".`
1334
2247
  };
2248
+ const before = await this.io.readText(target).catch(() => null);
1335
2249
  await this.io.remove(target);
2250
+ await this.audit(name, "remove_file", before, null, `removed ${filePath}`);
1336
2251
  return {
1337
2252
  ok: true,
1338
2253
  message: `Support file "${filePath}" removed from "${name}".`,
1339
2254
  path: target
1340
2255
  };
1341
2256
  }
1342
- async snapshotAll(reason = "pre-mutation") {
2257
+ /**
2258
+ * Snapshot the recoverable skills state: active tree, usage/suppression
2259
+ * sidecars, `.archive/` and caller-supplied extras. `extras` are opaque
2260
+ * side files the Snapshot owner cares about (curator state); they are
2261
+ * listed in the manifest and only those names are ever read back.
2262
+ */
2263
+ async snapshotAll(reason = "pre-mutation", extras = []) {
1343
2264
  const dest = join(join(this.root, ".backups"), `skills-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`);
1344
2265
  const names = await listNames(this.root, this.io);
1345
2266
  for (const name of names) await this.io.copy(skillDir(this.root, name), join(dest, name));
2267
+ const sidecars = [];
2268
+ for (const sidecar of [usageFile(this.root), suppressedFile(this.root)]) if (await this.io.exists(sidecar)) {
2269
+ const name = basename(sidecar);
2270
+ await this.io.copy(sidecar, join(dest, name));
2271
+ sidecars.push(name);
2272
+ }
2273
+ const archiveRoot = join(this.root, ".archive");
2274
+ let hasArchive = false;
2275
+ if (await this.io.exists(archiveRoot)) {
2276
+ await this.io.copy(archiveRoot, join(dest, ".archive"));
2277
+ hasArchive = true;
2278
+ }
2279
+ const extraNames = [];
2280
+ for (const extra of extras) {
2281
+ if (!SNAPSHOT_EXTRA_NAME_RE.test(extra.name)) continue;
2282
+ await this.io.writeText(join(dest, "extras", extra.name), extra.content);
2283
+ extraNames.push(extra.name);
2284
+ }
1346
2285
  await this.io.writeText(join(dest, "manifest.json"), JSON.stringify({
1347
2286
  reason,
1348
2287
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1349
- skills: names
2288
+ skills: names,
2289
+ sidecars,
2290
+ hasArchive,
2291
+ extras: extraNames
1350
2292
  }, null, 2));
2293
+ await this.retainSnapshots(5);
1351
2294
  return dest;
1352
2295
  }
2296
+ /** Read and normalize a snapshot manifest; null when the file is missing or unparsable. */
2297
+ async readSnapshotManifest(path) {
2298
+ const raw = await this.io.readText(join(path, "manifest.json"));
2299
+ if (raw === null) return null;
2300
+ try {
2301
+ const manifest = JSON.parse(raw);
2302
+ return {
2303
+ reason: typeof manifest.reason === "string" ? manifest.reason : "",
2304
+ createdAt: typeof manifest.createdAt === "string" ? manifest.createdAt : "",
2305
+ skills: Array.isArray(manifest.skills) ? manifest.skills : [],
2306
+ sidecars: Array.isArray(manifest.sidecars) ? manifest.sidecars : [],
2307
+ ...typeof manifest.hasArchive === "boolean" ? { hasArchive: manifest.hasArchive } : {},
2308
+ extras: Array.isArray(manifest.extras) ? manifest.extras : []
2309
+ };
2310
+ } catch {
2311
+ return null;
2312
+ }
2313
+ }
2314
+ /** Keep only the newest N snapshots (Hermes keep=5 parity); oldest folded into .backups history. */
2315
+ async retainSnapshots(keep) {
2316
+ const snapshots = await this.listSnapshots();
2317
+ for (const snapshot of snapshots.slice(keep)) try {
2318
+ await this.io.remove(snapshot.path);
2319
+ } catch {}
2320
+ }
1353
2321
  async listSnapshots() {
1354
2322
  const backupRoot = join(this.root, ".backups");
1355
2323
  let entries;
@@ -1361,36 +2329,69 @@ var SkillLibrary = class {
1361
2329
  const out = [];
1362
2330
  for (const name of entries.sort().reverse()) {
1363
2331
  if (!name.startsWith("skills-")) continue;
1364
- try {
1365
- const raw = await this.io.readText(join(backupRoot, name, "manifest.json"));
1366
- if (raw === null) continue;
1367
- const manifest = JSON.parse(raw);
1368
- out.push({
1369
- path: join(backupRoot, name),
1370
- createdAt: manifest.createdAt ?? "",
1371
- reason: manifest.reason ?? ""
1372
- });
1373
- } catch {}
2332
+ const manifest = await this.readSnapshotManifest(join(backupRoot, name));
2333
+ if (manifest === null) continue;
2334
+ out.push({
2335
+ path: join(backupRoot, name),
2336
+ createdAt: manifest.createdAt,
2337
+ reason: manifest.reason
2338
+ });
1374
2339
  }
1375
2340
  return out;
1376
2341
  }
1377
- async restoreLatestSnapshot() {
2342
+ /**
2343
+ * Read the extras of a snapshot, restricted to the names declared in the
2344
+ * manifest — an `extras/` directory is never listed directly, so unknown
2345
+ * files cannot leak back as state on the next restore.
2346
+ */
2347
+ async readSnapshotExtras(path) {
2348
+ const manifest = await this.readSnapshotManifest(path);
2349
+ if (manifest === null) return [];
2350
+ const extras = [];
2351
+ for (const name of manifest.extras) {
2352
+ if (!SNAPSHOT_EXTRA_NAME_RE.test(name)) continue;
2353
+ const content = await this.io.readText(join(path, "extras", name));
2354
+ if (content !== null) extras.push({
2355
+ name,
2356
+ content
2357
+ });
2358
+ }
2359
+ return extras;
2360
+ }
2361
+ /**
2362
+ * Manifest-driven restore of the latest snapshot: active tree, sidecars,
2363
+ * `.archive/` and (for full-state snapshots) the extras read back by the
2364
+ * caller. `extras` are additionally written into the pre-rollback safety
2365
+ * snapshot so the rollback itself is undoable with the same state.
2366
+ */
2367
+ async restoreLatestSnapshot(extras = []) {
1378
2368
  const latest = (await this.listSnapshots())[0];
1379
2369
  if (!latest) return {
1380
2370
  ok: false,
1381
2371
  message: "No skill snapshot available."
1382
2372
  };
1383
- await this.snapshotAll("pre-rollback");
2373
+ await this.snapshotAll("pre-rollback", extras);
1384
2374
  for (const name of await listNames(this.root, this.io)) await this.io.remove(skillDir(this.root, name));
1385
- const entries = await this.io.list(latest.path);
1386
- for (const entry of entries) {
1387
- if (entry === "manifest.json") continue;
2375
+ const manifest = await this.readSnapshotManifest(latest.path);
2376
+ if (manifest === null) for (const entry of await this.io.list(latest.path)) {
2377
+ if (entry === "manifest.json" || entry === "extras") continue;
1388
2378
  await this.io.copy(join(latest.path, entry), join(this.root, entry));
1389
2379
  }
2380
+ else {
2381
+ for (const name of manifest.skills) await this.io.copy(join(latest.path, name), join(this.root, name));
2382
+ for (const sidecar of manifest.sidecars) await this.io.copy(join(latest.path, sidecar), join(this.root, sidecar));
2383
+ const archiveRoot = join(this.root, ".archive");
2384
+ if (manifest.hasArchive === true) {
2385
+ await this.io.remove(archiveRoot);
2386
+ await this.io.copy(join(latest.path, ".archive"), archiveRoot);
2387
+ } else if (manifest.hasArchive === false) await this.io.remove(archiveRoot);
2388
+ }
2389
+ const snapshotExtras = await this.readSnapshotExtras(latest.path);
1390
2390
  return {
1391
2391
  ok: true,
1392
2392
  message: `Restored skill tree from ${latest.path}`,
1393
- path: latest.path
2393
+ path: latest.path,
2394
+ ...snapshotExtras.length === 0 ? {} : { extras: snapshotExtras }
1394
2395
  };
1395
2396
  }
1396
2397
  };
@@ -1403,7 +2404,7 @@ var SkillLibrary = class {
1403
2404
  function evolutionHome(env = process.env) {
1404
2405
  return join(env.DSH_HOME ?? join(homedir(), ".dsh"), "evolution");
1405
2406
  }
1406
- var JsonState = class {
2407
+ var JsonState = class JsonState {
1407
2408
  initial;
1408
2409
  path;
1409
2410
  value;
@@ -1412,14 +2413,24 @@ var JsonState = class {
1412
2413
  this.path = join(evolutionHome(env), name);
1413
2414
  this.value = this.loadSync();
1414
2415
  }
2416
+ /**
2417
+ * Deep-merge persisted state over the initial defaults. Nested plain
2418
+ * objects merge recursively (so a new default field added under an existing
2419
+ * object is preserved), while arrays and primitives take the on-disk value
2420
+ * wholesale. Keeps forward-compatible defaults across schema additions.
2421
+ */
2422
+ static mergeDeep(initial, persisted) {
2423
+ const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2424
+ if (!isRecord(initial) || !isRecord(persisted)) return isRecord(persisted) ? persisted : persisted == null ? initial : persisted;
2425
+ const out = { ...initial };
2426
+ for (const [key, value] of Object.entries(persisted)) out[key] = key in initial ? JsonState.mergeDeep(initial[key], value) : value;
2427
+ return out;
2428
+ }
1415
2429
  loadSync() {
1416
2430
  try {
1417
2431
  const raw = readFileSync(this.path, "utf8");
1418
2432
  const parsed = JSON.parse(raw);
1419
- return {
1420
- ...this.initial,
1421
- ...parsed
1422
- };
2433
+ return JsonState.mergeDeep(this.initial, parsed);
1423
2434
  } catch {
1424
2435
  return { ...this.initial };
1425
2436
  }
@@ -1443,14 +2454,11 @@ var JsonState = class {
1443
2454
  async reload() {
1444
2455
  try {
1445
2456
  const raw = await readFile(this.path, "utf8");
1446
- this.value = {
1447
- ...this.initial,
1448
- ...JSON.parse(raw)
1449
- };
2457
+ this.value = JsonState.mergeDeep(this.initial, JSON.parse(raw));
1450
2458
  } catch {
1451
2459
  this.value = { ...this.initial };
1452
2460
  }
1453
2461
  }
1454
2462
  };
1455
2463
  //#endregion
1456
- export { COMBINED_REVIEW_PROMPT, CURATOR_PROMPT, DEFAULT_SKILL_LIMITS, DSH_AUTHORING_STANDARDS, ENTRY_DELIMITER, JsonState, MAX_DESCRIPTION_LENGTH, MAX_SKILL_CONTENT_CHARS, MAX_SKILL_FILE_BYTES, MAX_SKILL_NAME_LENGTH, MEMORY_REVIEW_PROMPT, MemoryStore, PROMPT_BUNDLE, PROMPT_BUNDLE_ID, PROTECTED_BUILTIN_SKILLS, SKILL_NAME_RE, SKILL_REVIEW_PROMPT, SUPPORT_DIRS, SkillLibrary, advanceReview, buildCuratorRunReport, bumpPatch, bumpUse, bumpView, childPath, computeLifecycleTransitions, emptyRecord, evaluateThreat, evolutionHome, evolutionIoAdapter, foldTurn, getRecord, latestActivityAt, loadUsage, markAgentCreated, memoryRoot, nodeEvolutionIo, observeEvent, parseFrontmatter, reviewPrompt, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, usageFile, validateFrontmatter, verifyPromptBundle };
2464
+ export { COMBINED_REVIEW_PROMPT, COMPLETION_SKILL_REVIEW_PROMPT, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_CURATOR_INTERVAL_HOURS, 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, DSH_AUTHORING_STANDARDS, ENTRY_DELIMITER, JsonState, LOW_QUALITY_THRESHOLD, MAX_DESCRIPTION_LENGTH, 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, SKILL_NAME_RE, SKILL_REVIEW_PROMPT, SNAPSHOT_EXTRA_NAME_RE, SUPPORT_DIRS, SUPPRESSED_FILE_VERSION, SkillLibrary, advanceReview, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpUse, bumpView, computeDedupGroups, computeLifecycleTransitions, computeQualityScores, computeScopeView, contentHash, emptyRecord, evaluateThreat, evolutionHome, evolutionIoAdapter, foldTurn, getRecord, latestActivityAt, lifecycleCandidate, loadMutations, loadSuppressedNames, loadUsage, markAgentCreated, memoryRoot, mutationsFile, nodeEvolutionIo, observeEvent, parseCuratorNominations, parseFrontmatter, recordMutation, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, suppressedFile, usageFile, validateFrontmatter, verifyPromptBundle };