@lmzhen/dsh-evolution-core 0.3.50 → 0.3.52

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
@@ -13,16 +13,19 @@ import { load } from "js-yaml";
13
13
  /**
14
14
  * Run `task` inside `io.transact` when the backend provides it; otherwise fall
15
15
  * back to a plain read → task → write/remove sequence (no cross-process lock —
16
- * callers keep their single-process serialize chain as the second layer).
16
+ * callers keep their single-process serialize chain as the second layer). A
17
+ * byte-identical task result skips the write (C-07 — parity with the
18
+ * node backend's V5-03 short-circuit).
17
19
  */
18
20
  async function transactIo(io, path, task) {
19
21
  if (io.transact) {
20
22
  await io.transact(path, task);
21
23
  return;
22
24
  }
23
- const next = await task(await io.readText(path));
25
+ const current = await io.readText(path);
26
+ const next = await task(current);
24
27
  if (next === null) await io.remove(path);
25
- else await io.writeText(path, next);
28
+ else if (next !== current) await io.writeText(path, next);
26
29
  }
27
30
  /** Lazy adapter over an IO provider registry, shared by every evolution consumer. */
28
31
  function evolutionIoAdapter(provider) {
@@ -64,11 +67,17 @@ function evolutionIoAdapter(provider) {
64
67
  * so `io.spec.ts` can drive the self-heal path deterministically.
65
68
  */
66
69
  const pendingSelfCleanup = /* @__PURE__ */ new Map();
70
+ const RENAME_RETRY_BASE_MS = 50;
71
+ const RENAME_RETRY_MAX_DELAY_MS = 800;
72
+ const RENAME_RETRY_MAX_ATTEMPTS = 6;
67
73
  /**
68
74
  * Retry a rename that a peer is temporarily holding on Windows (EPERM/EBUSY):
69
- * a short 50ms backoff, at most 3 retries (~150ms budget), matching the
70
- * write-lock cadence. A non-transient code surfaces immediately. `fn` is the
71
- * rename primitive, injectable for deterministic tests.
75
+ * exponential backoff (50ms doubling, capped at 800ms) with a ~2s total
76
+ * budget, matching the write-lock cadence (C-28 the old 3x50ms
77
+ * budget turned a transient antivirus hold into a permanent write failure).
78
+ * A non-transient code surfaces immediately; a persistent EPERM/EBUSY
79
+ * rethrows with a pointer at the usual causes instead of a bare errno.
80
+ * `fn` is the rename primitive, injectable for deterministic tests.
72
81
  *
73
82
  * @param tmp - the source path to rename.
74
83
  * @param target - the destination path.
@@ -82,24 +91,85 @@ async function renameWithRetry(tmp, target, fn = rename) {
82
91
  } catch (error) {
83
92
  const code = error?.code;
84
93
  if (code !== "EPERM" && code !== "EBUSY") throw error;
85
- if (retry >= 3) throw error;
86
- await new Promise((resolve) => setTimeout(resolve, 50));
94
+ if (retry >= RENAME_RETRY_MAX_ATTEMPTS) {
95
+ const final = error;
96
+ final.message = `${final.message} (persisted after ${retry + 1} rename attempts over ~2s — the target may be held by another process or marked read-only; check antivirus, search indexers and file attributes)`;
97
+ throw final;
98
+ }
99
+ await new Promise((resolve) => setTimeout(resolve, Math.min(RENAME_RETRY_BASE_MS * 2 ** retry, RENAME_RETRY_MAX_DELAY_MS)));
87
100
  }
88
101
  }
89
102
  /**
103
+ * V10-06 (P1-1): the crash-durable tmp half of the upstream storage-json
104
+ * `writeAtomic` protocol (storage-json/src/atomic.ts:24-40): exclusive-create
105
+ * a same-directory tmp (`wx` — never clobbers), write, `handle.sync()`,
106
+ * close, then hand the tmp to `commitTmp` for the rename. Without the fsync a
107
+ * power loss could land the rename (metadata) before the data blocks and
108
+ * leave an empty/truncated target — which the state-json E-9 quarantine then
109
+ * amplifies into a permanent fail-loud. The tmp name keeps the
110
+ * `<target>.<pid>.<rand>.tmp` shape so sweepStaleTmps keeps matching.
111
+ * Mode parity note: no explicit mode is passed (upstream uses 0o600) — this
112
+ * seam also writes operator-editable skill/memory files, so the previous
113
+ * `writeFile` default (0o666 & ~umask) is deliberately preserved.
114
+ * `openImpl` is injectable so the sync-failure regression test can drive a
115
+ * failing `handle.sync()` deterministically.
116
+ */
117
+ async function writeDurableTmp(target, content, openImpl = open) {
118
+ const tmp = `${target}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
119
+ try {
120
+ const handle = await openImpl(tmp, "wx");
121
+ try {
122
+ await handle.writeFile(content, "utf8");
123
+ await handle.sync();
124
+ } finally {
125
+ await handle.close();
126
+ }
127
+ return tmp;
128
+ } catch (error) {
129
+ await rm(tmp, { force: true }).catch(() => {});
130
+ throw error;
131
+ }
132
+ }
133
+ /** V10-06 (P1-1): fsync a POSIX directory so a just-renamed entry is
134
+ * crash-durable (upstream atomic.ts `fsyncDirectory`). */
135
+ /* v8 ignore start -- Windows rejects O_RDONLY directory opens; POSIX coverage exercises this. */
136
+ async function fsyncDirectory(path) {
137
+ if (process.platform === "win32") return;
138
+ const handle = await open(path, "r");
139
+ try {
140
+ await handle.sync();
141
+ } finally {
142
+ await handle.close();
143
+ }
144
+ }
145
+ /* v8 ignore stop */
146
+ /**
90
147
  * F-366: commit a freshly-written tmp to its target inside the write lock. On a
91
148
  * still-failing rename the tmp is deleted immediately rather than left for the
92
149
  * (1h + dead-pid) sweep, so a live writer never leaks a tmp it abandoned.
150
+ * V10-06 (P1-1): after a successful rename the parent directory is fsynced on
151
+ * POSIX so the new directory entry itself is crash-durable (upstream atomic.ts
152
+ * tail; Windows skips — it rejects O_RDONLY directory opens). A dir-fsync
153
+ * failure propagates like upstream: the data is on disk, but the durability
154
+ * contract failed loud.
93
155
  */
94
156
  async function commitTmp(tmp, target) {
95
157
  try {
96
158
  await renameWithRetry(tmp, target);
159
+ await fsyncDirectory(dirname(target));
97
160
  } catch (error) {
98
161
  await rm(tmp, { force: true }).catch(() => {});
99
162
  throw error;
100
163
  }
101
164
  }
102
- function nodeEvolutionIo() {
165
+ /**
166
+ * Build the Node IO backend. `lockAttempts` scales the write-lock retry budget
167
+ * (attempts × 50ms); the default 40 (~2s, rc.69) covers production contention,
168
+ * while contention TESTS on a loaded runner may raise it (e.g. 240 ≈ 12s) —
169
+ * V10-06 integration: full-suite parallel load made 32-way takeover bursts
170
+ * exceed the default budget and fail loud.
171
+ */
172
+ function nodeEvolutionIo(lockAttempts = 40) {
103
173
  const isMissing = (error) => {
104
174
  const code = error?.code;
105
175
  return code === "ENOENT" || code === "ENOTDIR";
@@ -114,6 +184,24 @@ function nodeEvolutionIo() {
114
184
  }
115
185
  };
116
186
  /**
187
+ * V10-07 (P2-1): age threshold for taking over a lock whose body is TORN
188
+ * (non-empty, but the pid prefix does not parse to a positive integer). 1h:
189
+ * a legal hold (the ~2s lock/rename retry budgets, a slow task) never
190
+ * approaches minutes, so 1h fires only in the "torn write + creator long
191
+ * dead" scenario — far above any legitimate hold, far below "forever".
192
+ */
193
+ const LOCK_TEAR_TAKEOVER_MS = 36e5;
194
+ /**
195
+ * V10-06 (P1-1 integration fix): a takeover TICKET must never be reclaimed
196
+ * while its (live) holder can still be committing — the C-28 rename retry
197
+ * budget is ~2.35s under AV/indexer pressure, so the former 1s ticket age
198
+ * threshold let a contender steal an in-flight ticket, double-enter the
199
+ * critical section and drop one RMW (observed as 5/6 increments under
200
+ * full-suite load). 5s = retry budget + comfortable margin; a DEAD holder's
201
+ * ticket is still reclaimed immediately via the pid probe.
202
+ */
203
+ const TICKET_STALE_MS = 5e3;
204
+ /**
117
205
  * Cross-process write lock (claw `withFileLock` parity): an O_EXCL lock file
118
206
  * guards the atomic write. A >1s-old lock is taken over ONLY after probing
119
207
  * the holder pid it carries (rc.66): a LIVE holder is never stolen, so a
@@ -144,11 +232,15 @@ function nodeEvolutionIo() {
144
232
  * mtime over 1s is indistinguishable from a long task still executing in this
145
233
  * process, and recycling that live lock would double-hold it. A failure to
146
234
  * release in finally is recorded so the next write self-heals.
235
+ * V10-07 (P2-1): a third takeover shape — a TORN body (non-empty, no
236
+ * parseable pid) — is taken over after a wide 1h threshold with a
237
+ * console.warn; before this branch such a lock blocked every future writer
238
+ * forever.
147
239
  */
148
240
  const withWriteLock = async (path, task) => {
149
241
  const lock = `${path}.lock`;
150
242
  let myClaim = "";
151
- for (let attempt = 0; attempt < 40; attempt += 1) {
243
+ for (let attempt = 0; attempt < lockAttempts; attempt += 1) {
152
244
  let lockHandle = null;
153
245
  try {
154
246
  myClaim = `${process.pid}:${randomBytes(4).toString("hex")}`;
@@ -179,21 +271,27 @@ function nodeEvolutionIo() {
179
271
  }
180
272
  const staleDead = Number.isInteger(holder) && holder > 0 && Date.now() - st.mtimeMs > 1e3 && !holderAlive;
181
273
  const staleEmpty = holderContent === "" && Date.now() - st.mtimeMs > 1e3;
182
- if (staleDead || staleEmpty) {
274
+ const staleCorrupt = holderContent !== "" && !(Number.isInteger(holder) && holder > 0) && Date.now() - st.mtimeMs > LOCK_TEAR_TAKEOVER_MS;
275
+ if (staleDead || staleEmpty || staleCorrupt) {
276
+ if (staleCorrupt) console.warn(`evolution-io: took over a corrupt write lock "${lock}" (body ${JSON.stringify(holderContent)} has no parseable pid, lock older than 1h) — a previous writer likely crashed mid-write`);
183
277
  if (await readFile(lock, "utf8").catch(() => "") === holderContent) {
184
278
  const ticket = `${lock}.next`;
185
279
  try {
186
280
  const ticketBody = await readFile(ticket, "utf8").catch(() => "");
187
281
  const ticketMtime = await stat(ticket).then((s) => s.mtimeMs, () => 0);
188
282
  const ticketHolder = Number(ticketBody.split(":")[0] ?? "");
189
- if ((!Number.isInteger(ticketHolder) || ticketHolder <= 0 || !isAlive(ticketHolder) || Date.now() - ticketMtime > 1e3) && (ticketBody !== "" || Date.now() - ticketMtime > 1e3)) await rm(ticket, { force: true }).catch(() => {});
283
+ if ((!Number.isInteger(ticketHolder) || ticketHolder <= 0 || !isAlive(ticketHolder) || Date.now() - ticketMtime > TICKET_STALE_MS) && (ticketBody !== "" || Date.now() - ticketMtime > TICKET_STALE_MS)) await rm(ticket, { force: true }).catch(() => {});
190
284
  } catch {}
191
285
  try {
192
286
  await writeFile(ticket, `${process.pid}:${randomBytes(4).toString("hex")}`, { flag: "wx" });
193
287
  } catch {
194
288
  continue;
195
289
  }
196
- if (await readFile(lock, "utf8").catch(() => "") === holderContent) await rm(lock, { force: true }).catch(() => {});
290
+ const verify = await readFile(lock, "utf8").catch(() => "");
291
+ if (verify === holderContent) {
292
+ const verifyHolder = Number(verify.split(":")[0] ?? "");
293
+ if (!(Number.isInteger(verifyHolder) && verifyHolder > 0 && isAlive(verifyHolder))) await rm(lock, { force: true }).catch(() => {});
294
+ }
197
295
  await rm(ticket, { force: true }).catch(() => {});
198
296
  continue;
199
297
  }
@@ -207,13 +305,17 @@ function nodeEvolutionIo() {
207
305
  try {
208
306
  return await task();
209
307
  } finally {
210
- if (await readFile(lock, "utf8").catch(() => null) === myClaim) await rm(lock, { force: true }).catch(async () => {
308
+ if (await readFile(lock, "utf8").catch(() => null) === myClaim) await rm(lock, {
309
+ force: true,
310
+ maxRetries: 20,
311
+ retryDelay: 100
312
+ }).catch(async () => {
211
313
  const body = await readFile(lock, "utf8").catch(() => "");
212
314
  pendingSelfCleanup.set(lock, body);
213
315
  });
214
316
  }
215
317
  }
216
- throw new Error(`could not acquire write lock for ${path} after 40 attempts`);
318
+ throw new Error(`could not acquire write lock for ${path} after ${lockAttempts} attempts`);
217
319
  };
218
320
  /** 0.3.17 (E-8b): sweep tmp files a crashed writer left behind — same
219
321
  * `<target>.<pid>.<rand>.tmp` shape, older than 1h AND held by a dead pid.
@@ -231,6 +333,7 @@ function nodeEvolutionIo() {
231
333
  const prefix = `${base}.`;
232
334
  const lockName = `${base}.lock`;
233
335
  const ticketName = `${lockName}.next`;
336
+ const CORRUPT_SWEEP_AGE_MS = 168 * 36e5;
234
337
  for (const name of entries) {
235
338
  if (!name.startsWith(prefix) || name === lockName) continue;
236
339
  if (!name.endsWith(".tmp")) {
@@ -244,6 +347,14 @@ function nodeEvolutionIo() {
244
347
  const old = Date.now() - st.mtimeMs > 1e3;
245
348
  if (dead || old) await rm(ticketPath, { force: true });
246
349
  } catch {}
350
+ continue;
351
+ }
352
+ if (name.includes(".corrupt")) {
353
+ const corruptPath = join(dir, name);
354
+ try {
355
+ const st = await stat(corruptPath);
356
+ if (Date.now() - st.mtimeMs > CORRUPT_SWEEP_AGE_MS) await rm(corruptPath, { force: true });
357
+ } catch {}
247
358
  }
248
359
  continue;
249
360
  }
@@ -269,9 +380,7 @@ function nodeEvolutionIo() {
269
380
  await mkdir(dirname(path), { recursive: true });
270
381
  await withWriteLock(path, async () => {
271
382
  await sweepStaleTmps(path);
272
- const tmp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
273
- await writeFile(tmp, content, "utf8");
274
- await commitTmp(tmp, path);
383
+ await commitTmp(await writeDurableTmp(path, content), path);
275
384
  });
276
385
  },
277
386
  async transact(path, task) {
@@ -291,9 +400,7 @@ function nodeEvolutionIo() {
291
400
  return;
292
401
  }
293
402
  if (next === current) return;
294
- const tmp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
295
- await writeFile(tmp, next, "utf8");
296
- await commitTmp(tmp, path);
403
+ await commitTmp(await writeDurableTmp(path, next), path);
297
404
  });
298
405
  },
299
406
  async remove(path) {
@@ -399,7 +506,7 @@ function normalizeUsageRecord(record) {
399
506
  const base = emptyRecord();
400
507
  if (!record || typeof record !== "object" || Array.isArray(record)) return base;
401
508
  const raw = record;
402
- const num = (value, fallback) => typeof value === "number" && Number.isFinite(value) ? value : fallback;
509
+ const num = (value, fallback) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
403
510
  const bool = (value, fallback) => typeof value === "boolean" ? value : fallback;
404
511
  return {
405
512
  created_by: typeof raw.created_by === "string" ? raw.created_by : null,
@@ -793,7 +900,7 @@ function renderCuratorReportMarkdown(report) {
793
900
  ""
794
901
  ].join("\n");
795
902
  }
796
- const NOMINATION_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
903
+ const NOMINATION_NAME_RE = SKILL_NAME_RE;
797
904
  /**
798
905
  * Parse the curator LLM's YAML nomination block (consolidations + prunings).
799
906
  * Line-oriented and lenient by design: the LLM output is advisory, every name
@@ -1010,7 +1117,8 @@ function eventsFile(home) {
1010
1117
  return join(home, "evolution", "events.json");
1011
1118
  }
1012
1119
  function isEventRecord(event) {
1013
- return typeof event === "object" && event !== null && typeof event.seq === "number";
1120
+ const seq = event?.seq;
1121
+ return typeof event === "object" && event !== null && typeof seq === "number" && Number.isFinite(seq);
1014
1122
  }
1015
1123
  /**
1016
1124
  * Parse an event log body. A missing file, a whitespace-only file (rc.69:
@@ -1029,13 +1137,22 @@ function isEventRecord(event) {
1029
1137
  * damaged record is the only loss (self-heal semantics, matching the usage
1030
1138
  * sidecar's per-field normalization on read).
1031
1139
  */
1140
+ /**
1141
+ * Post-parse v1 gate shared by parseEvolutionEvents and appendEvolutionEvent
1142
+ * (C-06, v10 audit: the append used to JSON.parse the same body a second time
1143
+ * through parseEvolutionEvents — double parse cost per append). A null parse
1144
+ * (missing/whitespace/unparsable body) reads as an empty timeline.
1145
+ */
1146
+ function v1EventRecords(parsed) {
1147
+ if (parsed === null) return [];
1148
+ if (parsed.version !== void 0 && parsed.version !== 1) return [];
1149
+ if (!Array.isArray(parsed.events)) return [];
1150
+ return parsed.events.filter(isEventRecord);
1151
+ }
1032
1152
  function parseEvolutionEvents(raw) {
1033
1153
  if (raw === null || raw.trim() === "") return [];
1034
1154
  try {
1035
- const parsed = JSON.parse(raw);
1036
- if (parsed.version !== void 0 && parsed.version !== 1) return [];
1037
- if (!Array.isArray(parsed.events)) return [];
1038
- return parsed.events.filter(isEventRecord);
1155
+ return v1EventRecords(JSON.parse(raw));
1039
1156
  } catch {
1040
1157
  return [];
1041
1158
  }
@@ -1073,6 +1190,7 @@ async function listEventArchives(io, path) {
1073
1190
  async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE_AT) {
1074
1191
  let assigned = 0;
1075
1192
  let refuseMessage = "";
1193
+ let parsedBody = null;
1076
1194
  await transactIo(io, path, async (current) => {
1077
1195
  if (current !== null && current.trim() !== "") {
1078
1196
  let shape;
@@ -1081,6 +1199,7 @@ async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE
1081
1199
  } catch {
1082
1200
  return current;
1083
1201
  }
1202
+ parsedBody = shape;
1084
1203
  if (shape.version !== void 0 && shape.version !== 1) {
1085
1204
  const found = typeof shape.version === "number" || typeof shape.version === "string" ? String(shape.version) : "unknown";
1086
1205
  const kind = typeof shape.version === "number" || typeof shape.version === "string" ? typeof shape.version : "unknown";
@@ -1088,7 +1207,7 @@ async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE
1088
1207
  return current;
1089
1208
  }
1090
1209
  }
1091
- const nextEvents = await rotateIfDue(io, path, parseEvolutionEvents(current), rotateAt);
1210
+ const nextEvents = await rotateIfDue(io, path, await rotateIfDue(io, path, v1EventRecords(parsedBody), rotateAt), rotateAt);
1092
1211
  let maxSeq = nextEvents.reduce((max, entry) => Math.max(max, entry.seq), 0);
1093
1212
  if (maxSeq === 0) for (const name of await listEventArchives(io, path)) maxSeq = Math.max(maxSeq, Number.parseInt(name.slice(7, name.length - 5), 10));
1094
1213
  const record = {
@@ -1229,8 +1348,8 @@ async function readEvolutionTimeline(io, path) {
1229
1348
  * changes semantically: the bundle digest is the fail-closed signal for
1230
1349
  * review workers, so a stale id across deployments must be distinguishable.
1231
1350
  */
1232
- const PROMPT_BUNDLE_VERSION = 14;
1233
- const PROMPT_BUNDLE_ID = `dsh-evolution@14`;
1351
+ const PROMPT_BUNDLE_VERSION = 15;
1352
+ const PROMPT_BUNDLE_ID = `dsh-evolution@15`;
1234
1353
  const MEMORY_REVIEW_PROMPT = `[Auto-review — Memory]
1235
1354
  Review the conversation above and consider saving to memory if appropriate.
1236
1355
 
@@ -1477,6 +1596,18 @@ D. 库·整合纪律(计划形态约束)
1477
1596
  - 库规模无关:判据是事实与条款,不是库体量印象。
1478
1597
  - 信号机制疑问(阈值/检测原理)→ needs_human,不猜测机制。`;
1479
1598
  /**
1599
+ * One-line output instruction appended after the facts block in the maintain
1600
+ * subagent's prompt (persona carries the template, the prompt carries facts +
1601
+ * this instruction — one copy of the template in the model input, 011 v11
1602
+ * P3-4). F-16: this text used to be hardcoded in evolution-maintenance
1603
+ * orchestrate — a second model-facing prompt living OUTSIDE the bundle digest.
1604
+ * It now rides PROMPT_BUNDLE so the digest integrity check covers every
1605
+ * maintenance prompt. Adding the entry changes the bundle digest (the intended
1606
+ * fail-closed signal); PROMPT_BUNDLE_VERSION itself is owned by the core test
1607
+ * pin and stays untouched in this batch.
1608
+ */
1609
+ const MAINTAIN_OUTPUT_INSTRUCTION = "按模板契约输出 JSON 维护计划(verdict/plan/notes);除 skill 工具与维护模板外你无其他工具。";
1610
+ /**
1480
1611
  * System-prompt guidance section (Hermes `SKILLS_GUIDANCE`, DSH-adapted).
1481
1612
  * Registered as a system-prompt section by tool-skill-manage (it mounts
1482
1613
  * exactly when `skill_manage` is available — the DSH analogue of Hermes'
@@ -1505,12 +1636,12 @@ function sha256(text) {
1505
1636
  function createPromptBundle(prompts) {
1506
1637
  const canonical = JSON.stringify({
1507
1638
  id: PROMPT_BUNDLE_ID,
1508
- version: 14,
1639
+ version: 15,
1509
1640
  prompts: Object.fromEntries(Object.entries(prompts).sort())
1510
1641
  });
1511
1642
  return Object.freeze({
1512
1643
  id: PROMPT_BUNDLE_ID,
1513
- version: 14,
1644
+ version: 15,
1514
1645
  prompts: Object.freeze({ ...prompts }),
1515
1646
  sha256: sha256(canonical)
1516
1647
  });
@@ -1524,13 +1655,14 @@ const PROMPT_BUNDLE = createPromptBundle({
1524
1655
  curator: CURATOR_PROMPT,
1525
1656
  completion: COMPLETION_SKILL_REVIEW_PROMPT,
1526
1657
  maintain: MAINTAIN_PROMPT,
1658
+ maintainOutput: MAINTAIN_OUTPUT_INSTRUCTION,
1527
1659
  skillsGuidance: SKILLS_GUIDANCE
1528
1660
  });
1529
1661
  function verifyPromptBundle(bundle = PROMPT_BUNDLE) {
1530
- if (bundle.id !== PROMPT_BUNDLE_ID || bundle.version !== 14) return false;
1662
+ if (bundle.id !== PROMPT_BUNDLE_ID || bundle.version !== 15) return false;
1531
1663
  const canonical = JSON.stringify({
1532
1664
  id: PROMPT_BUNDLE_ID,
1533
- version: 14,
1665
+ version: 15,
1534
1666
  prompts: Object.fromEntries(Object.entries(bundle.prompts).sort())
1535
1667
  });
1536
1668
  return bundle.sha256 === sha256(canonical);
@@ -1611,6 +1743,43 @@ function buildLearnPrompt(userRequest) {
1611
1743
  ].join("\n");
1612
1744
  }
1613
1745
  //#endregion
1746
+ //#region lib/types/state-store.js
1747
+ /**
1748
+ * Evolution home path helpers: the DSH root and `$DSH_HOME/evolution` for
1749
+ * plugin-owned sidecar state (reports, activity store, feedback file,
1750
+ * state-domain data).
1751
+ *
1752
+ * C-10: PATH HELPERS ONLY — despite the file name there is no store
1753
+ * here. Durable evolution state lives in the state stack (evolution-state over
1754
+ * evolution-state-json / -domain); skills and memories live in skill-store.ts
1755
+ * / memory-store.ts. The file name is kept deliberately: renaming it would
1756
+ * touch every family import for zero behavior change, and the audit records
1757
+ * the mismatch as known naming debt.
1758
+ */
1759
+ /**
1760
+ * DSH home root: `$DSH_HOME` or `~/.dsh`. Single source of the empty-string
1761
+ * fallback — an EMPTY or WHITESPACE-ONLY DSH_HOME resolves to the default
1762
+ * home, never to a CWD-relative path (0.3.19 W1.3, 0.3.22 F-207);
1763
+ * V8-06 (0.3.47) extends the guard to whitespace (upstream home-paths:
1764
+ * `trim().length > 0` is the adoption test — `DSH_HOME=" "` must not produce
1765
+ * a sidecar under a relative "." path).
1766
+ * C-11: the adoption test and the RETURNED value now come from the
1767
+ * SAME trimmed source — the old form tested `trim()` but returned the raw
1768
+ * value, so `DSH_HOME=" /x "` was accepted AND persisted with literal spaces.
1769
+ * Known tradeoff vs upstream `resolveDshHome`: `~` is NOT expanded here —
1770
+ * documented as a deliberate difference in the v10 audit; revisit only if a
1771
+ * real deployment needs it.
1772
+ */
1773
+ function evolutionRoot(env = process.env) {
1774
+ const home = env.DSH_HOME?.trim();
1775
+ return home ? home : join(homedir(), ".dsh");
1776
+ }
1777
+ /** Evolution home path helper: `$DSH_HOME/evolution` for plugin-owned sidecar
1778
+ * state (reports, activity store, feedback file, state-domain data). */
1779
+ function evolutionHome(env = process.env) {
1780
+ return join(evolutionRoot(env), "evolution");
1781
+ }
1782
+ //#endregion
1614
1783
  //#region lib/types/serial.js
1615
1784
  /**
1616
1785
  * A process-local serial task queue: each task starts only after the previous
@@ -1788,7 +1957,7 @@ const PATTERNS = [
1788
1957
  label: "ssh_backdoor",
1789
1958
  category: "persistence",
1790
1959
  scope: "strict",
1791
- regex: /authorized_keys/i
1960
+ regex: /\bauthorized_keys\b/i
1792
1961
  },
1793
1962
  {
1794
1963
  label: "agent_config_mod",
@@ -1921,6 +2090,15 @@ function scanContentThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTION
1921
2090
  if (!blocked) return null;
1922
2091
  return `Blocked by security scan (${findings[0]?.label ?? "unknown"}). This content appears to contain potentially malicious instructions.`;
1923
2092
  }
2093
+ /**
2094
+ * V10-03 (P2-18): suffix the SkillLibrary/MemoryStore write gates append to a
2095
+ * block message — the hit label is already embedded by scanContentThreats /
2096
+ * scanMemoryThreats, this names the deployable self-heal path so the model
2097
+ * (or operator) can allowlist a known-benign label. The evolution-threat tool
2098
+ * channel deliberately does NOT append it: that channel has no
2099
+ * threatExemptLabels option to advertise.
2100
+ */
2101
+ const THREAT_EXEMPT_HINT = " If this is a legitimate false positive, the deployment can allow its label via the threatExemptLabels store option.";
1924
2102
  //#endregion
1925
2103
  //#region lib/types/memory-store.js
1926
2104
  /**
@@ -1961,7 +2139,7 @@ function previewEntries(entries) {
1961
2139
  return `\n\nCurrent entries (preview):\n${shown.join("\n")}${more}`;
1962
2140
  }
1963
2141
  function memoryRoot(env = process.env) {
1964
- return join(env.DSH_HOME || join(homedir(), ".dsh"), "memories");
2142
+ return join(evolutionRoot(env), "memories");
1965
2143
  }
1966
2144
  function fileFor(root, target) {
1967
2145
  return join(root, target === "memory" ? "MEMORY.md" : "USER.md");
@@ -1990,6 +2168,8 @@ var MemoryStore = class {
1990
2168
  root;
1991
2169
  maxFailures;
1992
2170
  io;
2171
+ /** V10-03 (P2-18): see MemoryStoreOptions.threatExemptLabels. */
2172
+ threatExemptLabels;
1993
2173
  /** V6-16 (0.3.37): same-process RMW serialization (the SkillLibrary queue) —
1994
2174
  * on a backend WITHOUT a transact lock two concurrent callers compute on the
1995
2175
  * same old content and the last rename wins, silently dropping one op's
@@ -2005,6 +2185,18 @@ var MemoryStore = class {
2005
2185
  this.addDatePrefix = options.addDatePrefix ?? false;
2006
2186
  this.root = options.root ?? memoryRoot();
2007
2187
  this.maxFailures = options.maxConsolidationFailures ?? 3;
2188
+ this.threatExemptLabels = options.threatExemptLabels ?? [];
2189
+ }
2190
+ /** V10-03 (P2-18): ScanOptions shared by every threat check of this store —
2191
+ * the constructor's exempt labels, empty by default (behavior unchanged). */
2192
+ threatScanOptions() {
2193
+ return this.threatExemptLabels.length > 0 ? { excludeLabels: this.threatExemptLabels } : {};
2194
+ }
2195
+ /** V10-03 (P2-18): the strict-scan write gate. A block message names the hit
2196
+ * label (scanMemoryThreats already embeds it) plus the self-heal hint. */
2197
+ memoryThreatBlock(text) {
2198
+ const threat = scanMemoryThreats(text, void 0, this.threatScanOptions());
2199
+ return threat === null ? null : threat + THREAT_EXEMPT_HINT;
2008
2200
  }
2009
2201
  limitFor(target) {
2010
2202
  return target === "memory" ? this.memoryLimit : this.userLimit;
@@ -2071,7 +2263,12 @@ var MemoryStore = class {
2071
2263
  * never loaded just to back it up. Failure to back up does not change the
2072
2264
  * refusal semantics. V8-23⑫ (0.3.49): ONE fixed backup name per target —
2073
2265
  * a fresh refusal overwrites it (the previous timestamped names accumulated
2074
- * per drift incident with no retention policy).
2266
+ * per drift incident with no retention policy). V9-08 (0.3.51) declares the
2267
+ * two failure shapes: (1) the pre-copy remove of the previous `.bak` fails —
2268
+ * harmless, because the copy contract is overwrite (`cp force`);
2269
+ * (2) the copy itself fails (disk/backend) — returns `null` and the refusal
2270
+ * message simply carries no backup suffix; the refusal semantics and the
2271
+ * on-disk file are untouched either way.
2075
2272
  */
2076
2273
  async backupFile(target) {
2077
2274
  const path = fileFor(this.root, target);
@@ -2124,7 +2321,13 @@ var MemoryStore = class {
2124
2321
  outcome = core.result;
2125
2322
  return core.write ?? current ?? null;
2126
2323
  });
2127
- return outcome;
2324
+ return outcome ?? {
2325
+ ok: false,
2326
+ message: "internal error: the memory transaction did not invoke the task; no write was performed",
2327
+ entries: [],
2328
+ chars: 0,
2329
+ limit: this.limitFor(target)
2330
+ };
2128
2331
  }
2129
2332
  /**
2130
2333
  * Single-entry add inside the transaction: shared checks (oversized,
@@ -2150,7 +2353,7 @@ var MemoryStore = class {
2150
2353
  write: null
2151
2354
  };
2152
2355
  }
2153
- const threat = scanMemoryThreats(content);
2356
+ const threat = this.memoryThreatBlock(content);
2154
2357
  if (threat) return {
2155
2358
  result: {
2156
2359
  ok: false,
@@ -2161,7 +2364,8 @@ var MemoryStore = class {
2161
2364
  },
2162
2365
  write: null
2163
2366
  };
2164
- if (hasEntryDelimiter(this.addDatePrefix ? `## ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}\n${content}` : content)) return {
2367
+ const prefixed = this.addDatePrefix ? `## ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}\n${content}` : content;
2368
+ if (hasEntryDelimiter(prefixed)) return {
2165
2369
  result: {
2166
2370
  ok: false,
2167
2371
  message: "Fact contains the entry delimiter (§) and would split into multiple entries; rewrite it as separate facts.",
@@ -2185,7 +2389,7 @@ var MemoryStore = class {
2185
2389
  write: null
2186
2390
  };
2187
2391
  }
2188
- const next = [...entries, this.addDatePrefix ? `## ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}\n${content}` : content];
2392
+ const next = [...entries, prefixed];
2189
2393
  const total = next.join(ENTRY_DELIMITER).length;
2190
2394
  const addLimit = this.limitFor(target);
2191
2395
  if (addLimit > 0 && total > addLimit) return {
@@ -2232,7 +2436,13 @@ var MemoryStore = class {
2232
2436
  outcome = core.result;
2233
2437
  return core.write ?? current ?? null;
2234
2438
  });
2235
- return outcome;
2439
+ return outcome ?? {
2440
+ ok: false,
2441
+ message: "internal error: the memory transaction did not invoke the task; no write was performed",
2442
+ entries: [],
2443
+ chars: 0,
2444
+ limit: this.limitFor(target)
2445
+ };
2236
2446
  }
2237
2447
  /** Batch RMW inside the transaction. `write: null` = failure/no-op, disk untouched. */
2238
2448
  async applyBatchCore(target, operations, raw) {
@@ -2251,6 +2461,7 @@ var MemoryStore = class {
2251
2461
  }
2252
2462
  const entries = [...new Set(normalizeEntries(raw))];
2253
2463
  const working = [...entries];
2464
+ const datePrefix = this.addDatePrefix ? `## ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}\n` : "";
2254
2465
  for (const [index, op] of operations.entries()) {
2255
2466
  const position = index + 1;
2256
2467
  if (op.action === "add") {
@@ -2265,7 +2476,7 @@ var MemoryStore = class {
2265
2476
  },
2266
2477
  write: null
2267
2478
  };
2268
- const threat = scanMemoryThreats(body);
2479
+ const threat = this.memoryThreatBlock(body);
2269
2480
  if (threat) return {
2270
2481
  result: {
2271
2482
  ok: false,
@@ -2276,7 +2487,7 @@ var MemoryStore = class {
2276
2487
  },
2277
2488
  write: null
2278
2489
  };
2279
- const entryBody = this.addDatePrefix ? `## ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}\n${body}` : body;
2490
+ const entryBody = `${datePrefix}${body}`;
2280
2491
  if (hasEntryDelimiter(entryBody)) return {
2281
2492
  result: {
2282
2493
  ok: false,
@@ -2344,7 +2555,7 @@ var MemoryStore = class {
2344
2555
  },
2345
2556
  write: null
2346
2557
  };
2347
- const threat = scanMemoryThreats(body);
2558
+ const threat = this.memoryThreatBlock(body);
2348
2559
  if (threat) return {
2349
2560
  result: {
2350
2561
  ok: false,
@@ -2355,7 +2566,8 @@ var MemoryStore = class {
2355
2566
  },
2356
2567
  write: null
2357
2568
  };
2358
- if (hasEntryDelimiter(body)) return {
2569
+ const entryBody = `${datePrefix}${body}`;
2570
+ if (hasEntryDelimiter(entryBody)) return {
2359
2571
  result: {
2360
2572
  ok: false,
2361
2573
  message: `Operation ${position} (replace): Fact contains the entry delimiter (§) and would split into multiple entries; rewrite it as separate facts.${previewEntries(entries)}`,
@@ -2365,7 +2577,7 @@ var MemoryStore = class {
2365
2577
  },
2366
2578
  write: null
2367
2579
  };
2368
- working[matchIndex] = body;
2580
+ working[matchIndex] = entryBody;
2369
2581
  }
2370
2582
  }
2371
2583
  const total = working.join(ENTRY_DELIMITER).length;
@@ -2404,13 +2616,13 @@ var MemoryStore = class {
2404
2616
  parts.push(`## ${label} — file skipped: ${oversized.size} bytes (limit ${oversized.limit * READ_GUARD_FACTOR}); not read`);
2405
2617
  continue;
2406
2618
  }
2407
- const safe = entries.filter((entry) => !scanMemoryThreats(entry));
2619
+ const safe = entries.filter((entry) => !scanMemoryThreats(entry, void 0, this.threatScanOptions()));
2408
2620
  if (safe.length > 0) {
2409
2621
  const body = safe.join(ENTRY_DELIMITER);
2410
2622
  const limit = this.limitFor(target);
2411
- const pct = limit > 0 ? Math.min(100, Math.floor(body.length * 100 / limit)) : 0;
2412
2623
  const note = safe.length === entries.length ? "" : ` (${entries.length - safe.length} threat-matched entries filtered)`;
2413
- parts.push(`## ${label} (${safe.length} entries) [${pct}% — ${body.length}/${limit} chars]${note}\n${body}`);
2624
+ const usage = limit > 0 ? ` [${Math.min(100, Math.floor(body.length * 100 / limit))}% — ${body.length}/${limit} chars]` : "";
2625
+ parts.push(`## ${label} (${safe.length} entries)${usage}${note}\n${body}`);
2414
2626
  }
2415
2627
  }
2416
2628
  return parts.join("\n\n");
@@ -2480,6 +2692,7 @@ async function recordMutation(root, io, record, cap = 500) {
2480
2692
  if (current !== null) try {
2481
2693
  JSON.parse(current);
2482
2694
  } catch {
2695
+ console.warn(`mutation audit record dropped: ${mutationsFile(root)} is malformed and was not overwritten`);
2483
2696
  return current;
2484
2697
  }
2485
2698
  const existing = parseMutationRecords(current);
@@ -2522,8 +2735,8 @@ function composePresetComposition(standardComposition, deltaComposition) {
2522
2735
  function compositionRowIds(composition) {
2523
2736
  const ids = /* @__PURE__ */ new Set();
2524
2737
  for (const line of composition.split("\n")) {
2525
- const match = /^- id:\s*(\S+)/.exec(line);
2526
- if (match) ids.add(match[1] ?? "");
2738
+ const id = /^- id:\s*(\S+)/.exec(line)?.[1];
2739
+ if (id) ids.add(id);
2527
2740
  }
2528
2741
  return ids;
2529
2742
  }
@@ -2590,7 +2803,10 @@ function computeQualityScores(input) {
2590
2803
  function normalize(content) {
2591
2804
  return content.toLowerCase().replace(/\s+/g, " ").trim();
2592
2805
  }
2593
- function contentHash$1(content) {
2806
+ /** C-26 (v10 audit): renamed from `contentHash` — mutations.ts exports a
2807
+ * contentHash of RAW bytes, this one hashes the NORMALIZED text for dedup
2808
+ * grouping. Same private helper, unambiguous name. */
2809
+ function normalizedHash(content) {
2594
2810
  return createHash("sha256").update(normalize(content)).digest("hex");
2595
2811
  }
2596
2812
  function tokenize(content) {
@@ -2612,7 +2828,7 @@ function computeDedupGroups(input) {
2612
2828
  const names = [...input.contents.keys()];
2613
2829
  const hashes = /* @__PURE__ */ new Map();
2614
2830
  for (const name of names) {
2615
- const hash = contentHash$1(input.contents.get(name) ?? "");
2831
+ const hash = normalizedHash(input.contents.get(name) ?? "");
2616
2832
  const bucket = hashes.get(hash);
2617
2833
  if (bucket) bucket.push(name);
2618
2834
  else hashes.set(hash, [name]);
@@ -2744,6 +2960,12 @@ const DEFAULT_HEALTH_THRESHOLDS = {
2744
2960
  * (`defaced`, `feedback`) are not counted as commit shas; the ISO branch
2745
2961
  * accepts a UTC `Z`, a numeric UTC offset (`+08:00`), or no timezone at all —
2746
2962
  * non-UTC timestamps used to escape detection (log-like content missed).
2963
+ *
2964
+ * Known boundary, recorded not fixed (C-22, v10 audit): the hex branch also
2965
+ * matches 7-40 char PURE-DIGIT strings (order numbers etc.), so stamp density
2966
+ * can be over-reported for such content. The misjudgment direction is "extra
2967
+ * stamp hits" only — never a miss — and no real incident exists; revisit only
2968
+ * if curator decisions are actually distorted (v10 deferral ledger).
2747
2969
  */
2748
2970
  const HEALTH_STAMP_RE = new RegExp(String.raw`\brc\.\d+\b|\b(?=[0-9a-f]{7,40}\b)[0-9a-f]*[0-9][0-9a-f]*\b|\b\d{4}-\d{2}-\d{2}(?:T[0-9:.]+(?:Z|[+-]\d{2}:?\d{2})?)?\b`, "g");
2749
2971
  /**
@@ -2788,7 +3010,7 @@ function assessStructureHealth(snapshot, thresholds = DEFAULT_HEALTH_THRESHOLDS)
2788
3010
  * Deterministic review signal gate.
2789
3011
  *
2790
3012
  * Scans a DSH session event log for durable learning signals before any LLM
2791
- * is spent. `turn/end` calls `observeTurn`; the returned review kind is
3013
+ * is spent. `turn/end` calls `observeEvent`; the returned review kind is
2792
3014
  * accumulated until a configured interval fires.
2793
3015
  */
2794
3016
  const CORRECTION_PATTERNS = [
@@ -3008,7 +3230,7 @@ function computeDriftSignals(snapshots) {
3008
3230
  const narrow = narrowNameMatches(snapshot.name);
3009
3231
  signals.push(narrow.length === 0 ? sig("narrow_name", "pass", "none") : sig("narrow_name", "over", narrow.join(", "), void 0, `name=${snapshot.name}`));
3010
3232
  const description = snapshot.description;
3011
- signals.push(description === void 0 ? sig("description_chars", "unknown", "missing", "60") : sig("description_chars", description.length > 60 ? "over" : "pass", `${description.length}`, `60`));
3233
+ signals.push(description === void 0 ? sig("description_chars", "unknown", "missing", `60`) : sig("description_chars", description.length > 60 ? "over" : "pass", `${description.length}`, `60`));
3012
3234
  const quality = snapshot.quality;
3013
3235
  signals.push(quality === null || quality === void 0 ? sig("quality_low", "unknown", "not-assessed") : sig("quality_low", quality < .3 ? "over" : "pass", quality.toFixed(2), `${LOW_QUALITY_THRESHOLD}`));
3014
3236
  return {
@@ -3056,7 +3278,7 @@ const RESTRUCTURE_TARGET_RE = /^references\/[a-z0-9](?!.*\.\.)[a-z0-9._-]*\.md$/
3056
3278
  /** Extra file name carried inside a snapshot's `extras/` directory. */
3057
3279
  const SNAPSHOT_EXTRA_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
3058
3280
  function skillsRoot(env = process.env) {
3059
- return join(env.DSH_HOME || join(homedir(), ".dsh"), "skills");
3281
+ return join(evolutionRoot(env), "skills");
3060
3282
  }
3061
3283
  /** 0.3.18 (S4.1, E-30): the ONE root resolution for every member that reads
3062
3284
  * the skills tree — tool-skill-manage / evolution-skill-catalog / skill-usage
@@ -3213,6 +3435,13 @@ function frontmatterYamlUnsafeValues(content) {
3213
3435
  * rewritten block no longer parses, or a rewritten value's parsed content
3214
3436
  * differs from the original, the rewrite is rolled back and reported in
3215
3437
  * `issues` (fail-loud, never a silent value corruption — P3-4).
3438
+ *
3439
+ * V10-02 (P2-3): the rewrite decision is PER LINE — each entry parses its own
3440
+ * value, so a duplicated key can never route one entry's unsafe value into a
3441
+ * different line's rewrite (the old key→Map lookup rewrote the FIRST (safe)
3442
+ * line with the SECOND line's quoted value, and the last-wins YAML reader
3443
+ * masked the damage). A duplicated key is itself invalid input and is
3444
+ * reported in `issues` (the write path refuses) instead of being rewritten.
3216
3445
  */
3217
3446
  function normalizeFrontmatter(content) {
3218
3447
  const block = frontmatterBlock(content);
@@ -3223,10 +3452,10 @@ function normalizeFrontmatter(content) {
3223
3452
  issues: []
3224
3453
  };
3225
3454
  const { lines, end, nl } = block;
3226
- const unsafe = new Map(frontmatterYamlUnsafeValues(content).map((entry) => [entry.key, entry.value]));
3227
- const originalValues = new Map(unsafe);
3228
3455
  const fields = [];
3229
3456
  const issues = [];
3457
+ const seen = /* @__PURE__ */ new Set();
3458
+ const originalValues = /* @__PURE__ */ new Map();
3230
3459
  let changed = false;
3231
3460
  for (let i = 1; i < end; i++) {
3232
3461
  const line = lines[i];
@@ -3234,16 +3463,29 @@ function normalizeFrontmatter(content) {
3234
3463
  const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
3235
3464
  if (!match) continue;
3236
3465
  const key = match[1];
3237
- const value = unsafe.get(key ?? "");
3238
- if (key === void 0 || value === void 0) continue;
3466
+ if (key === void 0) continue;
3467
+ const value = (match[2] ?? "").trim();
3468
+ if (seen.has(key)) {
3469
+ issues.push(`${key}: duplicate frontmatter key — remove the repeated entry and retry`);
3470
+ continue;
3471
+ }
3472
+ seen.add(key);
3473
+ if (!yamlPlainScalarNeedsQuotes(value)) continue;
3239
3474
  if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(value)) {
3240
3475
  issues.push(`${key}: value contains control characters — clean them manually`);
3241
3476
  continue;
3242
3477
  }
3243
3478
  lines[i] = `${key}: ${value.includes("\"") || value.includes("\\") ? `'${value.replace(/'/g, "''")}'` : `"${value}"`}`;
3479
+ originalValues.set(key, value);
3244
3480
  fields.push(key);
3245
3481
  changed = true;
3246
3482
  }
3483
+ if (issues.length > 0) return {
3484
+ content,
3485
+ changed: false,
3486
+ fields: [],
3487
+ issues
3488
+ };
3247
3489
  if (!changed) return {
3248
3490
  content,
3249
3491
  changed: false,
@@ -3293,7 +3535,7 @@ function validateFrontmatter(content, expectedName, limits = DEFAULT_SKILL_LIMIT
3293
3535
  const parsed = parseFrontmatter(content);
3294
3536
  if (!parsed) return "SKILL.md must start and end with YAML frontmatter and include a body.";
3295
3537
  if (!parsed.frontmatter.name) return "Frontmatter must include a name field.";
3296
- if (!/^[a-z0-9][a-z0-9-]*$/.test(parsed.frontmatter.name)) return `Invalid skill name "${parsed.frontmatter.name}" — use lowercase letters, digits, and hyphens.`;
3538
+ if (!SKILL_NAME_RE.test(parsed.frontmatter.name)) return `Invalid skill name "${parsed.frontmatter.name}" — use lowercase letters, digits, and hyphens.`;
3297
3539
  if (parsed.frontmatter.name.length > limits.maxNameLength) return `Skill name exceeds ${limits.maxNameLength} characters.`;
3298
3540
  if (expectedName && parsed.frontmatter.name !== expectedName) return `Frontmatter name "${parsed.frontmatter.name}" does not match target skill "${expectedName}".`;
3299
3541
  if (!parsed.frontmatter.description) return "Frontmatter must include a description field.";
@@ -3331,12 +3573,51 @@ async function listNames(root, io) {
3331
3573
  }
3332
3574
  return names.sort();
3333
3575
  }
3576
+ /** C-18: support file names are restricted to the
3577
+ * RESTRUCTURE_TARGET_RE character class (leading `[a-z0-9]`, then
3578
+ * `[a-z0-9._-]`) — drive-colon / odd-character / uppercase names can no
3579
+ * longer reach the filesystem through writeSupportFile / patch /
3580
+ * removeSupportFile. */
3581
+ const SUPPORT_FILE_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
3582
+ /** C-18: win32 reserves these stems with ANY extension (`nul.md` hits the
3583
+ * NUL device), and they are fully inside the charset above — so the reserved
3584
+ * set is checked on the first-dot prefix as well; the charset close alone
3585
+ * cannot refuse them. */
3586
+ const WIN32_RESERVED_DEVICE_NAMES = new Set([
3587
+ "con",
3588
+ "prn",
3589
+ "aux",
3590
+ "nul",
3591
+ "com1",
3592
+ "com2",
3593
+ "com3",
3594
+ "com4",
3595
+ "com5",
3596
+ "com6",
3597
+ "com7",
3598
+ "com8",
3599
+ "com9",
3600
+ "lpt1",
3601
+ "lpt2",
3602
+ "lpt3",
3603
+ "lpt4",
3604
+ "lpt5",
3605
+ "lpt6",
3606
+ "lpt7",
3607
+ "lpt8",
3608
+ "lpt9"
3609
+ ]);
3334
3610
  function validateSupportPath(filePath) {
3335
3611
  const normalized = filePath.replace(/\\/g, "/");
3336
3612
  if (normalized.includes("..")) return "Path traversal is not allowed.";
3337
3613
  const parts = normalized.split("/").filter(Boolean);
3338
3614
  if (parts.length === 0 || !SUPPORT_DIRS.includes(parts[0])) return `file_path must be under one of: ${SUPPORT_DIRS.join(", ")}.`;
3339
3615
  if (parts.length < 2) return "Provide a file name, not just a directory.";
3616
+ for (const part of parts.slice(1)) {
3617
+ if (!SUPPORT_FILE_NAME_RE.test(part)) return `Unsupported file name "${part}" — use lowercase letters, digits, dots, hyphens, and underscores (leading letter or digit).`;
3618
+ const stem = part.split(".")[0]?.toLowerCase() ?? "";
3619
+ if (WIN32_RESERVED_DEVICE_NAMES.has(stem)) return `Unsupported file name "${part}" — a Windows reserved device name.`;
3620
+ }
3340
3621
  return null;
3341
3622
  }
3342
3623
  /**
@@ -3510,11 +3791,15 @@ var SkillLibrary = class {
3510
3791
  * one skill never interleave their read-modify-write (the cross-process layer
3511
3792
  * is the IO backend's transact lock; this chain is the second layer). */
3512
3793
  serial;
3513
- constructor(root = skillsRoot(), io = nodeEvolutionIo(), limits = DEFAULT_SKILL_LIMITS, onMutation, transact) {
3794
+ /** V10-03 (P2-18): see the constructor's threatExemptLabels. Empty by
3795
+ * default — the strict ANY-hit-blocks policy is unchanged. */
3796
+ threatExemptLabels;
3797
+ constructor(root = skillsRoot(), io = nodeEvolutionIo(), limits = DEFAULT_SKILL_LIMITS, onMutation, transact, threatExemptLabels) {
3514
3798
  this.root = root;
3515
3799
  this.io = io;
3516
3800
  this.limits = limits;
3517
3801
  this.onMutation = onMutation;
3802
+ this.threatExemptLabels = threatExemptLabels ?? [];
3518
3803
  this.transact = transact ?? (io.transact ? (ioLike, path, task) => {
3519
3804
  const t = ioLike.transact;
3520
3805
  return t ? t(path, task) : transactIo(ioLike, path, task);
@@ -3558,12 +3843,24 @@ var SkillLibrary = class {
3558
3843
  this.onMutation?.(event);
3559
3844
  } catch {}
3560
3845
  }
3846
+ /** V10-03 (P2-18): ScanOptions shared by every write-path threat check —
3847
+ * the constructor's exempt labels, empty by default (behavior unchanged). */
3848
+ threatScanOptions() {
3849
+ return this.threatExemptLabels.length > 0 ? { excludeLabels: this.threatExemptLabels } : {};
3850
+ }
3851
+ /** V10-03 (P2-18): the strict-scan write gate. A block message names the hit
3852
+ * label (scanContentThreats already embeds it) plus the self-heal hint, so a
3853
+ * false-positive rewrite direction is actionable instead of a dead end. */
3854
+ contentThreatBlock(content) {
3855
+ const threat = scanContentThreats(content, void 0, this.threatScanOptions());
3856
+ return threat === null ? null : threat + THREAT_EXEMPT_HINT;
3857
+ }
3561
3858
  async list() {
3562
3859
  const summaries = [];
3563
3860
  for (const name of await listNames(this.root, this.io)) {
3564
3861
  const dir = this.dirOf(name);
3565
3862
  const md = await this.io.readText(join(dir, "SKILL.md"));
3566
- if (!md) continue;
3863
+ if (md === null) continue;
3567
3864
  const parsed = parseFrontmatter(md);
3568
3865
  let entries = [];
3569
3866
  try {
@@ -3571,9 +3868,10 @@ var SkillLibrary = class {
3571
3868
  } catch {}
3572
3869
  const has = (marker) => entries.includes(markerEntryName(marker));
3573
3870
  const protectedBy = has("bundled") ? "bundled" : has("hub-installed") ? "hub-installed" : has("pinned") ? "pinned" : null;
3871
+ const parsedDescription = parsed?.frontmatter.description;
3574
3872
  summaries.push({
3575
3873
  name,
3576
- description: parsed?.frontmatter.description ?? "",
3874
+ description: typeof parsedDescription === "string" ? parsedDescription : "",
3577
3875
  path: dir,
3578
3876
  protectedBy,
3579
3877
  managed: has("hermes-managed"),
@@ -3739,9 +4037,10 @@ var SkillLibrary = class {
3739
4037
  */
3740
4038
  async setPinned(name, pinned, origin = "foreground") {
3741
4039
  const normalized = name.trim();
3742
- if (!SKILL_NAME_RE.test(normalized) || normalized.length > this.limits.maxNameLength) return {
4040
+ const bad = this.badName(normalized);
4041
+ if (bad) return {
3743
4042
  ok: false,
3744
- message: `Invalid skill name "${normalized}". Use lowercase letters, digits, and hyphens (<= ${this.limits.maxNameLength}).`
4043
+ message: bad
3745
4044
  };
3746
4045
  if (origin === "background_review") return {
3747
4046
  ok: false,
@@ -3775,9 +4074,10 @@ var SkillLibrary = class {
3775
4074
  }
3776
4075
  async create(name, content, origin = "foreground") {
3777
4076
  const normalized = name.trim();
3778
- if (!SKILL_NAME_RE.test(normalized) || normalized.length > this.limits.maxNameLength) return {
4077
+ const bad = this.badName(normalized);
4078
+ if (bad) return {
3779
4079
  ok: false,
3780
- message: `Invalid skill name "${normalized}". Use lowercase letters, digits, and hyphens (<= ${this.limits.maxNameLength}).`
4080
+ message: bad
3781
4081
  };
3782
4082
  const validation = validateFrontmatter(content, normalized, this.limits);
3783
4083
  if (validation) return {
@@ -3797,7 +4097,7 @@ var SkillLibrary = class {
3797
4097
  message: revalidated
3798
4098
  };
3799
4099
  }
3800
- const threat = scanContentThreats(finalContent);
4100
+ const threat = this.contentThreatBlock(finalContent);
3801
4101
  if (threat) return {
3802
4102
  ok: false,
3803
4103
  message: threat
@@ -3807,6 +4107,11 @@ var SkillLibrary = class {
3807
4107
  ok: false,
3808
4108
  message: `Skill "${normalized}" already exists.`
3809
4109
  };
4110
+ const protection = await this.writeProtection(normalized, origin);
4111
+ if (protection) return {
4112
+ ok: false,
4113
+ message: `Skill "${normalized}" is protected (${protection}).`
4114
+ };
3810
4115
  const onDisk = finalContent.trimEnd() + "\n";
3811
4116
  await this.io.writeText(join(dir, "SKILL.md"), onDisk);
3812
4117
  if (origin !== "foreground") await this.io.writeText(markerPath(dir, "hermes-managed"), "");
@@ -3858,7 +4163,7 @@ var SkillLibrary = class {
3858
4163
  message: revalidated
3859
4164
  };
3860
4165
  }
3861
- const threat = scanContentThreats(finalContent);
4166
+ const threat = this.contentThreatBlock(finalContent);
3862
4167
  if (threat) return {
3863
4168
  ok: false,
3864
4169
  message: threat
@@ -4001,7 +4306,7 @@ var SkillLibrary = class {
4001
4306
  },
4002
4307
  write: null
4003
4308
  };
4004
- const threat = scanContentThreats(writeContent);
4309
+ const threat = this.contentThreatBlock(writeContent);
4005
4310
  if (threat) return {
4006
4311
  result: {
4007
4312
  ok: false,
@@ -4051,11 +4356,11 @@ var SkillLibrary = class {
4051
4356
  };
4052
4357
  const dir = this.dirOf(name);
4053
4358
  const md = await this.io.readText(join(dir, "SKILL.md"));
4054
- if (!md) return {
4359
+ if (md === null) return {
4055
4360
  ok: false,
4056
4361
  message: `Skill "${name}" not found.`
4057
4362
  };
4058
- const protection = await this.deleteProtection(name, options.allowBundled === void 0 ? {} : { allowBundled: options.allowBundled });
4363
+ const protection = await this.deleteProtection(name, options);
4059
4364
  if (protection) return {
4060
4365
  ok: false,
4061
4366
  message: protection === "pinned" ? `Skill "${name}" is pinned and cannot be archived. Remove the \`.pinned\` marker in its directory, then retry.` : `Skill "${name}" is protected (${protection}).`
@@ -4152,10 +4457,13 @@ var SkillLibrary = class {
4152
4457
  ok: false,
4153
4458
  message: "Consolidation requires at least one distinct source skill."
4154
4459
  };
4155
- for (const name of [targetName, ...normalizedSources]) if (!SKILL_NAME_RE.test(name)) return {
4156
- ok: false,
4157
- message: `Invalid skill name "${name}". Use lowercase letters, digits, and hyphens.`
4158
- };
4460
+ for (const name of [targetName, ...normalizedSources]) {
4461
+ const bad = this.badName(name);
4462
+ if (bad) return {
4463
+ ok: false,
4464
+ message: bad
4465
+ };
4466
+ }
4159
4467
  const targetDir = this.dirOf(targetName);
4160
4468
  const targetProtection = await this.writeProtection(targetName, origin);
4161
4469
  if (targetProtection) return {
@@ -4235,7 +4543,14 @@ var SkillLibrary = class {
4235
4543
  ok: false,
4236
4544
  message: `Skill "${targetName}" not found.`
4237
4545
  };
4238
- const writes = [...referenceWrites];
4546
+ const writes = [];
4547
+ for (const reference of referenceWrites) {
4548
+ const base = (await this.io.readText(reference.target).catch(() => null))?.trimEnd() ?? "";
4549
+ writes.push({
4550
+ target: reference.target,
4551
+ content: base === "" ? reference.content : `${base}\n\n${reference.content}`
4552
+ });
4553
+ }
4239
4554
  if (mode === "append") {
4240
4555
  const merged = freshTargetMd.trimEnd() + parts.join("\n") + "\n";
4241
4556
  const validation = validateFrontmatter(merged, targetName, this.limits);
@@ -4442,7 +4757,7 @@ var SkillLibrary = class {
4442
4757
  ok: false,
4443
4758
  message: `Write exceeds ${this.limits.maxSkillFileBytes} bytes: ${write.target}`
4444
4759
  };
4445
- const threat = scanContentThreats(write.content);
4760
+ const threat = this.contentThreatBlock(write.content);
4446
4761
  if (threat) return {
4447
4762
  ok: false,
4448
4763
  message: threat
@@ -4586,7 +4901,7 @@ var SkillLibrary = class {
4586
4901
  ok: false,
4587
4902
  message: `Support file exceeds ${this.limits.maxSkillFileBytes} bytes.`
4588
4903
  };
4589
- const threat = scanContentThreats(content);
4904
+ const threat = this.contentThreatBlock(content);
4590
4905
  if (threat) return {
4591
4906
  ok: false,
4592
4907
  message: threat
@@ -4851,26 +5166,4 @@ var SkillLibrary = class {
4851
5166
  }
4852
5167
  };
4853
5168
  //#endregion
4854
- //#region lib/types/state-store.js
4855
- /**
4856
- * Evolution home path helper: `$DSH_HOME/evolution` for plugin-owned sidecar
4857
- * state (reports, activity store, feedback file, state-domain data).
4858
- */
4859
- /**
4860
- * DSH home root: `$DSH_HOME` or `~/.dsh`. Single source of the empty-string
4861
- * fallback — an EMPTY or WHITESPACE-ONLY DSH_HOME resolves to the default
4862
- * home, never to a CWD-relative path (0.3.19 W1.3, 0.3.22 F-207);
4863
- * V8-06 (0.3.47) extends the guard to whitespace (upstream home-paths:
4864
- * `trim().length > 0` is the adoption test — `DSH_HOME=" "` must not produce
4865
- * a sidecar under a relative "." path).
4866
- */
4867
- function evolutionRoot(env = process.env) {
4868
- return env.DSH_HOME?.trim() ? env.DSH_HOME : join(homedir(), ".dsh");
4869
- }
4870
- /** Evolution home path helper: `$DSH_HOME/evolution` for plugin-owned sidecar
4871
- * state (reports, activity store, feedback file, state-domain data). */
4872
- function evolutionHome(env = process.env) {
4873
- return join(evolutionRoot(env), "evolution");
4874
- }
4875
- //#endregion
4876
- export { AUTHORING_DESCRIPTION_BAR, COMBINED_REVIEW_PLAN_PROMPT, COMBINED_REVIEW_PROMPT, COMPLETION_SKILL_REVIEW_PROMPT, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_CONSOLIDATION_FAILURES, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_MAX_OPS_PER_PLAN, DEFAULT_MEMORY_CHAR_LIMIT, DEFAULT_MIN_IDLE_HOURS, DEFAULT_MUTATION_CAP, DEFAULT_REVIEW_MEMORY_INTERVAL, DEFAULT_REVIEW_SKILL_INTERVAL, DEFAULT_SKILL_CONTENT_CHARS, DEFAULT_SKILL_LIMITS, DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS, DEFAULT_SKILL_REVIEW_TRIGGER, DEFAULT_STALE_AFTER_DAYS, DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS, DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS, DEFAULT_SUBSTANTIVE_MIN_USER_CHARS, DEFAULT_USER_CHAR_LIMIT, DRIFT_MAX_LINE_CHARS, DRIFT_SIGNALS_VERSION, DRIFT_SIGNAL_NOUNS, DSH_AUTHORING_STANDARDS, ENTRY_DELIMITER, EVENT_ARCHIVE_PREFIX, EVENT_LOG_RETAIN_ARCHIVES, EVENT_LOG_ROTATE_AT, EVENT_LOG_VERSION, EVOLUTION_WRITE_TOOLS, EvolutionGateSet, FORBIDDEN_CONTROL_KEYS, HEALTH_STAMP_RE, LOW_QUALITY_THRESHOLD, MAINTAIN_PROMPT, MAX_DESCRIPTION_LENGTH, MAX_RESTRUCTURE_MOVES, MAX_SKILL_CONTENT_CHARS, MAX_SKILL_FILE_BYTES, MAX_SKILL_NAME_LENGTH, MEMORY_REVIEW_PROMPT, MIN_STAMP_BODY_CHARS, MUTATIONS_FILE_VERSION, MemoryStore, PATTERN_OVERLAP, PROMPT_BUNDLE, PROMPT_BUNDLE_ID, PROMPT_BUNDLE_VERSION, PROTECTED_BUILTIN_SKILLS, QUALITY_WEIGHTS, RESTRUCTURE_TARGET_RE, SKILLS_GUIDANCE, SKILL_NAME_RE, SKILL_REVIEW_PLAN_PROMPT, SKILL_REVIEW_PROMPT, SNAPSHOT_EXTRA_NAME_RE, SUPPORT_DIRS, SUPPRESSED_FILE_VERSION, SkillLibrary, advanceReview, appendEvolutionEvent, applyCuratorFields, applyCuratorLifecycleFields, applyCuratorMetaFields, assessStructureHealth, authoringFeedback, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpUse, bumpView, clampedNumber, composePresetComposition, computeDedupGroups, computeDriftSignals, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, contentHash, createGateSet, duplicateHeadings, emptyRecord, evaluateThreat, eventsFile, evolutionHome, evolutionIoAdapter, evolutionRoot, findDriftSignal, foldCuratorFields, foldTurn, frontmatterBlock, frontmatterYamlUnsafeValues, getRecord, latestActivityAt, lifecycleCandidate, listEventArchives, loadMutations, loadSuppressedNames, loadUsage, makeSerialQueue, markAgentCreated, markerEntryName, memoryRoot, missingSupportPointers, mutateUsage, mutationsFile, narrowNameMatches, nodeEvolutionIo, normalizeFrontmatter, normalizeUsageRecord, observeEvent, overlongLines, parseCuratorNominations, parseEvolutionEvents, parseFrontmatter, pendingSelfCleanup, readEvolutionEvents, readEvolutionTimeline, recordMutation, redactSecrets, relatedSkillNames, renameWithRetry, renderCuratorReportMarkdown, resolveOrigins, resolveSkillsRoot, retainEventArchives, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, suppressedFile, transactIo, updateSuppressedNames, usageFile, usageObserved, validateFrontmatter, verifyPromptBundle, yamlPlainScalarNeedsQuotes };
5169
+ export { AUTHORING_DESCRIPTION_BAR, COMBINED_REVIEW_PLAN_PROMPT, COMBINED_REVIEW_PROMPT, COMPLETION_SKILL_REVIEW_PROMPT, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_CONSOLIDATION_FAILURES, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_MAX_OPS_PER_PLAN, DEFAULT_MEMORY_CHAR_LIMIT, DEFAULT_MIN_IDLE_HOURS, DEFAULT_MUTATION_CAP, DEFAULT_REVIEW_MEMORY_INTERVAL, DEFAULT_REVIEW_SKILL_INTERVAL, DEFAULT_SKILL_CONTENT_CHARS, DEFAULT_SKILL_LIMITS, DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS, DEFAULT_SKILL_REVIEW_TRIGGER, DEFAULT_STALE_AFTER_DAYS, DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS, DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS, DEFAULT_SUBSTANTIVE_MIN_USER_CHARS, DEFAULT_USER_CHAR_LIMIT, DRIFT_MAX_LINE_CHARS, DRIFT_SIGNALS_VERSION, DRIFT_SIGNAL_NOUNS, DSH_AUTHORING_STANDARDS, ENTRY_DELIMITER, EVENT_ARCHIVE_PREFIX, EVENT_LOG_RETAIN_ARCHIVES, EVENT_LOG_ROTATE_AT, EVENT_LOG_VERSION, EVOLUTION_WRITE_TOOLS, EvolutionGateSet, FORBIDDEN_CONTROL_KEYS, HEALTH_STAMP_RE, LOW_QUALITY_THRESHOLD, MAINTAIN_OUTPUT_INSTRUCTION, MAINTAIN_PROMPT, MAX_DESCRIPTION_LENGTH, MAX_RESTRUCTURE_MOVES, MAX_SKILL_CONTENT_CHARS, MAX_SKILL_FILE_BYTES, MAX_SKILL_NAME_LENGTH, MEMORY_REVIEW_PROMPT, MIN_STAMP_BODY_CHARS, MUTATIONS_FILE_VERSION, MemoryStore, PATTERN_OVERLAP, PROMPT_BUNDLE, PROMPT_BUNDLE_ID, PROMPT_BUNDLE_VERSION, PROTECTED_BUILTIN_SKILLS, QUALITY_WEIGHTS, RESTRUCTURE_TARGET_RE, SKILLS_GUIDANCE, SKILL_NAME_RE, SKILL_REVIEW_PLAN_PROMPT, SKILL_REVIEW_PROMPT, SNAPSHOT_EXTRA_NAME_RE, SUPPORT_DIRS, SUPPRESSED_FILE_VERSION, SkillLibrary, THREAT_EXEMPT_HINT, advanceReview, appendEvolutionEvent, applyCuratorFields, applyCuratorLifecycleFields, applyCuratorMetaFields, assessStructureHealth, authoringFeedback, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpUse, bumpView, clampedNumber, composePresetComposition, computeDedupGroups, computeDriftSignals, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, contentHash, createGateSet, duplicateHeadings, emptyRecord, evaluateThreat, eventsFile, evolutionHome, evolutionIoAdapter, evolutionRoot, findDriftSignal, foldCuratorFields, foldTurn, frontmatterBlock, frontmatterYamlUnsafeValues, getRecord, latestActivityAt, lifecycleCandidate, listEventArchives, loadMutations, loadSuppressedNames, loadUsage, makeSerialQueue, markAgentCreated, markerEntryName, memoryRoot, missingSupportPointers, mutateUsage, mutationsFile, narrowNameMatches, nodeEvolutionIo, normalizeFrontmatter, normalizeUsageRecord, observeEvent, overlongLines, parseCuratorNominations, parseEvolutionEvents, parseFrontmatter, pendingSelfCleanup, readEvolutionEvents, readEvolutionTimeline, recordMutation, redactSecrets, relatedSkillNames, renameWithRetry, renderCuratorReportMarkdown, resolveOrigins, resolveSkillsRoot, retainEventArchives, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, suppressedFile, transactIo, updateSuppressedNames, usageFile, usageObserved, validateFrontmatter, verifyPromptBundle, writeDurableTmp, yamlPlainScalarNeedsQuotes };