@lmzhen/dsh-evolution-core 0.1.0-rc.7 → 0.1.0-rc.71

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,16 +1,28 @@
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
- import { readFileSync } from "node:fs";
6
5
  //#region lib/types/io.js
7
6
  /**
8
- * Structural IO seam for the legacy facade stores.
7
+ * Structural IO seam for the evolution plugin family.
9
8
  *
10
- * The facade accepts any object exposing this small async file-tree surface.
11
- * Native DSH packages pass `ctx.evolutionIo.provider()`; standalone consumers
12
- * (and the facade's own tests) can use `nodeEvolutionIo`.
9
+ * Every evolution package passes `ctx.evolutionIo.provider()`; standalone
10
+ * consumers (and the core's own tests) can use `nodeEvolutionIo`.
13
11
  */
12
+ /**
13
+ * Run `task` inside `io.transact` when the backend provides it; otherwise fall
14
+ * back to a plain read → task → write/remove sequence (no cross-process lock —
15
+ * callers keep their single-process serialize chain as the second layer).
16
+ */
17
+ async function transactIo(io, path, task) {
18
+ if (io.transact) {
19
+ await io.transact(path, task);
20
+ return;
21
+ }
22
+ const next = await task(await io.readText(path));
23
+ if (next === null) await io.remove(path);
24
+ else await io.writeText(path, next);
25
+ }
14
26
  /** Lazy adapter over an IO provider registry, shared by every evolution consumer. */
