@lmzhen/dsh-evolution-core 0.1.0-rc.5 → 0.1.0-rc.50

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,5 +1,5 @@
1
- import { dirname, join } from "node:path";
2
- import { cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
1
+ import { basename, dirname, join } from "node:path";
2
+ import { cp, lstat, 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";
5
5
  import { readFileSync } from "node:fs";
@@ -11,6 +11,20 @@ import { readFileSync } from "node:fs";
11
11
  * Native DSH packages pass `ctx.evolutionIo.provider()`; standalone consumers
12
12
  * (and the facade's own tests) can use `nodeEvolutionIo`.
13
13
  */
14
+ /**
15
+ * Run `task` inside `io.transact` when the backend provides it; otherwise fall
16
+ * back to a plain read → task → write/remove sequence (no cross-process lock —
17
+ * callers keep their single-process serialize chain as the second layer).
18
+ */
19
+ async function transactIo(io, path, task) {
20
+ if (io.transact) {
21
+ await io.transact(path, task);
22
+ return;
23
+ }
24
+ const next = await task(await io.readText(path));
25
+ if (next === null) await io.remove(path);
26
+ else await io.writeText(path, next);
27
+ }
14
28
  /** Lazy adapter over an IO provider registry, shared by every evolution consumer. */
15
29
  function evolutionIoAdapter(provider) {
16
30
  return {
@@ -20,23 +34,95 @@ function evolutionIoAdapter(provider) {
20
34
  list: (path) => provider().list(path),
21
35
  exists: (path) => provider().exists(path),
22
36
  rename: (path, destination) => provider().rename(path, destination),
23
- copy: (path, destination) => provider().copy(path, destination)
37
+ copy: (path, destination) => provider().copy(path, destination),
38
+ size: (path) => {
39
+ const io = provider();
40
+ return io.size ? io.size(path) : Promise.resolve(null);
41
+ },
42
+ transact: (path, task) => {
43
+ const io = provider();
44
+ return io.transact ? io.transact(path, task) : transactIo(io, path, task);
45
+ },
46
+ isSymlink: (path) => {
47
+ const io = provider();
48
+ return io.isSymlink ? io.isSymlink(path) : Promise.resolve(null);
49
+ }
24
50
  };
25
51
  }
26
52
  function nodeEvolutionIo() {
53
+ const isMissing = (error) => {
54
+ const code = error?.code;
55
+ return code === "ENOENT" || code === "ENOTDIR";
56
+ };
57
+ /**
58
+ * Cross-process write lock (claw `withFileLock` parity): an O_EXCL lock file
59
+ * guards the atomic write; a >5s-old lock is treated as stale and taken
60
+ * over. After the retry budget the write proceeds unlocked — the lock is a
61
+ * best-effort accommodation for multi-process deployments, never a read of
62
+ * availability.
63
+ */
64
+ const withWriteLock = async (path, task) => {
65
+ const lock = `${path}.lock`;
66
+ for (let attempt = 0; attempt < 10; attempt += 1) try {
67
+ await writeFile(lock, String(process.pid), { flag: "wx" });
68
+ try {
69
+ return await task();
70
+ } finally {
71
+ await rm(lock, { force: true }).catch(() => {});
72
+ }
73
+ } catch (error) {
74
+ if (error?.code !== "EEXIST") throw error;
75
+ try {
76
+ const st = await stat(lock);
77
+ if (Date.now() - st.mtimeMs > 5e3) {
78
+ try {
79
+ await rm(lock, { force: true });
80
+ } catch {}
81
+ continue;
82
+ }
83
+ } catch {
84
+ continue;
85
+ }
86
+ await new Promise((resolve) => setTimeout(resolve, 50));
87
+ }
88
+ return await task();
89
+ };
27
90
  return {
28
91
  async readText(path) {
29
92
  try {
30
93
  return await readFile(path, "utf8");
31
- } catch {
32
- return null;
94
+ } catch (error) {
95
+ if (isMissing(error)) return null;
96
+ throw error;
33
97
  }
34
98
  },
35
99
  async writeText(path, content) {
36
100
  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);
101
+ await withWriteLock(path, async () => {
102
+ const tmp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
103
+ await writeFile(tmp, content, "utf8");
104
+ await rename(tmp, path);
105
+ });
106
+ },
107
+ async transact(path, task) {
108
+ await mkdir(dirname(path), { recursive: true });
109
+ await withWriteLock(path, async () => {
110
+ let current;
111
+ try {
112
+ current = await readFile(path, "utf8");
113
+ } catch (error) {
114
+ if (isMissing(error)) current = null;
115
+ else throw error;
116
+ }
117
+ const next = await task(current);
118
+ if (next === null) {
119
+ await rm(path, { force: true });
120
+ return;
121
+ }
122
+ const tmp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
123
+ await writeFile(tmp, next, "utf8");
124
+ await rename(tmp, path);
125
+ });
40
126
  },
41
127
  async remove(path) {
42
128
  await rm(path, {
@@ -47,16 +133,18 @@ function nodeEvolutionIo() {
47
133
  async list(path) {
48
134
  try {
49
135
  return await readdir(path);
50
- } catch {
51
- return [];
136
+ } catch (error) {
137
+ if (isMissing(error)) return [];
138
+ throw error;
52
139
  }
53
140
  },
54
141
  async exists(path) {
55
142
  try {
56
143
  await stat(path);
57
144
  return true;
58
- } catch {
59
- return false;
145
+ } catch (error) {
146
+ if (isMissing(error)) return false;
147
+ throw error;
60
148
  }
61
149
  },
62
150
  async rename(path, destination) {
@@ -69,13 +157,24 @@ function nodeEvolutionIo() {
69
157
  recursive: true,
70
158
  force: true
71
159
  });
160
+ },
161
+ async size(path) {
162
+ try {
163
+ return (await stat(path)).size;
164
+ } catch (error) {
165
+ if (isMissing(error)) return null;
166
+ throw error;
167
+ }
168
+ },
169
+ async isSymlink(path) {
170
+ try {
171
+ return (await lstat(path)).isSymbolicLink();
172
+ } catch {
173
+ return null;
174
+ }
72
175
  }
73
176
  };
74
177
  }
75
- /** Absolute path helper kept separate so stores stay platform-correct. */
76
- function childPath(parent, ...parts) {
77
- return join(parent, ...parts);
78
- }
79
178
  //#endregion
80
179
  //#region lib/types/usage.js
81
180
  /**
@@ -100,22 +199,65 @@ function emptyRecord() {
100
199
  archived_at: null
101
200
  };
102
201
  }
103
- async function loadUsage(root, io = nodeEvolutionIo()) {
202
+ const isTimestamp = (value) => value === null || typeof value === "string";
203
+ /**
204
+ * Field-level normalization for one sidecar record (rc.42 audit P2-3): the
205
+ * spread used to copy any junk through verbatim, so a corrupted file could
206
+ * carry `use_count: "3"` into the quality math and lifecycle comparisons as
207
+ * NaN. Every field falls back to its `emptyRecord()` baseline unless it has
208
+ * exactly the declared type; an invalid `created_at` anchors the age clock at
209
+ * now (first-sight defer semantics for a record whose age is unknowable).
210
+ * Pure — exported for unit tests; `loadUsage` is the production caller.
211
+ */
212
+ function normalizeUsageRecord(record) {
213
+ const base = emptyRecord();
214
+ if (!record || typeof record !== "object" || Array.isArray(record)) return base;
215
+ const raw = record;
216
+ const num = (value, fallback) => typeof value === "number" && Number.isFinite(value) ? value : fallback;
217
+ const bool = (value, fallback) => typeof value === "boolean" ? value : fallback;
218
+ return {
219
+ created_by: typeof raw.created_by === "string" ? raw.created_by : null,
220
+ use_count: num(raw.use_count, base.use_count),
221
+ view_count: num(raw.view_count, base.view_count),
222
+ patch_count: num(raw.patch_count, base.patch_count),
223
+ last_used_at: isTimestamp(raw.last_used_at) ? raw.last_used_at : base.last_used_at,
224
+ last_viewed_at: isTimestamp(raw.last_viewed_at) ? raw.last_viewed_at : base.last_viewed_at,
225
+ last_patched_at: isTimestamp(raw.last_patched_at) ? raw.last_patched_at : base.last_patched_at,
226
+ created_at: typeof raw.created_at === "string" ? raw.created_at : base.created_at,
227
+ state: raw.state === "stale" || raw.state === "archived" ? raw.state : "active",
228
+ pinned: bool(raw.pinned, base.pinned),
229
+ archived_at: isTimestamp(raw.archived_at) ? raw.archived_at : base.archived_at,
230
+ quality_score: typeof raw.quality_score === "number" && Number.isFinite(raw.quality_score) ? raw.quality_score : void 0,
231
+ quality_warn: typeof raw.quality_warn === "boolean" ? raw.quality_warn : void 0
232
+ };
233
+ }
234
+ /** Parse a raw usage sidecar; malformed content reads as empty (best-effort telemetry). */
235
+ function parseUsage(raw) {
104
236
  const map = /* @__PURE__ */ new Map();
105
- const raw = await io.readText(usageFile(root));
106
- if (raw !== null) try {
237
+ if (raw === null) return map;
238
+ try {
107
239
  const parsed = JSON.parse(raw);
108
- for (const [name, record] of Object.entries(parsed)) {
109
- const base = emptyRecord();
110
- map.set(name, {
111
- ...base,
112
- ...record,
113
- state: record.state === "stale" || record.state === "archived" ? record.state : "active"
114
- });
115
- }
240
+ for (const [name, record] of Object.entries(parsed)) map.set(name, normalizeUsageRecord(record));
116
241
  } catch {}
117
242
  return map;
118
243
  }
244
+ async function loadUsage(root, io = nodeEvolutionIo()) {
245
+ return parseUsage(await io.readText(usageFile(root)));
246
+ }
247
+ /**
248
+ * Atomic read-modify-write on the usage sidecar (rc.50 P2-2): `task` receives
249
+ * the map parsed from the current on-disk state and may mutate it; the result
250
+ * is persisted inside the same transact so a second process sharing DSH_HOME
251
+ * cannot interleave its RMW and lose a counter update. Callers keep their own
252
+ * single-process serialize chain as the second layer.
253
+ */
254
+ async function mutateUsage(root, io, task) {
255
+ await transactIo(io, usageFile(root), async (current) => {
256
+ const map = parseUsage(current);
257
+ await task(map);
258
+ return JSON.stringify(Object.fromEntries(map.entries()), null, 2);
259
+ });
260
+ }
119
261
  async function saveUsage(root, map, io = nodeEvolutionIo()) {
120
262
  const obj = Object.fromEntries(map.entries());
121
263
  await io.writeText(usageFile(root), JSON.stringify(obj, null, 2));
@@ -155,15 +297,169 @@ function latestActivityAt(record) {
155
297
  if (values.length === 0) return null;
156
298
  return values.sort().reverse()[0] ?? null;
157
299
  }
300
+ /**
301
+ * Curator suppression sidecar: built-in skills the curator has archived stay
302
+ * suppressed across re-seeds, so the lifecycle never fights a re-created
303
+ * bundled skill. Best-effort load/save, mirroring the usage sidecar posture.
304
+ * Versioned shape ({ version, names }) with legacy plain-array compat.
305
+ */
306
+ const SUPPRESSED_FILE_VERSION = 1;
307
+ function suppressedFile(root) {
308
+ return join(root, ".curator-suppressed.json");
309
+ }
310
+ async function loadSuppressedNames(root, io = nodeEvolutionIo()) {
311
+ return parseSuppressed(await io.readText(suppressedFile(root)));
312
+ }
313
+ function parseSuppressed(raw) {
314
+ if (raw === null) return /* @__PURE__ */ new Set();
315
+ try {
316
+ const parsed = JSON.parse(raw);
317
+ const names = Array.isArray(parsed) ? parsed : typeof parsed === "object" && parsed !== null && Array.isArray(parsed.names) ? parsed.names : [];
318
+ return new Set(names.filter((entry) => typeof entry === "string"));
319
+ } catch {
320
+ return /* @__PURE__ */ new Set();
321
+ }
322
+ }
323
+ async function saveSuppressedNames(root, names, io = nodeEvolutionIo()) {
324
+ await io.writeText(suppressedFile(root), JSON.stringify({
325
+ version: 1,
326
+ names: [...names].sort()
327
+ }, null, 2));
328
+ }
329
+ /**
330
+ * Atomic read-modify-write on the suppression sidecar (rc.50 P2-2): `task`
331
+ * receives the set parsed from the current on-disk state and may mutate it;
332
+ * the result is persisted inside the same transact so a second process
333
+ * sharing DSH_HOME cannot interleave its RMW. Best-effort posture unchanged.
334
+ */
335
+ async function updateSuppressedNames(root, io, task) {
336
+ await transactIo(io, suppressedFile(root), async (current) => {
337
+ const names = parseSuppressed(current);
338
+ await task(names);
339
+ return JSON.stringify({
340
+ version: 1,
341
+ names: [...names].sort()
342
+ }, null, 2);
343
+ });
344
+ }
345
+ //#endregion
346
+ //#region lib/types/constants.js
347
+ /**
348
+ * Shared constants for the dsh-evolution plugin family.
349
+ *
350
+ * Two classes of value live here, deliberately separated by section so future
351
+ * edits do not blur the semantic boundary:
352
+ *
353
+ * 1. **Fixed protocol/format/security invariants** — changing these breaks an
354
+ * on-disk format, a naming/format contract, a path-security boundary, or a
355
+ * cross-component invariant. They are NOT exposed as deployment config.
356
+ *
357
+ * 2. **Cross-package shared tunable defaults** — the same semantic default is
358
+ * read (with a config override path) by more than one package (e.g.
359
+ * `evolution-policy` and `evolution-curator` both default `staleAfterDays`
360
+ * to 30). Centralizing them here means one authoritative default: a config
361
+ * override still applies per package, but the fallback is single-sourced.
362
+ *
363
+ * Package-private tunables (used by exactly one package) stay in that package,
364
+ * not here — see evolution-replay's `DEFAULT_WEIGHTS` and evolution-feedback's
365
+ * threshold, which are intentionally left where they are used.
366
+ * @module @lmzhen/dsh-evolution-core
367
+ */
368
+ /** Skill frontmatter `name` validated for the file name (lowercase + hyphen). */
369
+ const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
370
+ /** Allowed skill support-file subdirectories (path-traversal boundary). */
371
+ const SUPPORT_DIRS = [
372
+ "references",
373
+ "templates",
374
+ "scripts",
375
+ "assets"
376
+ ];
377
+ /** Delimiter between durable memory entries (on-disk storage format). */
378
+ const ENTRY_DELIMITER = "\n§\n";
379
+ /** Built-in skill names the curator must never lifecycle-manage. */
380
+ const PROTECTED_BUILTIN_SKILLS = new Set(["plan"]);
381
+ const MAX_SKILL_NAME_LENGTH = 64;
382
+ const MAX_DESCRIPTION_LENGTH = 1024;
383
+ const MAX_SKILL_CONTENT_CHARS = 1e5;
384
+ const MAX_SKILL_FILE_BYTES = 1048576;
385
+ const DEFAULT_REVIEW_MEMORY_INTERVAL = 10;
386
+ const DEFAULT_REVIEW_SKILL_INTERVAL = 10;
387
+ /** Skill-review completion channel trigger mode: 'cadence' | 'completion' | 'both'. */
388
+ const DEFAULT_SKILL_REVIEW_TRIGGER = "both";
389
+ /** Cumulative session tool calls before a session counts as "proven long" for the completion channel. */
390
+ const DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS = 20;
391
+ const DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS = 3;
392
+ const DEFAULT_SUBSTANTIVE_MIN_USER_CHARS = 200;
393
+ const DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS = 500;
394
+ const DEFAULT_MAX_OPS_PER_PLAN = 32;
395
+ const DEFAULT_CURATOR_INTERVAL_HOURS = 168;
396
+ const DEFAULT_MIN_IDLE_HOURS = 2;
397
+ const DEFAULT_STALE_AFTER_DAYS = 30;
398
+ const DEFAULT_ARCHIVE_AFTER_DAYS = 90;
399
+ const DEFAULT_MEMORY_CHAR_LIMIT = 2200;
400
+ const DEFAULT_USER_CHAR_LIMIT = 1375;
401
+ /** Consolidation-failure backoff cap, shared by MemoryStore and memory-files' Config default. */
402
+ const DEFAULT_CONSOLIDATION_FAILURES = 3;
403
+ const DEFAULT_SKILL_CONTENT_CHARS = 1e5;
404
+ //#endregion
405
+ //#region lib/types/gates.js
406
+ /**
407
+ * The control-plane protection sets, held once and queried everywhere
408
+ * (decision B, rc.44 plan M2): the lifecycle engine, the scope view, the LLM
409
+ * nomination gate and the control-plane consolidate all answer "is this name
410
+ * off limits — and why" from the same instance, so the gate sets can never
411
+ * drift apart the way the three pre-rc.46 implementations did.
412
+ *
413
+ * Scope boundary: a GateSet covers NAME-SET protections only. Marker-based
414
+ * protections (pinned / bundled / hub-installed) are file markers resolved by
415
+ * `SkillLibrary.writeProtection` / `deleteProtection` — they depend on the
416
+ * filesystem and the write origin, not on a name list.
417
+ * @module @lmzhen/dsh-evolution-core
418
+ */
419
+ var EvolutionGateSet = class {
420
+ exclude;
421
+ referenced;
422
+ suppressed;
423
+ constructor(inputs = {}) {
424
+ this.exclude = inputs.exclude ?? /* @__PURE__ */ new Set();
425
+ this.referenced = inputs.referenced ?? /* @__PURE__ */ new Set();
426
+ this.suppressed = inputs.suppressed ?? /* @__PURE__ */ new Set();
427
+ }
428
+ /**
429
+ * The first protection blocking this name, or null. Any hit blocks — the
430
+ * order is diagnostic only, so a name in two sets reports the first.
431
+ */
432
+ blockReason(name) {
433
+ if (this.exclude.has(name)) return "excluded";
434
+ if (this.referenced.has(name)) return "referenced";
435
+ if (this.suppressed.has(name)) return "suppressed";
436
+ if (PROTECTED_BUILTIN_SKILLS.has(name)) return "protected-builtin";
437
+ return null;
438
+ }
439
+ isBlocked(name) {
440
+ return this.blockReason(name) !== null;
441
+ }
442
+ };
443
+ /** Build a GateSet from the curator-style config field names. */
444
+ function createGateSet(config) {
445
+ return new EvolutionGateSet({
446
+ exclude: config.excludeSkillNames,
447
+ referenced: config.referencedSkillNames,
448
+ suppressed: config.suppressedNames
449
+ });
450
+ }
158
451
  //#endregion
159
452
  //#region lib/types/curator.js
160
453
  /**
161
454
  * Deterministic skill curator: active → stale → archived transitions.
162
- * Pure function; file moves are performed by SkillLibrary.
455
+ * Pure function with one deliberate side effect: records in the passed
456
+ * `usage` map are MUTATED (state/archived_at) to carry the transition — the
457
+ * caller owns the map and decides whether to clone first (dry-run) or persist
458
+ * after. File moves are performed by SkillLibrary.
163
459
  */
164
- const PROTECTED_BUILTIN_SKILLS = new Set(["plan"]);
165
460
  function buildCuratorRunReport(input) {
166
461
  return {
462
+ schemaVersion: 1,
167
463
  runId: input.runId,
168
464
  startedAt: input.startedAt,
169
465
  finishedAt: input.finishedAt,
@@ -172,25 +468,144 @@ function buildCuratorRunReport(input) {
172
468
  archiveCandidates: [...input.archiveCandidates],
173
469
  archived: [...input.archived],
174
470
  failed: [...input.failed],
175
- ...input.snapshotPath === void 0 ? {} : { snapshotPath: input.snapshotPath }
471
+ ...input.snapshotPath === void 0 ? {} : { snapshotPath: input.snapshotPath },
472
+ ...input.llmReviewEnabled === void 0 ? {} : { llmReviewEnabled: input.llmReviewEnabled }
473
+ };
474
+ }
475
+ /**
476
+ * Render a curator run report as a compact human-readable markdown digest
477
+ * (G6): run metadata first, then the notable sections (archived / failed /
478
+ * stale candidates / LLM nominations).
479
+ */
480
+ function renderCuratorReportMarkdown(report) {
481
+ const lines = [
482
+ `# Curator run ${report.runId}`,
483
+ "",
484
+ `- **Started** ${report.startedAt}`,
485
+ `- **Finished** ${report.finishedAt}`,
486
+ `- **Stale candidates**: ${report.staleCandidates.length}`,
487
+ `- **LLM nominations**: ${report.llmNominations.length}`,
488
+ `- **Archived**: ${report.archived.length}`,
489
+ `- **Failed**: ${report.failed.length}`,
490
+ ...report.snapshotPath === void 0 ? [] : [`- **Snapshot**: ${report.snapshotPath}`],
491
+ ...report.llmReviewEnabled === void 0 ? [] : [`- **llmReview**: ${report.llmReviewEnabled}`]
492
+ ];
493
+ const section = (title, items) => items.length === 0 ? [] : [
494
+ "",
495
+ `## ${title}`,
496
+ "",
497
+ ...items.map((item) => `- ${item}`)
498
+ ];
499
+ return [
500
+ ...lines,
501
+ ...section("Archived", report.archived.map((item) => `${item.name} (${item.reason})`)),
502
+ ...section("Failed", report.failed.map((item) => `${item.name}: ${item.reason}`)),
503
+ ...section("Stale candidates", report.staleCandidates),
504
+ ...section("LLM nominations", report.llmNominations),
505
+ ""
506
+ ].join("\n");
507
+ }
508
+ const NOMINATION_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
509
+ /**
510
+ * Parse the curator LLM's YAML nomination block (consolidations + prunings).
511
+ * Line-oriented and lenient by design: the LLM output is advisory, every name
512
+ * is re-validated against the tree before any file move happens downstream.
513
+ */
514
+ function parseCuratorNominations(text) {
515
+ const prunings = [];
516
+ const consolidations = [];
517
+ let section = null;
518
+ let currentFrom = "";
519
+ for (const line of text.split("\n")) {
520
+ const consolidated = /^\s*-\s*from:\s*([a-z0-9][a-z0-9-]*)\s*$/.exec(line);
521
+ if (consolidated) {
522
+ section = "consolidations";
523
+ currentFrom = consolidated[1] ?? "";
524
+ continue;
525
+ }
526
+ const into = /^\s*into:\s*([a-z0-9][a-z0-9-]*)\s*$/.exec(line);
527
+ if (into) {
528
+ const intoName = into[1] ?? "";
529
+ if (section === "consolidations" && currentFrom !== "" && currentFrom !== intoName) consolidations.push({
530
+ from: currentFrom,
531
+ into: intoName
532
+ });
533
+ currentFrom = "";
534
+ continue;
535
+ }
536
+ const pruned = /^\s*-\s*name:\s*([a-z0-9][a-z0-9-]*)\s*$/.exec(line);
537
+ if (pruned) {
538
+ section = "prunings";
539
+ const name = pruned[1];
540
+ if (name) prunings.push(name);
541
+ }
542
+ }
543
+ const valid = (name) => NOMINATION_NAME_RE.test(name);
544
+ return {
545
+ prunings: prunings.filter(valid),
546
+ consolidations: consolidations.filter((item) => valid(item.from) && valid(item.into))
547
+ };
548
+ }
549
+ /**
550
+ * The lifecycle-candidate gate, shared by the transition engine and the scope
551
+ * view so the two can never disagree: records failing ANY of these gates are
552
+ * outside the managed scope.
553
+ */
554
+ function lifecycleCandidate(name, record, config, bundled, gates = createGateSet(config)) {
555
+ if (record.pinned) return false;
556
+ if (gates.isBlocked(name)) return false;
557
+ if (!(record.created_by === "agent" || config.manageUnmanaged === true) && !(config.pruneBuiltins === true && bundled)) return false;
558
+ if (record.state === "archived") return false;
559
+ return true;
560
+ }
561
+ /**
562
+ * Read-only scope classification, derived from the SAME gate the transition
563
+ * engine uses (`lifecycleCandidate`), so the view always predicts what a
564
+ * curator pass may touch. `protectedNames` carries the marker info the usage
565
+ * records lack (bundled / hub-installed / pinned from `SkillLibrary.list()`).
566
+ */
567
+ function computeScopeView(usage, config, protectedNames, gates) {
568
+ const managed = [];
569
+ const watched = [];
570
+ const qualityWarned = [];
571
+ const exempted = [];
572
+ const protectedSet = /* @__PURE__ */ new Set();
573
+ const gateSet = gates ?? createGateSet(config);
574
+ for (const [name, record] of usage) {
575
+ if (gateSet.exclude.has(name) || gateSet.referenced.has(name)) {
576
+ exempted.push(name);
577
+ continue;
578
+ }
579
+ const bundled = config.bundledNames?.has(name) === true;
580
+ const suppressed = gateSet.suppressed.has(name);
581
+ if (record.pinned || bundled || suppressed || protectedNames?.has(name) === true) protectedSet.add(name);
582
+ if (lifecycleCandidate(name, record, config, bundled, gateSet)) {
583
+ managed.push(name);
584
+ if (record.state === "stale" || record.quality_warn === true) watched.push(name);
585
+ if (record.quality_warn === true) qualityWarned.push(name);
586
+ }
587
+ }
588
+ return {
589
+ managed: managed.sort(),
590
+ watched: watched.sort(),
591
+ qualityWarned: qualityWarned.sort(),
592
+ exempted: exempted.sort(),
593
+ protected: [...protectedSet].sort()
176
594
  };
177
595
  }
178
596
  function daysSince(iso, created, now) {
179
597
  return (now - new Date(iso ?? created).getTime()) / 864e5;
180
598
  }
181
- function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Date()) {
599
+ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Date(), gates) {
182
600
  const result = {
183
601
  transitions: [],
184
602
  archive: [],
185
603
  reactivate: [],
186
604
  markStale: []
187
605
  };
606
+ const gateSet = gates ?? createGateSet(config);
188
607
  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;
608
+ if (!lifecycleCandidate(name, record, config, config.bundledNames?.has(name) === true, gateSet)) continue;
194
609
  const age = daysSince(null, record.created_at, now.getTime());
195
610
  if (record.use_count === 0 && age < config.staleAfterDays) continue;
196
611
  const idle = daysSince(latestActivityAt(record), record.created_at, now.getTime());
@@ -242,6 +657,225 @@ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Da
242
657
  return result;
243
658
  }
244
659
  //#endregion
660
+ //#region lib/types/prompts.js
661
+ /**
662
+ * Review and curation prompts adapted from Hermes Agent
663
+ * `agent/background_review.py`, `agent/curator.py`, and
664
+ * `agent/learn_prompt.py`, with tool names translated to the DSH-native
665
+ * catalog (`memory`, `skill_manage`, `skill`, `bash`, `str_replace_editor`).
666
+ *
667
+ * Every prompt is pinned in a versioned bundle. Review workers verify the
668
+ * bundle digest before spending a model call, so a partially-patched
669
+ * deployment fails closed instead of silently running a truncated prompt.
670
+ */
671
+ /**
672
+ * Prompt bundle identity. Bump both id and version whenever a prompt's text
673
+ * changes semantically: the bundle digest is the fail-closed signal for
674
+ * review workers, so a stale id across deployments must be distinguishable.
675
+ */
676
+ const PROMPT_BUNDLE_ID = "dsh-evolution@3";
677
+ const PROMPT_BUNDLE_VERSION = 3;
678
+ const MEMORY_REVIEW_PROMPT = `[Auto-review — Memory]
679
+ Review the conversation above and consider saving to memory if appropriate.
680
+
681
+ Focus on:
682
+ 1. Has the user revealed things about themselves — persona, desires, preferences, or personal details worth remembering?
683
+ 2. Has the user expressed expectations about how you should behave, their work style, or ways they want you to operate?
684
+
685
+ If something stands out, save it using the memory tool.
686
+ If nothing is worth saving, just say "Nothing to save." and stop.`;
687
+ const SKILL_REVIEW_PROMPT = `[Auto-review — Skills]
688
+ Review the conversation above and update the skill library. Be ACTIVE — most sessions produce at least one skill update, even if small.
689
+
690
+ 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.
691
+
692
+ Signals that warrant action:
693
+ - The user corrected your style, tone, format, verbosity, workflow, or approach.
694
+ - A non-trivial technique, fix, workaround, or debugging path emerged.
695
+ - A loaded skill turned out wrong, missing, or outdated — patch it now.
696
+
697
+ Only update skills you loaded or read in THIS session; never touch skills you have not read.
698
+
699
+ Preference order:
700
+ 1. Patch a skill that was loaded or read this session.
701
+ 2. Patch an existing umbrella skill.
702
+ 3. Add references/, templates/, or scripts/ support under an existing skill.
703
+ 4. Create a new class-level umbrella skill only when nothing fits.
704
+
705
+ Protected skills (bundled/hub-installed) must not be edited. Pinned skills are read-only to the background review: the pinned write guard refuses background changes, so only the foreground may update or archive them.
706
+
707
+ Do NOT capture:
708
+ - Environment-dependent failures (missing binaries, unconfigured credentials).
709
+ - Negative claims about tools ("browser tools do not work").
710
+ - Transient errors that resolved during the session.
711
+ - One-off task narratives.
712
+
713
+ 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.
714
+
715
+ "Nothing to save." is a real option but should NOT be the default.`;
716
+ const COMBINED_REVIEW_PROMPT = `[Auto-review]
717
+ Review the conversation above and update two things.
718
+
719
+ **Memory**: who the user is. Save durable user preferences, personal details, and expectations with the memory tool.
720
+
721
+ **Skills**: how to do this class of task. Be ACTIVE. Only update skills you loaded or read in THIS session. Follow the same class-level umbrella policy, preference order, protected-skill rules, and do-not-capture list as a skill review.
722
+
723
+ 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.`;
724
+ const CURATOR_PROMPT = `You are the skill curator. Maintain a healthy, class-level skill library, not a flat pile of narrow one-session skills.
725
+
726
+ 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.
727
+
728
+ Right target shape: class-level skills with rich SKILL.md + references/, templates/, scripts/ support files for session-specific detail.
729
+
730
+ Hard rules:
731
+ 1. NEVER hard-delete a skill. Archive (moving to .archive/) is the maximum destructive action; archives are recoverable, deletion is not.
732
+ 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.
733
+ 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.
734
+ 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.
735
+ 5. Judge overlap on CONTENT, not on usage counters.
736
+ 6. Before archiving a merged skill, ensure its unique content was preserved in the umbrella.
737
+
738
+ How to work:
739
+ 1. Scan the candidate list. Identify PREFIX CLUSTERS — skills sharing a first word or domain keyword (expect 10-25 clusters).
740
+ 2. For each cluster with 2+ members, ask "what is the UMBRELLA CLASS these skills serve?" and consolidate:
741
+ a. MERGE INTO AN EXISTING UMBRELLA (patch a labeled section for each sibling's unique insight, then archive the siblings).
742
+ b. CREATE A NEW UMBRELLA SKILL.md covering the shared workflow with short labeled subsections, then archive the absorbed siblings.
743
+ c. DEMOTE session-specific detail to references/, templates/, or scripts/ under the umbrella.
744
+ 3. Keep the umbrella body tight and scannable: exact commands, verbatim paths, ~100-200 lines; never invent flags or APIs.
745
+
746
+ Produce a YAML summary with exactly this shape:
747
+ consolidations:
748
+ - from: <old-skill-name>
749
+ into: <umbrella-skill-name>
750
+ reason: <one short sentence>
751
+ prunings:
752
+ - name: <skill-name>
753
+ reason: <one short sentence>
754
+ Nominate a pruning only when archival is clearly safe (stale AND genuinely obsolete or fully absorbed elsewhere).`;
755
+ const CURATOR_DRY_RUN_BANNER = `═══════════════════════════════════════════════════════════════
756
+ DRY-RUN — REPORT ONLY. DO NOT MUTATE THE SKILL LIBRARY.
757
+ ═══════════════════════════════════════════════════════════════
758
+
759
+ This is a PREVIEW pass. Follow every instruction above EXCEPT:
760
+ • Do NOT call skill_manage with action=create, update, patch, delete, write_file, or remove_file.
761
+ • Do NOT move, copy, or rewrite any file under the skills tree.
762
+
763
+ 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.
764
+
765
+ If you accidentally take a mutating action, say so explicitly in the summary.`;
766
+ const COMPLETION_SKILL_REVIEW_PROMPT = `[Auto-review — Skills · task complete]
767
+ Your current task now appears complete. Before wrapping up, review the approach and update the skill library via skill_manage.
768
+
769
+ Follow the skills review policy: be ACTIVE, prefer class-level umbrellas, patch ONLY skills loaded or read 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.
770
+
771
+ Do NOT modify output files or re-run the task. If you are still mid-task, ignore this.`;
772
+ function reviewPrompt(kind) {
773
+ if (kind === "memory") return MEMORY_REVIEW_PROMPT;
774
+ if (kind === "skill") return SKILL_REVIEW_PROMPT;
775
+ return COMBINED_REVIEW_PROMPT;
776
+ }
777
+ function sha256(text) {
778
+ return createHash("sha256").update(text).digest("hex");
779
+ }
780
+ function createPromptBundle(prompts) {
781
+ const canonical = JSON.stringify({
782
+ id: PROMPT_BUNDLE_ID,
783
+ version: 3,
784
+ prompts: Object.fromEntries(Object.entries(prompts).sort())
785
+ });
786
+ return Object.freeze({
787
+ id: PROMPT_BUNDLE_ID,
788
+ version: 3,
789
+ prompts: Object.freeze({ ...prompts }),
790
+ sha256: sha256(canonical)
791
+ });
792
+ }
793
+ const PROMPT_BUNDLE = createPromptBundle({
794
+ memory: MEMORY_REVIEW_PROMPT,
795
+ skill: SKILL_REVIEW_PROMPT,
796
+ combined: COMBINED_REVIEW_PROMPT,
797
+ curator: CURATOR_PROMPT,
798
+ completion: COMPLETION_SKILL_REVIEW_PROMPT
799
+ });
800
+ function verifyPromptBundle(bundle = PROMPT_BUNDLE) {
801
+ if (bundle.id !== "dsh-evolution@3" || bundle.version !== 3) return false;
802
+ const canonical = JSON.stringify({
803
+ id: PROMPT_BUNDLE_ID,
804
+ version: 3,
805
+ prompts: Object.fromEntries(Object.entries(bundle.prompts).sort())
806
+ });
807
+ return bundle.sha256 === sha256(canonical);
808
+ }
809
+ const DSH_AUTHORING_STANDARDS = `Follow the Hermes skill-authoring standards, translated to DSH tools.
810
+
811
+ Frontmatter:
812
+ - name: lowercase-hyphenated, <=64 chars, no spaces.
813
+ - 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.
814
+ - version: 0.1.0
815
+ - author: always the literal value "Hermes". NEVER fill it from the environment, git config, or any identity you can probe.
816
+ - platforms: declare [macos], [linux], and/or [windows] only when the skill is genuinely OS-bound; omit for portable skills.
817
+ - metadata.hermes.tags: a few Capitalized, Relevant, Tags.
818
+ - metadata.hermes.related_skills: [a, b] — name sibling skills this one builds on or is referenced by (optional; feeds the quality references factor).
819
+
820
+ Body section order (omit only when empty):
821
+ 1. "# <Human Title>" then a 2-3 sentence intro: what it does, what it does NOT do, key dependency stance.
822
+ 2. "## When to Use" — concrete trigger phrases.
823
+ 3. "## Prerequisites" — exact env vars, install steps, credentials.
824
+ 4. "## How to Run" — canonical invocation framed through DSH tools.
825
+ 5. "## Quick Reference" — flat command/endpoint list.
826
+ 6. "## Procedure" — numbered steps with copy-paste-exact commands.
827
+ 7. "## Pitfalls" — known limits and rate limits.
828
+ 8. "## Verification" — one check proving the skill worked.
829
+
830
+ DSH-tool framing:
831
+ - Reference DSH tools by name in backticks: \`bash\`, \`str_replace_editor\`, \`write\`, \`skill\`, \`skill_manage\`, \`memory\`.
832
+ - Do not name wrapped shell utilities when a DSH tool already covers them.
833
+ - Larger scripts belong under \`scripts/\` (written with \`skill_manage write_file\`) and are referenced from SKILL.md by relative path.
834
+
835
+ Quality bar:
836
+ - Prefer verbatim flags, paths, and APIs from the source. Never invent them.
837
+ - Keep it tight: ~100 lines simple, ~200 complex.
838
+ - No router/index/hub skills that only point at other skills.
839
+ - References go in \`references/\`, templates in \`templates/\`.`;
840
+ //#endregion
841
+ //#region lib/types/learn-prompt.js
842
+ /**
843
+ * Open-ended `/evolution learn` prompt builder.
844
+ *
845
+ * `learn` is open-ended: the user can name anything they can describe — a
846
+ * directory of code, an API doc URL, a workflow they just walked the agent
847
+ * through, or pasted notes. The prompt instructs the live agent to gather the
848
+ * named sources with its existing tools, then author a single SKILL.md via
849
+ * `skill_manage` following `DSH_AUTHORING_STANDARDS`. There is no separate
850
+ * distillation engine and no model-tool footprint.
851
+ */
852
+ /**
853
+ * Build the agent prompt for an open-ended `/evolution learn` request.
854
+ *
855
+ * @param userRequest free-text the user gave after `/evolution learn`; an
856
+ * empty string falls back to "the workflow we just went through".
857
+ * @returns a complete instruction the agent runs as a normal turn.
858
+ */
859
+ function buildLearnPrompt(userRequest) {
860
+ return [
861
+ "[/learn] The user wants you to learn a reusable skill from the request below, and save it.",
862
+ "",
863
+ "THE REQUEST:",
864
+ userRequest.trim() || "the workflow we just went through in this conversation — review the steps taken and distill them into a reusable skill",
865
+ "",
866
+ "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.",
867
+ "",
868
+ "Do this:",
869
+ "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.",
870
+ "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.",
871
+ "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.",
872
+ "",
873
+ DSH_AUTHORING_STANDARDS,
874
+ "",
875
+ "When done, tell the user the skill name, its category, and a one-line summary of what it captured."
876
+ ].join("\n");
877
+ }
878
+ //#endregion
245
879
  //#region lib/types/threats.js
246
880
  /**
247
881
  * Threat scanning for agent-authored memory and skill content.
@@ -417,10 +1051,12 @@ const SCOPE_ORDER = {
417
1051
  context: 2,
418
1052
  strict: 3
419
1053
  };
1054
+ const NO_SCAN_OPTIONS = {};
420
1055
  /**
421
1056
  * Scan text at `scope`. Patterns are cumulative: `strict` includes all scopes.
1057
+ * `options.excludeLabels` removes matching patterns without changing `scope`.
422
1058
  */
423
- function scanThreats(text, scope = "strict", maxScanChars = 65536) {
1059
+ function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
424
1060
  const findings = [];
425
1061
  if (ZERO_WIDTH_CHARS.test(text)) findings.push({
426
1062
  label: "unicode_zero_width",
@@ -433,8 +1069,10 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536) {
433
1069
  scope
434
1070
  });
435
1071
  const normalized = text.normalize("NFKC").slice(0, maxScanChars);
1072
+ const excluded = new Set(options.excludeLabels ?? []);
436
1073
  for (const pattern of PATTERNS) {
437
1074
  if (SCOPE_ORDER[pattern.scope] > SCOPE_ORDER[scope]) continue;
1075
+ if (excluded.has(pattern.label)) continue;
438
1076
  if (pattern.regex.test(normalized)) findings.push({
439
1077
  label: pattern.label,
440
1078
  category: pattern.category,
@@ -444,24 +1082,24 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536) {
444
1082
  return findings;
445
1083
  }
446
1084
  /** 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);
1085
+ function evaluateThreat(text, scope = "strict", maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
1086
+ const findings = scanThreats(text, scope, maxScanChars, options);
449
1087
  return {
450
1088
  blocked: findings.length > 0,
451
1089
  findings
452
1090
  };
453
1091
  }
454
1092
  /** User-facing block message for memory writes. */
455
- function scanMemoryThreats(text, maxScanChars = 65536) {
456
- const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars);
1093
+ function scanMemoryThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
1094
+ const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars, options);
457
1095
  if (!blocked) return null;
458
1096
  const pattern = findings.find((f) => f.category !== "unicode_obfuscation");
459
1097
  if (pattern) return `Blocked by security scan (${pattern.label}). Rephrase without instruction-like language.`;
460
1098
  return "Blocked by security scan: invisible or potentially malicious Unicode detected.";
461
1099
  }
462
1100
  /** User-facing block message for skill content writes. */
463
- function scanContentThreats(text, maxScanChars = 65536) {
464
- const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars);
1101
+ function scanContentThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
1102
+ const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars, options);
465
1103
  if (!blocked) return null;
466
1104
  return `Blocked by security scan (${findings[0]?.label ?? "unknown"}). This content appears to contain potentially malicious instructions.`;
467
1105
  }
@@ -471,7 +1109,39 @@ function scanContentThreats(text, maxScanChars = 65536) {
471
1109
  * File-backed durable memory with Hermes-compatible semantics.
472
1110
  * Stores are MEMORY.md and USER.md under $DSH_HOME/memories (~/.dsh/memories).
473
1111
  */
474
- const ENTRY_DELIMITER = "\n§\n";
1112
+ /**
1113
+ * Read-guard factor: a memory file larger than this multiple of its target's
1114
+ * char limit is treated as externally corrupted and skipped instead of being
1115
+ * read whole (aligned with claw `tools/memory.ts` size guard, which uses the
1116
+ * same 10× bound around a file that should never exceed the store limit).
1117
+ */
1118
+ const READ_GUARD_FACTOR = 10;
1119
+ /**
1120
+ * Consolidation-failure backoff window (package-private, rc.42 audit P2-1):
1121
+ * only failures inside the window count toward `maxConsolidationFailures`.
1122
+ * The store cannot observe turn boundaries, so the model-facing "this turn"
1123
+ * phrasing is approximated with ten minutes — generous enough to cover one
1124
+ * turn's retry loop, short enough that a failure yesterday never makes today's
1125
+ * first refusal say "stop retrying".
1126
+ */
1127
+ const FAILURE_WINDOW_MS = 10 * 6e4;
1128
+ /**
1129
+ * Recoverable-error preview bounds (B-line G5, Hermes `_previews` parity):
1130
+ * failed replace/remove/batch calls echo the current entries so the model can
1131
+ * self-recover without re-reading the store. Bounded to five entries of eighty
1132
+ * characters each; package-private because it is an error-message shape, not a
1133
+ * behavior switch.
1134
+ */
1135
+ const ERROR_PREVIEW_ENTRIES = 5;
1136
+ const ERROR_PREVIEW_WIDTH = 80;
1137
+ function previewEntries(entries) {
1138
+ if (entries.length === 0) return "";
1139
+ const shown = entries.slice(0, ERROR_PREVIEW_ENTRIES).map((entry) => {
1140
+ return `- ${entry.length > ERROR_PREVIEW_WIDTH ? `${entry.slice(0, ERROR_PREVIEW_WIDTH)}…` : entry}`;
1141
+ });
1142
+ const more = entries.length > ERROR_PREVIEW_ENTRIES ? `\n (+${entries.length - ERROR_PREVIEW_ENTRIES} more)` : "";
1143
+ return `\n\nCurrent entries (preview):\n${shown.join("\n")}${more}`;
1144
+ }
475
1145
  function memoryRoot(env = process.env) {
476
1146
  return join(env.DSH_HOME ?? join(homedir(), ".dsh"), "memories");
477
1147
  }
@@ -495,6 +1165,7 @@ var MemoryStore = class {
495
1165
  maxFailures;
496
1166
  io;
497
1167
  failureCount = 0;
1168
+ lastFailureAt = 0;
498
1169
  constructor(options = {}) {
499
1170
  this.io = options.io ?? nodeEvolutionIo();
500
1171
  this.memoryLimit = options.memoryCharLimit ?? 2200;
@@ -506,7 +1177,24 @@ var MemoryStore = class {
506
1177
  limitFor(target) {
507
1178
  return target === "memory" ? this.memoryLimit : this.userLimit;
508
1179
  }
1180
+ /**
1181
+ * Read-guard probe: `{ size, limit }` when the on-disk file exceeds
1182
+ * `limit * READ_GUARD_FACTOR` bytes, `null` when it is absent, unknown
1183
+ * (backend without a size probe), under the bound, or the target has no
1184
+ * limit configured.
1185
+ */
1186
+ async oversizedFile(target) {
1187
+ const size = await this.io.size?.(fileFor(this.root, target));
1188
+ if (size === null || size === void 0) return null;
1189
+ const limit = this.limitFor(target);
1190
+ if (limit <= 0) return null;
1191
+ return size > limit * READ_GUARD_FACTOR ? {
1192
+ size,
1193
+ limit
1194
+ } : null;
1195
+ }
509
1196
  async read(target) {
1197
+ if (await this.oversizedFile(target)) return [];
510
1198
  const raw = await this.io.readText(fileFor(this.root, target));
511
1199
  return raw === null ? [] : [...new Set(normalizeEntries(raw))];
512
1200
  }
@@ -517,24 +1205,86 @@ var MemoryStore = class {
517
1205
  this.failureCount = 0;
518
1206
  }
519
1207
  failure(target, message, entries) {
1208
+ if (Date.now() - this.lastFailureAt > FAILURE_WINDOW_MS) this.failureCount = 0;
1209
+ this.lastFailureAt = Date.now();
520
1210
  this.failureCount += 1;
521
1211
  const chars = entries.join(ENTRY_DELIMITER).length;
522
1212
  if (this.failureCount > this.maxFailures) return {
523
1213
  ok: false,
524
- message: `Memory consolidation failed ${this.failureCount} times this turn. Stop retrying memory calls and continue with the user's task.`,
1214
+ message: `Memory consolidation failed ${this.failureCount} times this turn. Stop retrying memory calls and continue with the user's task.${previewEntries(entries)}`,
525
1215
  entries,
526
1216
  chars,
527
1217
  limit: this.limitFor(target)
528
1218
  };
529
1219
  return {
530
1220
  ok: false,
531
- message,
1221
+ message: `${message}${previewEntries(entries)}`,
532
1222
  entries,
533
1223
  chars,
534
1224
  limit: this.limitFor(target)
535
1225
  };
536
1226
  }
1227
+ /**
1228
+ * StorageHint percentage must clamp at 100 like the render header: a drifted
1229
+ * entry can push chars past the limit, and "Storage at 125%" contradicts the
1230
+ * clamped usage indicator.
1231
+ */
1232
+ storageHint(target, chars) {
1233
+ const limit = this.limitFor(target);
1234
+ if (limit <= 0) return "";
1235
+ const percent = Math.min(100, Math.floor(chars * 100 / limit));
1236
+ return percent >= 80 ? ` ⚠️ Storage at ${percent}% (${chars}/${limit} chars).` : "";
1237
+ }
1238
+ /**
1239
+ * Best-effort raw-copy backup of the on-disk file to `<file>.bak.<stamp>`
1240
+ * before a refusal, so an externally modified (or oversized) file stays
1241
+ * recoverable. Copies bytes instead of reading them so a pathologically
1242
+ * large file is never loaded just to back it up. Failure to back up does
1243
+ * not change the refusal semantics.
1244
+ */
1245
+ async backupFile(target) {
1246
+ const path = fileFor(this.root, target);
1247
+ const unique = `${(/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 14)}-${Math.random().toString(36).slice(2, 8)}`;
1248
+ try {
1249
+ await this.io.copy(path, `${path}.bak.${unique}`);
1250
+ return `${path}.bak.${unique}`;
1251
+ } catch {
1252
+ return null;
1253
+ }
1254
+ }
1255
+ /**
1256
+ * Read-guard refusal for write paths. Returns the refusal result when the
1257
+ * target file is oversized, `null` otherwise. The file is skipped for
1258
+ * reading (never loaded), backed up by raw copy, and the model is told to
1259
+ * fix it manually — mirroring the drift refusal so corrupted state is never
1260
+ * silently overwritten.
1261
+ */
1262
+ async oversizedRefusal(target) {
1263
+ const oversized = await this.oversizedFile(target);
1264
+ if (!oversized) return null;
1265
+ const backup = await this.backupFile(target);
1266
+ const suffix = backup ? ` A backup was saved to ${basename(backup)}.` : "";
1267
+ return {
1268
+ ok: false,
1269
+ message: `Memory file is ${oversized.size} bytes (limit ${oversized.limit * READ_GUARD_FACTOR}) — skipping read.${suffix} Fix the file manually, then retry.`,
1270
+ entries: [],
1271
+ chars: 0,
1272
+ limit: this.limitFor(target)
1273
+ };
1274
+ }
537
1275
  async add(target, facts) {
1276
+ const refusal = await this.oversizedRefusal(target);
1277
+ if (refusal) return refusal;
1278
+ if (await this.detectDrift(target)) {
1279
+ const backup = await this.backupFile(target);
1280
+ return {
1281
+ ok: false,
1282
+ message: `External drift detected in memory file.${backup ? ` A backup was saved to ${basename(backup)}.` : ""} Resolve the drift before retrying.`,
1283
+ entries: [],
1284
+ chars: 0,
1285
+ limit: this.limitFor(target)
1286
+ };
1287
+ }
538
1288
  const content = facts.trim();
539
1289
  if (!content) return {
540
1290
  ok: false,
@@ -556,7 +1306,7 @@ var MemoryStore = class {
556
1306
  this.resetFailures();
557
1307
  return {
558
1308
  ok: true,
559
- message: "Entry already exists (no duplicate added).",
1309
+ message: `Entry already exists (no duplicate added).${this.storageHint(target, entries.join(ENTRY_DELIMITER).length)}`,
560
1310
  entries,
561
1311
  chars: entries.join(ENTRY_DELIMITER).length,
562
1312
  limit: this.limitFor(target)
@@ -564,12 +1314,13 @@ var MemoryStore = class {
564
1314
  }
565
1315
  const next = [...entries, this.addDatePrefix ? `## ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}\n${content}` : content];
566
1316
  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);
1317
+ const addLimit = this.limitFor(target);
1318
+ 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
1319
  await this.write(target, next);
569
1320
  this.resetFailures();
570
1321
  return {
571
1322
  ok: true,
572
- message: "Entry added.",
1323
+ message: `Entry added.${this.storageHint(target, total)}`,
573
1324
  entries: next,
574
1325
  chars: total,
575
1326
  limit: this.limitFor(target)
@@ -583,13 +1334,18 @@ var MemoryStore = class {
583
1334
  }
584
1335
  async mutate(target, oldText, action, facts) {
585
1336
  const needle = oldText.trim();
586
- if (!needle) return {
587
- ok: false,
588
- message: "old_text cannot be empty.",
589
- entries: [],
590
- chars: 0,
591
- limit: this.limitFor(target)
592
- };
1337
+ if (!needle) {
1338
+ const current = await this.read(target);
1339
+ return {
1340
+ ok: false,
1341
+ message: `old_text cannot be empty.${previewEntries(current)}`,
1342
+ entries: current,
1343
+ chars: current.join(ENTRY_DELIMITER).length,
1344
+ limit: this.limitFor(target)
1345
+ };
1346
+ }
1347
+ const refusal = await this.oversizedRefusal(target);
1348
+ if (refusal) return refusal;
593
1349
  const content = action === "replace" ? (facts ?? "").trim() : "";
594
1350
  if (action === "replace" && !content) return {
595
1351
  ok: false,
@@ -608,13 +1364,16 @@ var MemoryStore = class {
608
1364
  limit: this.limitFor(target)
609
1365
  };
610
1366
  }
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
- };
1367
+ if (await this.detectDrift(target)) {
1368
+ const backup = await this.backupFile(target);
1369
+ return {
1370
+ ok: false,
1371
+ message: `External drift detected in memory file.${backup ? ` A backup was saved to ${basename(backup)}.` : ""} Resolve the drift before retrying.`,
1372
+ entries: [],
1373
+ chars: 0,
1374
+ limit: this.limitFor(target)
1375
+ };
1376
+ }
618
1377
  const entries = await this.read(target);
619
1378
  const matches = entries.map((entry, index) => ({
620
1379
  entry,
@@ -633,12 +1392,13 @@ var MemoryStore = class {
633
1392
  if (action === "remove") next.splice(index, 1);
634
1393
  else next[index] = content;
635
1394
  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);
1395
+ const mutateLimit = this.limitFor(target);
1396
+ if (mutateLimit > 0 && total > mutateLimit) return this.failure(target, `Resulting memory would exceed the ${mutateLimit} char limit.`, entries);
637
1397
  await this.write(target, next);
638
1398
  this.resetFailures();
639
1399
  return {
640
1400
  ok: true,
641
- message: `Entry ${action === "remove" ? "removed" : "replaced"}.`,
1401
+ message: `Entry ${action === "remove" ? "removed" : "replaced"}.${this.storageHint(target, total)}`,
642
1402
  entries: next,
643
1403
  chars: total,
644
1404
  limit: this.limitFor(target)
@@ -652,13 +1412,18 @@ var MemoryStore = class {
652
1412
  chars: 0,
653
1413
  limit: this.limitFor(target)
654
1414
  };
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
- };
1415
+ const refusal = await this.oversizedRefusal(target);
1416
+ if (refusal) return refusal;
1417
+ if (await this.detectDrift(target)) {
1418
+ const backup = await this.backupFile(target);
1419
+ return {
1420
+ ok: false,
1421
+ message: `External drift detected in memory file.${backup ? ` A backup was saved to ${basename(backup)}.` : ""} Resolve the drift before retrying.`,
1422
+ entries: [],
1423
+ chars: 0,
1424
+ limit: this.limitFor(target)
1425
+ };
1426
+ }
662
1427
  const entries = await this.read(target);
663
1428
  const working = [...entries];
664
1429
  for (const [index, op] of operations.entries()) {
@@ -667,7 +1432,7 @@ var MemoryStore = class {
667
1432
  const body = (op.facts ?? "").trim();
668
1433
  if (!body) return {
669
1434
  ok: false,
670
- message: `Operation ${position} (add): facts is required. No operations were applied.`,
1435
+ message: `Operation ${position} (add): facts is required. No operations were applied.${previewEntries(entries)}`,
671
1436
  entries,
672
1437
  chars: entries.join(ENTRY_DELIMITER).length,
673
1438
  limit: this.limitFor(target)
@@ -686,7 +1451,7 @@ var MemoryStore = class {
686
1451
  const needle = (op.old_text ?? "").trim();
687
1452
  if (!needle) return {
688
1453
  ok: false,
689
- message: `Operation ${position} (${op.action}): old_text is required. No operations were applied.`,
1454
+ message: `Operation ${position} (${op.action}): old_text is required. No operations were applied.${previewEntries(entries)}`,
690
1455
  entries,
691
1456
  chars: entries.join(ENTRY_DELIMITER).length,
692
1457
  limit: this.limitFor(target)
@@ -698,7 +1463,7 @@ var MemoryStore = class {
698
1463
  if (matches.length === 0) return this.failure(target, `Operation ${position}: no entry matching "${needle}" found. No operations were applied.`, entries);
699
1464
  if (new Set(matches.map((m) => m.entry)).size > 1) return {
700
1465
  ok: false,
701
- message: `Operation ${position}: "${needle}" matched multiple distinct entries. No operations were applied.`,
1466
+ message: `Operation ${position}: "${needle}" matched multiple distinct entries. No operations were applied.${previewEntries(entries)}`,
702
1467
  entries,
703
1468
  chars: entries.join(ENTRY_DELIMITER).length,
704
1469
  limit: this.limitFor(target)
@@ -709,7 +1474,7 @@ var MemoryStore = class {
709
1474
  const body = (op.facts ?? "").trim();
710
1475
  if (!body) return {
711
1476
  ok: false,
712
- message: `Operation ${position} (replace): facts is required.`,
1477
+ message: `Operation ${position} (replace): facts is required.${previewEntries(entries)}`,
713
1478
  entries,
714
1479
  chars: entries.join(ENTRY_DELIMITER).length,
715
1480
  limit: this.limitFor(target)
@@ -726,12 +1491,13 @@ var MemoryStore = class {
726
1491
  }
727
1492
  }
728
1493
  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);
1494
+ const batchLimit = this.limitFor(target);
1495
+ 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
1496
  await this.write(target, working);
731
1497
  this.resetFailures();
732
1498
  return {
733
1499
  ok: true,
734
- message: `Applied ${operations.length} operation(s).`,
1500
+ message: `Applied ${operations.length} operation(s).${this.storageHint(target, total)}`,
735
1501
  entries: working,
736
1502
  chars: total,
737
1503
  limit: this.limitFor(target)
@@ -741,172 +1507,239 @@ var MemoryStore = class {
741
1507
  const memory = await this.read("memory");
742
1508
  const user = await this.read("user");
743
1509
  const parts = [];
744
- for (const [target, entries] of [["Memory", memory], ["User Profile", user]]) {
1510
+ for (const [target, label, entries] of [[
1511
+ "memory",
1512
+ "Memory",
1513
+ memory
1514
+ ], [
1515
+ "user",
1516
+ "User Profile",
1517
+ user
1518
+ ]]) {
1519
+ const oversized = entries.length === 0 ? await this.oversizedFile(target) : null;
1520
+ if (oversized) {
1521
+ parts.push(`## ${label} — file skipped: ${oversized.size} bytes (limit ${oversized.limit * READ_GUARD_FACTOR}); not read`);
1522
+ continue;
1523
+ }
745
1524
  const safe = entries.filter((entry) => !scanMemoryThreats(entry));
746
1525
  if (safe.length > 0) {
747
1526
  const body = safe.join(ENTRY_DELIMITER);
1527
+ const limit = this.limitFor(target);
1528
+ const pct = limit > 0 ? Math.min(100, Math.floor(body.length * 100 / limit)) : 0;
748
1529
  const note = safe.length === entries.length ? "" : ` (${entries.length - safe.length} threat-matched entries filtered)`;
749
- parts.push(`## ${target} (${safe.length} entries)${note}\n${body}`);
1530
+ parts.push(`## ${label} (${safe.length} entries) [${pct}% — ${body.length}/${limit} chars]${note}\n${body}`);
750
1531
  }
751
1532
  }
752
1533
  return parts.join("\n\n");
753
1534
  }
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
- }
765
- async detectDrift(target) {
766
- const raw = await this.io.readText(fileFor(this.root, target));
767
- if (raw === null) return false;
768
- return normalizeEntries(raw).join(ENTRY_DELIMITER) !== raw.trim();
769
- }
770
- };
771
- //#endregion
772
- //#region lib/types/prompts.js
773
- /**
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.
782
- */
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;
1535
+ /**
1536
+ * Detect on-disk drift: true when the file is not in the canonical
1537
+ * `render(normalizeEntries(raw))` form. This catches structural anomalies
1538
+ * the writer would quietly normalize away (empty/`§`-only entries, stray
1539
+ * blank lines, leading/trailing delimiters) that indicate the file was
1540
+ * edited outside MemoryStore. Purely single-canonical content reaches the
1541
+ * same serialization and returns false, so a normal write is never flagged.
1542
+ *
1543
+ * An absent, empty, or whitespace-only file is the "never written" state
1544
+ * (rc.42 audit P1-6): it parses to zero entries, so the canonical form
1545
+ * `'\n'` can never byte-match it and every write path was permanently
1546
+ * refused with "External drift detected" — including the repairs the model
1547
+ * would need to make. Such files are adopted instead of flagged.
1548
+ */
1549
+ async detectDrift(target) {
1550
+ if (await this.oversizedFile(target)) return true;
1551
+ const raw = await this.io.readText(fileFor(this.root, target));
1552
+ if (raw === null || raw.trim() === "") return false;
1553
+ const entries = normalizeEntries(raw);
1554
+ const limit = this.limitFor(target);
1555
+ if (limit > 0 && entries.some((entry) => entry.length > limit)) return true;
1556
+ return render(entries) !== raw;
1557
+ }
1558
+ };
1559
+ //#endregion
1560
+ //#region lib/types/mutations.js
1561
+ /**
1562
+ * Curator/author audit trail: `.mutations.json` records every skill mutation
1563
+ * with before/after content hashes so any automated edit is reviewable and
1564
+ * replayable. Best-effort persistence, mirroring the usage sidecar posture.
1565
+ * @module @lmzhen/dsh-evolution-core
1566
+ */
1567
+ const DEFAULT_MUTATION_CAP = 500;
1568
+ /** Version of the `.mutations.json` file shape; writers always emit the current one. */
1569
+ const MUTATIONS_FILE_VERSION = 1;
1570
+ function mutationsFile(root) {
1571
+ return join(root, ".mutations.json");
849
1572
  }
850
- function sha256(text) {
851
- return createHash("sha256").update(text).digest("hex");
1573
+ function contentHash(content) {
1574
+ return createHash("sha256").update(content).digest("hex");
852
1575
  }
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,
861
- version: 1,
862
- prompts: Object.freeze({ ...prompts }),
863
- sha256: sha256(canonical)
864
- });
1576
+ /**
1577
+ * Parse a raw mutations sidecar; malformed content reads as empty (auditing is
1578
+ * best-effort). Versioned shape ({ version, records }) with legacy
1579
+ * plain-array compat, plus a field-level guard for records without the
1580
+ * required identity/timestamp fields (rc.42 audit P2-3).
1581
+ */
1582
+ function parseMutationRecords(raw) {
1583
+ if (raw === null) return [];
1584
+ try {
1585
+ const parsed = JSON.parse(raw);
1586
+ return (Array.isArray(parsed) ? parsed : typeof parsed === "object" && parsed !== null && Array.isArray(parsed.records) ? parsed.records : []).filter((entry) => typeof entry === "object" && entry !== null && typeof entry.skillName === "string" && typeof entry.action === "string" && typeof entry.at === "string");
1587
+ } catch {
1588
+ return [];
1589
+ }
865
1590
  }
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())
1591
+ async function loadMutations(root, io = nodeEvolutionIo()) {
1592
+ return parseMutationRecords(await io.readText(mutationsFile(root)));
1593
+ }
1594
+ /** Append one record, trim to `cap`, and write atomically (versioned shape). */
1595
+ async function recordMutation(root, io, record, cap = 500) {
1596
+ await transactIo(io, mutationsFile(root), (current) => {
1597
+ const existing = parseMutationRecords(current);
1598
+ existing.push(record);
1599
+ const trimmed = existing.length > cap ? existing.slice(existing.length - cap) : existing;
1600
+ return Promise.resolve(JSON.stringify({
1601
+ version: 1,
1602
+ records: trimmed
1603
+ }, null, 2));
877
1604
  });
878
- return bundle.sha256 === sha256(canonical);
879
1605
  }
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/\`.`;
1606
+ //#endregion
1607
+ //#region lib/types/quality.js
1608
+ /**
1609
+ * Quality scoring and near-duplicate detection for the curated skill library.
1610
+ *
1611
+ * Pure functions over data inputs so the scoring policy is unit-testable and
1612
+ * the same math feeds the usage sidecar, the `skill_manage review` surface and
1613
+ * the learning graph. Weights follow the Hermes/hermes-claw six-factor model;
1614
+ * mutation maturity is a documented DSH approximation (single per-month patch
1615
+ * trend ratio replaces the claw timestamp-trend formula, since DSH usage
1616
+ * records only carry the last patched timestamp).
1617
+ * @module @lmzhen/dsh-evolution-core
1618
+ */
1619
+ const QUALITY_WEIGHTS = {
1620
+ usageFrequency: .25,
1621
+ stability: .2,
1622
+ recency: .2,
1623
+ references: .1,
1624
+ mutationMaturity: .2,
1625
+ richness: .05
1626
+ };
1627
+ /** Score below which a skill is flagged for review. */
1628
+ const LOW_QUALITY_THRESHOLD = .3;
1629
+ function clamp01(value) {
1630
+ return Math.max(0, Math.min(1, value));
1631
+ }
1632
+ function daysBetween(from, now) {
1633
+ return Math.max(0, (now.getTime() - new Date(from).getTime()) / 864e5);
1634
+ }
1635
+ function computeQualityScores(input) {
1636
+ const now = input.now ?? /* @__PURE__ */ new Date();
1637
+ const scores = /* @__PURE__ */ new Map();
1638
+ for (const [name, record] of input.usage) {
1639
+ const ageDays = Math.max(1, daysBetween(record.created_at, now));
1640
+ const idleDays = daysBetween(latestActivityAt(record) ?? record.created_at, now);
1641
+ const patchCount = record.patch_count;
1642
+ const useCount = record.use_count;
1643
+ const usageFrequency = clamp01(useCount / ageDays);
1644
+ const stability = useCount === 0 ? 1 : clamp01(1 - patchCount / useCount);
1645
+ const recency = idleDays < 30 ? 1 : clamp01(1 - (idleDays - 30) / 150);
1646
+ const references = clamp01((input.referenceCounts?.get(name) ?? 0) / 3);
1647
+ const mutationMaturity = patchCount === 0 ? .3 : patchCount === 1 ? .4 : clamp01((patchCount - 1) / Math.max(1, ageDays / 30));
1648
+ const richness = clamp01((input.supportDirs?.get(name) ?? 0) * .175);
1649
+ const factors = {
1650
+ usageFrequency,
1651
+ stability,
1652
+ recency,
1653
+ references,
1654
+ mutationMaturity,
1655
+ richness
1656
+ };
1657
+ 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;
1658
+ scores.set(name, {
1659
+ score,
1660
+ factors,
1661
+ warn: score < LOW_QUALITY_THRESHOLD
1662
+ });
1663
+ }
1664
+ return scores;
1665
+ }
1666
+ function normalize(content) {
1667
+ return content.toLowerCase().replace(/\s+/g, " ").trim();
1668
+ }
1669
+ function contentHash$1(content) {
1670
+ return createHash("sha256").update(normalize(content)).digest("hex");
1671
+ }
1672
+ function tokenize(content) {
1673
+ return new Set(normalize(content).split(/[^a-z0-9\u4e00-\u9fff]+/).filter(Boolean));
1674
+ }
1675
+ function jaccard(a, b) {
1676
+ if (a.size === 0 || b.size === 0) return 0;
1677
+ let intersection = 0;
1678
+ for (const token of a) if (b.has(token)) intersection += 1;
1679
+ return intersection / (a.size + b.size - intersection);
1680
+ }
1681
+ /**
1682
+ * Two-phase near-duplicate clustering: exact normalized-hash groups first,
1683
+ * then token-Jaccard edges at {@link DEDUP_SIMILARITY_THRESHOLD} with a token
1684
+ * ratio guard, union-find across the whole set.
1685
+ */
1686
+ function computeDedupGroups(input) {
1687
+ const threshold = input.threshold ?? .95;
1688
+ const names = [...input.contents.keys()];
1689
+ const hashes = /* @__PURE__ */ new Map();
1690
+ for (const name of names) {
1691
+ const hash = contentHash$1(input.contents.get(name) ?? "");
1692
+ const bucket = hashes.get(hash);
1693
+ if (bucket) bucket.push(name);
1694
+ else hashes.set(hash, [name]);
1695
+ }
1696
+ const parent = /* @__PURE__ */ new Map();
1697
+ const find = (x) => {
1698
+ const root = parent.get(x) ?? x;
1699
+ if (root !== x) parent.set(x, find(root));
1700
+ return parent.get(x) ?? x;
1701
+ };
1702
+ const union = (a, b) => {
1703
+ const [ra, rb] = [find(a), find(b)];
1704
+ if (ra !== rb) parent.set(rb, ra);
1705
+ };
1706
+ for (const [hash, bucketNames] of hashes) {
1707
+ const first = bucketNames[0];
1708
+ if (first === void 0 || bucketNames.length === 1) continue;
1709
+ for (let index = 1; index < bucketNames.length; index += 1) {
1710
+ const peer = bucketNames[index];
1711
+ if (peer) union(first, peer);
1712
+ }
1713
+ }
1714
+ const tokens = /* @__PURE__ */ new Map();
1715
+ const tokenSet = (name) => {
1716
+ let set = tokens.get(name);
1717
+ if (!set) {
1718
+ set = tokenize(input.contents.get(name) ?? "");
1719
+ tokens.set(name, set);
1720
+ }
1721
+ return set;
1722
+ };
1723
+ for (let index = 0; index < names.length; index += 1) {
1724
+ const a = names[index];
1725
+ if (a === void 0) continue;
1726
+ for (let other = index + 1; other < names.length; other += 1) {
1727
+ const b = names[other];
1728
+ if (b === void 0) continue;
1729
+ const [ta, tb] = [tokenSet(a), tokenSet(b)];
1730
+ if (Math.max(ta.size, tb.size) / Math.max(1, Math.min(ta.size, tb.size)) > 5) continue;
1731
+ if (jaccard(ta, tb) >= threshold) union(a, b);
1732
+ }
1733
+ }
1734
+ const groups = /* @__PURE__ */ new Map();
1735
+ for (const name of names) {
1736
+ const root = find(name);
1737
+ const group = groups.get(root);
1738
+ if (group) group.push(name);
1739
+ else groups.set(root, [name]);
1740
+ }
1741
+ return [...groups.values()].filter((group) => group.length > 1);
1742
+ }
910
1743
  //#endregion
911
1744
  //#region lib/types/signals.js
912
1745
  /**
@@ -994,26 +1827,41 @@ function foldTurn(session, fromSeq) {
994
1827
  * it created unless a `.hermes-managed` marker opts a skill in. Archival is a
995
1828
  * move to `.archive/` — never a hard delete.
996
1829
  */
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
1830
  const DEFAULT_SKILL_LIMITS = {
1003
1831
  maxNameLength: 64,
1004
1832
  maxDescriptionLength: MAX_DESCRIPTION_LENGTH,
1005
1833
  maxSkillContentChars: MAX_SKILL_CONTENT_CHARS,
1006
1834
  maxSkillFileBytes: MAX_SKILL_FILE_BYTES
1007
1835
  };
1008
- const SUPPORT_DIRS = [
1009
- "references",
1010
- "templates",
1011
- "scripts",
1012
- "assets"
1013
- ];
1836
+ /** Extra file name carried inside a snapshot's `extras/` directory. */
1837
+ const SNAPSHOT_EXTRA_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
1014
1838
  function skillsRoot(env = process.env) {
1015
1839
  return join(env.DSH_HOME ?? join(homedir(), ".dsh"), "skills");
1016
1840
  }
1841
+ /**
1842
+ * Map a requesting session onto the two origin surfaces (rc.44 plan M2-2.3):
1843
+ * the APPROVAL surface treats every delegated subagent as the autonomous
1844
+ * review channel, while the LIBRARY surface keeps the Hermes distinction -
1845
+ * the review fork is 'background_review' (the pinned guard blocks its
1846
+ * writes) and any other subagent is 'subagent' (agent-authored, not
1847
+ * review-channel). `isReview` marks the caller as the background review
1848
+ * pipeline itself. Single source: the two tools and the review executor all
1849
+ * read this table instead of re-deriving it.
1850
+ */
1851
+ function resolveOrigins(headerOrigin, isReview = false) {
1852
+ if (isReview) return {
1853
+ approval: "background_review",
1854
+ library: "background_review"
1855
+ };
1856
+ if (headerOrigin === "subagent") return {
1857
+ approval: "background_review",
1858
+ library: "subagent"
1859
+ };
1860
+ return {
1861
+ approval: "foreground",
1862
+ library: "foreground"
1863
+ };
1864
+ }
1017
1865
  function skillDir(root, name) {
1018
1866
  return join(root, name);
1019
1867
  }
@@ -1040,6 +1888,26 @@ function parseFrontmatter(content) {
1040
1888
  body
1041
1889
  };
1042
1890
  }
1891
+ /**
1892
+ * Skill names referenced by a SKILL.md's `related_skills` frontmatter
1893
+ * (B-line G3, rc.44): the single parsing source for the quality references
1894
+ * factor and the learning-graph edges. The DSH frontmatter parser keeps the
1895
+ * YAML value as a string (`"[a, b]"`), so names are scanned out of it; each
1896
+ * must satisfy the skill-name shape and the referencing skill itself is
1897
+ * excluded. Pure and deduplicated.
1898
+ */
1899
+ function relatedSkillNames(content, exclude) {
1900
+ const parsed = parseFrontmatter(content);
1901
+ if (!parsed) return [];
1902
+ const raw = parsed.frontmatter["related_skills"];
1903
+ if (typeof raw !== "string") return [];
1904
+ const names = /* @__PURE__ */ new Set();
1905
+ for (const match of Array.from(raw.matchAll(/[a-z0-9][a-z0-9-]*/g))) {
1906
+ const target = match[0];
1907
+ if (target && SKILL_NAME_RE.test(target) && target !== exclude) names.add(target);
1908
+ }
1909
+ return [...names];
1910
+ }
1043
1911
  function validateFrontmatter(content, expectedName, limits = DEFAULT_SKILL_LIMITS) {
1044
1912
  const parsed = parseFrontmatter(content);
1045
1913
  if (!parsed) return "SKILL.md must start and end with YAML frontmatter and include a body.";
@@ -1069,65 +1937,274 @@ function validateSupportPath(filePath) {
1069
1937
  if (parts.length < 2) return "Provide a file name, not just a directory.";
1070
1938
  return null;
1071
1939
  }
1940
+ /**
1941
+ * Index of `pattern` inside `content`, treating whitespace runs (spaces/tabs)
1942
+ * as flexible and literal escape sequences (`\n`, `\t`, `\r`) as their real
1943
+ * characters: a PATTERN whitespace run matches any content run of any length
1944
+ * (even empty), while extra whitespace that only exists in the content is not
1945
+ * skipped — the flexibility is one-sided on the pattern, and a backslash-
1946
+ * escaped char in the pattern matches the real char in the content
1947
+ * (model-copy drift). Returns the [start, end) range in the ORIGINAL content
1948
+ * so a patch can replace exactly the matched span and keep every other byte
1949
+ * intact. Returns null when no fuzzy match exists.
1950
+ */
1951
+ function fuzzyIndexOf(content, pattern, from = 0) {
1952
+ const isSpace = (char) => char !== void 0 && /[ \t]/.test(char);
1953
+ const escaped = (char) => {
1954
+ if (char === "n") return "\n";
1955
+ if (char === "t") return " ";
1956
+ if (char === "r") return "\r";
1957
+ return null;
1958
+ };
1959
+ for (let start = from; start < content.length; start += 1) {
1960
+ let contentIndex = start;
1961
+ let patternIndex = 0;
1962
+ while (patternIndex < pattern.length && contentIndex < content.length) {
1963
+ const patternChar = pattern[patternIndex];
1964
+ const contentChar = content[contentIndex];
1965
+ if (isSpace(patternChar)) {
1966
+ while (patternIndex < pattern.length && isSpace(pattern[patternIndex])) patternIndex += 1;
1967
+ while (contentIndex < content.length && isSpace(content[contentIndex])) contentIndex += 1;
1968
+ continue;
1969
+ }
1970
+ const escapedChar = patternChar === "\\" ? escaped(pattern[patternIndex + 1]) : null;
1971
+ if (escapedChar !== null && contentChar === escapedChar) {
1972
+ patternIndex += 2;
1973
+ contentIndex += 1;
1974
+ continue;
1975
+ }
1976
+ if (patternChar === contentChar) {
1977
+ contentIndex += 1;
1978
+ patternIndex += 1;
1979
+ continue;
1980
+ }
1981
+ break;
1982
+ }
1983
+ if (patternIndex === pattern.length) return [start, contentIndex];
1984
+ }
1985
+ return null;
1986
+ }
1987
+ /** Trim leading whitespace of the first line and trailing whitespace of the last line. */
1988
+ function trimPatternBoundaries(pattern) {
1989
+ const from = pattern.search(/\S/);
1990
+ const trimmed = from < 0 ? pattern : pattern.slice(from);
1991
+ const trailing = trimmed.search(/\s+$/);
1992
+ return trailing < 0 ? trimmed : trimmed.slice(0, trailing);
1993
+ }
1994
+ /** Replace only the fuzzy-matched span, preserving all surrounding bytes. */
1995
+ function fuzzyReplace(content, oldString, newString, replaceAll) {
1996
+ let current = content;
1997
+ let scanFrom = 0;
1998
+ for (;;) {
1999
+ const match = fuzzyIndexOf(current, oldString, scanFrom);
2000
+ if (match === null) return current;
2001
+ const [start, end] = match;
2002
+ const next = current.slice(0, start) + newString + current.slice(end);
2003
+ if (!replaceAll) return next;
2004
+ current = next;
2005
+ scanFrom = start + newString.length;
2006
+ }
2007
+ }
1072
2008
  function fuzzyPatch(content, oldString, newString, replaceAll = false) {
2009
+ if (oldString === "") return null;
1073
2010
  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);
2011
+ const boundary = trimPatternBoundaries(oldString);
2012
+ if (boundary === "") return null;
2013
+ if (boundary !== oldString) {
2014
+ if (fuzzyIndexOf(content, boundary) !== null) {
2015
+ const patched = fuzzyReplace(content, boundary, newString, replaceAll);
2016
+ return patched === content ? null : patched;
2017
+ }
2018
+ }
2019
+ if (fuzzyIndexOf(content, oldString) !== null) {
2020
+ const patched = fuzzyReplace(content, oldString, newString, replaceAll);
2021
+ return patched === content ? null : patched;
2022
+ }
1078
2023
  return null;
1079
2024
  }
1080
2025
  var SkillLibrary = class {
1081
2026
  root;
1082
2027
  limits;
1083
2028
  io;
1084
- constructor(root = skillsRoot(), io = nodeEvolutionIo(), limits = DEFAULT_SKILL_LIMITS) {
2029
+ onMutation;
2030
+ constructor(root = skillsRoot(), io = nodeEvolutionIo(), limits = DEFAULT_SKILL_LIMITS, onMutation) {
1085
2031
  this.root = root;
1086
2032
  this.io = io;
1087
2033
  this.limits = limits;
2034
+ this.onMutation = onMutation;
2035
+ }
2036
+ /** Notify the mutation observer after a successful write; observers must never fail the mutation. */
2037
+ notifyMutation(event) {
2038
+ try {
2039
+ this.onMutation?.(event);
2040
+ } catch {}
1088
2041
  }
1089
2042
  async list() {
1090
2043
  const summaries = [];
1091
2044
  for (const name of await listNames(this.root, this.io)) {
1092
- const dir = skillDir(this.root, name);
2045
+ const dir = this.dirOf(name);
1093
2046
  const md = await this.io.readText(join(dir, "SKILL.md"));
1094
2047
  if (!md) continue;
1095
2048
  const parsed = parseFrontmatter(md);
1096
- const protectedBy = await this.deleteProtection(name);
1097
- const managed = await this.io.exists(markerPath(dir, "hermes-managed"));
2049
+ let entries = [];
2050
+ try {
2051
+ entries = await this.io.list(dir);
2052
+ } catch {}
2053
+ const has = (marker) => entries.includes(marker);
2054
+ const protectedBy = has("bundled") ? "bundled" : has("hub-installed") ? "hub-installed" : has("pinned") ? "pinned" : null;
1098
2055
  summaries.push({
1099
2056
  name,
1100
2057
  description: parsed?.frontmatter.description ?? "",
1101
2058
  path: dir,
1102
2059
  protectedBy,
1103
- managed,
2060
+ managed: has("hermes-managed"),
1104
2061
  archived: false
1105
2062
  });
1106
2063
  }
1107
2064
  return summaries;
1108
2065
  }
1109
- async read(name) {
1110
- return this.io.readText(join(skillDir(this.root, name), "SKILL.md"));
2066
+ async read(rawName) {
2067
+ const name = rawName.trim();
2068
+ if (this.badName(name) !== null) return null;
2069
+ return this.io.readText(join(this.dirOf(name), "SKILL.md"));
2070
+ }
2071
+ /**
2072
+
2073
+ * Single path-building choke point (rc.42 audit P2-5): every directory path
2074
+
2075
+ * is built from the TRIMMED name, so a name that passes `badName` (which
2076
+
2077
+ * trims before validating) can never mint a second, whitespace-padded
2078
+
2079
+ * directory next to the real one. Callers keep passing raw user input.
2080
+
2081
+ */
2082
+ dirOf(name) {
2083
+ return skillDir(this.root, name.trim());
2084
+ }
2085
+ /** Name-format guard shared by every path-building mutator/reader. */
2086
+ badName(name) {
2087
+ const normalized = name.trim();
2088
+ 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}).`;
2089
+ return null;
1111
2090
  }
1112
- async writeProtection(name) {
1113
- const dir = skillDir(this.root, name);
2091
+ async writeProtection(rawName, origin = "foreground") {
2092
+ const name = rawName.trim();
2093
+ const dir = this.dirOf(name);
1114
2094
  for (const marker of ["bundled", "hub-installed"]) if (await this.io.exists(markerPath(dir, marker))) return marker;
2095
+ if (origin === "background_review" && await this.io.exists(markerPath(dir, "pinned"))) return "pinned";
1115
2096
  return null;
1116
2097
  }
1117
- async deleteProtection(name) {
1118
- const dir = skillDir(this.root, name);
1119
- for (const marker of [
2098
+ async deleteProtection(rawName, options = {}) {
2099
+ const name = rawName.trim();
2100
+ const dir = this.dirOf(name);
2101
+ const markers = options.allowBundled ? ["hub-installed", "pinned"] : [
1120
2102
  "bundled",
1121
2103
  "hub-installed",
1122
2104
  "pinned"
1123
- ]) if (await this.io.exists(markerPath(dir, marker))) return marker;
2105
+ ];
2106
+ for (const marker of markers) if (await this.io.exists(markerPath(dir, marker))) return marker;
1124
2107
  return null;
1125
2108
  }
1126
- async isManaged(name) {
1127
- const dir = skillDir(this.root, name);
2109
+ async isManaged(rawName) {
2110
+ const name = rawName.trim();
2111
+ const dir = this.dirOf(name);
1128
2112
  return await this.io.exists(markerPath(dir, "hermes-managed"));
1129
2113
  }
1130
- async create(name, content, origin) {
2114
+ /** Whether the skill carries the bundled marker (curator prune-builtins eligibility). */
2115
+ async isBundled(rawName) {
2116
+ const name = rawName.trim();
2117
+ if (this.badName(name) !== null) return false;
2118
+ const dir = this.dirOf(name);
2119
+ return await this.io.exists(markerPath(dir, "bundled"));
2120
+ }
2121
+ /** Whether the skill carries the pinned marker (the marker is the factual source; usage.pinned mirrors it). */
2122
+ async isPinned(rawName) {
2123
+ const name = rawName.trim();
2124
+ if (this.badName(name) !== null) return false;
2125
+ const dir = this.dirOf(name);
2126
+ return await this.io.exists(markerPath(dir, "pinned"));
2127
+ }
2128
+ /** Count non-empty support subdirectories (richness input for quality scoring). */
2129
+ async countSupportDirs(rawName) {
2130
+ const name = rawName.trim();
2131
+ if (this.badName(name) !== null) return 0;
2132
+ const dir = this.dirOf(name);
2133
+ let entries;
2134
+ try {
2135
+ entries = await this.io.list(dir);
2136
+ } catch {
2137
+ return 0;
2138
+ }
2139
+ let count = 0;
2140
+ for (const subdir of SUPPORT_DIRS) {
2141
+ if (!entries.includes(subdir)) continue;
2142
+ try {
2143
+ if ((await this.io.list(join(dir, subdir))).some((file) => file !== ".gitkeep")) count += 1;
2144
+ } catch {}
2145
+ }
2146
+ return count;
2147
+ }
2148
+ /** Best-effort audit trail entry; never blocks the mutation. */
2149
+ async audit(skillName, action, before, after, summary) {
2150
+ try {
2151
+ await recordMutation(this.root, this.io, {
2152
+ skillName,
2153
+ action,
2154
+ ...before === null ? {} : { beforeHash: contentHash(before) },
2155
+ ...after === null ? {} : { afterHash: contentHash(after) },
2156
+ summary,
2157
+ at: (/* @__PURE__ */ new Date()).toISOString()
2158
+ });
2159
+ } catch {}
2160
+ }
2161
+ /** Recent mutation audit records (read-only inspection surface). */
2162
+ async listMutations() {
2163
+ return await loadMutations(this.root, this.io);
2164
+ }
2165
+ /**
2166
+ * Pin or unpin a skill (`.pinned` marker). Pinned skills are protected from
2167
+ * deletion, from background-review writes, and from the lifecycle — a
2168
+ * protective mutation, so the autonomous pipeline may never call it. The
2169
+ * marker write is the only state change; content is untouched.
2170
+ */
2171
+ async setPinned(name, pinned, origin = "foreground") {
2172
+ const normalized = name.trim();
2173
+ if (!SKILL_NAME_RE.test(normalized) || normalized.length > this.limits.maxNameLength) return {
2174
+ ok: false,
2175
+ message: `Invalid skill name "${normalized}". Use lowercase letters, digits, and hyphens (<= ${this.limits.maxNameLength}).`
2176
+ };
2177
+ if (origin === "background_review") return {
2178
+ ok: false,
2179
+ message: "Only the foreground (user or the main agent) may pin or unpin skills."
2180
+ };
2181
+ const dir = this.dirOf(normalized);
2182
+ const marker = markerPath(dir, "pinned");
2183
+ const existing = await this.io.exists(marker);
2184
+ if (pinned && existing) return {
2185
+ ok: true,
2186
+ message: `Skill "${normalized}" is already pinned.`,
2187
+ path: dir
2188
+ };
2189
+ if (!pinned && !existing) return {
2190
+ ok: true,
2191
+ message: `Skill "${normalized}" is not pinned; nothing to do.`,
2192
+ path: dir
2193
+ };
2194
+ if (!await this.io.exists(join(dir, "SKILL.md"))) return {
2195
+ ok: false,
2196
+ message: `Skill "${normalized}" not found.`
2197
+ };
2198
+ if (pinned) await this.io.writeText(marker, "");
2199
+ else await this.io.remove(marker);
2200
+ await this.audit(normalized, pinned ? "pin" : "unpin", null, null, pinned ? "pinned" : "unpinned");
2201
+ return {
2202
+ ok: true,
2203
+ message: pinned ? `Skill "${normalized}" pinned: protected from deletion, background review, and the lifecycle.` : `Skill "${normalized}" unpinned.`,
2204
+ path: dir
2205
+ };
2206
+ }
2207
+ async create(name, content, origin = "foreground") {
1131
2208
  const normalized = name.trim();
1132
2209
  if (!SKILL_NAME_RE.test(normalized) || normalized.length > this.limits.maxNameLength) return {
1133
2210
  ok: false,
@@ -1143,26 +2220,39 @@ var SkillLibrary = class {
1143
2220
  ok: false,
1144
2221
  message: threat
1145
2222
  };
1146
- const dir = skillDir(this.root, normalized);
2223
+ const dir = this.dirOf(normalized);
1147
2224
  if (await this.io.exists(join(dir, "SKILL.md"))) return {
1148
2225
  ok: false,
1149
2226
  message: `Skill "${normalized}" already exists.`
1150
2227
  };
1151
2228
  await this.io.writeText(join(dir, "SKILL.md"), content.trimEnd() + "\n");
1152
- if (origin === "background_review") await this.io.writeText(markerPath(dir, "hermes-managed"), "");
2229
+ if (origin !== "foreground") await this.io.writeText(markerPath(dir, "hermes-managed"), "");
2230
+ await this.audit(normalized, "create", null, content, "created");
2231
+ this.notifyMutation({
2232
+ action: "create",
2233
+ name: normalized,
2234
+ filePath: dir
2235
+ });
1153
2236
  return {
1154
2237
  ok: true,
1155
2238
  message: `Skill "${normalized}" created.`,
1156
2239
  path: dir
1157
2240
  };
1158
2241
  }
1159
- async update(name, content) {
1160
- const dir = skillDir(this.root, name);
1161
- if (!await this.io.readText(join(dir, "SKILL.md"))) return {
2242
+ async update(rawName, content, origin = "foreground") {
2243
+ const name = rawName.trim();
2244
+ const badName = this.badName(name);
2245
+ if (badName) return {
2246
+ ok: false,
2247
+ message: badName
2248
+ };
2249
+ const dir = this.dirOf(name);
2250
+ const md = await this.io.readText(join(dir, "SKILL.md"));
2251
+ if (!md) return {
1162
2252
  ok: false,
1163
2253
  message: `Skill "${name}" not found.`
1164
2254
  };
1165
- const protection = await this.writeProtection(name);
2255
+ const protection = await this.writeProtection(name, origin);
1166
2256
  if (protection) return {
1167
2257
  ok: false,
1168
2258
  message: `Skill "${name}" is protected (${protection}).`
@@ -1178,20 +2268,32 @@ var SkillLibrary = class {
1178
2268
  message: threat
1179
2269
  };
1180
2270
  await this.io.writeText(join(dir, "SKILL.md"), content.trimEnd() + "\n");
2271
+ await this.audit(name, "update", md, content, "updated");
2272
+ this.notifyMutation({
2273
+ action: "update",
2274
+ name,
2275
+ filePath: dir
2276
+ });
1181
2277
  return {
1182
2278
  ok: true,
1183
2279
  message: `Skill "${name}" updated.`,
1184
2280
  path: dir
1185
2281
  };
1186
2282
  }
1187
- async patch(name, oldString, newString, filePath = "", replaceAll = false) {
1188
- const dir = skillDir(this.root, name);
2283
+ async patch(rawName, oldString, newString, filePath = "", replaceAll = false, origin = "foreground") {
2284
+ const name = rawName.trim();
2285
+ const badName = this.badName(name);
2286
+ if (badName) return {
2287
+ ok: false,
2288
+ message: badName
2289
+ };
2290
+ const dir = this.dirOf(name);
1189
2291
  const skillMd = join(dir, "SKILL.md");
1190
2292
  if (!await this.io.exists(skillMd)) return {
1191
2293
  ok: false,
1192
2294
  message: `Skill "${name}" not found.`
1193
2295
  };
1194
- const protection = await this.writeProtection(name);
2296
+ const protection = await this.writeProtection(name, origin);
1195
2297
  if (protection) return {
1196
2298
  ok: false,
1197
2299
  message: `Skill "${name}" is protected (${protection}).`
@@ -1213,7 +2315,7 @@ var SkillLibrary = class {
1213
2315
  message: `File not found: ${patchLabel}`
1214
2316
  };
1215
2317
  const patched = fuzzyPatch(md, oldString, newString, replaceAll);
1216
- if (!patched) return {
2318
+ if (patched === null) return {
1217
2319
  ok: false,
1218
2320
  message: `Could not find old_string in "${name}/${patchLabel}". Use update for a full rewrite.`
1219
2321
  };
@@ -1238,40 +2340,68 @@ var SkillLibrary = class {
1238
2340
  message: threat
1239
2341
  };
1240
2342
  await this.io.writeText(target, patched.trimEnd() + "\n");
2343
+ await this.audit(name, "patch", md, patched, `patched ${patchLabel}`);
2344
+ this.notifyMutation({
2345
+ action: "patch",
2346
+ name,
2347
+ filePath: dir
2348
+ });
1241
2349
  return {
1242
2350
  ok: true,
1243
2351
  message: `Skill "${name}" patched (${patchLabel}).`,
1244
2352
  path: dir
1245
2353
  };
1246
2354
  }
1247
- async archive(name, absorbedInto = "") {
1248
- const dir = skillDir(this.root, name);
1249
- if (!await this.io.readText(join(dir, "SKILL.md"))) return {
2355
+ async archive(rawName, options = {}) {
2356
+ const name = rawName.trim();
2357
+ const badName = this.badName(name);
2358
+ if (badName) return {
2359
+ ok: false,
2360
+ message: badName
2361
+ };
2362
+ const dir = this.dirOf(name);
2363
+ const md = await this.io.readText(join(dir, "SKILL.md"));
2364
+ if (!md) return {
1250
2365
  ok: false,
1251
2366
  message: `Skill "${name}" not found.`
1252
2367
  };
1253
- const protection = await this.deleteProtection(name);
2368
+ const protection = await this.deleteProtection(name, options.allowBundled === void 0 ? {} : { allowBundled: options.allowBundled });
1254
2369
  if (protection) return {
1255
2370
  ok: false,
1256
2371
  message: `Skill "${name}" is protected (${protection}).`
1257
2372
  };
1258
- if (absorbedInto) {
1259
- if (!await this.io.readText(join(skillDir(this.root, absorbedInto), "SKILL.md"))) return {
2373
+ if (options.absorbedInto) {
2374
+ if (!await this.io.readText(join(this.dirOf(options.absorbedInto), "SKILL.md"))) return {
1260
2375
  ok: false,
1261
- message: `absorbed_into="${absorbedInto}" does not exist.`
2376
+ message: `absorbed_into="${options.absorbedInto}" does not exist.`
1262
2377
  };
1263
2378
  }
1264
2379
  const archiveRoot = join(this.root, ".archive");
1265
- let dest = join(archiveRoot, name);
1266
- if (await this.io.exists(dest)) dest = join(archiveRoot, `${name}-${(/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 14)}`);
2380
+ let dest = join(archiveRoot, name.trim());
2381
+ if (await this.io.exists(dest)) {
2382
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 14);
2383
+ dest = join(archiveRoot, `${name.trim()}-${stamp}`);
2384
+ }
2385
+ if (this.io.isSymlink) {
2386
+ if (await this.io.isSymlink(dir) === true) return {
2387
+ ok: false,
2388
+ message: `Skill "${name}" is a symlink; refusing to archive it.`
2389
+ };
2390
+ }
1267
2391
  try {
1268
2392
  await this.io.rename(dir, dest);
1269
2393
  } catch {
1270
2394
  await this.io.copy(dir, dest);
1271
2395
  await this.io.remove(dir);
1272
2396
  }
1273
- const reason = absorbedInto ? `Consolidated into ${absorbedInto}` : "Archived by self-evolution curator";
2397
+ const reason = options.reason ?? (options.absorbedInto ? `Consolidated into ${options.absorbedInto}` : "Archived by self-evolution curator");
1274
2398
  await this.io.writeText(join(dest, ".archive-reason"), `${(/* @__PURE__ */ new Date()).toISOString()}: ${reason}\n`);
2399
+ await this.audit(name, "archive", md, null, reason);
2400
+ this.notifyMutation({
2401
+ action: "archive",
2402
+ name,
2403
+ archivedPath: dest
2404
+ });
1275
2405
  return {
1276
2406
  ok: true,
1277
2407
  message: `Skill "${name}" archived to .archive.`,
@@ -1283,26 +2413,27 @@ var SkillLibrary = class {
1283
2413
  * an absorbed-into marker. Hermes-style consolidation: overlapping skills
1284
2414
  * collapse into one, and the originals stay recoverable under `.archive/`.
1285
2415
  */
1286
- async consolidate(target, sources) {
1287
- const normalizedSources = [...new Set(sources)].filter((name) => name !== target);
2416
+ async consolidate(target, sources, origin = "foreground") {
2417
+ const targetName = target.trim();
2418
+ const normalizedSources = [...new Set(sources.map((name) => name.trim()))].filter((name) => name !== targetName);
1288
2419
  if (normalizedSources.length === 0) return {
1289
2420
  ok: false,
1290
2421
  message: "Consolidation requires at least one distinct source skill."
1291
2422
  };
1292
- for (const name of [target, ...normalizedSources]) if (!SKILL_NAME_RE.test(name)) return {
2423
+ for (const name of [targetName, ...normalizedSources]) if (!SKILL_NAME_RE.test(name)) return {
1293
2424
  ok: false,
1294
2425
  message: `Invalid skill name "${name}". Use lowercase letters, digits, and hyphens.`
1295
2426
  };
1296
- const targetDir = skillDir(this.root, target);
2427
+ const targetDir = this.dirOf(targetName);
1297
2428
  const targetMd = await this.io.readText(join(targetDir, "SKILL.md"));
1298
2429
  if (!targetMd) return {
1299
2430
  ok: false,
1300
- message: `Skill "${target}" not found.`
2431
+ message: `Skill "${targetName}" not found.`
1301
2432
  };
1302
- const targetProtection = await this.writeProtection(target);
2433
+ const targetProtection = await this.writeProtection(targetName, origin);
1303
2434
  if (targetProtection) return {
1304
2435
  ok: false,
1305
- message: `Skill "${target}" is protected (${targetProtection}).`
2436
+ message: `Skill "${targetName}" is protected (${targetProtection}).`
1306
2437
  };
1307
2438
  const parts = [];
1308
2439
  for (const source of normalizedSources) {
@@ -1311,7 +2442,7 @@ var SkillLibrary = class {
1311
2442
  ok: false,
1312
2443
  message: `Skill "${source}" is protected (${protection}).`
1313
2444
  };
1314
- const sourceMd = await this.io.readText(join(skillDir(this.root, source), "SKILL.md"));
2445
+ const sourceMd = await this.io.readText(join(this.dirOf(source), "SKILL.md"));
1315
2446
  if (!sourceMd) return {
1316
2447
  ok: false,
1317
2448
  message: `Skill "${source}" not found.`
@@ -1324,7 +2455,7 @@ var SkillLibrary = class {
1324
2455
  parts.push(`\n<!-- consolidated from ${source} at ${(/* @__PURE__ */ new Date()).toISOString()} -->\n${parsed.body.trim()}`);
1325
2456
  }
1326
2457
  const merged = targetMd.trimEnd() + parts.join("\n") + "\n";
1327
- const validation = validateFrontmatter(merged, target, this.limits);
2458
+ const validation = validateFrontmatter(merged, targetName, this.limits);
1328
2459
  if (validation) return {
1329
2460
  ok: false,
1330
2461
  message: `Consolidation rejected: ${validation}`
@@ -1334,14 +2465,30 @@ var SkillLibrary = class {
1334
2465
  ok: false,
1335
2466
  message: threat
1336
2467
  };
1337
- await this.io.writeText(join(targetDir, "SKILL.md"), merged);
1338
- for (const source of normalizedSources) {
1339
- const archived = await this.archive(source, target);
1340
- if (!archived.ok) return archived;
2468
+ const archived = [];
2469
+ try {
2470
+ for (const source of normalizedSources) {
2471
+ const result = await this.archive(source, { absorbedInto: targetName });
2472
+ if (!result.ok) throw new Error(result.message);
2473
+ archived.push(source);
2474
+ }
2475
+ await this.io.writeText(join(targetDir, "SKILL.md"), merged);
2476
+ } catch (error) {
2477
+ await this.io.writeText(join(targetDir, "SKILL.md"), targetMd).catch(() => {});
2478
+ for (const source of archived.reverse()) await this.restoreFromArchive(source).catch(() => {});
2479
+ return {
2480
+ ok: false,
2481
+ message: `Consolidation failed and was rolled back: ${error instanceof Error ? error.message : String(error)}`
2482
+ };
1341
2483
  }
2484
+ this.notifyMutation({
2485
+ action: "consolidate",
2486
+ name: targetName,
2487
+ filePath: targetDir
2488
+ });
1342
2489
  return {
1343
2490
  ok: true,
1344
- message: `Consolidated ${normalizedSources.join(", ")} into "${target}".`,
2491
+ message: `Consolidated ${normalizedSources.join(", ")} into "${targetName}".`,
1345
2492
  path: targetDir
1346
2493
  };
1347
2494
  }
@@ -1350,12 +2497,13 @@ var SkillLibrary = class {
1350
2497
  * recoverability: archival never deletes, and this is the control-plane
1351
2498
  * path back. The `.archive-reason` marker is dropped on restore.
1352
2499
  */
1353
- async restoreFromArchive(name) {
2500
+ async restoreFromArchive(rawName) {
2501
+ const name = rawName.trim();
1354
2502
  if (!SKILL_NAME_RE.test(name)) return {
1355
2503
  ok: false,
1356
2504
  message: `Invalid skill name "${name}". Use lowercase letters, digits, and hyphens.`
1357
2505
  };
1358
- if (await this.io.exists(join(skillDir(this.root, name), "SKILL.md"))) return {
2506
+ if (await this.io.exists(join(this.dirOf(name), "SKILL.md"))) return {
1359
2507
  ok: false,
1360
2508
  message: `Skill "${name}" already exists in the active root; refusing to overwrite.`
1361
2509
  };
@@ -1375,7 +2523,13 @@ var SkillLibrary = class {
1375
2523
  message: `Skill "${name}" is not in .archive.`
1376
2524
  };
1377
2525
  const source = join(archiveRoot, chosen);
1378
- const dest = skillDir(this.root, name);
2526
+ const dest = this.dirOf(name);
2527
+ if (this.io.isSymlink) {
2528
+ if (await this.io.isSymlink(source) === true) return {
2529
+ ok: false,
2530
+ message: `Archived entry "${chosen}" is a symlink; refusing to restore it.`
2531
+ };
2532
+ }
1379
2533
  try {
1380
2534
  await this.io.rename(source, dest);
1381
2535
  } catch {
@@ -1383,19 +2537,30 @@ var SkillLibrary = class {
1383
2537
  await this.io.remove(source);
1384
2538
  }
1385
2539
  if (await this.io.exists(join(dest, ".archive-reason"))) await this.io.remove(join(dest, ".archive-reason"));
2540
+ this.notifyMutation({
2541
+ action: "restore",
2542
+ name,
2543
+ filePath: dest
2544
+ });
1386
2545
  return {
1387
2546
  ok: true,
1388
2547
  message: `Skill "${name}" restored from .archive.`,
1389
2548
  path: dest
1390
2549
  };
1391
2550
  }
1392
- async writeSupportFile(name, filePath, content) {
1393
- const dir = skillDir(this.root, name);
2551
+ async writeSupportFile(rawName, filePath, content, origin = "foreground") {
2552
+ const name = rawName.trim();
2553
+ const badName = this.badName(name);
2554
+ if (badName) return {
2555
+ ok: false,
2556
+ message: badName
2557
+ };
2558
+ const dir = this.dirOf(name);
1394
2559
  if (!await this.io.exists(join(dir, "SKILL.md"))) return {
1395
2560
  ok: false,
1396
2561
  message: `Skill "${name}" not found.`
1397
2562
  };
1398
- const protection = await this.writeProtection(name);
2563
+ const protection = await this.writeProtection(name, origin);
1399
2564
  if (protection) return {
1400
2565
  ok: false,
1401
2566
  message: `Skill "${name}" is protected (${protection}).`
@@ -1415,20 +2580,33 @@ var SkillLibrary = class {
1415
2580
  message: threat
1416
2581
  };
1417
2582
  const target = join(dir, ...filePath.replace(/\\/g, "/").split("/").filter(Boolean));
2583
+ const existing = await this.io.readText(target).catch(() => null);
1418
2584
  await this.io.writeText(target, content);
2585
+ await this.audit(name, "write_file", existing, content, `wrote ${filePath}`);
2586
+ this.notifyMutation({
2587
+ action: "write_file",
2588
+ name,
2589
+ filePath: target
2590
+ });
1419
2591
  return {
1420
2592
  ok: true,
1421
2593
  message: `Support file "${filePath}" written to "${name}".`,
1422
2594
  path: target
1423
2595
  };
1424
2596
  }
1425
- async removeSupportFile(name, filePath) {
1426
- const dir = skillDir(this.root, name);
2597
+ async removeSupportFile(rawName, filePath, origin = "foreground") {
2598
+ const name = rawName.trim();
2599
+ const badName = this.badName(name);
2600
+ if (badName) return {
2601
+ ok: false,
2602
+ message: badName
2603
+ };
2604
+ const dir = this.dirOf(name);
1427
2605
  if (!await this.io.exists(join(dir, "SKILL.md"))) return {
1428
2606
  ok: false,
1429
2607
  message: `Skill "${name}" not found.`
1430
2608
  };
1431
- const protection = await this.writeProtection(name);
2609
+ const protection = await this.writeProtection(name, origin);
1432
2610
  if (protection) return {
1433
2611
  ok: false,
1434
2612
  message: `Skill "${name}" is protected (${protection}).`
@@ -1443,24 +2621,88 @@ var SkillLibrary = class {
1443
2621
  ok: false,
1444
2622
  message: `File "${filePath}" not found in skill "${name}".`
1445
2623
  };
2624
+ const before = await this.io.readText(target).catch(() => null);
1446
2625
  await this.io.remove(target);
2626
+ await this.audit(name, "remove_file", before, null, `removed ${filePath}`);
2627
+ this.notifyMutation({
2628
+ action: "remove_file",
2629
+ name,
2630
+ filePath: target
2631
+ });
1447
2632
  return {
1448
2633
  ok: true,
1449
2634
  message: `Support file "${filePath}" removed from "${name}".`,
1450
2635
  path: target
1451
2636
  };
1452
2637
  }
1453
- async snapshotAll(reason = "pre-mutation") {
1454
- const dest = join(join(this.root, ".backups"), `skills-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`);
2638
+ /**
2639
+ * Snapshot the recoverable skills state: active tree, usage/suppression
2640
+ * sidecars, `.archive/` and caller-supplied extras. `extras` are opaque
2641
+ * side files the Snapshot owner cares about (curator state); they are
2642
+ * listed in the manifest and only those names are ever read back.
2643
+ */
2644
+ async snapshotAll(reason = "pre-mutation", extras = []) {
2645
+ const backupRoot = join(this.root, ".backups");
2646
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
2647
+ let dest = join(backupRoot, `skills-${stamp}`);
2648
+ while (await this.io.exists(dest)) dest = join(backupRoot, `skills-${stamp}-${Math.random().toString(36).slice(2, 8)}`);
1455
2649
  const names = await listNames(this.root, this.io);
1456
- for (const name of names) await this.io.copy(skillDir(this.root, name), join(dest, name));
2650
+ await Promise.all(names.map(async (name) => {
2651
+ await this.io.copy(this.dirOf(name), join(dest, name));
2652
+ }));
2653
+ const sidecars = [];
2654
+ for (const sidecar of [usageFile(this.root), suppressedFile(this.root)]) if (await this.io.exists(sidecar)) {
2655
+ const name = basename(sidecar);
2656
+ await this.io.copy(sidecar, join(dest, name));
2657
+ sidecars.push(name);
2658
+ }
2659
+ const archiveRoot = join(this.root, ".archive");
2660
+ let hasArchive = false;
2661
+ if (await this.io.exists(archiveRoot)) {
2662
+ await this.io.copy(archiveRoot, join(dest, ".archive"));
2663
+ hasArchive = true;
2664
+ }
2665
+ const validExtras = extras.filter((extra) => SNAPSHOT_EXTRA_NAME_RE.test(extra.name));
2666
+ const extraNames = validExtras.map((extra) => extra.name);
2667
+ await Promise.all(validExtras.map(async (extra) => {
2668
+ await this.io.writeText(join(dest, "extras", extra.name), extra.content);
2669
+ }));
1457
2670
  await this.io.writeText(join(dest, "manifest.json"), JSON.stringify({
1458
2671
  reason,
1459
2672
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1460
- skills: names
2673
+ skills: names,
2674
+ sidecars,
2675
+ hasArchive,
2676
+ extras: extraNames
1461
2677
  }, null, 2));
2678
+ await this.retainSnapshots(5);
1462
2679
  return dest;
1463
2680
  }
2681
+ /** Read and normalize a snapshot manifest; null when the file is missing or unparsable. */
2682
+ async readSnapshotManifest(path) {
2683
+ const raw = await this.io.readText(join(path, "manifest.json"));
2684
+ if (raw === null) return null;
2685
+ try {
2686
+ const manifest = JSON.parse(raw);
2687
+ return {
2688
+ reason: typeof manifest.reason === "string" ? manifest.reason : "",
2689
+ createdAt: typeof manifest.createdAt === "string" ? manifest.createdAt : "",
2690
+ skills: Array.isArray(manifest.skills) ? manifest.skills : [],
2691
+ sidecars: Array.isArray(manifest.sidecars) ? manifest.sidecars : [],
2692
+ ...typeof manifest.hasArchive === "boolean" ? { hasArchive: manifest.hasArchive } : {},
2693
+ extras: Array.isArray(manifest.extras) ? manifest.extras : []
2694
+ };
2695
+ } catch {
2696
+ return null;
2697
+ }
2698
+ }
2699
+ /** Keep only the newest N snapshots (Hermes keep=5 parity); oldest folded into .backups history. */
2700
+ async retainSnapshots(keep) {
2701
+ const snapshots = await this.listSnapshots();
2702
+ for (const snapshot of snapshots.slice(keep)) try {
2703
+ await this.io.remove(snapshot.path);
2704
+ } catch {}
2705
+ }
1464
2706
  async listSnapshots() {
1465
2707
  const backupRoot = join(this.root, ".backups");
1466
2708
  let entries;
@@ -1472,36 +2714,82 @@ var SkillLibrary = class {
1472
2714
  const out = [];
1473
2715
  for (const name of entries.sort().reverse()) {
1474
2716
  if (!name.startsWith("skills-")) continue;
1475
- try {
1476
- const raw = await this.io.readText(join(backupRoot, name, "manifest.json"));
1477
- if (raw === null) continue;
1478
- const manifest = JSON.parse(raw);
1479
- out.push({
1480
- path: join(backupRoot, name),
1481
- createdAt: manifest.createdAt ?? "",
1482
- reason: manifest.reason ?? ""
1483
- });
1484
- } catch {}
2717
+ const manifest = await this.readSnapshotManifest(join(backupRoot, name));
2718
+ if (manifest === null) continue;
2719
+ out.push({
2720
+ path: join(backupRoot, name),
2721
+ createdAt: manifest.createdAt,
2722
+ reason: manifest.reason
2723
+ });
1485
2724
  }
1486
2725
  return out;
1487
2726
  }
1488
- async restoreLatestSnapshot() {
2727
+ /**
2728
+ * Read the extras of a snapshot, restricted to the names declared in the
2729
+ * manifest — an `extras/` directory is never listed directly, so unknown
2730
+ * files cannot leak back as state on the next restore.
2731
+ */
2732
+ async readSnapshotExtras(path) {
2733
+ const manifest = await this.readSnapshotManifest(path);
2734
+ if (manifest === null) return [];
2735
+ const extras = [];
2736
+ for (const name of manifest.extras) {
2737
+ if (!SNAPSHOT_EXTRA_NAME_RE.test(name)) continue;
2738
+ const content = await this.io.readText(join(path, "extras", name));
2739
+ if (content !== null) extras.push({
2740
+ name,
2741
+ content
2742
+ });
2743
+ }
2744
+ return extras;
2745
+ }
2746
+ /**
2747
+ * Manifest-driven restore of the latest snapshot: active tree, sidecars,
2748
+ * `.archive/` and (for full-state snapshots) the extras read back by the
2749
+ * caller. `extras` are additionally written into the pre-rollback safety
2750
+ * snapshot so the rollback itself is undoable with the same state.
2751
+ */
2752
+ async restoreLatestSnapshot(extras = []) {
1489
2753
  const latest = (await this.listSnapshots())[0];
1490
2754
  if (!latest) return {
1491
2755
  ok: false,
1492
2756
  message: "No skill snapshot available."
1493
2757
  };
1494
- await this.snapshotAll("pre-rollback");
1495
- for (const name of await listNames(this.root, this.io)) await this.io.remove(skillDir(this.root, name));
1496
- const entries = await this.io.list(latest.path);
1497
- for (const entry of entries) {
1498
- if (entry === "manifest.json") continue;
2758
+ await this.snapshotAll("pre-rollback", extras);
2759
+ let rootEntries;
2760
+ try {
2761
+ rootEntries = await this.io.list(this.root);
2762
+ } catch {
2763
+ rootEntries = [];
2764
+ }
2765
+ for (const entry of rootEntries) {
2766
+ if (entry.startsWith(".")) continue;
2767
+ await this.io.remove(join(this.root, entry));
2768
+ }
2769
+ const manifest = await this.readSnapshotManifest(latest.path);
2770
+ if (manifest === null) for (const entry of await this.io.list(latest.path)) {
2771
+ if (entry === "manifest.json" || entry === "extras") continue;
1499
2772
  await this.io.copy(join(latest.path, entry), join(this.root, entry));
1500
2773
  }
2774
+ else {
2775
+ for (const name of manifest.skills) await this.io.copy(join(latest.path, name), join(this.root, name));
2776
+ for (const sidecar of manifest.sidecars) await this.io.copy(join(latest.path, sidecar), join(this.root, sidecar));
2777
+ const archiveRoot = join(this.root, ".archive");
2778
+ if (manifest.hasArchive === true) {
2779
+ await this.io.remove(archiveRoot);
2780
+ await this.io.copy(join(latest.path, ".archive"), archiveRoot);
2781
+ } else if (manifest.hasArchive === false) await this.io.remove(archiveRoot);
2782
+ }
2783
+ const snapshotExtras = await this.readSnapshotExtras(latest.path);
2784
+ this.notifyMutation({
2785
+ action: "restore",
2786
+ name: "snapshot"
2787
+ });
1501
2788
  return {
1502
2789
  ok: true,
1503
2790
  message: `Restored skill tree from ${latest.path}`,
1504
- path: latest.path
2791
+ path: latest.path,
2792
+ ...snapshotExtras.length === 0 ? {} : { extras: snapshotExtras }
1505
2793
  };
1506
2794
  }
1507
2795
  };
@@ -1514,7 +2802,7 @@ var SkillLibrary = class {
1514
2802
  function evolutionHome(env = process.env) {
1515
2803
  return join(env.DSH_HOME ?? join(homedir(), ".dsh"), "evolution");
1516
2804
  }
1517
- var JsonState = class {
2805
+ var JsonState = class JsonState {
1518
2806
  initial;
1519
2807
  path;
1520
2808
  value;
@@ -1523,14 +2811,24 @@ var JsonState = class {
1523
2811
  this.path = join(evolutionHome(env), name);
1524
2812
  this.value = this.loadSync();
1525
2813
  }
2814
+ /**
2815
+ * Deep-merge persisted state over the initial defaults. Nested plain
2816
+ * objects merge recursively (so a new default field added under an existing
2817
+ * object is preserved), while arrays and primitives take the on-disk value
2818
+ * wholesale. Keeps forward-compatible defaults across schema additions.
2819
+ */
2820
+ static mergeDeep(initial, persisted) {
2821
+ const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2822
+ if (!isRecord(initial) || !isRecord(persisted)) return isRecord(persisted) ? persisted : persisted == null ? initial : persisted;
2823
+ const out = { ...initial };
2824
+ for (const [key, value] of Object.entries(persisted)) out[key] = key in initial ? JsonState.mergeDeep(initial[key], value) : value;
2825
+ return out;
2826
+ }
1526
2827
  loadSync() {
1527
2828
  try {
1528
2829
  const raw = readFileSync(this.path, "utf8");
1529
2830
  const parsed = JSON.parse(raw);
1530
- return {
1531
- ...this.initial,
1532
- ...parsed
1533
- };
2831
+ return JsonState.mergeDeep(this.initial, parsed);
1534
2832
  } catch {
1535
2833
  return { ...this.initial };
1536
2834
  }
@@ -1554,14 +2852,11 @@ var JsonState = class {
1554
2852
  async reload() {
1555
2853
  try {
1556
2854
  const raw = await readFile(this.path, "utf8");
1557
- this.value = {
1558
- ...this.initial,
1559
- ...JSON.parse(raw)
1560
- };
2855
+ this.value = JsonState.mergeDeep(this.initial, JSON.parse(raw));
1561
2856
  } catch {
1562
2857
  this.value = { ...this.initial };
1563
2858
  }
1564
2859
  }
1565
2860
  };
1566
2861
  //#endregion
1567
- 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 };
2862
+ export { COMBINED_REVIEW_PROMPT, COMPLETION_SKILL_REVIEW_PROMPT, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_CONSOLIDATION_FAILURES, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_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, EvolutionGateSet, 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, createGateSet, emptyRecord, evaluateThreat, evolutionHome, evolutionIoAdapter, foldTurn, getRecord, latestActivityAt, lifecycleCandidate, loadMutations, loadSuppressedNames, loadUsage, markAgentCreated, memoryRoot, mutateUsage, mutationsFile, nodeEvolutionIo, normalizeUsageRecord, observeEvent, parseCuratorNominations, parseFrontmatter, recordMutation, relatedSkillNames, renderCuratorReportMarkdown, resolveOrigins, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, suppressedFile, transactIo, updateSuppressedNames, usageFile, validateFrontmatter, verifyPromptBundle };