15
27
  function evolutionIoAdapter(provider) {
16
28
  return {
@@ -20,23 +32,112 @@ function evolutionIoAdapter(provider) {
20
32
  list: (path) => provider().list(path),
21
33
  exists: (path) => provider().exists(path),
22
34
  rename: (path, destination) => provider().rename(path, destination),
23
- copy: (path, destination) => provider().copy(path, destination)
35
+ copy: (path, destination) => provider().copy(path, destination),
36
+ size: (path) => {
37
+ const io = provider();
38
+ return io.size ? io.size(path) : Promise.resolve(null);
39
+ },
40
+ transact: (path, task) => {
41
+ const io = provider();
42
+ return io.transact ? io.transact(path, task) : transactIo(io, path, task);
43
+ },
44
+ isSymlink: (path) => {
45
+ const io = provider();
46
+ return io.isSymlink ? io.isSymlink(path) : Promise.resolve(null);
47
+ }
24
48
  };
25
49
  }
26
50
  function nodeEvolutionIo() {
51
+ const isMissing = (error) => {
52
+ const code = error?.code;
53
+ return code === "ENOENT" || code === "ENOTDIR";
54
+ };
55
+ /** True when the pid is alive (EPERM = alive but unowned; ESRCH = gone). */
56
+ const isAlive = (pid) => {
57
+ try {
58
+ process.kill(pid, 0);
59
+ return true;
60
+ } catch (error) {
61
+ return error?.code === "EPERM";
62
+ }
63
+ };
64
+ /**
65
+ * Cross-process write lock (claw `withFileLock` parity): an O_EXCL lock file
66
+ * guards the atomic write. A >5s-old lock is taken over ONLY after probing
67
+ * the holder pid it carries (rc.66): a LIVE holder is never stolen, so a
68
+ * slow writer no longer loses its lock to a peer at the 5s mark (the
69
+ * takeover is the only best-effort surface; the retry budget fails loud —
70
+ * rc.65 — instead of ever proceeding unlocked). Budget = 40 * 50ms (~2s,
71
+ * rc.69): 8-writer contention bursts on a loaded CI runner exceed 10
72
+ * attempts (500ms), and a fail-loud throw was observed instead of a clean
73
+ * serialization.
74
+ */
75
+ const withWriteLock = async (path, task) => {
76
+ const lock = `${path}.lock`;
77
+ for (let attempt = 0; attempt < 40; attempt += 1) try {
78
+ await writeFile(lock, String(process.pid), { flag: "wx" });
79
+ try {
80
+ return await task();
81
+ } finally {
82
+ await rm(lock, { force: true }).catch(() => {});
83
+ }
84
+ } catch (error) {
85
+ const code = error?.code;
86
+ if (code !== "EEXIST" && code !== "EPERM") throw error;
87
+ try {
88
+ const st = await stat(lock);
89
+ if (Date.now() - st.mtimeMs > 5e3) {
90
+ const holder = Number(await readFile(lock, "utf8").catch(() => ""));
91
+ if (!(Number.isInteger(holder) && holder > 0 && isAlive(holder))) {
92
+ try {
93
+ await rm(lock, { force: true });
94
+ } catch {}
95
+ continue;
96
+ }
97
+ }
98
+ } catch {
99
+ continue;
100
+ }
101
+ await new Promise((resolve) => setTimeout(resolve, 50));
102
+ }
103
+ throw new Error(`could not acquire write lock for ${path} after 40 attempts`);
104
+ };
27
105
  return {
28
106
  async readText(path) {
29
107
  try {
30
108
  return await readFile(path, "utf8");
31
- } catch {
32
- return null;
109
+ } catch (error) {
110
+ if (isMissing(error)) return null;
111
+ throw error;
33
112
  }
34
113
  },
35
114
  async writeText(path, content) {
36
115
  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);
116
+ await withWriteLock(path, async () => {
117
+ const tmp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
118
+ await writeFile(tmp, content, "utf8");
119
+ await rename(tmp, path);
120
+ });
121
+ },
122
+ async transact(path, task) {
123
+ await mkdir(dirname(path), { recursive: true });
124
+ await withWriteLock(path, async () => {
125
+ let current;
126
+ try {
127
+ current = await readFile(path, "utf8");
128
+ } catch (error) {
129
+ if (isMissing(error)) current = null;
130
+ else throw error;
131
+ }
132
+ const next = await task(current);
133
+ if (next === null) {
134
+ await rm(path, { force: true });
135
+ return;
136
+ }
137
+ const tmp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
138
+ await writeFile(tmp, next, "utf8");
139
+ await rename(tmp, path);
140
+ });
40
141
  },
41
142
  async remove(path) {
42
143
  await rm(path, {
@@ -47,16 +148,18 @@ function nodeEvolutionIo() {
47
148
  async list(path) {
48
149
  try {
49
150
  return await readdir(path);
50
- } catch {
51
- return [];
151
+ } catch (error) {
152
+ if (isMissing(error)) return [];
153
+ throw error;
52
154
  }
53
155
  },
54
156
  async exists(path) {
55
157
  try {
56
158
  await stat(path);
57
159
  return true;
58
- } catch {
59
- return false;
160
+ } catch (error) {
161
+ if (isMissing(error)) return false;
162
+ throw error;
60
163
  }
61
164
  },
62
165
  async rename(path, destination) {
@@ -69,13 +172,24 @@ function nodeEvolutionIo() {
69
172
  recursive: true,
70
173
  force: true
71
174
  });
175
+ },
176
+ async size(path) {
177
+ try {
178
+ return (await stat(path)).size;
179
+ } catch (error) {
180
+ if (isMissing(error)) return null;
181
+ throw error;
182
+ }
183
+ },
184
+ async isSymlink(path) {
185
+ try {
186
+ return (await lstat(path)).isSymbolicLink();
187
+ } catch {
188
+ return null;
189
+ }
72
190
  }
73
191
  };
74
192
  }
75
- /** Absolute path helper kept separate so stores stay platform-correct. */
76
- function childPath(parent, ...parts) {
77
- return join(parent, ...parts);
78
- }
79
193
  //#endregion
80
194
  //#region lib/types/usage.js
81
195
  /**
@@ -100,22 +214,106 @@ function emptyRecord() {
100
214
  archived_at: null
101
215
  };
102
216
  }
103
- async function loadUsage(root, io = nodeEvolutionIo()) {
217
+ /** A timestamp passes only when `Date.parse` yields a finite epoch (N-3): a bare
218
+ * string check let garbage like "not-a-date" propagate as Invalid Date → NaN
219
+ * into quality math and lifecycle comparisons. */
220
+ const validTimestamp = (value) => typeof value === "string" && Number.isFinite(Date.parse(value));
221
+ const nullableTimestamp = (value) => value === null || validTimestamp(value);
222
+ /**
223
+ * Field-level normalization for one sidecar record (rc.42 audit P2-3): the
224
+ * spread used to copy any junk through verbatim, so a corrupted file could
225
+ * carry `use_count: "3"` into the quality math and lifecycle comparisons as
226
+ * NaN. Every field falls back to its `emptyRecord()` baseline unless it has
227
+ * exactly the declared type; an invalid `created_at` anchors the age clock at
228
+ * now (first-sight defer semantics for a record whose age is unknowable).
229
+ * Timestamps additionally require a parseable date (N-3): `"not-a-date"`
230
+ * would otherwise survive the type check as Invalid Date.
231
+ * Pure — exported for unit tests; `loadUsage` is the production caller.
232
+ */
233
+ function normalizeUsageRecord(record) {
234
+ const base = emptyRecord();
235
+ if (!record || typeof record !== "object" || Array.isArray(record)) return base;
236
+ const raw = record;
237
+ const num = (value, fallback) => typeof value === "number" && Number.isFinite(value) ? value : fallback;
238
+ const bool = (value, fallback) => typeof value === "boolean" ? value : fallback;
239
+ return {
240
+ created_by: typeof raw.created_by === "string" ? raw.created_by : null,
241
+ use_count: num(raw.use_count, base.use_count),
242
+ view_count: num(raw.view_count, base.view_count),
243
+ patch_count: num(raw.patch_count, base.patch_count),
244
+ last_used_at: nullableTimestamp(raw.last_used_at) ? raw.last_used_at : base.last_used_at,
245
+ last_viewed_at: nullableTimestamp(raw.last_viewed_at) ? raw.last_viewed_at : base.last_viewed_at,
246
+ last_patched_at: nullableTimestamp(raw.last_patched_at) ? raw.last_patched_at : base.last_patched_at,
247
+ created_at: validTimestamp(raw.created_at) ? raw.created_at : base.created_at,
248
+ state: raw.state === "stale" || raw.state === "archived" ? raw.state : "active",
249
+ pinned: bool(raw.pinned, base.pinned),
250
+ archived_at: nullableTimestamp(raw.archived_at) ? raw.archived_at : base.archived_at,
251
+ quality_score: typeof raw.quality_score === "number" && Number.isFinite(raw.quality_score) ? raw.quality_score : void 0,
252
+ quality_warn: typeof raw.quality_warn === "boolean" ? raw.quality_warn : void 0
253
+ };
254
+ }
255
+ /** Parse a raw usage sidecar; malformed content reads as empty (best-effort telemetry). */
256
+ function parseUsage(raw) {
104
257
  const map = /* @__PURE__ */ new Map();
105
- const raw = await io.readText(usageFile(root));
106
- if (raw !== null) try {
258
+ if (raw === null) return map;
259
+ try {
107
260
  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
- }
261
+ for (const [name, record] of Object.entries(parsed)) map.set(name, normalizeUsageRecord(record));
116
262
  } catch {}
117
263
  return map;
118
264
  }
265
+ async function loadUsage(root, io = nodeEvolutionIo()) {
266
+ return parseUsage(await io.readText(usageFile(root)));
267
+ }
268
+ /**
269
+ * Atomic read-modify-write on the usage sidecar (rc.50 P2-2): `task` receives
270
+ * the map parsed from the current on-disk state and may mutate it; the result
271
+ * is persisted inside the same transact so a second process sharing DSH_HOME
272
+ * cannot interleave its RMW and lose a counter update. Callers keep their own
273
+ * single-process serialize chain as the second layer.
274
+ */
275
+ async function mutateUsage(root, io, task) {
276
+ await transactIo(io, usageFile(root), async (current) => {
277
+ if (current !== null) try {
278
+ JSON.parse(current);
279
+ } catch {
280
+ return current;
281
+ }
282
+ const map = parseUsage(current);
283
+ await task(map);
284
+ return JSON.stringify(Object.fromEntries(map.entries()), null, 2);
285
+ });
286
+ }
287
+ /**
288
+ * Curator-owned usage fields (rc.67 K-2): the curator writes ONLY this set —
289
+ * lifecycle state, archive stamp, the six-factor quality pair, and the
290
+ * marker-mirrored pin flag. Counter and activity-stamp fields belong to the
291
+ * tool-telemetry side (skill-usage / tool-skill-manage), which bumps them
292
+ * through its own transact-backed RMW. A whole-record overwrite by either
293
+ * side would clobber the other side's concurrent increment, so cross-side
294
+ * folds copy this set only.
295
+ */
296
+ function applyCuratorFields(disk, curated) {
297
+ disk.state = curated.state;
298
+ disk.archived_at = curated.archived_at;
299
+ disk.quality_score = curated.quality_score;
300
+ disk.quality_warn = curated.quality_warn;
301
+ disk.pinned = curated.pinned;
302
+ }
303
+ /**
304
+ * Fold a curator run-start snapshot onto the current on-disk map (rc.67 K-2):
305
+ * each curated record is projected onto its disk peer by copying only the
306
+ * curator-owned fields, so a concurrent tool-side bump between snapshot and
307
+ * save survives. Records absent from the snapshot are left untouched; a
308
+ * curated record with no disk peer is seeded from the snapshot.
309
+ */
310
+ function foldCuratorFields(disk, curated) {
311
+ for (const [name, record] of curated) {
312
+ const diskRecord = disk.get(name);
313
+ if (diskRecord) applyCuratorFields(diskRecord, record);
314
+ else disk.set(name, { ...record });
315
+ }
316
+ }
119
317
  async function saveUsage(root, map, io = nodeEvolutionIo()) {
120
318
  const obj = Object.fromEntries(map.entries());
121
319
  await io.writeText(usageFile(root), JSON.stringify(obj, null, 2));
@@ -155,15 +353,174 @@ function latestActivityAt(record) {
155
353
  if (values.length === 0) return null;
156
354
  return values.sort().reverse()[0] ?? null;
157
355
  }
356
+ /**
357
+ * Curator suppression sidecar: built-in skills the curator has archived stay
358
+ * suppressed across re-seeds, so the lifecycle never fights a re-created
359
+ * bundled skill. Best-effort load/save, mirroring the usage sidecar posture.
360
+ * Versioned shape ({ version, names }) with legacy plain-array compat.
361
+ */
362
+ const SUPPRESSED_FILE_VERSION = 1;
363
+ function suppressedFile(root) {
364
+ return join(root, ".curator-suppressed.json");
365
+ }
366
+ async function loadSuppressedNames(root, io = nodeEvolutionIo()) {
367
+ return parseSuppressed(await io.readText(suppressedFile(root)));
368
+ }
369
+ function parseSuppressed(raw) {
370
+ if (raw === null) return /* @__PURE__ */ new Set();
371
+ try {
372
+ const parsed = JSON.parse(raw);
373
+ const names = Array.isArray(parsed) ? parsed : typeof parsed === "object" && parsed !== null && Array.isArray(parsed.names) ? parsed.names : [];
374
+ return new Set(names.filter((entry) => typeof entry === "string"));
375
+ } catch {
376
+ return /* @__PURE__ */ new Set();
377
+ }
378
+ }
379
+ async function saveSuppressedNames(root, names, io = nodeEvolutionIo()) {
380
+ await io.writeText(suppressedFile(root), JSON.stringify({
381
+ version: 1,
382
+ names: [...names].sort()
383
+ }, null, 2));
384
+ }
385
+ /**
386
+ * Atomic read-modify-write on the suppression sidecar (rc.50 P2-2): `task`
387
+ * receives the set parsed from the current on-disk state and may mutate it;
388
+ * the result is persisted inside the same transact so a second process
389
+ * sharing DSH_HOME cannot interleave its RMW. Best-effort posture unchanged.
390
+ */
391
+ async function updateSuppressedNames(root, io, task) {
392
+ await transactIo(io, suppressedFile(root), async (current) => {
393
+ if (current !== null) try {
394
+ JSON.parse(current);
395
+ } catch {
396
+ return current;
397
+ }
398
+ const names = parseSuppressed(current);
399
+ await task(names);
400
+ return JSON.stringify({
401
+ version: 1,
402
+ names: [...names].sort()
403
+ }, null, 2);
404
+ });
405
+ }
406
+ //#endregion
407
+ //#region lib/types/constants.js
408
+ /**
409
+ * Shared constants for the dsh-evolution plugin family.
410
+ *
411
+ * Two classes of value live here, deliberately separated by section so future
412
+ * edits do not blur the semantic boundary:
413
+ *
414
+ * 1. **Fixed protocol/format/security invariants** — changing these breaks an
415
+ * on-disk format, a naming/format contract, a path-security boundary, or a
416
+ * cross-component invariant. They are NOT exposed as deployment config.
417
+ *
418
+ * 2. **Cross-package shared tunable defaults** — the same semantic default is
419
+ * read (with a config override path) by more than one package (e.g.
420
+ * `evolution-policy` and `evolution-curator` both default `staleAfterDays`
421
+ * to 30). Centralizing them here means one authoritative default: a config
422
+ * override still applies per package, but the fallback is single-sourced.
423
+ *
424
+ * Package-private tunables (used by exactly one package) stay in that package,
425
+ * not here — see evolution-replay's `DEFAULT_WEIGHTS` and evolution-feedback's
426
+ * threshold, which are intentionally left where they are used.
427
+ * @module @lmzhen/dsh-evolution-core
428
+ */
429
+ /** Skill frontmatter `name` validated for the file name (lowercase + hyphen). */
430
+ const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
431
+ /** Allowed skill support-file subdirectories (path-traversal boundary). */
432
+ const SUPPORT_DIRS = [
433
+ "references",
434
+ "templates",
435
+ "scripts",
436
+ "assets"
437
+ ];
438
+ /** Delimiter between durable memory entries (on-disk storage format). */
439
+ const ENTRY_DELIMITER = "\n§\n";
440
+ /** Built-in skill names the curator must never lifecycle-manage. */
441
+ const PROTECTED_BUILTIN_SKILLS = new Set(["plan"]);
442
+ const MAX_SKILL_NAME_LENGTH = 64;
443
+ const MAX_DESCRIPTION_LENGTH = 1024;
444
+ const MAX_SKILL_CONTENT_CHARS = 1e5;
445
+ const MAX_SKILL_FILE_BYTES = 1048576;
446
+ const DEFAULT_REVIEW_MEMORY_INTERVAL = 10;
447
+ const DEFAULT_REVIEW_SKILL_INTERVAL = 10;
448
+ /** Skill-review completion channel trigger mode: 'cadence' | 'completion' | 'both'. */
449
+ const DEFAULT_SKILL_REVIEW_TRIGGER = "both";
450
+ /** Cumulative session tool calls before a session counts as "proven long" for the completion channel. */
451
+ const DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS = 20;
452
+ const DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS = 3;
453
+ const DEFAULT_SUBSTANTIVE_MIN_USER_CHARS = 200;
454
+ const DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS = 500;
455
+ const DEFAULT_MAX_OPS_PER_PLAN = 32;
456
+ const DEFAULT_CURATOR_INTERVAL_HOURS = 168;
457
+ const DEFAULT_MIN_IDLE_HOURS = 2;
458
+ const DEFAULT_STALE_AFTER_DAYS = 30;
459
+ const DEFAULT_ARCHIVE_AFTER_DAYS = 90;
460
+ const DEFAULT_MEMORY_CHAR_LIMIT = 2200;
461
+ const DEFAULT_USER_CHAR_LIMIT = 1375;
462
+ /** Consolidation-failure backoff cap, shared by MemoryStore and memory-files' Config default. */
463
+ const DEFAULT_CONSOLIDATION_FAILURES = 3;
464
+ const DEFAULT_SKILL_CONTENT_CHARS = 1e5;
465
+ //#endregion
466
+ //#region lib/types/gates.js
467
+ /**
468
+ * The control-plane protection sets, held once and queried everywhere
469
+ * (decision B, rc.44 plan M2): the lifecycle engine, the scope view, the LLM
470
+ * nomination gate and the control-plane consolidate all answer "is this name
471
+ * off limits — and why" from the same instance, so the gate sets can never
472
+ * drift apart the way the three pre-rc.46 implementations did.
473
+ *
474
+ * Scope boundary: a GateSet covers NAME-SET protections only. Marker-based
475
+ * protections (pinned / bundled / hub-installed) are file markers resolved by
476
+ * `SkillLibrary.writeProtection` / `deleteProtection` — they depend on the
477
+ * filesystem and the write origin, not on a name list.
478
+ * @module @lmzhen/dsh-evolution-core
479
+ */
480
+ var EvolutionGateSet = class {
481
+ exclude;
482
+ referenced;
483
+ suppressed;
484
+ constructor(inputs = {}) {
485
+ this.exclude = inputs.exclude ?? /* @__PURE__ */ new Set();
486
+ this.referenced = inputs.referenced ?? /* @__PURE__ */ new Set();
487
+ this.suppressed = inputs.suppressed ?? /* @__PURE__ */ new Set();
488
+ }
489
+ /**
490
+ * The first protection blocking this name, or null. Any hit blocks — the
491
+ * order is diagnostic only, so a name in two sets reports the first.
492
+ */
493
+ blockReason(name) {
494
+ if (this.exclude.has(name)) return "excluded";
495
+ if (this.referenced.has(name)) return "referenced";
496
+ if (this.suppressed.has(name)) return "suppressed";
497
+ if (PROTECTED_BUILTIN_SKILLS.has(name)) return "protected-builtin";
498
+ return null;
499
+ }
500
+ isBlocked(name) {
501
+ return this.blockReason(name) !== null;
502
+ }
503
+ };
504
+ /** Build a GateSet from the curator-style config field names. */
505
+ function createGateSet(config) {
506
+ return new EvolutionGateSet({
507
+ exclude: config.excludeSkillNames,
508
+ referenced: config.referencedSkillNames,
509
+ suppressed: config.suppressedNames
510
+ });
511
+ }
158
512
  //#endregion
159
513
  //#region lib/types/curator.js
160
514
  /**
161
515
  * Deterministic skill curator: active → stale → archived transitions.
162
- * Pure function; file moves are performed by SkillLibrary.
516
+ * Pure function with one deliberate side effect: records in the passed
517
+ * `usage` map are MUTATED (state/archived_at) to carry the transition — the
518
+ * caller owns the map and decides whether to clone first (dry-run) or persist
519
+ * after. File moves are performed by SkillLibrary.
163
520
  */
164
- const PROTECTED_BUILTIN_SKILLS = new Set(["plan"]);
165
521
  function buildCuratorRunReport(input) {
166
522
  return {
523
+ schemaVersion: 1,
167
524
  runId: input.runId,
168
525
  startedAt: input.startedAt,
169
526
  finishedAt: input.finishedAt,
@@ -172,25 +529,145 @@ function buildCuratorRunReport(input) {
172
529
  archiveCandidates: [...input.archiveCandidates],
173
530
  archived: [...input.archived],
174
531
  failed: [...input.failed],
175
- ...input.snapshotPath === void 0 ? {} : { snapshotPath: input.snapshotPath }
532
+ ...input.consolidated === void 0 ? {} : { consolidated: [...input.consolidated] },
533
+ ...input.snapshotPath === void 0 ? {} : { snapshotPath: input.snapshotPath },
534
+ ...input.llmReviewEnabled === void 0 ? {} : { llmReviewEnabled: input.llmReviewEnabled }
535
+ };
536
+ }
537
+ /**
538
+ * Render a curator run report as a compact human-readable markdown digest
539
+ * (G6): run metadata first, then the notable sections (archived / failed /
540
+ * stale candidates / LLM nominations).
541
+ */
542
+ function renderCuratorReportMarkdown(report) {
543
+ const lines = [
544
+ `# Curator run ${report.runId}`,
545
+ "",
546
+ `- **Started** ${report.startedAt}`,
547
+ `- **Finished** ${report.finishedAt}`,
548
+ `- **Stale candidates**: ${report.staleCandidates.length}`,
549
+ `- **LLM nominations**: ${report.llmNominations.length}`,
550
+ `- **Archived**: ${report.archived.length}`,
551
+ `- **Failed**: ${report.failed.length}`,
552
+ ...report.snapshotPath === void 0 ? [] : [`- **Snapshot**: ${report.snapshotPath}`],
553
+ ...report.llmReviewEnabled === void 0 ? [] : [`- **llmReview**: ${report.llmReviewEnabled}`]
554
+ ];
555
+ const section = (title, items) => items.length === 0 ? [] : [
556
+ "",
557
+ `## ${title}`,
558
+ "",
559
+ ...items.map((item) => `- ${item}`)
560
+ ];
561
+ return [
562
+ ...lines,
563
+ ...section("Archived", report.archived.map((item) => `${item.name} (${item.reason})`)),
564
+ ...section("Failed", report.failed.map((item) => `${item.name}: ${item.reason}`)),
565
+ ...section("Stale candidates", report.staleCandidates),
566
+ ...section("LLM nominations", report.llmNominations),
567
+ ""
568
+ ].join("\n");
569
+ }
570
+ const NOMINATION_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
571
+ /**
572
+ * Parse the curator LLM's YAML nomination block (consolidations + prunings).
573
+ * Line-oriented and lenient by design: the LLM output is advisory, every name
574
+ * is re-validated against the tree before any file move happens downstream.
575
+ */
576
+ function parseCuratorNominations(text) {
577
+ const prunings = [];
578
+ const consolidations = [];
579
+ let section = null;
580
+ let currentFrom = "";
581
+ for (const line of text.split("\n")) {
582
+ const consolidated = /^\s*-\s*from:\s*([a-z0-9][a-z0-9-]*)\s*$/.exec(line);
583
+ if (consolidated) {
584
+ section = "consolidations";
585
+ currentFrom = consolidated[1] ?? "";
586
+ continue;
587
+ }
588
+ const into = /^\s*into:\s*([a-z0-9][a-z0-9-]*)\s*$/.exec(line);
589
+ if (into) {
590
+ const intoName = into[1] ?? "";
591
+ if (section === "consolidations" && currentFrom !== "" && currentFrom !== intoName) consolidations.push({
592
+ from: currentFrom,
593
+ into: intoName
594
+ });
595
+ currentFrom = "";
596
+ continue;
597
+ }
598
+ const pruned = /^\s*-\s*name:\s*([a-z0-9][a-z0-9-]*)\s*$/.exec(line);
599
+ if (pruned) {
600
+ section = "prunings";
601
+ const name = pruned[1];
602
+ if (name) prunings.push(name);
603
+ }
604
+ }
605
+ const valid = (name) => NOMINATION_NAME_RE.test(name);
606
+ return {
607
+ prunings: prunings.filter(valid),
608
+ consolidations: consolidations.filter((item) => valid(item.from) && valid(item.into))
609
+ };
610
+ }
611
+ /**
612
+ * The lifecycle-candidate gate, shared by the transition engine and the scope
613
+ * view so the two can never disagree: records failing ANY of these gates are
614
+ * outside the managed scope.
615
+ */
616
+ function lifecycleCandidate(name, record, config, bundled, gates = createGateSet(config)) {
617
+ if (record.pinned) return false;
618
+ if (gates.isBlocked(name)) return false;
619
+ if (!(record.created_by === "agent" || config.manageUnmanaged === true) && !(config.pruneBuiltins === true && bundled)) return false;
620
+ if (record.state === "archived") return false;
621
+ return true;
622
+ }
623
+ /**
624
+ * Read-only scope classification, derived from the SAME gate the transition
625
+ * engine uses (`lifecycleCandidate`), so the view always predicts what a
626
+ * curator pass may touch. `protectedNames` carries the marker info the usage
627
+ * records lack (bundled / hub-installed / pinned from `SkillLibrary.list()`).
628
+ */
629
+ function computeScopeView(usage, config, protectedNames, gates) {
630
+ const managed = [];
631
+ const watched = [];
632
+ const qualityWarned = [];
633
+ const exempted = [];
634
+ const protectedSet = /* @__PURE__ */ new Set();
635
+ const gateSet = gates ?? createGateSet(config);
636
+ for (const [name, record] of usage) {
637
+ if (gateSet.exclude.has(name) || gateSet.referenced.has(name)) {
638
+ exempted.push(name);
639
+ continue;
640
+ }
641
+ const bundled = config.bundledNames?.has(name) === true;
642
+ const suppressed = gateSet.suppressed.has(name);
643
+ if (record.pinned || bundled || suppressed || protectedNames?.has(name) === true) protectedSet.add(name);
644
+ if (lifecycleCandidate(name, record, config, bundled, gateSet)) {
645
+ managed.push(name);
646
+ if (record.state === "stale" || record.quality_warn === true) watched.push(name);
647
+ if (record.quality_warn === true) qualityWarned.push(name);
648
+ }
649
+ }
650
+ return {
651
+ managed: managed.sort(),
652
+ watched: watched.sort(),
653
+ qualityWarned: qualityWarned.sort(),
654
+ exempted: exempted.sort(),
655
+ protected: [...protectedSet].sort()
176
656
  };
177
657
  }
178
658
  function daysSince(iso, created, now) {
179
659
  return (now - new Date(iso ?? created).getTime()) / 864e5;
180
660
  }
181
- function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Date()) {
661
+ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Date(), gates) {
182
662
  const result = {
183
663
  transitions: [],
184
664
  archive: [],
185
665
  reactivate: [],
186
666
  markStale: []
187
667
  };
668
+ const gateSet = gates ?? createGateSet(config);
188
669
  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;
670
+ if (!lifecycleCandidate(name, record, config, config.bundledNames?.has(name) === true, gateSet)) continue;
194
671
  const age = daysSince(null, record.created_at, now.getTime());
195
672
  if (record.use_count === 0 && age < config.staleAfterDays) continue;
196
673
  const idle = daysSince(latestActivityAt(record), record.created_at, now.getTime());
@@ -242,6 +719,504 @@ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Da
242
719
  return result;
243
720
  }
244
721
  //#endregion
722
+ //#region lib/types/evolution-events.js
723
+ /**
724
+ * Self-evolution event log (rc.68): an append-only sidecar under
725
+ * `$DSH_HOME/evolution/events.json` that is the single source of truth for
726
+ * the self-improvement loop. Feedback increments and learn actions share one
727
+ * ordered timeline (`seq` is the ordering key), so "feedback before/after a
728
+ * learn on target X" is answerable. The aggregate `feedback.json` is a
729
+ * rebuildable boot cache, never the truth.
730
+ *
731
+ * The malformed-refusal posture matches the whole sidecar family (rc.65): an
732
+ * append NEVER rewrites a corrupt log — the bytes stay untouched.
733
+ */
734
+ const EVENT_LOG_VERSION = 1;
735
+ /** Active-log split point (rc.71): when the active log reaches this many events
736
+ * the older half is rotated into an archive; the active stays bounded so a
737
+ * single append stays O(active) instead of O(total-history). Tunable default —
738
+ * callers may override per append (the tests use small values). */
739
+ const EVENT_LOG_ROTATE_AT = 4e3;
740
+ /** Number of archives retained (rc.71): older archives are pruned at rotation,
741
+ * mirroring retainReports. The horizon covers the loop-analysis window. */
742
+ const EVENT_LOG_RETAIN_ARCHIVES = 10;
743
+ /** Archive file prefix: `events-<lastArchivedSeq>.json`. The active file is
744
+ * `events.json` and never matches this glob. */
745
+ const EVENT_ARCHIVE_PREFIX = "events-";
746
+ function eventsFile(home) {
747
+ return join(home, "evolution", "events.json");
748
+ }
749
+ function isEventRecord(event) {
750
+ return typeof event === "object" && event !== null && typeof event.seq === "number";
751
+ }
752
+ /**
753
+ * Parse an event log body. A missing file, a whitespace-only file (rc.69:
754
+ * rebuildable, NOT malformed) or a corrupt one reads as empty; corrupt content
755
+ * is still refused on append, never overwritten.
756
+ *
757
+ * Per-entry normalization (rc.70 F-1): entries without a numeric `seq` are
758
+ * skipped here and dropped at the next append — valid entries survive, the
759
+ * damaged record is the only loss (self-heal semantics, matching the usage
760
+ * sidecar's per-field normalization on read).
761
+ */
762
+ function parseEvolutionEvents(raw) {
763
+ if (raw === null || raw.trim() === "") return [];
764
+ try {
765
+ const parsed = JSON.parse(raw);
766
+ if (!Array.isArray(parsed.events)) return [];
767
+ return parsed.events.filter(isEventRecord);
768
+ } catch {
769
+ return [];
770
+ }
771
+ }
772
+ /**
773
+ * Append one event under the write lock (rc.68): `seq` = current max + 1
774
+ * computed inside the transact, so two processes appending concurrently never
775
+ * collide. A malformed log is refused (bytes preserved) and the append fails.
776
+ * Returns the assigned seq.
777
+ *
778
+ * Rotation (rc.71, 007 design): when the active log reaches `rotateAt`, the
779
+ * older half is copied into an archive inside the SAME transact (the archive
780
+ * path has its own lock, so no recursion) and the active is replaced with the
781
+ * newer half + the new event. seqs stay globally monotonic; a crash between
782
+ * archive write and active write leaves both copies, which the timeline merge
783
+ * dedupes by seq. An archive-write failure aborts the append (active keeps the
784
+ * full old content — no loss) and the caller's best-effort handling applies.
785
+ */
786
+ async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE_AT) {
787
+ let assigned = 0;
788
+ await transactIo(io, path, async (current) => {
789
+ if (current !== null && current.trim() !== "") try {
790
+ JSON.parse(current);
791
+ } catch {
792
+ return current;
793
+ }
794
+ const nextEvents = await rotateIfDue(io, path, parseEvolutionEvents(current), rotateAt);
795
+ const maxSeq = nextEvents.reduce((max, entry) => Math.max(max, entry.seq), 0);
796
+ const record = {
797
+ ...event,
798
+ seq: maxSeq + 1,
799
+ at: (/* @__PURE__ */ new Date()).toISOString()
800
+ };
801
+ assigned = record.seq;
802
+ return JSON.stringify({
803
+ version: 1,
804
+ events: [...nextEvents, record]
805
+ }, null, 2);
806
+ });
807
+ if (assigned === 0) throw new Error(`evolution event log is malformed and was not touched: ${path}`);
808
+ return assigned;
809
+ }
810
+ /**
811
+ * Split the active log at its midpoint when due: the older half is written to
812
+ * `events-<lastArchivedSeq>.json` (await — a failed archive write aborts the
813
+ * append so the active is never truncated without its copy), old archives are
814
+ * pruned, and the newer half is returned as the next active body. No-op when
815
+ * under the threshold.
816
+ */
817
+ async function rotateIfDue(io, path, events, rotateAt) {
818
+ if (events.length < rotateAt) return events;
819
+ const mid = Math.ceil(events.length / 2);
820
+ const head = events.slice(0, mid);
821
+ const tail = events.slice(mid);
822
+ const anchor = tail[0]?.seq ?? events[events.length - 1]?.seq ?? 0;
823
+ const archivePath = join(dirname(path), `${EVENT_ARCHIVE_PREFIX}${anchor - 1}.json`);
824
+ await io.writeText(archivePath, JSON.stringify({
825
+ version: 1,
826
+ events: head
827
+ }, null, 2));
828
+ await retainEventArchives(io, path);
829
+ return tail;
830
+ }
831
+ /**
832
+ * Prune old event archives (rc.71): keep the newest `EVENT_LOG_RETAIN_ARCHIVES`.
833
+ * The name's numeric part is the last archived seq, so ordering is NUMERIC —
834
+ * lexicographic would rank `events-10` before `events-2`. Best-effort per
835
+ * removal; exported for the retention test.
836
+ */
837
+ async function retainEventArchives(io, path) {
838
+ const dir = dirname(path);
839
+ const names = (await io.list(dir)).filter((name) => name.startsWith("events-") && name.endsWith(".json"));
840
+ const archiveSeq = (name) => Number.parseInt(name.slice(7, name.length - 5), 10) || 0;
841
+ names.sort((a, b) => archiveSeq(a) - archiveSeq(b));
842
+ const excess = names.slice(0, Math.max(0, names.length - 10));
843
+ for (const name of excess) await io.remove(join(dir, name)).catch(() => {});
844
+ }
845
+ /** Read the event log; a missing/whitespace-only file reads as empty,
846
+ * corrupt content is flagged (and refused on append). */
847
+ async function readEvolutionEvents(io, path) {
848
+ const raw = await io.readText(path);
849
+ if (raw === null || raw.trim() === "") return {
850
+ events: [],
851
+ malformed: false
852
+ };
853
+ try {
854
+ const parsed = JSON.parse(raw);
855
+ if (!Array.isArray(parsed.events)) return {
856
+ events: [],
857
+ malformed: false
858
+ };
859
+ return {
860
+ events: parsed.events.filter(isEventRecord),
861
+ malformed: false
862
+ };
863
+ } catch {
864
+ return {
865
+ events: [],
866
+ malformed: true
867
+ };
868
+ }
869
+ }
870
+ /**
871
+ * Read the full timeline (rc.71): active log + all archives, merged by seq
872
+ * (active copy wins, duplicates only arise from the rotation crash window),
873
+ * sorted ascending. Per-file malformed flag as in `readEvolutionEvents`; a
874
+ * malformed ARCHIVE is skipped (never bricks the boot) and still flagged.
875
+ */
876
+ async function readEvolutionTimeline(io, path) {
877
+ const dir = dirname(path);
878
+ const names = (await io.list(dir)).filter((name) => name.startsWith("events-") && name.endsWith(".json")).sort();
879
+ let malformed = false;
880
+ const bySeq = /* @__PURE__ */ new Map();
881
+ for (const name of names) {
882
+ const read = await readEvolutionEvents(io, join(dir, name));
883
+ if (read.malformed) malformed = true;
884
+ for (const event of read.events) bySeq.set(event.seq, event);
885
+ }
886
+ const active = await readEvolutionEvents(io, path);
887
+ if (active.malformed) malformed = true;
888
+ for (const event of active.events) bySeq.set(event.seq, event);
889
+ return {
890
+ events: [...bySeq.values()].sort((a, b) => a.seq - b.seq),
891
+ malformed
892
+ };
893
+ }
894
+ //#endregion
895
+ //#region lib/types/prompts.js
896
+ /**
897
+ * Review and curation prompts adapted from Hermes Agent
898
+ * `agent/background_review.py`, `agent/curator.py`, and
899
+ * `agent/learn_prompt.py`, with tool names translated to the DSH-native
900
+ * catalog (`memory`, `skill_manage`, `skill`, `bash`, `str_replace_editor`).
901
+ *
902
+ * Alignment policy (2026-08-29): the OPERATIONAL steps and instructions the
903
+ * model follows mirror the Hermes originals structurally (signal list,
904
+ * preference order, support-file taxonomy, curator package integrity,
905
+ * consolidated/pruned reporting block). Tool and platform differences are
906
+ * DSH-adapted (native tool names, pinned-within-review semantics, this
907
+ * platform's index cap), and DSH-only additions are marked as such.
908
+ *
909
+ * Every prompt is pinned in a versioned bundle. Review workers verify the
910
+ * bundle digest before spending a model call, so a partially-patched
911
+ * deployment fails closed instead of silently running a truncated prompt.
912
+ */
913
+ /**
914
+ * Prompt bundle identity. Bump both id and version whenever a prompt's text
915
+ * changes semantically: the bundle digest is the fail-closed signal for
916
+ * review workers, so a stale id across deployments must be distinguishable.
917
+ */
918
+ const PROMPT_BUNDLE_ID = "dsh-evolution@7";
919
+ const PROMPT_BUNDLE_VERSION = 7;
920
+ const MEMORY_REVIEW_PROMPT = `[Auto-review — Memory]
921
+ Review the conversation above and consider saving to memory if appropriate.
922
+
923
+ Focus on:
924
+ 1. Has the user revealed things about themselves — persona, desires, preferences, or personal details worth remembering?
925
+ 2. Has the user expressed expectations about how you should behave, their work style, or ways they want you to operate?
926
+
927
+ If something stands out, save it using the memory tool.
928
+ If nothing is worth saving, just say "Nothing to save." and stop.`;
929
+ const SKILL_REVIEW_PROMPT = `[Auto-review — Skills]
930
+ Review the conversation above and update the skill library. Be ACTIVE — most sessions produce at least one skill update, even if small. A pass that does nothing is a missed learning opportunity, not a neutral outcome.
931
+
932
+ Target shape of the library: CLASS-LEVEL skills, each with a rich SKILL.md and a references/ directory for session-specific detail. Not a long flat list of narrow one-session-one-skill entries. This shapes HOW you update, not WHETHER you update.
933
+
934
+ Signals to look for (any one of these warrants action):
935
+ • User corrected your style, tone, format, legibility, or verbosity. Frustration signals like 'stop doing X', 'this is too verbose', 'don't format like this', 'why are you explaining', 'just give me the answer', 'you always do Y and I hate it', or an explicit 'remember this' are FIRST-CLASS skill signals, not just memory signals. Update the relevant skill(s) to embed the preference so the next session starts already knowing.
936
+ • User corrected your workflow, approach, or sequence of steps. Encode the correction as a pitfall or explicit step in the skill that governs that class of task.
937
+ • Non-trivial technique, fix, workaround, debugging path, or tool-usage pattern emerged that a future session would benefit from. Capture it.
938
+ • A skill that got loaded or consulted this session turned out to be wrong, missing a step, or outdated. Patch it NOW.
939
+
940
+ Read-before-write (enforced by this channel): update, patch, delete, or write support files ONLY into skills you loaded or read in THIS session — ops on unread skills are dropped; CREATE of a brand-new umbrella is the only exception.
941
+
942
+ Preference order — prefer the earliest action that fits, but do pick one when a signal above fired:
943
+ 1. UPDATE A CURRENTLY-LOADED SKILL. Look back through the conversation for skills the user loaded or you read. If any of them covers the territory of the new learning, PATCH that one first. It is the skill that was in play, so it's the right one to extend.
944
+ 2. UPDATE AN EXISTING UMBRELLA. If no loaded skill fits but an existing class-level skill does, patch it. Add a subsection, a pitfall, or broaden a trigger.
945
+ 3. ADD A SUPPORT FILE under an existing umbrella. Skills can be packaged with three kinds of support files — use the right directory per kind:
946
+ • references/<topic>.md — session-specific detail (error transcripts, reproduction recipes, provider quirks) AND condensed knowledge banks: quoted research, API docs, external authoritative excerpts, or domain notes you found while working on the problem. Write it concise and for the value of the task, not as a full mirror of upstream docs.
947
+ • templates/<name>.<ext> — starter files meant to be copied and modified (boilerplate configs, scaffolding, a known-good example the agent can reproduce with modifications).
948
+ • scripts/<name>.<ext> — statically re-runnable actions the skill can invoke directly (verification scripts, fixture generators, deterministic probes, anything the agent should run rather than hand-type each time).
949
+ Add support files via skill_manage action=write_file with file_path starting 'references/', 'templates/', or 'scripts/'. The umbrella's SKILL.md should gain a one-line pointer to any new support file so future agents know it exists.
950
+ 4. CREATE A NEW CLASS-LEVEL UMBRELLA SKILL when no existing skill covers the class. The name MUST be at the class level. The name MUST NOT be a specific PR number, error string, feature codename, library-alone name, or 'fix-X / debug-Y / audit-Z-today' session artifact. If the proposed name only makes sense for today's task, it's wrong — fall back to (1), (2), or (3).
951
+
952
+ User-preference embedding (important): when the user expressed a style/format/workflow preference, the update belongs in the SKILL.md body, not just in memory. Memory captures 'who the user is and what the current situation and state of your operations are'; skills capture 'how to do this class of task for this user'. When they complain about how you handled a task, the skill that governs that task needs to carry the lesson.
953
+
954
+ If you notice two existing skills that overlap, note it in your reply — the background curator handles consolidation at scale.
955
+
956
+ Two-tier deposition discipline (DSH addition, same spirit as the umbrella rule): before writing, classify the knowledge:
957
+ • PATTERN (reusable — symptom → mechanism → fix → verification, still valuable next session) belongs in the SKILL.md body.
958
+ • LOG (one-off — commit SHAs, npm/profile states, what this release changed, this session's process narrative) belongs in a references/ file, never the body. Body density IS reuse rate. Keep new entries tight: a pattern fits in 2-8 physical lines; prefer changing the current-state pointer over appending history.
959
+
960
+ Protected skills (DO NOT edit these):
961
+ • Bundled skills (shipped with the platform).
962
+ • Hub-installed skills (installed from a hub).
963
+ Pinned skills are read-only to THIS background review pass — the pinned write guard refuses background changes, so only the foreground may update or archive them. Foreground and delegated-subagent writes to pinned skills remain allowed.
964
+ If the only skills that need updating are protected, say 'Nothing to save.' and stop.
965
+
966
+ Do NOT capture (these become persistent self-imposed constraints that bite you later when the environment changes):
967
+ • Environment-dependent failures: missing binaries, fresh-install errors, post-migration path mismatches, 'command not found', unconfigured credentials, uninstalled packages. The user can fix these — they are not durable rules.
968
+ • Negative claims about tools or features ('browser tools do not work', 'X tool is broken', 'cannot use Y'). These harden into refusals the agent cites against itself for months after the actual problem was fixed.
969
+ • Session-specific transient errors that resolved before the conversation ended. If retrying worked, the lesson is the retry pattern, not the original failure.
970
+ • One-off task narratives. A user asking 'summarize today's market' or 'analyze this PR' is not a class of work that warrants a skill.
971
+
972
+ If a tool failed because of setup state, capture the FIX (install command, config step, env var to set) under an existing setup or troubleshooting skill — never 'this tool does not work' as a standalone constraint.
973
+
974
+ 'Nothing to save.' is a real option but should NOT be the default. If the session ran smoothly with no corrections and produced no new technique, just say 'Nothing to save.' and stop. Otherwise, act.`;
975
+ const COMBINED_REVIEW_PROMPT = `[Auto-review]
976
+ Review the conversation above and update two things:
977
+
978
+ **Memory**: who the user is. Did the user reveal persona, desires, preferences, personal details, or expectations about how you should behave? Save facts about the user and durable preferences with the memory tool.
979
+
980
+ **Skills**: how to do this class of task. Be ACTIVE — most sessions produce at least one skill update. A pass that does nothing is a missed learning opportunity, not a neutral outcome.
981
+
982
+ Target shape of the skill library: CLASS-LEVEL skills with a rich SKILL.md and a references/ directory for session-specific detail. Not a long flat list of narrow one-session-one-skill entries.
983
+
984
+ Signals that warrant a skill update (any one is enough):
985
+ • User corrected your style, tone, format, legibility, verbosity, or approach. Frustration is a FIRST-CLASS skill signal, not just a memory signal. 'stop doing X', 'don't format like this', 'I hate when you Y' — embed the lesson in the skill that governs that task so the next session starts fixed.
986
+ • Non-trivial technique, fix, workaround, or debugging path emerged.
987
+ • A skill that was loaded or consulted turned out wrong, missing, or outdated — patch it now.
988
+
989
+ Read-before-write (enforced by this channel): update, patch, delete, or write support files ONLY into skills you loaded or read in THIS session — ops on unread skills are dropped; CREATE of a brand-new umbrella is the only exception.
990
+
991
+ Preference order for skills — pick the earliest that fits:
992
+ 1. UPDATE A CURRENTLY-LOADED SKILL. Check what skills were loaded or read in the conversation. If one of them covers the learning, PATCH it first. It was in play; it's the right place.
993
+ 2. UPDATE AN EXISTING UMBRELLA. Patch it.
994
+ 3. ADD A SUPPORT FILE under an existing umbrella via skill_manage action=write_file. Three kinds: references/<topic>.md for session-specific detail OR condensed knowledge banks (quoted research, API docs excerpts, domain notes) written concise and task-focused; templates/<name>.<ext> for starter files meant to be copied and modified; scripts/<name>.<ext> for statically re-runnable actions (verification, fixture generators, probes). Add a one-line pointer in SKILL.md so future agents find them.
995
+ 4. CREATE A NEW CLASS-LEVEL UMBRELLA when nothing exists. Name at the class level — NOT a PR number, error string, codename, library-alone name, or 'fix-X / debug-Y' session artifact. If the name only fits today's task, fall back to (1), (2), or (3).
996
+
997
+ Two-tier deposition discipline (DSH addition): classify before writing — PATTERN (symptom → mechanism → fix → verification) goes in the SKILL.md body; LOG (commit SHAs, npm/profile states, this release's change list, this session's narrative) goes in a references/ file. Body density IS reuse rate; a pattern fits in 2-8 physical lines.
998
+
999
+ User-preference embedding: when the user complains about how you handled a task, update the skill that governs that task — memory alone isn't enough. Memory says 'who the user is and what the current situation and state of your operations are'; skills say 'how to do this class of task for this user'. Both should carry user-preference lessons when relevant.
1000
+
1001
+ If you notice overlapping existing skills, mention it — the background curator handles consolidation.
1002
+
1003
+ Protected skills (DO NOT edit these):
1004
+ • Bundled skills (shipped with the platform).
1005
+ • Hub-installed skills (installed from a hub).
1006
+ Pinned skills are read-only to THIS background review pass — the pinned write guard refuses background changes, so only the foreground may update or archive them. Foreground and delegated-subagent writes to pinned skills remain allowed.
1007
+ If the only skills that need updating are protected, say 'Nothing to save.' and stop.
1008
+
1009
+ Do NOT capture as skills (these become persistent self-imposed constraints that bite you later when the environment changes):
1010
+ • Environment-dependent failures: missing binaries, fresh-install errors, post-migration path mismatches, 'command not found', unconfigured credentials, uninstalled packages. The user can fix these — they are not durable rules.
1011
+ • Negative claims about tools or features ('browser tools do not work', 'X tool is broken', 'cannot use Y'). These harden into refusals the agent cites against itself for months after the actual problem was fixed.
1012
+ • Session-specific transient errors that resolved before the conversation ended. If retrying worked, the lesson is the retry pattern, not the original failure.
1013
+ • One-off task narratives. A user asking 'summarize today's market' or 'analyze this PR' is not a class of work that warrants a skill.
1014
+
1015
+ If a tool failed because of setup state, capture the FIX (install command, config step, env var to set) under an existing setup or troubleshooting skill — never 'this tool does not work' as a standalone constraint.
1016
+
1017
+ Act on whichever of the two dimensions 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.`;
1018
+ const CURATOR_PROMPT = `You are the skill curator. Maintain a healthy, class-level skill library, not a flat pile of narrow one-session skills.
1019
+
1020
+ This is an UMBRELLA-BUILDING consolidation pass, not a passive audit and not a duplicate-finder.
1021
+
1022
+ 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.
1023
+
1024
+ Right target shape: class-level skills with rich SKILL.md + references/, templates/, scripts/ support files for session-specific detail.
1025
+
1026
+ Hard rules:
1027
+ 1. NEVER hard-delete a skill. Archive (moving to .archive/) is the maximum destructive action; archives are recoverable, deletion is not.
1028
+ 2. Do not touch bundled, hub-installed, pinned, or scheduled-task-referenced (referenced) skills. Referenced skills are fully protected — never consolidated, never pruned (there is no scheduled-task reference-rewriting pass; a referenced skill stays in place by design).
1029
+ 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. Never archive a never-used skill unless it is at least 30 days old AND its content is genuinely obsolete or fully absorbed elsewhere.
1030
+ 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.
1031
+ 5. Judge overlap on CONTENT, not on usage counters.
1032
+ 6. Before archiving a merged skill, ensure its unique content was preserved in the umbrella.
1033
+
1034
+ How to work:
1035
+ 1. Scan the candidate list. Identify PREFIX CLUSTERS — skills sharing a first word or domain keyword. Expected cluster count scales with the library: a large collection may show 10-25 prefix clusters, a small one often has none — a clean "nothing to consolidate" summary is the correct small-library outcome, not a shortage of ambition.
1036
+ 2. For each cluster with 2+ members, ask "what is the UMBRELLA CLASS these skills serve?" and consolidate:
1037
+ a. MERGE INTO AN EXISTING UMBRELLA (patch a labeled section for each sibling's unique insight, then archive the siblings).
1038
+ b. CREATE A NEW UMBRELLA SKILL.md covering the shared workflow with short labeled subsections, then archive the absorbed siblings.
1039
+ c. DEMOTE session-specific detail to references/, templates/, or scripts/ under the umbrella. Use the right directory per kind:
1040
+ • references/<topic>.md — session-specific detail OR condensed knowledge banks (quoted research, API docs excerpts, domain notes, provider quirks, reproduction recipes) written concise and task-focused.
1041
+ • templates/<name>.<ext> — starter files meant to be copied and modified.
1042
+ • scripts/<name>.<ext> — statically re-runnable actions (verification scripts, fixture generators, probes).
1043
+ 3. Package integrity — not optional: inspect each skill as a COMPLETE directory package, not just SKILL.md. A skill root may include references/, templates/, scripts/, and assets/. If the source skill has support files OR its SKILL.md contains relative links to them, DO NOT flatten only SKILL.md into <umbrella>/references/<old>.md. Choose one safe path instead: keep it as a standalone skill, OR fully merge by re-homing every needed support file into the umbrella's canonical directories AND rewriting the destination instructions to the new paths, OR archive the entire original skill package unchanged. Never leave demoted instructions pointing at files left behind under the old skill directory.
1044
+ 4. Flag skills whose NAME is too narrow (contains a PR number, a feature codename, a specific error string, an 'audit'/'diagnosis'/'salvage' session artifact) — they almost always belong as a subsection or support file under a class-level umbrella.
1045
+ 5. Iterate. After one consolidation round, scan the remaining set and look for the NEXT umbrella opportunity. Don't stop after 3 merges.
1046
+
1047
+ You are a NOMINATOR, not an executor: this channel has NO tools. Your single deliverable is the structured YAML block below. Never narrate actions you did not take ("merged", "patched", "archived") — you are proposing, and the deterministic engine executes only names from the candidate pool it gave you. (A future execution view would expose skill_manage; today it does not.)
1048
+
1049
+ 'keep' is a legitimate decision ONLY when the skill is already a class-level umbrella and none of the proposed merges would improve discoverability. 'This is narrow but distinct from its siblings' is NOT a reason to keep — it's a reason to move it under an umbrella as a subsection or support file.
1050
+
1051
+ Expected output: real umbrella-ification. Process every obvious cluster. If you end the pass with obvious clusters still untouched, you stopped too early — go back and look at the clusters you left alone.
1052
+
1053
+ Keep the umbrella body tight and scannable: exact commands, verbatim paths, ~100-200 lines; never invent flags or APIs.
1054
+
1055
+ When done, write a human summary THEN the structured machine-readable block. The block is the contract: every skill you would move to .archive/ MUST appear in exactly one of the two lists. Return ONLY the YAML block after the summary — no post-block prose. Format EXACTLY:
1056
+
1057
+ ## Structured summary (required)
1058
+ \`\`\`yaml
1059
+ consolidations:
1060
+ - from: <old-skill-name>
1061
+ into: <umbrella-skill-name>
1062
+ reason: <one short sentence — why merged, not just 'similar'>
1063
+ prunings:
1064
+ - name: <skill-name>
1065
+ reason: <one short sentence — why archived with no merge target>
1066
+ \`\`\`
1067
+
1068
+ Every skill you would move to .archive/ MUST appear in exactly one of the two lists. If you consolidated X into umbrella Y (patched Y, wrote a references file to Y, or created Y with X's content absorbed), X goes under consolidations with into: Y. If you archived X with no absorption — truly stale, irrelevant, or obsolete — X goes under prunings. Leave a list empty (consolidations: []) if none. Do not omit the block. The block comes AFTER your human-readable summary of clusters processed, patches made, and decisions left alone.`;
1069
+ const CURATOR_DRY_RUN_BANNER = `═══════════════════════════════════════════════════════════════
1070
+ DRY-RUN — REPORT ONLY. DO NOT MUTATE THE SKILL LIBRARY.
1071
+ ═══════════════════════════════════════════════════════════════
1072
+
1073
+ This is a PREVIEW pass. Follow every instruction above EXCEPT:
1074
+ • Do NOT call skill_manage with action=create, update, patch, delete, write_file, or remove_file.
1075
+ • Do NOT move, copy, or rewrite any file under the skills tree.
1076
+
1077
+ 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.
1078
+
1079
+ If you accidentally take a mutating action, say so explicitly in the summary.`;
1080
+ const COMPLETION_SKILL_REVIEW_PROMPT = `[Auto-review — Skills · task complete]
1081
+ Your current task now appears complete. Before wrapping up, review the approach and update the skill library via skill_manage.
1082
+
1083
+ 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.
1084
+
1085
+ Do NOT modify output files or re-run the task. If you are still mid-task, ignore this.`;
1086
+ /**
1087
+ * System-prompt guidance section (Hermes `SKILLS_GUIDANCE`, DSH-adapted).
1088
+ * Registered as a system-prompt section by tool-skill-manage (it mounts
1089
+ * exactly when `skill_manage` is available — the DSH analogue of Hermes'
1090
+ * `if "skill_manage" in agent.valid_tool_names` condition). Instructs the
1091
+ * model to save/repair skills on its own initiative.
1092
+ */
1093
+ const SKILLS_GUIDANCE = `Skills guidance:
1094
+ • After completing a complex task (5+ tool calls), fixing a tricky error, or discovering a non-trivial workflow, save the approach as a skill with skill_manage so you can reuse it next time.
1095
+ • When using a skill and finding it outdated, incomplete, or wrong, patch it immediately with skill_manage (action='patch') — don't wait to be asked. Skills that aren't maintained become liabilities.`;
1096
+ const PLAN_CHANNEL_NOTE = `
1097
+
1098
+ CHANNEL (subagent): this review channel mounts only the read-only \`skill\` tool — you have NO \`skill_manage\`, NO \`memory\`. Your deliverable is the structured JSON plan below (outputSchema). Describe the patches/creates you RECOMMEND in the plan; never narrate actions you took.`;
1099
+ /** Subagent-channel variant: same review policy, channel-limited deliverable (M-2). */
1100
+ const SKILL_REVIEW_PLAN_PROMPT = `${SKILL_REVIEW_PROMPT}${PLAN_CHANNEL_NOTE}`;
1101
+ /** Subagent-channel variant of the combined review (M-2). */
1102
+ const COMBINED_REVIEW_PLAN_PROMPT = `${COMBINED_REVIEW_PROMPT}${PLAN_CHANNEL_NOTE}`;
1103
+ function reviewPrompt(kind, channel = "agent") {
1104
+ if (kind === "memory") return MEMORY_REVIEW_PROMPT;
1105
+ if (channel === "plan") return kind === "skill" ? SKILL_REVIEW_PLAN_PROMPT : COMBINED_REVIEW_PLAN_PROMPT;
1106
+ if (kind === "skill") return SKILL_REVIEW_PROMPT;
1107
+ return COMBINED_REVIEW_PROMPT;
1108
+ }
1109
+ function sha256(text) {
1110
+ return createHash("sha256").update(text).digest("hex");
1111
+ }
1112
+ function createPromptBundle(prompts) {
1113
+ const canonical = JSON.stringify({
1114
+ id: PROMPT_BUNDLE_ID,
1115
+ version: 7,
1116
+ prompts: Object.fromEntries(Object.entries(prompts).sort())
1117
+ });
1118
+ return Object.freeze({
1119
+ id: PROMPT_BUNDLE_ID,
1120
+ version: 7,
1121
+ prompts: Object.freeze({ ...prompts }),
1122
+ sha256: sha256(canonical)
1123
+ });
1124
+ }
1125
+ const PROMPT_BUNDLE = createPromptBundle({
1126
+ memory: MEMORY_REVIEW_PROMPT,
1127
+ skill: SKILL_REVIEW_PROMPT,
1128
+ combined: COMBINED_REVIEW_PROMPT,
1129
+ skillPlan: SKILL_REVIEW_PLAN_PROMPT,
1130
+ combinedPlan: COMBINED_REVIEW_PLAN_PROMPT,
1131
+ curator: CURATOR_PROMPT,
1132
+ completion: COMPLETION_SKILL_REVIEW_PROMPT,
1133
+ skillsGuidance: SKILLS_GUIDANCE
1134
+ });
1135
+ function verifyPromptBundle(bundle = PROMPT_BUNDLE) {
1136
+ if (bundle.id !== "dsh-evolution@7" || bundle.version !== 7) return false;
1137
+ const canonical = JSON.stringify({
1138
+ id: PROMPT_BUNDLE_ID,
1139
+ version: 7,
1140
+ prompts: Object.fromEntries(Object.entries(bundle.prompts).sort())
1141
+ });
1142
+ return bundle.sha256 === sha256(canonical);
1143
+ }
1144
+ const DSH_AUTHORING_STANDARDS = `Follow the Hermes skill-authoring standards, translated to DSH tools.
1145
+
1146
+ Frontmatter:
1147
+ - name: lowercase-hyphenated, <=64 chars, no spaces.
1148
+ - 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. If the description contains a colon, wrap the whole value in double quotes.
1149
+ - version: 0.1.0
1150
+ - author: always the literal value "Hermes". NEVER fill it from the environment, git config, or any identity you can probe — an environment-derived name is a privacy leak the user never opted into (skills get shared and published), and the skill names itself as Hermes.
1151
+ - platforms: declare [macos], [linux], and/or [windows] only when the skill is genuinely OS-bound (osascript/apt/systemctl => the matching OS; /proc, signal.SIGKILL => linux; fcntl/termios => POSIX). Prefer fixing it cross-platform first (tempdir, pathlib, pure-Node); omit the field for portable skills.
1152
+ - metadata.hermes.tags: a few Capitalized, Relevant, Tags.
1153
+ - metadata.hermes.related_skills: [a, b] — name sibling skills this one builds on or is referenced by (optional; feeds the quality references factor).
1154
+
1155
+ Body section order (omit only when empty):
1156
+ 1. "# <Human Title>" then a 2-3 sentence intro: what it does, what it does NOT do, key dependency stance.
1157
+ 2. "## When to Use" — concrete trigger phrases.
1158
+ 3. "## Prerequisites" — exact env vars, install steps, credentials.
1159
+ 4. "## How to Run" — canonical invocation framed through DSH tools.
1160
+ 5. "## Quick Reference" — flat command/endpoint list.
1161
+ 6. "## Procedure" — numbered steps with copy-paste-exact commands.
1162
+ 7. "## Pitfalls" — known limits and rate limits.
1163
+ 8. "## Verification" — one check proving the skill worked.
1164
+
1165
+ DSH-tool framing:
1166
+ - Reference DSH tools by name in backticks: \`bash\`, \`str_replace_editor\`, \`write\`, \`skill\`, \`skill_manage\`, \`memory\`.
1167
+ - Do not name wrapped shell utilities when a DSH tool already covers them.
1168
+ - Larger scripts belong under \`scripts/\` (written with \`skill_manage write_file\`) and are referenced from SKILL.md by relative path.
1169
+
1170
+ Quality bar:
1171
+ - Prefer verbatim flags, paths, and APIs from the source. Never invent them.
1172
+ - Keep it tight: ~100 lines simple, ~200 complex.
1173
+ - No router/index/hub skills that only point at other skills.
1174
+ - References go in \`references/\`, templates in \`templates/\`.
1175
+
1176
+ Learn workflow (when the user asks you to learn a reusable skill, or you decide to turn a source/request into one):
1177
+ 1. Gather every source named (files, URLs, "what we just did", pasted notes) with the tools you already have — and treat prose after a source as authoring requirements, not noise.
1178
+ 2. Apply every requirement and constraint from the request to the SKILL.md you author.
1179
+ 3. Author exactly ONE SKILL.md and save it with \`skill_manage\` (action=create); non-trivial scripts go under \`scripts/\`.
1180
+ 4. When done, tell the user the skill name, its category, and a one-line summary of what it captured.`;
1181
+ //#endregion
1182
+ //#region lib/types/learn-prompt.js
1183
+ /**
1184
+ * Open-ended `/evolution learn` prompt builder.
1185
+ *
1186
+ * `learn` is open-ended: the user can name anything they can describe — a
1187
+ * directory of code, an API doc URL, a workflow they just walked the agent
1188
+ * through, or pasted notes. The prompt instructs the live agent to gather the
1189
+ * named sources with its existing tools, then author a single SKILL.md via
1190
+ * `skill_manage` following `DSH_AUTHORING_STANDARDS`. There is no separate
1191
+ * distillation engine and no model-tool footprint.
1192
+ */
1193
+ /**
1194
+ * Build the agent prompt for an open-ended `/evolution learn` request.
1195
+ *
1196
+ * @param userRequest free-text the user gave after `/evolution learn`; an
1197
+ * empty string falls back to "the workflow we just went through".
1198
+ * @returns a complete instruction the agent runs as a normal turn.
1199
+ */
1200
+ function buildLearnPrompt(userRequest) {
1201
+ return [
1202
+ "[/learn] The user wants you to learn a reusable skill from the request below, and save it.",
1203
+ "",
1204
+ "THE REQUEST:",
1205
+ userRequest.trim() || "the workflow we just went through in this conversation — review the steps taken and distill them into a reusable skill",
1206
+ "",
1207
+ "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.",
1208
+ "",
1209
+ "Do this:",
1210
+ "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.",
1211
+ "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.",
1212
+ "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.",
1213
+ "",
1214
+ DSH_AUTHORING_STANDARDS,
1215
+ "",
1216
+ "When done, tell the user the skill name, its category, and a one-line summary of what it captured."
1217
+ ].join("\n");
1218
+ }
1219
+ //#endregion
245
1220
  //#region lib/types/threats.js
246
1221
  /**
247
1222
  * Threat scanning for agent-authored memory and skill content.
@@ -417,10 +1392,12 @@ const SCOPE_ORDER = {
417
1392
  context: 2,
418
1393
  strict: 3
419
1394
  };
1395
+ const NO_SCAN_OPTIONS = {};
420
1396
  /**
421
1397
  * Scan text at `scope`. Patterns are cumulative: `strict` includes all scopes.
1398
+ * `options.excludeLabels` removes matching patterns without changing `scope`.
422
1399
  */
423
- function scanThreats(text, scope = "strict", maxScanChars = 65536) {
1400
+ function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
424
1401
  const findings = [];
425
1402
  if (ZERO_WIDTH_CHARS.test(text)) findings.push({
426
1403
  label: "unicode_zero_width",
@@ -433,8 +1410,10 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536) {
433
1410
  scope
434
1411
  });
435
1412
  const normalized = text.normalize("NFKC").slice(0, maxScanChars);
1413
+ const excluded = new Set(options.excludeLabels ?? []);
436
1414
  for (const pattern of PATTERNS) {
437
1415
  if (SCOPE_ORDER[pattern.scope] > SCOPE_ORDER[scope]) continue;
1416
+ if (excluded.has(pattern.label)) continue;
438
1417
  if (pattern.regex.test(normalized)) findings.push({
439
1418
  label: pattern.label,
440
1419
  category: pattern.category,
@@ -444,24 +1423,24 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536) {
444
1423
  return findings;
445
1424
  }
446
1425
  /** 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);
1426
+ function evaluateThreat(text, scope = "strict", maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
1427
+ const findings = scanThreats(text, scope, maxScanChars, options);
449
1428
  return {
450
1429
  blocked: findings.length > 0,
451
1430
  findings
452
1431
  };
453
1432
  }
454
1433
  /** User-facing block message for memory writes. */
455
- function scanMemoryThreats(text, maxScanChars = 65536) {
456
- const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars);
1434
+ function scanMemoryThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
1435
+ const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars, options);
457
1436
  if (!blocked) return null;
458
1437
  const pattern = findings.find((f) => f.category !== "unicode_obfuscation");
459
1438
  if (pattern) return `Blocked by security scan (${pattern.label}). Rephrase without instruction-like language.`;
460
1439
  return "Blocked by security scan: invisible or potentially malicious Unicode detected.";
461
1440
  }
462
1441
  /** User-facing block message for skill content writes. */
463
- function scanContentThreats(text, maxScanChars = 65536) {
464
- const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars);
1442
+ function scanContentThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
1443
+ const { blocked, findings } = evaluateThreat(text, "strict", maxScanChars, options);
465
1444
  if (!blocked) return null;
466
1445
  return `Blocked by security scan (${findings[0]?.label ?? "unknown"}). This content appears to contain potentially malicious instructions.`;
467
1446
  }
@@ -471,7 +1450,39 @@ function scanContentThreats(text, maxScanChars = 65536) {
471
1450
  * File-backed durable memory with Hermes-compatible semantics.
472
1451
  * Stores are MEMORY.md and USER.md under $DSH_HOME/memories (~/.dsh/memories).
473
1452
  */
474
- const ENTRY_DELIMITER = "\n§\n";
1453
+ /**
1454
+ * Read-guard factor: a memory file larger than this multiple of its target's
1455
+ * char limit is treated as externally corrupted and skipped instead of being
1456
+ * read whole (aligned with claw `tools/memory.ts` size guard, which uses the
1457
+ * same 10× bound around a file that should never exceed the store limit).
1458
+ */
1459
+ const READ_GUARD_FACTOR = 10;
1460
+ /**
1461
+ * Consolidation-failure backoff window (package-private, rc.42 audit P2-1):
1462
+ * only failures inside the window count toward `maxConsolidationFailures`.
1463
+ * The store cannot observe turn boundaries, so the model-facing "this turn"
1464
+ * phrasing is approximated with ten minutes — generous enough to cover one
1465
+ * turn's retry loop, short enough that a failure yesterday never makes today's
1466
+ * first refusal say "stop retrying".
1467
+ */
1468
+ const FAILURE_WINDOW_MS = 10 * 6e4;
1469
+ /**
1470
+ * Recoverable-error preview bounds (B-line G5, Hermes `_previews` parity):
1471
+ * failed replace/remove/batch calls echo the current entries so the model can
1472
+ * self-recover without re-reading the store. Bounded to five entries of eighty
1473
+ * characters each; package-private because it is an error-message shape, not a
1474
+ * behavior switch.
1475
+ */
1476
+ const ERROR_PREVIEW_ENTRIES = 5;
1477
+ const ERROR_PREVIEW_WIDTH = 80;
1478
+ function previewEntries(entries) {
1479
+ if (entries.length === 0) return "";
1480
+ const shown = entries.slice(0, ERROR_PREVIEW_ENTRIES).map((entry) => {
1481
+ return `- ${entry.length > ERROR_PREVIEW_WIDTH ? `${entry.slice(0, ERROR_PREVIEW_WIDTH)}…` : entry}`;
1482
+ });
1483
+ const more = entries.length > ERROR_PREVIEW_ENTRIES ? `\n (+${entries.length - ERROR_PREVIEW_ENTRIES} more)` : "";
1484
+ return `\n\nCurrent entries (preview):\n${shown.join("\n")}${more}`;
1485
+ }
475
1486
  function memoryRoot(env = process.env) {
476
1487
  return join(env.DSH_HOME ?? join(homedir(), ".dsh"), "memories");
477
1488
  }
@@ -495,6 +1506,7 @@ var MemoryStore = class {
495
1506
  maxFailures;
496
1507
  io;
497
1508
  failureCount = 0;
1509
+ lastFailureAt = 0;
498
1510
  constructor(options = {}) {
499
1511
  this.io = options.io ?? nodeEvolutionIo();
500
1512
  this.memoryLimit = options.memoryCharLimit ?? 2200;
@@ -506,7 +1518,24 @@ var MemoryStore = class {
506
1518
  limitFor(target) {
507
1519
  return target === "memory" ? this.memoryLimit : this.userLimit;
508
1520
  }
1521
+ /**
1522
+ * Read-guard probe: `{ size, limit }` when the on-disk file exceeds
1523
+ * `limit * READ_GUARD_FACTOR` bytes, `null` when it is absent, unknown
1524
+ * (backend without a size probe), under the bound, or the target has no
1525
+ * limit configured.
1526
+ */
1527
+ async oversizedFile(target) {
1528
+ const size = await this.io.size?.(fileFor(this.root, target));
1529
+ if (size === null || size === void 0) return null;
1530
+ const limit = this.limitFor(target);
1531
+ if (limit <= 0) return null;
1532
+ return size > limit * READ_GUARD_FACTOR ? {
1533
+ size,
1534
+ limit
1535
+ } : null;
1536
+ }
509
1537
  async read(target) {
1538
+ if (await this.oversizedFile(target)) return [];
510
1539
  const raw = await this.io.readText(fileFor(this.root, target));
511
1540
  return raw === null ? [] : [...new Set(normalizeEntries(raw))];
512
1541
  }
@@ -517,133 +1546,168 @@ var MemoryStore = class {
517
1546
  this.failureCount = 0;
518
1547
  }
519
1548
  failure(target, message, entries) {
1549
+ if (Date.now() - this.lastFailureAt > FAILURE_WINDOW_MS) this.failureCount = 0;
1550
+ this.lastFailureAt = Date.now();
520
1551
  this.failureCount += 1;
521
1552
  const chars = entries.join(ENTRY_DELIMITER).length;
522
1553
  if (this.failureCount > this.maxFailures) return {
523
1554
  ok: false,
524
- message: `Memory consolidation failed ${this.failureCount} times this turn. Stop retrying memory calls and continue with the user's task.`,
1555
+ message: `Memory consolidation failed ${this.failureCount} times this turn. Stop retrying memory calls and continue with the user's task.${previewEntries(entries)}`,
525
1556
  entries,
526
1557
  chars,
527
1558
  limit: this.limitFor(target)
528
1559
  };
529
1560
  return {
530
1561
  ok: false,
531
- message,
1562
+ message: `${message}${previewEntries(entries)}`,
532
1563
  entries,
533
1564
  chars,
534
1565
  limit: this.limitFor(target)
535
1566
  };
536
1567
  }
537
- async add(target, facts) {
538
- const content = facts.trim();
539
- if (!content) return {
1568
+ /**
1569
+ * StorageHint percentage must clamp at 100 like the render header: a drifted
1570
+ * entry can push chars past the limit, and "Storage at 125%" contradicts the
1571
+ * clamped usage indicator.
1572
+ */
1573
+ storageHint(target, chars) {
1574
+ const limit = this.limitFor(target);
1575
+ if (limit <= 0) return "";
1576
+ const percent = Math.min(100, Math.floor(chars * 100 / limit));
1577
+ return percent >= 80 ? ` ⚠️ Storage at ${percent}% (${chars}/${limit} chars).` : "";
1578
+ }
1579
+ /**
1580
+ * Best-effort raw-copy backup of the on-disk file to `<file>.bak.<stamp>`
1581
+ * before a refusal, so an externally modified (or oversized) file stays
1582
+ * recoverable. Copies bytes instead of reading them so a pathologically
1583
+ * large file is never loaded just to back it up. Failure to back up does
1584
+ * not change the refusal semantics.
1585
+ */
1586
+ async backupFile(target) {
1587
+ const path = fileFor(this.root, target);
1588
+ const unique = `${(/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 14)}-${Math.random().toString(36).slice(2, 8)}`;
1589
+ try {
1590
+ await this.io.copy(path, `${path}.bak.${unique}`);
1591
+ return `${path}.bak.${unique}`;
1592
+ } catch {
1593
+ return null;
1594
+ }
1595
+ }
1596
+ /**
1597
+ * Read-guard refusal for write paths. Returns the refusal result when the
1598
+ * target file is oversized, `null` otherwise. The file is skipped for
1599
+ * reading (never loaded), backed up by raw copy, and the model is told to
1600
+ * fix it manually — mirroring the drift refusal so corrupted state is never
1601
+ * silently overwritten.
1602
+ */
1603
+ async oversizedRefusal(target) {
1604
+ const oversized = await this.oversizedFile(target);
1605
+ if (!oversized) return null;
1606
+ const backup = await this.backupFile(target);
1607
+ const suffix = backup ? ` A backup was saved to ${basename(backup)}.` : "";
1608
+ return {
540
1609
  ok: false,
541
- message: "Content cannot be empty.",
1610
+ message: `Memory file is ${oversized.size} bytes (limit ${oversized.limit * READ_GUARD_FACTOR}) — skipping read.${suffix} Fix the file manually, then retry.`,
542
1611
  entries: [],
543
1612
  chars: 0,
544
1613
  limit: this.limitFor(target)
545
1614
  };
546
- const threat = scanMemoryThreats(content);
547
- if (threat) return {
1615
+ }
1616
+ async add(target, facts) {
1617
+ if (!facts.trim()) return {
548
1618
  ok: false,
549
- message: threat,
1619
+ message: "Content cannot be empty.",
550
1620
  entries: [],
551
1621
  chars: 0,
552
1622
  limit: this.limitFor(target)
553
1623
  };
554
- const entries = await this.read(target);
555
- if (entries.some((entry) => stripDatePrefix(entry) === content)) {
556
- this.resetFailures();
1624
+ const path = fileFor(this.root, target);
1625
+ const refusal = await this.oversizedRefusal(target);
1626
+ if (refusal) return refusal;
1627
+ let outcome;
1628
+ await transactIo(this.io, path, async (current) => {
1629
+ const core = await this.addCore(target, facts, current ?? "");
1630
+ outcome = core.result;
1631
+ return core.write ?? current ?? null;
1632
+ });
1633
+ return outcome;
1634
+ }
1635
+ /**
1636
+ * Single-entry add inside the transaction: shared checks (oversized,
1637
+ * drift, threat) and the content computation. `raw` is the locked view
1638
+ * (`current`) — never a second IO read. `write: null` means "no change".
1639
+ */
1640
+ async addCore(target, facts, raw) {
1641
+ const content = facts.trim();
1642
+ if (!content) return {
1643
+ result: this.failure(target, "Content cannot be empty.", []),
1644
+ write: null
1645
+ };
1646
+ if (this.driftFromRaw(target, raw)) {
1647
+ const backup = await this.backupFile(target);
557
1648
  return {
558
- ok: true,
559
- message: "Entry already exists (no duplicate added).",
560
- entries,
561
- chars: entries.join(ENTRY_DELIMITER).length,
562
- limit: this.limitFor(target)
1649
+ result: {
1650
+ ok: false,
1651
+ message: `External drift detected in memory file.${backup ? ` A backup was saved to ${basename(backup)}.` : ""} Resolve the drift before retrying.`,
1652
+ entries: [],
1653
+ chars: 0,
1654
+ limit: this.limitFor(target)
1655
+ },
1656
+ write: null
563
1657
  };
564
1658
  }
565
- const next = [...entries, this.addDatePrefix ? `## ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}\n${content}` : content];
566
- 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);
568
- await this.write(target, next);
569
- this.resetFailures();
570
- return {
571
- ok: true,
572
- message: "Entry added.",
573
- entries: next,
574
- chars: total,
575
- limit: this.limitFor(target)
576
- };
577
- }
578
- async replace(target, oldText, facts) {
579
- return this.mutate(target, oldText, "replace", facts);
580
- }
581
- async remove(target, oldText) {
582
- return this.mutate(target, oldText, "remove", void 0);
583
- }
584
- async mutate(target, oldText, action, facts) {
585
- 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
- };
593
- const content = action === "replace" ? (facts ?? "").trim() : "";
594
- if (action === "replace" && !content) return {
595
- ok: false,
596
- message: "facts is required for replace; use remove to delete.",
597
- entries: [],
598
- chars: 0,
599
- limit: this.limitFor(target)
600
- };
601
- if (action === "replace") {
602
- const threat = scanMemoryThreats(content);
603
- if (threat) return {
1659
+ const threat = scanMemoryThreats(content);
1660
+ if (threat) return {
1661
+ result: {
604
1662
  ok: false,
605
1663
  message: threat,
606
1664
  entries: [],
607
1665
  chars: 0,
608
1666
  limit: this.limitFor(target)
1667
+ },
1668
+ write: null
1669
+ };
1670
+ const entries = [...new Set(normalizeEntries(raw))];
1671
+ if (entries.some((entry) => stripDatePrefix(entry) === content)) {
1672
+ this.resetFailures();
1673
+ return {
1674
+ result: {
1675
+ ok: true,
1676
+ message: `Entry already exists (no duplicate added).${this.storageHint(target, entries.join(ENTRY_DELIMITER).length)}`,
1677
+ entries,
1678
+ chars: entries.join(ENTRY_DELIMITER).length,
1679
+ limit: this.limitFor(target)
1680
+ },
1681
+ write: null
609
1682
  };
610
1683
  }
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
- };
618
- const entries = await this.read(target);
619
- const matches = entries.map((entry, index) => ({
620
- entry,
621
- index
622
- })).filter(({ entry }) => entry.includes(needle));
623
- if (matches.length === 0) return this.failure(target, `No entry matching "${needle}" found.`, entries);
624
- if (new Set(matches.map((m) => m.entry)).size > 1) return {
625
- ok: false,
626
- message: `Multiple distinct entries matched "${needle}". Be more specific.`,
627
- entries,
628
- chars: entries.join(ENTRY_DELIMITER).length,
629
- limit: this.limitFor(target)
630
- };
631
- const index = matches[0]?.index ?? -1;
632
- const next = [...entries];
633
- if (action === "remove") next.splice(index, 1);
634
- else next[index] = content;
1684
+ const next = [...entries, this.addDatePrefix ? `## ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}\n${content}` : content];
635
1685
  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);
637
- await this.write(target, next);
1686
+ const addLimit = this.limitFor(target);
1687
+ if (addLimit > 0 && total > addLimit) return {
1688
+ result: this.failure(target, `Adding this entry would exceed the ${addLimit} char limit. Consolidate or remove stale entries, then retry.`, entries),
1689
+ write: null
1690
+ };
638
1691
  this.resetFailures();
639
1692
  return {
640
- ok: true,
641
- message: `Entry ${action === "remove" ? "removed" : "replaced"}.`,
642
- entries: next,
643
- chars: total,
644
- limit: this.limitFor(target)
1693
+ result: {
1694
+ ok: true,
1695
+ message: `Entry added.${this.storageHint(target, total)}`,
1696
+ entries: next,
1697
+ chars: total,
1698
+ limit: this.limitFor(target)
1699
+ },
1700
+ write: render(next)
645
1701
  };
646
1702
  }
1703
+ /** Canonical-form drift check derived from the locked view (same formula as `detectDrift`, no second read). */
1704
+ driftFromRaw(target, raw) {
1705
+ if (raw.trim() === "") return false;
1706
+ const entries = normalizeEntries(raw);
1707
+ const limit = this.limitFor(target);
1708
+ if (limit > 0 && entries.some((entry) => entry.length > limit)) return true;
1709
+ return render(entries) !== raw;
1710
+ }
647
1711
  async applyBatch(target, operations) {
648
1712
  if (operations.length === 0) return {
649
1713
  ok: false,
@@ -652,261 +1716,402 @@ var MemoryStore = class {
652
1716
  chars: 0,
653
1717
  limit: this.limitFor(target)
654
1718
  };
655
- if (await this.detectDrift(target)) return {
656
- ok: false,
657
- message: "External drift detected in memory file. Resolve the drift before retrying.",
658
- entries: [],
659
- chars: 0,
660
- limit: this.limitFor(target)
661
- };
662
- const entries = await this.read(target);
1719
+ const path = fileFor(this.root, target);
1720
+ const refusal = await this.oversizedRefusal(target);
1721
+ if (refusal) return refusal;
1722
+ let outcome;
1723
+ await transactIo(this.io, path, async (current) => {
1724
+ const core = await this.applyBatchCore(target, operations, current ?? "");
1725
+ outcome = core.result;
1726
+ return core.write ?? current ?? null;
1727
+ });
1728
+ return outcome;
1729
+ }
1730
+ /** Batch RMW inside the transaction. `write: null` = failure/no-op, disk untouched. */
1731
+ async applyBatchCore(target, operations, raw) {
1732
+ if (this.driftFromRaw(target, raw)) {
1733
+ const backup = await this.backupFile(target);
1734
+ return {
1735
+ result: {
1736
+ ok: false,
1737
+ message: `External drift detected in memory file.${backup ? ` A backup was saved to ${basename(backup)}.` : ""} Resolve the drift before retrying.`,
1738
+ entries: [],
1739
+ chars: 0,
1740
+ limit: this.limitFor(target)
1741
+ },
1742
+ write: null
1743
+ };
1744
+ }
1745
+ const entries = [...new Set(normalizeEntries(raw))];
663
1746
  const working = [...entries];
664
1747
  for (const [index, op] of operations.entries()) {
665
1748
  const position = index + 1;
666
1749
  if (op.action === "add") {
667
1750
  const body = (op.facts ?? "").trim();
668
1751
  if (!body) return {
669
- ok: false,
670
- message: `Operation ${position} (add): facts is required. No operations were applied.`,
671
- entries,
672
- chars: entries.join(ENTRY_DELIMITER).length,
673
- limit: this.limitFor(target)
1752
+ result: {
1753
+ ok: false,
1754
+ message: `Operation ${position} (add): facts is required. No operations were applied.${previewEntries(entries)}`,
1755
+ entries,
1756
+ chars: entries.join(ENTRY_DELIMITER).length,
1757
+ limit: this.limitFor(target)
1758
+ },
1759
+ write: null
674
1760
  };
675
1761
  const threat = scanMemoryThreats(body);
676
1762
  if (threat) return {
677
- ok: false,
678
- message: `Operation ${position}: ${threat}`,
679
- entries,
680
- chars: entries.join(ENTRY_DELIMITER).length,
681
- limit: this.limitFor(target)
1763
+ result: {
1764
+ ok: false,
1765
+ message: `Operation ${position}: ${threat}`,
1766
+ entries,
1767
+ chars: entries.join(ENTRY_DELIMITER).length,
1768
+ limit: this.limitFor(target)
1769
+ },
1770
+ write: null
682
1771
  };
683
1772
  if (!working.some((entry) => stripDatePrefix(entry) === body)) working.push(this.addDatePrefix ? `## ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}\n${body}` : body);
684
1773
  continue;
685
1774
  }
686
1775
  const needle = (op.old_text ?? "").trim();
687
1776
  if (!needle) return {
688
- ok: false,
689
- message: `Operation ${position} (${op.action}): old_text is required. No operations were applied.`,
690
- entries,
691
- chars: entries.join(ENTRY_DELIMITER).length,
692
- limit: this.limitFor(target)
1777
+ result: {
1778
+ ok: false,
1779
+ message: `Operation ${position} (${op.action}): old_text is required. No operations were applied.${previewEntries(entries)}`,
1780
+ entries,
1781
+ chars: entries.join(ENTRY_DELIMITER).length,
1782
+ limit: this.limitFor(target)
1783
+ },
1784
+ write: null
693
1785
  };
694
1786
  const matches = working.map((entry, matchIndex) => ({
695
1787
  entry,
696
1788
  matchIndex
697
1789
  })).filter(({ entry }) => entry.includes(needle));
698
- if (matches.length === 0) return this.failure(target, `Operation ${position}: no entry matching "${needle}" found. No operations were applied.`, entries);
1790
+ if (matches.length === 0) return {
1791
+ result: this.failure(target, `Operation ${position}: no entry matching "${needle}" found. No operations were applied.`, entries),
1792
+ write: null
1793
+ };
699
1794
  if (new Set(matches.map((m) => m.entry)).size > 1) return {
700
- ok: false,
701
- message: `Operation ${position}: "${needle}" matched multiple distinct entries. No operations were applied.`,
702
- entries,
703
- chars: entries.join(ENTRY_DELIMITER).length,
704
- limit: this.limitFor(target)
1795
+ result: {
1796
+ ok: false,
1797
+ message: `Operation ${position}: "${needle}" matched multiple distinct entries. No operations were applied.${previewEntries(entries)}`,
1798
+ entries,
1799
+ chars: entries.join(ENTRY_DELIMITER).length,
1800
+ limit: this.limitFor(target)
1801
+ },
1802
+ write: null
705
1803
  };
706
1804
  const matchIndex = matches[0]?.matchIndex ?? -1;
707
1805
  if (op.action === "remove") working.splice(matchIndex, 1);
708
1806
  else {
709
1807
  const body = (op.facts ?? "").trim();
710
1808
  if (!body) return {
711
- ok: false,
712
- message: `Operation ${position} (replace): facts is required.`,
713
- entries,
714
- chars: entries.join(ENTRY_DELIMITER).length,
715
- limit: this.limitFor(target)
1809
+ result: {
1810
+ ok: false,
1811
+ message: `Operation ${position} (replace): facts is required.${previewEntries(entries)}`,
1812
+ entries,
1813
+ chars: entries.join(ENTRY_DELIMITER).length,
1814
+ limit: this.limitFor(target)
1815
+ },
1816
+ write: null
716
1817
  };
717
1818
  const threat = scanMemoryThreats(body);
718
1819
  if (threat) return {
719
- ok: false,
720
- message: `Operation ${position}: ${threat}`,
721
- entries,
722
- chars: entries.join(ENTRY_DELIMITER).length,
723
- limit: this.limitFor(target)
1820
+ result: {
1821
+ ok: false,
1822
+ message: `Operation ${position}: ${threat}`,
1823
+ entries,
1824
+ chars: entries.join(ENTRY_DELIMITER).length,
1825
+ limit: this.limitFor(target)
1826
+ },
1827
+ write: null
724
1828
  };
725
1829
  working[matchIndex] = body;
726
1830
  }
727
1831
  }
728
1832
  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);
730
- await this.write(target, working);
1833
+ const batchLimit = this.limitFor(target);
1834
+ if (batchLimit > 0 && total > batchLimit) return {
1835
+ result: this.failure(target, `Batch result (${total} chars) exceeds the ${batchLimit} limit. Remove or shorten more entries in the same batch.`, entries),
1836
+ write: null
1837
+ };
731
1838
  this.resetFailures();
732
1839
  return {
733
- ok: true,
734
- message: `Applied ${operations.length} operation(s).`,
735
- entries: working,
736
- chars: total,
737
- limit: this.limitFor(target)
1840
+ result: {
1841
+ ok: true,
1842
+ message: `Applied ${operations.length} operation(s).${this.storageHint(target, total)}`,
1843
+ entries: working,
1844
+ chars: total,
1845
+ limit: this.limitFor(target)
1846
+ },
1847
+ write: render(working)
738
1848
  };
739
1849
  }
740
1850
  async renderContext() {
741
1851
  const memory = await this.read("memory");
742
1852
  const user = await this.read("user");
743
1853
  const parts = [];
744
- for (const [target, entries] of [["Memory", memory], ["User Profile", user]]) {
1854
+ for (const [target, label, entries] of [[
1855
+ "memory",
1856
+ "Memory",
1857
+ memory
1858
+ ], [
1859
+ "user",
1860
+ "User Profile",
1861
+ user
1862
+ ]]) {
1863
+ const oversized = entries.length === 0 ? await this.oversizedFile(target) : null;
1864
+ if (oversized) {
1865
+ parts.push(`## ${label} — file skipped: ${oversized.size} bytes (limit ${oversized.limit * READ_GUARD_FACTOR}); not read`);
1866
+ continue;
1867
+ }
745
1868
  const safe = entries.filter((entry) => !scanMemoryThreats(entry));
746
1869
  if (safe.length > 0) {
747
1870
  const body = safe.join(ENTRY_DELIMITER);
1871
+ const limit = this.limitFor(target);
1872
+ const pct = limit > 0 ? Math.min(100, Math.floor(body.length * 100 / limit)) : 0;
748
1873
  const note = safe.length === entries.length ? "" : ` (${entries.length - safe.length} threat-matched entries filtered)`;
749
- parts.push(`## ${target} (${safe.length} entries)${note}\n${body}`);
1874
+ parts.push(`## ${label} (${safe.length} entries) [${pct}% — ${body.length}/${limit} chars]${note}\n${body}`);
750
1875
  }
751
1876
  }
752
1877
  return parts.join("\n\n");
753
1878
  }
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
- }
1879
+ /**
1880
+ * Detect on-disk drift: true when the file is not in the canonical
1881
+ * `render(normalizeEntries(raw))` form. This catches structural anomalies
1882
+ * the writer would quietly normalize away (empty/`§`-only entries, stray
1883
+ * blank lines, leading/trailing delimiters) that indicate the file was
1884
+ * edited outside MemoryStore. Purely single-canonical content reaches the
1885
+ * same serialization and returns false, so a normal write is never flagged.
1886
+ *
1887
+ * An absent, empty, or whitespace-only file is the "never written" state
1888
+ * (rc.42 audit P1-6): it parses to zero entries, so the canonical form
1889
+ * `'\n'` can never byte-match it and every write path was permanently
1890
+ * refused with "External drift detected" — including the repairs the model
1891
+ * would need to make. Such files are adopted instead of flagged.
1892
+ */
765
1893
  async detectDrift(target) {
1894
+ if (await this.oversizedFile(target)) return true;
766
1895
  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();
1896
+ if (raw === null || raw.trim() === "") return false;
1897
+ const entries = normalizeEntries(raw);
1898
+ const limit = this.limitFor(target);
1899
+ if (limit > 0 && entries.some((entry) => entry.length > limit)) return true;
1900
+ return render(entries) !== raw;
769
1901
  }
770
1902
  };
771
1903
  //#endregion
772
- //#region lib/types/prompts.js
1904
+ //#region lib/types/mutations.js
773
1905
  /**
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.
1906
+ * Curator/author audit trail: `.mutations.json` records every skill mutation
1907
+ * with before/after content hashes so any automated edit is reviewable and
1908
+ * replayable. Best-effort persistence, mirroring the usage sidecar posture.
1909
+ * @module @lmzhen/dsh-evolution-core
782
1910
  */
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;
1911
+ const DEFAULT_MUTATION_CAP = 500;
1912
+ /** Version of the `.mutations.json` file shape; writers always emit the current one. */
1913
+ const MUTATIONS_FILE_VERSION = 1;
1914
+ function mutationsFile(root) {
1915
+ return join(root, ".mutations.json");
1916
+ }
1917
+ function contentHash(content) {
1918
+ return createHash("sha256").update(content).digest("hex");
1919
+ }
1920
+ /**
1921
+ * Parse a raw mutations sidecar; malformed content reads as empty (auditing is
1922
+ * best-effort). Versioned shape ({ version, records }) with legacy
1923
+ * plain-array compat, plus a field-level guard for records without the
1924
+ * required identity/timestamp fields (rc.42 audit P2-3).
1925
+ */
1926
+ function parseMutationRecords(raw) {
1927
+ if (raw === null) return [];
1928
+ try {
1929
+ const parsed = JSON.parse(raw);
1930
+ 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");
1931
+ } catch {
1932
+ return [];
1933
+ }
1934
+ }
1935
+ async function loadMutations(root, io = nodeEvolutionIo()) {
1936
+ return parseMutationRecords(await io.readText(mutationsFile(root)));
1937
+ }
1938
+ /** Append one record, trim to `cap`, and write atomically (versioned shape). */
1939
+ async function recordMutation(root, io, record, cap = 500) {
1940
+ await transactIo(io, mutationsFile(root), async (current) => {
1941
+ if (current !== null) try {
1942
+ JSON.parse(current);
1943
+ } catch {
1944
+ return current;
1945
+ }
1946
+ const existing = parseMutationRecords(current);
1947
+ existing.push(record);
1948
+ const trimmed = existing.length > cap ? existing.slice(existing.length - cap) : existing;
1949
+ return Promise.resolve(JSON.stringify({
1950
+ version: 1,
1951
+ records: trimmed
1952
+ }, null, 2));
1953
+ });
849
1954
  }
850
- function sha256(text) {
851
- return createHash("sha256").update(text).digest("hex");
1955
+ //#endregion
1956
+ //#region lib/types/quality.js
1957
+ /**
1958
+ * Quality scoring and near-duplicate detection for the curated skill library.
1959
+ *
1960
+ * Pure functions over data inputs so the scoring policy is unit-testable and
1961
+ * the same math feeds the usage sidecar, the `skill_manage review` surface and
1962
+ * the learning graph. Weights follow the Hermes/hermes-claw six-factor model;
1963
+ * mutation maturity is a documented DSH approximation (single per-month patch
1964
+ * trend ratio replaces the claw timestamp-trend formula, since DSH usage
1965
+ * records only carry the last patched timestamp).
1966
+ * @module @lmzhen/dsh-evolution-core
1967
+ */
1968
+ const QUALITY_WEIGHTS = {
1969
+ usageFrequency: .25,
1970
+ stability: .2,
1971
+ recency: .2,
1972
+ references: .1,
1973
+ mutationMaturity: .2,
1974
+ richness: .05
1975
+ };
1976
+ /** Score below which a skill is flagged for review. */
1977
+ const LOW_QUALITY_THRESHOLD = .3;
1978
+ function clamp01(value) {
1979
+ return Math.max(0, Math.min(1, value));
852
1980
  }
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
- });
1981
+ function daysBetween(from, now) {
1982
+ return Math.max(0, (now.getTime() - new Date(from).getTime()) / 864e5);
865
1983
  }
866
- const PROMPT_BUNDLE = createPromptBundle({
867
- memory: MEMORY_REVIEW_PROMPT,
868
- skill: SKILL_REVIEW_PROMPT,
869
- combined: COMBINED_REVIEW_PROMPT,
870
- curator: CURATOR_PROMPT
871
- });
872
- function verifyPromptBundle(bundle = PROMPT_BUNDLE) {
873
- const canonical = JSON.stringify({
874
- id: bundle.id,
875
- version: bundle.version,
876
- prompts: Object.fromEntries(Object.entries(bundle.prompts).sort())
877
- });
878
- return bundle.sha256 === sha256(canonical);
1984
+ function computeQualityScores(input) {
1985
+ const now = input.now ?? /* @__PURE__ */ new Date();
1986
+ const scores = /* @__PURE__ */ new Map();
1987
+ for (const [name, record] of input.usage) {
1988
+ const ageDays = Math.max(1, daysBetween(record.created_at, now));
1989
+ const idleDays = daysBetween(latestActivityAt(record) ?? record.created_at, now);
1990
+ const patchCount = record.patch_count;
1991
+ const useCount = record.use_count;
1992
+ const usageFrequency = clamp01(useCount / ageDays);
1993
+ const stability = useCount === 0 ? 1 : clamp01(1 - patchCount / useCount);
1994
+ const recency = idleDays < 30 ? 1 : clamp01(1 - (idleDays - 30) / 150);
1995
+ const references = clamp01((input.referenceCounts?.get(name) ?? 0) / 3);
1996
+ const mutationMaturity = patchCount === 0 ? .3 : patchCount === 1 ? .4 : clamp01((patchCount - 1) / Math.max(1, ageDays / 30));
1997
+ const richness = clamp01((input.supportDirs?.get(name) ?? 0) * .175);
1998
+ const factors = {
1999
+ usageFrequency,
2000
+ stability,
2001
+ recency,
2002
+ references,
2003
+ mutationMaturity,
2004
+ richness
2005
+ };
2006
+ 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;
2007
+ scores.set(name, {
2008
+ score,
2009
+ factors,
2010
+ warn: score < LOW_QUALITY_THRESHOLD
2011
+ });
2012
+ }
2013
+ return scores;
2014
+ }
2015
+ function normalize(content) {
2016
+ return content.toLowerCase().replace(/\s+/g, " ").trim();
2017
+ }
2018
+ function contentHash$1(content) {
2019
+ return createHash("sha256").update(normalize(content)).digest("hex");
2020
+ }
2021
+ function tokenize(content) {
2022
+ return new Set(normalize(content).split(/[^a-z0-9\u4e00-\u9fff]+/).filter(Boolean));
2023
+ }
2024
+ function jaccard(a, b) {
2025
+ if (a.size === 0 || b.size === 0) return 0;
2026
+ let intersection = 0;
2027
+ for (const token of a) if (b.has(token)) intersection += 1;
2028
+ return intersection / (a.size + b.size - intersection);
2029
+ }
2030
+ /**
2031
+ * Two-phase near-duplicate clustering: exact normalized-hash groups first,
2032
+ * then token-Jaccard edges at {@link DEDUP_SIMILARITY_THRESHOLD} with a token
2033
+ * ratio guard, union-find across the whole set.
2034
+ */
2035
+ function computeDedupGroups(input) {
2036
+ const threshold = input.threshold ?? .95;
2037
+ const names = [...input.contents.keys()];
2038
+ const hashes = /* @__PURE__ */ new Map();
2039
+ for (const name of names) {
2040
+ const hash = contentHash$1(input.contents.get(name) ?? "");
2041
+ const bucket = hashes.get(hash);
2042
+ if (bucket) bucket.push(name);
2043
+ else hashes.set(hash, [name]);
2044
+ }
2045
+ const parent = /* @__PURE__ */ new Map();
2046
+ const find = (x) => {
2047
+ const root = parent.get(x) ?? x;
2048
+ if (root !== x) parent.set(x, find(root));
2049
+ return parent.get(x) ?? x;
2050
+ };
2051
+ const union = (a, b) => {
2052
+ const [ra, rb] = [find(a), find(b)];
2053
+ if (ra !== rb) parent.set(rb, ra);
2054
+ };
2055
+ for (const [hash, bucketNames] of hashes) {
2056
+ const first = bucketNames[0];
2057
+ if (first === void 0 || bucketNames.length === 1) continue;
2058
+ for (let index = 1; index < bucketNames.length; index += 1) {
2059
+ const peer = bucketNames[index];
2060
+ if (peer) union(first, peer);
2061
+ }
2062
+ }
2063
+ const tokens = /* @__PURE__ */ new Map();
2064
+ const tokenSet = (name) => {
2065
+ let set = tokens.get(name);
2066
+ if (!set) {
2067
+ set = tokenize(input.contents.get(name) ?? "");
2068
+ tokens.set(name, set);
2069
+ }
2070
+ return set;
2071
+ };
2072
+ for (let index = 0; index < names.length; index += 1) {
2073
+ const a = names[index];
2074
+ if (a === void 0) continue;
2075
+ for (let other = index + 1; other < names.length; other += 1) {
2076
+ const b = names[other];
2077
+ if (b === void 0) continue;
2078
+ const [ta, tb] = [tokenSet(a), tokenSet(b)];
2079
+ if (Math.max(ta.size, tb.size) / Math.max(1, Math.min(ta.size, tb.size)) > 5) continue;
2080
+ if (jaccard(ta, tb) >= threshold) union(a, b);
2081
+ }
2082
+ }
2083
+ const groups = /* @__PURE__ */ new Map();
2084
+ for (const name of names) {
2085
+ const root = find(name);
2086
+ const group = groups.get(root);
2087
+ if (group) group.push(name);
2088
+ else groups.set(root, [name]);
2089
+ }
2090
+ return [...groups.values()].filter((group) => group.length > 1);
2091
+ }
2092
+ /**
2093
+ * Prefix-cluster index over a name set (rc.67 merge heuristic, input side):
2094
+ * the curator prompt asks the model to identify "prefix clusters — skills
2095
+ * sharing a first word or domain keyword"; the deterministic index supplies
2096
+ * stable ground truth instead of letting the model infer clusters from the
2097
+ * raw list. Key = first alphanumeric run of the lowercased name; groups with
2098
+ * at least two members, largest first then alphabetical. Orientation-only:
2099
+ * nomination authority stays with the LLM and the candidate-pool gates.
2100
+ */
2101
+ function computePrefixClusters(names) {
2102
+ const groups = /* @__PURE__ */ new Map();
2103
+ for (const name of names) {
2104
+ const key = name.toLowerCase().split(/[^a-z0-9]+/)[0];
2105
+ if (!key) continue;
2106
+ const bucket = groups.get(key);
2107
+ if (bucket) bucket.push(name);
2108
+ else groups.set(key, [name]);
2109
+ }
2110
+ return [...groups.entries()].filter(([, members]) => members.length >= 2).map(([key, members]) => ({
2111
+ key,
2112
+ members
2113
+ })).sort((a, b) => b.members.length - a.members.length || a.key.localeCompare(b.key));
879
2114
  }
880
- const DSH_AUTHORING_STANDARDS = `Follow the Hermes skill-authoring standards, translated to DSH tools.
881
-
882
- Frontmatter:
883
- - name: lowercase-hyphenated, <=64 chars, no spaces.
884
- - description: ONE sentence, <=60 characters, ends with a period. State the capability, not the implementation. No marketing words. Do NOT repeat the skill name. Count the characters before saving.
885
- - version: 0.1.0
886
- - author: always the literal value "Hermes". NEVER fill it from the environment, git config, or any identity you can probe.
887
- - platforms: declare [macos], [linux], and/or [windows] only when the skill is genuinely OS-bound; omit for portable skills.
888
- - metadata.hermes.tags: a few Capitalized, Relevant, Tags.
889
-
890
- Body section order (omit only when empty):
891
- 1. "# <Human Title>" then a 2-3 sentence intro: what it does, what it does NOT do, key dependency stance.
892
- 2. "## When to Use" — concrete trigger phrases.
893
- 3. "## Prerequisites" — exact env vars, install steps, credentials.
894
- 4. "## How to Run" — canonical invocation framed through DSH tools.
895
- 5. "## Quick Reference" — flat command/endpoint list.
896
- 6. "## Procedure" — numbered steps with copy-paste-exact commands.
897
- 7. "## Pitfalls" — known limits and rate limits.
898
- 8. "## Verification" — one check proving the skill worked.
899
-
900
- DSH-tool framing:
901
- - Reference DSH tools by name in backticks: \`bash\`, \`str_replace_editor\`, \`write\`, \`skill\`, \`skill_manage\`, \`memory\`.
902
- - Do not name wrapped shell utilities when a DSH tool already covers them.
903
- - Larger scripts belong under \`scripts/\` (written with \`skill_manage write_file\`) and are referenced from SKILL.md by relative path.
904
-
905
- Quality bar:
906
- - Prefer verbatim flags, paths, and APIs from the source. Never invent them.
907
- - Keep it tight: ~100 lines simple, ~200 complex.
908
- - No router/index/hub skills that only point at other skills.
909
- - References go in \`references/\`, templates in \`templates/\`.`;
910
2115
  //#endregion
911
2116
  //#region lib/types/signals.js
912
2117
  /**
@@ -994,31 +2199,53 @@ function foldTurn(session, fromSeq) {
994
2199
  * it created unless a `.hermes-managed` marker opts a skill in. Archival is a
995
2200
  * move to `.archive/` — never a hard delete.
996
2201
  */
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
2202
  const DEFAULT_SKILL_LIMITS = {
1003
2203
  maxNameLength: 64,
1004
2204
  maxDescriptionLength: MAX_DESCRIPTION_LENGTH,
1005
2205
  maxSkillContentChars: MAX_SKILL_CONTENT_CHARS,
1006
2206
  maxSkillFileBytes: MAX_SKILL_FILE_BYTES
1007
2207
  };
1008
- const SUPPORT_DIRS = [
1009
- "references",
1010
- "templates",
1011
- "scripts",
1012
- "assets"
1013
- ];
2208
+ /** Extra file name carried inside a snapshot's `extras/` directory. */
2209
+ const SNAPSHOT_EXTRA_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
1014
2210
  function skillsRoot(env = process.env) {
1015
2211
  return join(env.DSH_HOME ?? join(homedir(), ".dsh"), "skills");
1016
2212
  }
2213
+ /**
2214
+ * Map a requesting session onto the two origin surfaces (rc.44 plan M2-2.3):
2215
+ * the APPROVAL surface treats every delegated subagent as the autonomous
2216
+ * review channel, while the LIBRARY surface keeps the Hermes distinction -
2217
+ * the review fork is 'background_review' (the pinned guard blocks its
2218
+ * writes) and any other subagent is 'subagent' (agent-authored, not
2219
+ * review-channel). `isReview` marks the caller as the background review
2220
+ * pipeline itself. Single source: the two tools and the review executor all
2221
+ * read this table instead of re-deriving it.
2222
+ */
2223
+ function resolveOrigins(headerOrigin, isReview = false) {
2224
+ if (isReview) return {
2225
+ approval: "background_review",
2226
+ library: "background_review"
2227
+ };
2228
+ if (headerOrigin === "subagent") return {
2229
+ approval: "background_review",
2230
+ library: "subagent"
2231
+ };
2232
+ return {
2233
+ approval: "foreground",
2234
+ library: "foreground"
2235
+ };
2236
+ }
1017
2237
  function skillDir(root, name) {
1018
2238
  return join(root, name);
1019
2239
  }
2240
+ /** Dot-prefixed on-disk marker name. SINGLE source: `list()` matches directory
2241
+ * entries against this name, and path builders must never hardcode a marker
2242
+ * literal (N-1: the rc.49 exists()-probe convergence dropped the dot,
2243
+ * poisoning every protectedBy/managed report). */
2244
+ function markerEntryName(marker) {
2245
+ return `.${marker}`;
2246
+ }
1020
2247
  function markerPath(dir, marker) {
1021
- return join(dir, `.${marker}`);
2248
+ return join(dir, markerEntryName(marker));
1022
2249
  }
1023
2250
  function parseFrontmatter(content) {
1024
2251
  if (!content.trimStart().startsWith("---")) return null;
@@ -1040,6 +2267,26 @@ function parseFrontmatter(content) {
1040
2267
  body
1041
2268
  };
1042
2269
  }
2270
+ /**
2271
+ * Skill names referenced by a SKILL.md's `related_skills` frontmatter
2272
+ * (B-line G3, rc.44): the single parsing source for the quality references
2273
+ * factor and the learning-graph edges. The DSH frontmatter parser keeps the
2274
+ * YAML value as a string (`"[a, b]"`), so names are scanned out of it; each
2275
+ * must satisfy the skill-name shape and the referencing skill itself is
2276
+ * excluded. Pure and deduplicated.
2277
+ */
2278
+ function relatedSkillNames(content, exclude) {
2279
+ const parsed = parseFrontmatter(content);
2280
+ if (!parsed) return [];
2281
+ const raw = parsed.frontmatter["related_skills"];
2282
+ if (typeof raw !== "string") return [];
2283
+ const names = /* @__PURE__ */ new Set();
2284
+ for (const match of Array.from(raw.matchAll(/[a-z0-9][a-z0-9-]*/g))) {
2285
+ const target = match[0];
2286
+ if (target && SKILL_NAME_RE.test(target) && target !== exclude) names.add(target);
2287
+ }
2288
+ return [...names];
2289
+ }
1043
2290
  function validateFrontmatter(content, expectedName, limits = DEFAULT_SKILL_LIMITS) {
1044
2291
  const parsed = parseFrontmatter(content);
1045
2292
  if (!parsed) return "SKILL.md must start and end with YAML frontmatter and include a body.";
@@ -1052,6 +2299,31 @@ function validateFrontmatter(content, expectedName, limits = DEFAULT_SKILL_LIMIT
1052
2299
  if (content.length > limits.maxSkillContentChars) return `SKILL.md content exceeds ${limits.maxSkillContentChars} characters.`;
1053
2300
  return null;
1054
2301
  }
2302
+ /** Hermes authoring quality bar for descriptions (the 60-char Rule). The
2303
+ * platform's own index limit stays in `validateFrontmatter`; this bar is the
2304
+ * target the authoring standard names, enforced as ADVISORY feedback. */
2305
+ const AUTHORING_DESCRIPTION_BAR = 60;
2306
+ /**
2307
+ * Advisory authoring feedback (P0): evaluate frontmatter against the
2308
+ * authoring bar WITHOUT changing platform validation semantics. The bar is
2309
+ * the quality target, `validateFrontmatter`'s limits are the compatibility
2310
+ * floor, and this bridge layer tells the model when its text would be
2311
+ * truncated or route-poor instead of silently shipping it.
2312
+ */
2313
+ function authoringFeedback(frontmatter) {
2314
+ const description = frontmatter.description ?? "";
2315
+ const over60 = description.length > 60;
2316
+ const hasColon = description.includes(":");
2317
+ const lines = [];
2318
+ lines.push(over60 ? `Description is ${description.length}/60 characters — exceeds the 60-char authoring bar (Hermes standard; the catalog truncates at the configured platform cap).` : `Description ${description.length}/60 characters — within the authoring bar.`);
2319
+ if (hasColon) lines.push("Description contains a colon — wrap the whole value in double quotes.");
2320
+ return {
2321
+ descriptionChars: description.length,
2322
+ over60,
2323
+ hasColon,
2324
+ lines
2325
+ };
2326
+ }
1055
2327
  async function listNames(root, io) {
1056
2328
  const entries = await io.list(root);
1057
2329
  const names = [];
@@ -1069,65 +2341,274 @@ function validateSupportPath(filePath) {
1069
2341
  if (parts.length < 2) return "Provide a file name, not just a directory.";
1070
2342
  return null;
1071
2343
  }
2344
+ /**
2345
+ * Index of `pattern` inside `content`, treating whitespace runs (spaces/tabs)
2346
+ * as flexible and literal escape sequences (`\n`, `\t`, `\r`) as their real
2347
+ * characters: a PATTERN whitespace run matches any content run of any length
2348
+ * (even empty), while extra whitespace that only exists in the content is not
2349
+ * skipped — the flexibility is one-sided on the pattern, and a backslash-
2350
+ * escaped char in the pattern matches the real char in the content
2351
+ * (model-copy drift). Returns the [start, end) range in the ORIGINAL content
2352
+ * so a patch can replace exactly the matched span and keep every other byte
2353
+ * intact. Returns null when no fuzzy match exists.
2354
+ */
2355
+ function fuzzyIndexOf(content, pattern, from = 0) {
2356
+ const isSpace = (char) => char !== void 0 && /[ \t]/.test(char);
2357
+ const escaped = (char) => {
2358
+ if (char === "n") return "\n";
2359
+ if (char === "t") return " ";
2360
+ if (char === "r") return "\r";
2361
+ return null;
2362
+ };
2363
+ for (let start = from; start < content.length; start += 1) {
2364
+ let contentIndex = start;
2365
+ let patternIndex = 0;
2366
+ while (patternIndex < pattern.length && contentIndex < content.length) {
2367
+ const patternChar = pattern[patternIndex];
2368
+ const contentChar = content[contentIndex];
2369
+ if (isSpace(patternChar)) {
2370
+ while (patternIndex < pattern.length && isSpace(pattern[patternIndex])) patternIndex += 1;
2371
+ while (contentIndex < content.length && isSpace(content[contentIndex])) contentIndex += 1;
2372
+ continue;
2373
+ }
2374
+ const escapedChar = patternChar === "\\" ? escaped(pattern[patternIndex + 1]) : null;
2375
+ if (escapedChar !== null && contentChar === escapedChar) {
2376
+ patternIndex += 2;
2377
+ contentIndex += 1;
2378
+ continue;
2379
+ }
2380
+ if (patternChar === contentChar) {
2381
+ contentIndex += 1;
2382
+ patternIndex += 1;
2383
+ continue;
2384
+ }
2385
+ break;
2386
+ }
2387
+ if (patternIndex === pattern.length) return [start, contentIndex];
2388
+ }
2389
+ return null;
2390
+ }
2391
+ /** Trim leading whitespace of the first line and trailing whitespace of the last line. */
2392
+ function trimPatternBoundaries(pattern) {
2393
+ const from = pattern.search(/\S/);
2394
+ const trimmed = from < 0 ? pattern : pattern.slice(from);
2395
+ const trailing = trimmed.search(/\s+$/);
2396
+ return trailing < 0 ? trimmed : trimmed.slice(0, trailing);
2397
+ }
2398
+ /** Replace only the fuzzy-matched span, preserving all surrounding bytes. */
2399
+ function fuzzyReplace(content, oldString, newString, replaceAll) {
2400
+ let current = content;
2401
+ let scanFrom = 0;
2402
+ for (;;) {
2403
+ const match = fuzzyIndexOf(current, oldString, scanFrom);
2404
+ if (match === null) return current;
2405
+ const [start, end] = match;
2406
+ const next = current.slice(0, start) + newString + current.slice(end);
2407
+ if (!replaceAll) return next;
2408
+ current = next;
2409
+ scanFrom = start + newString.length;
2410
+ }
2411
+ }
1072
2412
  function fuzzyPatch(content, oldString, newString, replaceAll = false) {
2413
+ if (oldString === "") return null;
1073
2414
  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);
2415
+ const boundary = trimPatternBoundaries(oldString);
2416
+ if (boundary === "") return null;
2417
+ if (boundary !== oldString) {
2418
+ if (fuzzyIndexOf(content, boundary) !== null) {
2419
+ const patched = fuzzyReplace(content, boundary, newString, replaceAll);
2420
+ return patched === content ? null : patched;
2421
+ }
2422
+ }
2423
+ if (fuzzyIndexOf(content, oldString) !== null) {
2424
+ const patched = fuzzyReplace(content, oldString, newString, replaceAll);
2425
+ return patched === content ? null : patched;
2426
+ }
1078
2427
  return null;
1079
2428
  }
1080
2429
  var SkillLibrary = class {
1081
2430
  root;
1082
2431
  limits;
1083
2432
  io;
1084
- constructor(root = skillsRoot(), io = nodeEvolutionIo(), limits = DEFAULT_SKILL_LIMITS) {
2433
+ onMutation;
2434
+ constructor(root = skillsRoot(), io = nodeEvolutionIo(), limits = DEFAULT_SKILL_LIMITS, onMutation) {
1085
2435
  this.root = root;
1086
2436
  this.io = io;
1087
2437
  this.limits = limits;
2438
+ this.onMutation = onMutation;
2439
+ }
2440
+ /** Notify the mutation observer after a successful write; observers must never fail the mutation. */
2441
+ notifyMutation(event) {
2442
+ try {
2443
+ this.onMutation?.(event);
2444
+ } catch {}
1088
2445
  }
1089
2446
  async list() {
1090
2447
  const summaries = [];
1091
2448
  for (const name of await listNames(this.root, this.io)) {
1092
- const dir = skillDir(this.root, name);
2449
+ const dir = this.dirOf(name);
1093
2450
  const md = await this.io.readText(join(dir, "SKILL.md"));
1094
2451
  if (!md) continue;
1095
2452
  const parsed = parseFrontmatter(md);
1096
- const protectedBy = await this.deleteProtection(name);
1097
- const managed = await this.io.exists(markerPath(dir, "hermes-managed"));
2453
+ let entries = [];
2454
+ try {
2455
+ entries = await this.io.list(dir);
2456
+ } catch {}
2457
+ const has = (marker) => entries.includes(markerEntryName(marker));
2458
+ const protectedBy = has("bundled") ? "bundled" : has("hub-installed") ? "hub-installed" : has("pinned") ? "pinned" : null;
1098
2459
  summaries.push({
1099
2460
  name,
1100
2461
  description: parsed?.frontmatter.description ?? "",
1101
2462
  path: dir,
1102
2463
  protectedBy,
1103
- managed,
2464
+ managed: has("hermes-managed"),
1104
2465
  archived: false
1105
2466
  });
1106
2467
  }
1107
2468
  return summaries;
1108
2469
  }
1109
- async read(name) {
1110
- return this.io.readText(join(skillDir(this.root, name), "SKILL.md"));
2470
+ async read(rawName) {
2471
+ const name = rawName.trim();
2472
+ if (this.badName(name) !== null) return null;
2473
+ return this.io.readText(join(this.dirOf(name), "SKILL.md"));
2474
+ }
2475
+ /**
2476
+
2477
+ * Single path-building choke point (rc.42 audit P2-5): every directory path
2478
+
2479
+ * is built from the TRIMMED name, so a name that passes `badName` (which
2480
+
2481
+ * trims before validating) can never mint a second, whitespace-padded
2482
+
2483
+ * directory next to the real one. Callers keep passing raw user input.
2484
+
2485
+ */
2486
+ dirOf(name) {
2487
+ return skillDir(this.root, name.trim());
2488
+ }
2489
+ /** Name-format guard shared by every path-building mutator/reader. */
2490
+ badName(name) {
2491
+ const normalized = name.trim();
2492
+ 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}).`;
2493
+ return null;
1111
2494
  }
1112
- async writeProtection(name) {
1113
- const dir = skillDir(this.root, name);
2495
+ async writeProtection(rawName, origin = "foreground") {
2496
+ const name = rawName.trim();
2497
+ const dir = this.dirOf(name);
1114
2498
  for (const marker of ["bundled", "hub-installed"]) if (await this.io.exists(markerPath(dir, marker))) return marker;
2499
+ if (origin === "background_review" && await this.io.exists(markerPath(dir, "pinned"))) return "pinned";
1115
2500
  return null;
1116
2501
  }
1117
- async deleteProtection(name) {
1118
- const dir = skillDir(this.root, name);
1119
- for (const marker of [
2502
+ async deleteProtection(rawName, options = {}) {
2503
+ const name = rawName.trim();
2504
+ const dir = this.dirOf(name);
2505
+ const markers = options.allowBundled ? ["hub-installed", "pinned"] : [
1120
2506
  "bundled",
1121
2507
  "hub-installed",
1122
2508
  "pinned"
1123
- ]) if (await this.io.exists(markerPath(dir, marker))) return marker;
2509
+ ];
2510
+ for (const marker of markers) if (await this.io.exists(markerPath(dir, marker))) return marker;
1124
2511
  return null;
1125
2512
  }
1126
- async isManaged(name) {
1127
- const dir = skillDir(this.root, name);
2513
+ async isManaged(rawName) {
2514
+ const name = rawName.trim();
2515
+ const dir = this.dirOf(name);
1128
2516
  return await this.io.exists(markerPath(dir, "hermes-managed"));
1129
2517
  }
1130
- async create(name, content, origin) {
2518
+ /** Whether the skill carries the bundled marker (curator prune-builtins eligibility). */
2519
+ async isBundled(rawName) {
2520
+ const name = rawName.trim();
2521
+ if (this.badName(name) !== null) return false;
2522
+ const dir = this.dirOf(name);
2523
+ return await this.io.exists(markerPath(dir, "bundled"));
2524
+ }
2525
+ /** Whether the skill carries the pinned marker (the marker is the factual source; usage.pinned mirrors it). */
2526
+ async isPinned(rawName) {
2527
+ const name = rawName.trim();
2528
+ if (this.badName(name) !== null) return false;
2529
+ const dir = this.dirOf(name);
2530
+ return await this.io.exists(markerPath(dir, "pinned"));
2531
+ }
2532
+ /** Count non-empty support subdirectories (richness input for quality scoring). */
2533
+ async countSupportDirs(rawName) {
2534
+ const name = rawName.trim();
2535
+ if (this.badName(name) !== null) return 0;
2536
+ const dir = this.dirOf(name);
2537
+ let entries;
2538
+ try {
2539
+ entries = await this.io.list(dir);
2540
+ } catch {
2541
+ return 0;
2542
+ }
2543
+ let count = 0;
2544
+ for (const subdir of SUPPORT_DIRS) {
2545
+ if (!entries.includes(subdir)) continue;
2546
+ try {
2547
+ if ((await this.io.list(join(dir, subdir))).some((file) => file !== ".gitkeep")) count += 1;
2548
+ } catch {}
2549
+ }
2550
+ return count;
2551
+ }
2552
+ /** Best-effort audit trail entry; never blocks the mutation. */
2553
+ async audit(skillName, action, before, after, summary) {
2554
+ try {
2555
+ await recordMutation(this.root, this.io, {
2556
+ skillName,
2557
+ action,
2558
+ ...before === null ? {} : { beforeHash: contentHash(before) },
2559
+ ...after === null ? {} : { afterHash: contentHash(after) },
2560
+ summary,
2561
+ at: (/* @__PURE__ */ new Date()).toISOString()
2562
+ });
2563
+ } catch {}
2564
+ }
2565
+ /** Recent mutation audit records (read-only inspection surface). */
2566
+ async listMutations() {
2567
+ return await loadMutations(this.root, this.io);
2568
+ }
2569
+ /**
2570
+ * Pin or unpin a skill (`.pinned` marker). Pinned skills are protected from
2571
+ * deletion, from background-review writes, and from the lifecycle — a
2572
+ * protective mutation, so the autonomous pipeline may never call it. The
2573
+ * marker write is the only state change; content is untouched.
2574
+ */
2575
+ async setPinned(name, pinned, origin = "foreground") {
2576
+ const normalized = name.trim();
2577
+ if (!SKILL_NAME_RE.test(normalized) || normalized.length > this.limits.maxNameLength) return {
2578
+ ok: false,
2579
+ message: `Invalid skill name "${normalized}". Use lowercase letters, digits, and hyphens (<= ${this.limits.maxNameLength}).`
2580
+ };
2581
+ if (origin === "background_review") return {
2582
+ ok: false,
2583
+ message: "Only the foreground (user or the main agent) may pin or unpin skills."
2584
+ };
2585
+ const dir = this.dirOf(normalized);
2586
+ const marker = markerPath(dir, "pinned");
2587
+ const existing = await this.io.exists(marker);
2588
+ if (pinned && existing) return {
2589
+ ok: true,
2590
+ message: `Skill "${normalized}" is already pinned.`,
2591
+ path: dir
2592
+ };
2593
+ if (!pinned && !existing) return {
2594
+ ok: true,
2595
+ message: `Skill "${normalized}" is not pinned; nothing to do.`,
2596
+ path: dir
2597
+ };
2598
+ if (!await this.io.exists(join(dir, "SKILL.md"))) return {
2599
+ ok: false,
2600
+ message: `Skill "${normalized}" not found.`
2601
+ };
2602
+ if (pinned) await this.io.writeText(marker, "");
2603
+ else await this.io.remove(marker);
2604
+ await this.audit(normalized, pinned ? "pin" : "unpin", null, null, pinned ? "pinned" : "unpinned");
2605
+ return {
2606
+ ok: true,
2607
+ message: pinned ? `Skill "${normalized}" pinned: protected from deletion, background review, and the lifecycle.` : `Skill "${normalized}" unpinned.`,
2608
+ path: dir
2609
+ };
2610
+ }
2611
+ async create(name, content, origin = "foreground") {
1131
2612
  const normalized = name.trim();
1132
2613
  if (!SKILL_NAME_RE.test(normalized) || normalized.length > this.limits.maxNameLength) return {
1133
2614
  ok: false,
@@ -1143,26 +2624,39 @@ var SkillLibrary = class {
1143
2624
  ok: false,
1144
2625
  message: threat
1145
2626
  };
1146
- const dir = skillDir(this.root, normalized);
2627
+ const dir = this.dirOf(normalized);
1147
2628
  if (await this.io.exists(join(dir, "SKILL.md"))) return {
1148
2629
  ok: false,
1149
2630
  message: `Skill "${normalized}" already exists.`
1150
2631
  };
1151
2632
  await this.io.writeText(join(dir, "SKILL.md"), content.trimEnd() + "\n");
1152
- if (origin === "background_review") await this.io.writeText(markerPath(dir, "hermes-managed"), "");
2633
+ if (origin !== "foreground") await this.io.writeText(markerPath(dir, "hermes-managed"), "");
2634
+ await this.audit(normalized, "create", null, content, "created");
2635
+ this.notifyMutation({
2636
+ action: "create",
2637
+ name: normalized,
2638
+ filePath: dir
2639
+ });
1153
2640
  return {
1154
2641
  ok: true,
1155
2642
  message: `Skill "${normalized}" created.`,
1156
2643
  path: dir
1157
2644
  };
1158
2645
  }
1159
- async update(name, content) {
1160
- const dir = skillDir(this.root, name);
1161
- if (!await this.io.readText(join(dir, "SKILL.md"))) return {
2646
+ async update(rawName, content, origin = "foreground") {
2647
+ const name = rawName.trim();
2648
+ const badName = this.badName(name);
2649
+ if (badName) return {
2650
+ ok: false,
2651
+ message: badName
2652
+ };
2653
+ const dir = this.dirOf(name);
2654
+ const md = await this.io.readText(join(dir, "SKILL.md"));
2655
+ if (!md) return {
1162
2656
  ok: false,
1163
2657
  message: `Skill "${name}" not found.`
1164
2658
  };
1165
- const protection = await this.writeProtection(name);
2659
+ const protection = await this.writeProtection(name, origin);
1166
2660
  if (protection) return {
1167
2661
  ok: false,
1168
2662
  message: `Skill "${name}" is protected (${protection}).`
@@ -1178,20 +2672,32 @@ var SkillLibrary = class {
1178
2672
  message: threat
1179
2673
  };
1180
2674
  await this.io.writeText(join(dir, "SKILL.md"), content.trimEnd() + "\n");
2675
+ await this.audit(name, "update", md, content, "updated");
2676
+ this.notifyMutation({
2677
+ action: "update",
2678
+ name,
2679
+ filePath: dir
2680
+ });
1181
2681
  return {
1182
2682
  ok: true,
1183
2683
  message: `Skill "${name}" updated.`,
1184
2684
  path: dir
1185
2685
  };
1186
2686
  }
1187
- async patch(name, oldString, newString, filePath = "", replaceAll = false) {
1188
- const dir = skillDir(this.root, name);
2687
+ async patch(rawName, oldString, newString, filePath = "", replaceAll = false, origin = "foreground") {
2688
+ const name = rawName.trim();
2689
+ const badName = this.badName(name);
2690
+ if (badName) return {
2691
+ ok: false,
2692
+ message: badName
2693
+ };
2694
+ const dir = this.dirOf(name);
1189
2695
  const skillMd = join(dir, "SKILL.md");
1190
2696
  if (!await this.io.exists(skillMd)) return {
1191
2697
  ok: false,
1192
2698
  message: `Skill "${name}" not found.`
1193
2699
  };
1194
- const protection = await this.writeProtection(name);
2700
+ const protection = await this.writeProtection(name, origin);
1195
2701
  if (protection) return {
1196
2702
  ok: false,
1197
2703
  message: `Skill "${name}" is protected (${protection}).`
@@ -1213,7 +2719,7 @@ var SkillLibrary = class {
1213
2719
  message: `File not found: ${patchLabel}`
1214
2720
  };
1215
2721
  const patched = fuzzyPatch(md, oldString, newString, replaceAll);
1216
- if (!patched) return {
2722
+ if (patched === null) return {
1217
2723
  ok: false,
1218
2724
  message: `Could not find old_string in "${name}/${patchLabel}". Use update for a full rewrite.`
1219
2725
  };
@@ -1238,40 +2744,69 @@ var SkillLibrary = class {
1238
2744
  message: threat
1239
2745
  };
1240
2746
  await this.io.writeText(target, patched.trimEnd() + "\n");
2747
+ await this.audit(name, "patch", md, patched, `patched ${patchLabel}`);
2748
+ this.notifyMutation({
2749
+ action: "patch",
2750
+ name,
2751
+ filePath: dir
2752
+ });
1241
2753
  return {
1242
2754
  ok: true,
1243
2755
  message: `Skill "${name}" patched (${patchLabel}).`,
1244
2756
  path: dir
1245
2757
  };
1246
2758
  }
1247
- async archive(name, absorbedInto = "") {
1248
- const dir = skillDir(this.root, name);
1249
- if (!await this.io.readText(join(dir, "SKILL.md"))) return {
2759
+ async archive(rawName, options = {}) {
2760
+ const name = rawName.trim();
2761
+ const badName = this.badName(name);
2762
+ if (badName) return {
2763
+ ok: false,
2764
+ message: badName
2765
+ };
2766
+ const dir = this.dirOf(name);
2767
+ const md = await this.io.readText(join(dir, "SKILL.md"));
2768
+ if (!md) return {
1250
2769
  ok: false,
1251
2770
  message: `Skill "${name}" not found.`
1252
2771
  };
1253
- const protection = await this.deleteProtection(name);
2772
+ const protection = await this.deleteProtection(name, options.allowBundled === void 0 ? {} : { allowBundled: options.allowBundled });
1254
2773
  if (protection) return {
1255
2774
  ok: false,
1256
2775
  message: `Skill "${name}" is protected (${protection}).`
1257
2776
  };
1258
- if (absorbedInto) {
1259
- if (!await this.io.readText(join(skillDir(this.root, absorbedInto), "SKILL.md"))) return {
2777
+ if (options.absorbedInto) {
2778
+ if (!await this.io.readText(join(this.dirOf(options.absorbedInto), "SKILL.md"))) return {
1260
2779
  ok: false,
1261
- message: `absorbed_into="${absorbedInto}" does not exist.`
2780
+ message: `absorbed_into="${options.absorbedInto}" does not exist.`
1262
2781
  };
1263
2782
  }
1264
2783
  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)}`);
2784
+ let dest = join(archiveRoot, name.trim());
2785
+ if (await this.io.exists(dest)) {
2786
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 14);
2787
+ dest = join(archiveRoot, `${name.trim()}-${stamp}`);
2788
+ while (await this.io.exists(dest)) dest = join(archiveRoot, `${name.trim()}-${stamp}-${Math.random().toString(36).slice(2, 8)}`);
2789
+ }
2790
+ if (this.io.isSymlink) {
2791
+ if (await this.io.isSymlink(dir) === true) return {
2792
+ ok: false,
2793
+ message: `Skill "${name}" is a symlink; refusing to archive it.`
2794
+ };
2795
+ }
1267
2796
  try {
1268
2797
  await this.io.rename(dir, dest);
1269
2798
  } catch {
1270
2799
  await this.io.copy(dir, dest);
1271
2800
  await this.io.remove(dir);
1272
2801
  }
1273
- const reason = absorbedInto ? `Consolidated into ${absorbedInto}` : "Archived by self-evolution curator";
2802
+ const reason = options.reason ?? (options.absorbedInto ? `Consolidated into ${options.absorbedInto}` : "Archived by self-evolution curator");
1274
2803
  await this.io.writeText(join(dest, ".archive-reason"), `${(/* @__PURE__ */ new Date()).toISOString()}: ${reason}\n`);
2804
+ await this.audit(name, "archive", md, null, reason);
2805
+ this.notifyMutation({
2806
+ action: "archive",
2807
+ name,
2808
+ archivedPath: dest
2809
+ });
1275
2810
  return {
1276
2811
  ok: true,
1277
2812
  message: `Skill "${name}" archived to .archive.`,
@@ -1283,26 +2818,27 @@ var SkillLibrary = class {
1283
2818
  * an absorbed-into marker. Hermes-style consolidation: overlapping skills
1284
2819
  * collapse into one, and the originals stay recoverable under `.archive/`.
1285
2820
  */
1286
- async consolidate(target, sources) {
1287
- const normalizedSources = [...new Set(sources)].filter((name) => name !== target);
2821
+ async consolidate(target, sources, origin = "foreground") {
2822
+ const targetName = target.trim();
2823
+ const normalizedSources = [...new Set(sources.map((name) => name.trim()))].filter((name) => name !== targetName);
1288
2824
  if (normalizedSources.length === 0) return {
1289
2825
  ok: false,
1290
2826
  message: "Consolidation requires at least one distinct source skill."
1291
2827
  };
1292
- for (const name of [target, ...normalizedSources]) if (!SKILL_NAME_RE.test(name)) return {
2828
+ for (const name of [targetName, ...normalizedSources]) if (!SKILL_NAME_RE.test(name)) return {
1293
2829
  ok: false,
1294
2830
  message: `Invalid skill name "${name}". Use lowercase letters, digits, and hyphens.`
1295
2831
  };
1296
- const targetDir = skillDir(this.root, target);
2832
+ const targetDir = this.dirOf(targetName);
1297
2833
  const targetMd = await this.io.readText(join(targetDir, "SKILL.md"));
1298
2834
  if (!targetMd) return {
1299
2835
  ok: false,
1300
- message: `Skill "${target}" not found.`
2836
+ message: `Skill "${targetName}" not found.`
1301
2837
  };
1302
- const targetProtection = await this.writeProtection(target);
2838
+ const targetProtection = await this.writeProtection(targetName, origin);
1303
2839
  if (targetProtection) return {
1304
2840
  ok: false,
1305
- message: `Skill "${target}" is protected (${targetProtection}).`
2841
+ message: `Skill "${targetName}" is protected (${targetProtection}).`
1306
2842
  };
1307
2843
  const parts = [];
1308
2844
  for (const source of normalizedSources) {
@@ -1311,7 +2847,7 @@ var SkillLibrary = class {
1311
2847
  ok: false,
1312
2848
  message: `Skill "${source}" is protected (${protection}).`
1313
2849
  };
1314
- const sourceMd = await this.io.readText(join(skillDir(this.root, source), "SKILL.md"));
2850
+ const sourceMd = await this.io.readText(join(this.dirOf(source), "SKILL.md"));
1315
2851
  if (!sourceMd) return {
1316
2852
  ok: false,
1317
2853
  message: `Skill "${source}" not found.`
@@ -1324,7 +2860,7 @@ var SkillLibrary = class {
1324
2860
  parts.push(`\n<!-- consolidated from ${source} at ${(/* @__PURE__ */ new Date()).toISOString()} -->\n${parsed.body.trim()}`);
1325
2861
  }
1326
2862
  const merged = targetMd.trimEnd() + parts.join("\n") + "\n";
1327
- const validation = validateFrontmatter(merged, target, this.limits);
2863
+ const validation = validateFrontmatter(merged, targetName, this.limits);
1328
2864
  if (validation) return {
1329
2865
  ok: false,
1330
2866
  message: `Consolidation rejected: ${validation}`
@@ -1334,14 +2870,30 @@ var SkillLibrary = class {
1334
2870
  ok: false,
1335
2871
  message: threat
1336
2872
  };
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;
2873
+ const archived = [];
2874
+ try {
2875
+ for (const source of normalizedSources) {
2876
+ const result = await this.archive(source, { absorbedInto: targetName });
2877
+ if (!result.ok) throw new Error(result.message);
2878
+ archived.push(source);
2879
+ }
2880
+ await this.io.writeText(join(targetDir, "SKILL.md"), merged);
2881
+ } catch (error) {
2882
+ await this.io.writeText(join(targetDir, "SKILL.md"), targetMd).catch(() => {});
2883
+ for (const source of archived.reverse()) await this.restoreFromArchive(source).catch(() => {});
2884
+ return {
2885
+ ok: false,
2886
+ message: `Consolidation failed and was rolled back: ${error instanceof Error ? error.message : String(error)}`
2887
+ };
1341
2888
  }
2889
+ this.notifyMutation({
2890
+ action: "consolidate",
2891
+ name: targetName,
2892
+ filePath: targetDir
2893
+ });
1342
2894
  return {
1343
2895
  ok: true,
1344
- message: `Consolidated ${normalizedSources.join(", ")} into "${target}".`,
2896
+ message: `Consolidated ${normalizedSources.join(", ")} into "${targetName}".`,
1345
2897
  path: targetDir
1346
2898
  };
1347
2899
  }
@@ -1350,12 +2902,13 @@ var SkillLibrary = class {
1350
2902
  * recoverability: archival never deletes, and this is the control-plane
1351
2903
  * path back. The `.archive-reason` marker is dropped on restore.
1352
2904
  */
1353
- async restoreFromArchive(name) {
2905
+ async restoreFromArchive(rawName) {
2906
+ const name = rawName.trim();
1354
2907
  if (!SKILL_NAME_RE.test(name)) return {
1355
2908
  ok: false,
1356
2909
  message: `Invalid skill name "${name}". Use lowercase letters, digits, and hyphens.`
1357
2910
  };
1358
- if (await this.io.exists(join(skillDir(this.root, name), "SKILL.md"))) return {
2911
+ if (await this.io.exists(join(this.dirOf(name), "SKILL.md"))) return {
1359
2912
  ok: false,
1360
2913
  message: `Skill "${name}" already exists in the active root; refusing to overwrite.`
1361
2914
  };
@@ -1375,7 +2928,13 @@ var SkillLibrary = class {
1375
2928
  message: `Skill "${name}" is not in .archive.`
1376
2929
  };
1377
2930
  const source = join(archiveRoot, chosen);
1378
- const dest = skillDir(this.root, name);
2931
+ const dest = this.dirOf(name);
2932
+ if (this.io.isSymlink) {
2933
+ if (await this.io.isSymlink(source) === true) return {
2934
+ ok: false,
2935
+ message: `Archived entry "${chosen}" is a symlink; refusing to restore it.`
2936
+ };
2937
+ }
1379
2938
  try {
1380
2939
  await this.io.rename(source, dest);
1381
2940
  } catch {
@@ -1383,19 +2942,30 @@ var SkillLibrary = class {
1383
2942
  await this.io.remove(source);
1384
2943
  }
1385
2944
  if (await this.io.exists(join(dest, ".archive-reason"))) await this.io.remove(join(dest, ".archive-reason"));
2945
+ this.notifyMutation({
2946
+ action: "restore",
2947
+ name,
2948
+ filePath: dest
2949
+ });
1386
2950
  return {
1387
2951
  ok: true,
1388
2952
  message: `Skill "${name}" restored from .archive.`,
1389
2953
  path: dest
1390
2954
  };
1391
2955
  }
1392
- async writeSupportFile(name, filePath, content) {
1393
- const dir = skillDir(this.root, name);
2956
+ async writeSupportFile(rawName, filePath, content, origin = "foreground") {
2957
+ const name = rawName.trim();
2958
+ const badName = this.badName(name);
2959
+ if (badName) return {
2960
+ ok: false,
2961
+ message: badName
2962
+ };
2963
+ const dir = this.dirOf(name);
1394
2964
  if (!await this.io.exists(join(dir, "SKILL.md"))) return {
1395
2965
  ok: false,
1396
2966
  message: `Skill "${name}" not found.`
1397
2967
  };
1398
- const protection = await this.writeProtection(name);
2968
+ const protection = await this.writeProtection(name, origin);
1399
2969
  if (protection) return {
1400
2970
  ok: false,
1401
2971
  message: `Skill "${name}" is protected (${protection}).`
@@ -1415,20 +2985,33 @@ var SkillLibrary = class {
1415
2985
  message: threat
1416
2986
  };
1417
2987
  const target = join(dir, ...filePath.replace(/\\/g, "/").split("/").filter(Boolean));
2988
+ const existing = await this.io.readText(target).catch(() => null);
1418
2989
  await this.io.writeText(target, content);
2990
+ await this.audit(name, "write_file", existing, content, `wrote ${filePath}`);
2991
+ this.notifyMutation({
2992
+ action: "write_file",
2993
+ name,
2994
+ filePath: target
2995
+ });
1419
2996
  return {
1420
2997
  ok: true,
1421
2998
  message: `Support file "${filePath}" written to "${name}".`,
1422
2999
  path: target
1423
3000
  };
1424
3001
  }
1425
- async removeSupportFile(name, filePath) {
1426
- const dir = skillDir(this.root, name);
3002
+ async removeSupportFile(rawName, filePath, origin = "foreground") {
3003
+ const name = rawName.trim();
3004
+ const badName = this.badName(name);
3005
+ if (badName) return {
3006
+ ok: false,
3007
+ message: badName
3008
+ };
3009
+ const dir = this.dirOf(name);
1427
3010
  if (!await this.io.exists(join(dir, "SKILL.md"))) return {
1428
3011
  ok: false,
1429
3012
  message: `Skill "${name}" not found.`
1430
3013
  };
1431
- const protection = await this.writeProtection(name);
3014
+ const protection = await this.writeProtection(name, origin);
1432
3015
  if (protection) return {
1433
3016
  ok: false,
1434
3017
  message: `Skill "${name}" is protected (${protection}).`
@@ -1443,24 +3026,88 @@ var SkillLibrary = class {
1443
3026
  ok: false,
1444
3027
  message: `File "${filePath}" not found in skill "${name}".`
1445
3028
  };
3029
+ const before = await this.io.readText(target).catch(() => null);
1446
3030
  await this.io.remove(target);
3031
+ await this.audit(name, "remove_file", before, null, `removed ${filePath}`);
3032
+ this.notifyMutation({
3033
+ action: "remove_file",
3034
+ name,
3035
+ filePath: target
3036
+ });
1447
3037
  return {
1448
3038
  ok: true,
1449
3039
  message: `Support file "${filePath}" removed from "${name}".`,
1450
3040
  path: target
1451
3041
  };
1452
3042
  }
1453
- async snapshotAll(reason = "pre-mutation") {
1454
- const dest = join(join(this.root, ".backups"), `skills-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`);
3043
+ /**
3044
+ * Snapshot the recoverable skills state: active tree, usage/suppression
3045
+ * sidecars, `.archive/` and caller-supplied extras. `extras` are opaque
3046
+ * side files the Snapshot owner cares about (curator state); they are
3047
+ * listed in the manifest and only those names are ever read back.
3048
+ */
3049
+ async snapshotAll(reason = "pre-mutation", extras = []) {
3050
+ const backupRoot = join(this.root, ".backups");
3051
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3052
+ let dest = join(backupRoot, `skills-${stamp}`);
3053
+ while (await this.io.exists(dest)) dest = join(backupRoot, `skills-${stamp}-${Math.random().toString(36).slice(2, 8)}`);
1455
3054
  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));
3055
+ await Promise.all(names.map(async (name) => {
3056
+ await this.io.copy(this.dirOf(name), join(dest, name));
3057
+ }));
3058
+ const sidecars = [];
3059
+ for (const sidecar of [usageFile(this.root), suppressedFile(this.root)]) if (await this.io.exists(sidecar)) {
3060
+ const name = basename(sidecar);
3061
+ await this.io.copy(sidecar, join(dest, name));
3062
+ sidecars.push(name);
3063
+ }
3064
+ const archiveRoot = join(this.root, ".archive");
3065
+ let hasArchive = false;
3066
+ if (await this.io.exists(archiveRoot)) {
3067
+ await this.io.copy(archiveRoot, join(dest, ".archive"));
3068
+ hasArchive = true;
3069
+ }
3070
+ const validExtras = extras.filter((extra) => SNAPSHOT_EXTRA_NAME_RE.test(extra.name));
3071
+ const extraNames = validExtras.map((extra) => extra.name);
3072
+ await Promise.all(validExtras.map(async (extra) => {
3073
+ await this.io.writeText(join(dest, "extras", extra.name), extra.content);
3074
+ }));
1457
3075
  await this.io.writeText(join(dest, "manifest.json"), JSON.stringify({
1458
3076
  reason,
1459
3077
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1460
- skills: names
3078
+ skills: names,
3079
+ sidecars,
3080
+ hasArchive,
3081
+ extras: extraNames
1461
3082
  }, null, 2));
3083
+ await this.retainSnapshots(5);
1462
3084
  return dest;
1463
3085
  }
3086
+ /** Read and normalize a snapshot manifest; null when the file is missing or unparsable. */
3087
+ async readSnapshotManifest(path) {
3088
+ const raw = await this.io.readText(join(path, "manifest.json"));
3089
+ if (raw === null) return null;
3090
+ try {
3091
+ const manifest = JSON.parse(raw);
3092
+ return {
3093
+ reason: typeof manifest.reason === "string" ? manifest.reason : "",
3094
+ createdAt: typeof manifest.createdAt === "string" ? manifest.createdAt : "",
3095
+ skills: Array.isArray(manifest.skills) ? manifest.skills : [],
3096
+ sidecars: Array.isArray(manifest.sidecars) ? manifest.sidecars : [],
3097
+ ...typeof manifest.hasArchive === "boolean" ? { hasArchive: manifest.hasArchive } : {},
3098
+ extras: Array.isArray(manifest.extras) ? manifest.extras : []
3099
+ };
3100
+ } catch {
3101
+ return null;
3102
+ }
3103
+ }
3104
+ /** Keep only the newest N snapshots (Hermes keep=5 parity); older ones are removed outright. */
3105
+ async retainSnapshots(keep) {
3106
+ const snapshots = await this.listSnapshots();
3107
+ for (const snapshot of snapshots.slice(keep)) try {
3108
+ await this.io.remove(snapshot.path);
3109
+ } catch {}
3110
+ }
1464
3111
  async listSnapshots() {
1465
3112
  const backupRoot = join(this.root, ".backups");
1466
3113
  let entries;
@@ -1472,96 +3119,93 @@ var SkillLibrary = class {
1472
3119
  const out = [];
1473
3120
  for (const name of entries.sort().reverse()) {
1474
3121
  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 {}
3122
+ const manifest = await this.readSnapshotManifest(join(backupRoot, name));
3123
+ if (manifest === null) continue;
3124
+ out.push({
3125
+ path: join(backupRoot, name),
3126
+ createdAt: manifest.createdAt,
3127
+ reason: manifest.reason
3128
+ });
1485
3129
  }
1486
3130
  return out;
1487
3131
  }
1488
- async restoreLatestSnapshot() {
3132
+ /**
3133
+ * Read the extras of a snapshot, restricted to the names declared in the
3134
+ * manifest — an `extras/` directory is never listed directly, so unknown
3135
+ * files cannot leak back as state on the next restore.
3136
+ */
3137
+ async readSnapshotExtras(path) {
3138
+ const manifest = await this.readSnapshotManifest(path);
3139
+ if (manifest === null) return [];
3140
+ const extras = [];
3141
+ for (const name of manifest.extras) {
3142
+ if (!SNAPSHOT_EXTRA_NAME_RE.test(name)) continue;
3143
+ const content = await this.io.readText(join(path, "extras", name));
3144
+ if (content !== null) extras.push({
3145
+ name,
3146
+ content
3147
+ });
3148
+ }
3149
+ return extras;
3150
+ }
3151
+ /**
3152
+ * Manifest-driven restore of the latest snapshot: active tree, sidecars,
3153
+ * `.archive/` and (for full-state snapshots) the extras read back by the
3154
+ * caller. `extras` are additionally written into the pre-rollback safety
3155
+ * snapshot so the rollback itself is undoable with the same state.
3156
+ */
3157
+ async restoreLatestSnapshot(extras = []) {
1489
3158
  const latest = (await this.listSnapshots())[0];
1490
3159
  if (!latest) return {
1491
3160
  ok: false,
1492
3161
  message: "No skill snapshot available."
1493
3162
  };
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;
3163
+ await this.snapshotAll("pre-rollback", extras);
3164
+ let rootEntries;
3165
+ try {
3166
+ rootEntries = await this.io.list(this.root);
3167
+ } catch {
3168
+ rootEntries = [];
3169
+ }
3170
+ for (const entry of rootEntries) {
3171
+ if (entry.startsWith(".")) continue;
3172
+ await this.io.remove(join(this.root, entry));
3173
+ }
3174
+ const manifest = await this.readSnapshotManifest(latest.path);
3175
+ if (manifest === null) for (const entry of await this.io.list(latest.path)) {
3176
+ if (entry === "manifest.json" || entry === "extras") continue;
1499
3177
  await this.io.copy(join(latest.path, entry), join(this.root, entry));
1500
3178
  }
3179
+ else {
3180
+ for (const name of manifest.skills) await this.io.copy(join(latest.path, name), join(this.root, name));
3181
+ for (const sidecar of manifest.sidecars) await this.io.copy(join(latest.path, sidecar), join(this.root, sidecar));
3182
+ const archiveRoot = join(this.root, ".archive");
3183
+ if (manifest.hasArchive === true) {
3184
+ await this.io.remove(archiveRoot);
3185
+ await this.io.copy(join(latest.path, ".archive"), archiveRoot);
3186
+ } else if (manifest.hasArchive === false) await this.io.remove(archiveRoot);
3187
+ }
3188
+ const snapshotExtras = await this.readSnapshotExtras(latest.path);
3189
+ this.notifyMutation({
3190
+ action: "restore",
3191
+ name: "snapshot"
3192
+ });
1501
3193
  return {
1502
3194
  ok: true,
1503
3195
  message: `Restored skill tree from ${latest.path}`,
1504
- path: latest.path
3196
+ path: latest.path,
3197
+ ...snapshotExtras.length === 0 ? {} : { extras: snapshotExtras }
1505
3198
  };
1506
3199
  }
1507
3200
  };
1508
3201
  //#endregion
1509
3202
  //#region lib/types/state-store.js
1510
3203
  /**
1511
- * Small crash-safe JSON state store for plugin-owned sidecar state.
1512
- * Writes are atomic (temp + rename). Reads are synchronous for startup use.
3204
+ * Evolution home path helper: `$DSH_HOME/evolution` for plugin-owned sidecar
3205
+ * state (reports, activity store, feedback file, state-domain data).
1513
3206
  */
1514
3207
  function evolutionHome(env = process.env) {
1515
3208
  return join(env.DSH_HOME ?? join(homedir(), ".dsh"), "evolution");
1516
3209
  }
1517
- var JsonState = class {
1518
- initial;
1519
- path;
1520
- value;
1521
- constructor(name, initial, env = process.env) {
1522
- this.initial = initial;
1523
- this.path = join(evolutionHome(env), name);
1524
- this.value = this.loadSync();
1525
- }
1526
- loadSync() {
1527
- try {
1528
- const raw = readFileSync(this.path, "utf8");
1529
- const parsed = JSON.parse(raw);
1530
- return {
1531
- ...this.initial,
1532
- ...parsed
1533
- };
1534
- } catch {
1535
- return { ...this.initial };
1536
- }
1537
- }
1538
- get() {
1539
- return this.value;
1540
- }
1541
- set(value) {
1542
- this.value = value;
1543
- }
1544
- update(mutator) {
1545
- mutator(this.value);
1546
- }
1547
- async flush() {
1548
- await mkdir(dirname(this.path), { recursive: true });
1549
- const tmp = `${this.path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
1550
- await writeFile(tmp, JSON.stringify(this.value, null, 2), "utf8");
1551
- await rename(tmp, this.path);
1552
- }
1553
- /** Merge-on-load helper for persisted maps/records. */
1554
- async reload() {
1555
- try {
1556
- const raw = await readFile(this.path, "utf8");
1557
- this.value = {
1558
- ...this.initial,
1559
- ...JSON.parse(raw)
1560
- };
1561
- } catch {
1562
- this.value = { ...this.initial };
1563
- }
1564
- }
1565
- };
1566
3210
  //#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 };
3211
+ export { AUTHORING_DESCRIPTION_BAR, COMBINED_REVIEW_PLAN_PROMPT, COMBINED_REVIEW_PROMPT, COMPLETION_SKILL_REVIEW_PROMPT, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_CONSOLIDATION_FAILURES, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_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, EVENT_ARCHIVE_PREFIX, EVENT_LOG_RETAIN_ARCHIVES, EVENT_LOG_ROTATE_AT, EVENT_LOG_VERSION, EvolutionGateSet, 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, SKILLS_GUIDANCE, SKILL_NAME_RE, SKILL_REVIEW_PLAN_PROMPT, SKILL_REVIEW_PROMPT, SNAPSHOT_EXTRA_NAME_RE, SUPPORT_DIRS, SUPPRESSED_FILE_VERSION, SkillLibrary, advanceReview, appendEvolutionEvent, applyCuratorFields, authoringFeedback, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpUse, bumpView, computeDedupGroups, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, contentHash, createGateSet, emptyRecord, evaluateThreat, eventsFile, evolutionHome, evolutionIoAdapter, foldCuratorFields, foldTurn, getRecord, latestActivityAt, lifecycleCandidate, loadMutations, loadSuppressedNames, loadUsage, markAgentCreated, memoryRoot, mutateUsage, mutationsFile, nodeEvolutionIo, normalizeUsageRecord, observeEvent, parseCuratorNominations, parseEvolutionEvents, parseFrontmatter, readEvolutionEvents, readEvolutionTimeline, recordMutation, relatedSkillNames, renderCuratorReportMarkdown, resolveOrigins, retainEventArchives, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, suppressedFile, transactIo, updateSuppressedNames, usageFile, validateFrontmatter, verifyPromptBundle };