@lmzhen/dsh-evolution-core 0.3.51 → 0.3.53

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);
@@ -1613,8 +1745,16 @@ function buildLearnPrompt(userRequest) {
1613
1745
  //#endregion
1614
1746
  //#region lib/types/state-store.js
1615
1747
  /**
1616
- * Evolution home path helper: `$DSH_HOME/evolution` for plugin-owned sidecar
1617
- * state (reports, activity store, feedback file, state-domain data).
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.
1618
1758
  */
1619
1759
  /**
1620
1760
  * DSH home root: `$DSH_HOME` or `~/.dsh`. Single source of the empty-string
@@ -1623,9 +1763,16 @@ function buildLearnPrompt(userRequest) {
1623
1763
  * V8-06 (0.3.47) extends the guard to whitespace (upstream home-paths:
1624
1764
  * `trim().length > 0` is the adoption test — `DSH_HOME=" "` must not produce
1625
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.
1626
1772
  */
1627
1773
  function evolutionRoot(env = process.env) {
1628
- return env.DSH_HOME?.trim() ? env.DSH_HOME : join(homedir(), ".dsh");
1774
+ const home = env.DSH_HOME?.trim();
1775
+ return home ? home : join(homedir(), ".dsh");
1629
1776
  }
1630
1777
  /** Evolution home path helper: `$DSH_HOME/evolution` for plugin-owned sidecar
1631
1778
  * state (reports, activity store, feedback file, state-domain data). */
@@ -1943,6 +2090,15 @@ function scanContentThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTION
1943
2090
  if (!blocked) return null;
1944
2091
  return `Blocked by security scan (${findings[0]?.label ?? "unknown"}). This content appears to contain potentially malicious instructions.`;
1945
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.";
1946
2102
  //#endregion
1947
2103
  //#region lib/types/memory-store.js
1948
2104
  /**
@@ -2012,6 +2168,8 @@ var MemoryStore = class {
2012
2168
  root;
2013
2169
  maxFailures;
2014
2170
  io;
2171
+ /** V10-03 (P2-18): see MemoryStoreOptions.threatExemptLabels. */
2172
+ threatExemptLabels;
2015
2173
  /** V6-16 (0.3.37): same-process RMW serialization (the SkillLibrary queue) —
2016
2174
  * on a backend WITHOUT a transact lock two concurrent callers compute on the
2017
2175
  * same old content and the last rename wins, silently dropping one op's
@@ -2027,6 +2185,18 @@ var MemoryStore = class {
2027
2185
  this.addDatePrefix = options.addDatePrefix ?? false;
2028
2186
  this.root = options.root ?? memoryRoot();
2029
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;
2030
2200
  }
2031
2201
  limitFor(target) {
2032
2202
  return target === "memory" ? this.memoryLimit : this.userLimit;
@@ -2151,7 +2321,13 @@ var MemoryStore = class {
2151
2321
  outcome = core.result;
2152
2322
  return core.write ?? current ?? null;
2153
2323
  });
2154
- 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
+ };
2155
2331
  }
2156
2332
  /**
2157
2333
  * Single-entry add inside the transaction: shared checks (oversized,
@@ -2177,7 +2353,7 @@ var MemoryStore = class {
2177
2353
  write: null
2178
2354
  };
2179
2355
  }
2180
- const threat = scanMemoryThreats(content);
2356
+ const threat = this.memoryThreatBlock(content);
2181
2357
  if (threat) return {
2182
2358
  result: {
2183
2359
  ok: false,
@@ -2188,7 +2364,8 @@ var MemoryStore = class {
2188
2364
  },
2189
2365
  write: null
2190
2366
  };
2191
- 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 {
2192
2369
  result: {
2193
2370
  ok: false,
2194
2371
  message: "Fact contains the entry delimiter (§) and would split into multiple entries; rewrite it as separate facts.",
@@ -2212,7 +2389,7 @@ var MemoryStore = class {
2212
2389
  write: null
2213
2390
  };
2214
2391
  }
2215
- const next = [...entries, this.addDatePrefix ? `## ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}\n${content}` : content];
2392
+ const next = [...entries, prefixed];
2216
2393
  const total = next.join(ENTRY_DELIMITER).length;
2217
2394
  const addLimit = this.limitFor(target);
2218
2395
  if (addLimit > 0 && total > addLimit) return {
@@ -2259,7 +2436,13 @@ var MemoryStore = class {
2259
2436
  outcome = core.result;
2260
2437
  return core.write ?? current ?? null;
2261
2438
  });
2262
- 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
+ };
2263
2446
  }
2264
2447
  /** Batch RMW inside the transaction. `write: null` = failure/no-op, disk untouched. */
2265
2448
  async applyBatchCore(target, operations, raw) {
@@ -2278,6 +2461,7 @@ var MemoryStore = class {
2278
2461
  }
2279
2462
  const entries = [...new Set(normalizeEntries(raw))];
2280
2463
  const working = [...entries];
2464
+ const datePrefix = this.addDatePrefix ? `## ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}\n` : "";
2281
2465
  for (const [index, op] of operations.entries()) {
2282
2466
  const position = index + 1;
2283
2467
  if (op.action === "add") {
@@ -2292,7 +2476,7 @@ var MemoryStore = class {
2292
2476
  },
2293
2477
  write: null
2294
2478
  };
2295
- const threat = scanMemoryThreats(body);
2479
+ const threat = this.memoryThreatBlock(body);
2296
2480
  if (threat) return {
2297
2481
  result: {
2298
2482
  ok: false,
@@ -2303,7 +2487,7 @@ var MemoryStore = class {
2303
2487
  },
2304
2488
  write: null
2305
2489
  };
2306
- const entryBody = this.addDatePrefix ? `## ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}\n${body}` : body;
2490
+ const entryBody = `${datePrefix}${body}`;
2307
2491
  if (hasEntryDelimiter(entryBody)) return {
2308
2492
  result: {
2309
2493
  ok: false,
@@ -2371,7 +2555,7 @@ var MemoryStore = class {
2371
2555
  },
2372
2556
  write: null
2373
2557
  };
2374
- const threat = scanMemoryThreats(body);
2558
+ const threat = this.memoryThreatBlock(body);
2375
2559
  if (threat) return {
2376
2560
  result: {
2377
2561
  ok: false,
@@ -2382,7 +2566,8 @@ var MemoryStore = class {
2382
2566
  },
2383
2567
  write: null
2384
2568
  };
2385
- if (hasEntryDelimiter(body)) return {
2569
+ const entryBody = `${datePrefix}${body}`;
2570
+ if (hasEntryDelimiter(entryBody)) return {
2386
2571
  result: {
2387
2572
  ok: false,
2388
2573
  message: `Operation ${position} (replace): Fact contains the entry delimiter (§) and would split into multiple entries; rewrite it as separate facts.${previewEntries(entries)}`,
@@ -2392,7 +2577,7 @@ var MemoryStore = class {
2392
2577
  },
2393
2578
  write: null
2394
2579
  };
2395
- working[matchIndex] = body;
2580
+ working[matchIndex] = entryBody;
2396
2581
  }
2397
2582
  }
2398
2583
  const total = working.join(ENTRY_DELIMITER).length;
@@ -2431,13 +2616,13 @@ var MemoryStore = class {
2431
2616
  parts.push(`## ${label} — file skipped: ${oversized.size} bytes (limit ${oversized.limit * READ_GUARD_FACTOR}); not read`);
2432
2617
  continue;
2433
2618
  }
2434
- const safe = entries.filter((entry) => !scanMemoryThreats(entry));
2619
+ const safe = entries.filter((entry) => !scanMemoryThreats(entry, void 0, this.threatScanOptions()));
2435
2620
  if (safe.length > 0) {
2436
2621
  const body = safe.join(ENTRY_DELIMITER);
2437
2622
  const limit = this.limitFor(target);
2438
- const pct = limit > 0 ? Math.min(100, Math.floor(body.length * 100 / limit)) : 0;
2439
2623
  const note = safe.length === entries.length ? "" : ` (${entries.length - safe.length} threat-matched entries filtered)`;
2440
- 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}`);
2441
2626
  }
2442
2627
  }
2443
2628
  return parts.join("\n\n");
@@ -2507,6 +2692,7 @@ async function recordMutation(root, io, record, cap = 500) {
2507
2692
  if (current !== null) try {
2508
2693
  JSON.parse(current);
2509
2694
  } catch {
2695
+ console.warn(`mutation audit record dropped: ${mutationsFile(root)} is malformed and was not overwritten`);
2510
2696
  return current;
2511
2697
  }
2512
2698
  const existing = parseMutationRecords(current);
@@ -2533,6 +2719,14 @@ async function recordMutation(root, io, record, cap = 500) {
2533
2719
  * standard row id fails loud by default, and `DSH_EVOLUTION_ALLOW_ROW_COLLISIONS=1`
2534
2720
  * downgrades it to a warning that keeps both (the row mounts twice).
2535
2721
  *
2722
+ * V10-14 / 0.3.53 (P1-2): BOTH composers now inject the Hermes 60-char catalog
2723
+ * cap onto the standard-sourced `- id: tool-skill` row of the composed preset
2724
+ * (see injectCatalogDescriptionCap) — the session-visible tool-skill instance
2725
+ * mounts in the preset's own standing scope, where no profile-root patch can
2726
+ * reach it. 0.3.53 moved the injection INTO the composer so
2727
+ * `/evolution preset install` (the npm user's only preset path) gets it too;
2728
+ * install-layered applies the byte-identical rule, pinned by installer.spec.
2729
+ *
2536
2730
  * Row ids are read from `- id:` lines; an id present in both fragments would
2537
2731
  * mount twice and could shadow the platform row, so it fails loud.
2538
2732
  * @param standardComposition - the runtime `standard` preset composition.
@@ -2544,13 +2738,53 @@ function composePresetComposition(standardComposition, deltaComposition) {
2544
2738
  const collisions = [...compositionRowIds(deltaComposition)].filter((id) => standardIds.has(id)).sort();
2545
2739
  if (collisions.length > 0 && process.env.DSH_EVOLUTION_ALLOW_ROW_COLLISIONS !== "1") throw new Error(`evolution preset composition: delta rows collide with runtime standard rows: ${collisions.join(", ")}`);
2546
2740
  if (collisions.length > 0) console.warn(`evolution preset composition: warning — delta rows collide with standard rows (${collisions.join(", ")}); keeping both (DSH_EVOLUTION_ALLOW_ROW_COLLISIONS=1)`);
2547
- return `${standardComposition.replace(/\s+$/, "")}\n\n${deltaComposition.trim()}\n`;
2741
+ return injectCatalogDescriptionCap(`${standardComposition.replace(/\s+$/, "")}\n\n${deltaComposition.trim()}\n`);
2742
+ }
2743
+ /**
2744
+ * V10-14 (P1-2), 0.3.53: inject the Hermes 60-char catalog cap onto the
2745
+ * standard-sourced `- id: tool-skill` row of a composed preset.
2746
+ *
2747
+ * The session-visible `tool-skill` instance mounts in the agent preset's own
2748
+ * standing scope; a profile-root patch (evolution-host/cordis.patch.yml)
2749
+ * cannot reach it, so without this injection the catalog's read side runs the
2750
+ * platform default (500). Text-level rewrite in the same line-scan style as
2751
+ * compositionRowIds (no YAML library):
2752
+ * - idempotent: a tool-skill item that already carries a `config:` key is
2753
+ * left byte-identical, so re-running the installer never doubles the key;
2754
+ * - the injected block carries a marker comment so a diff of the generated
2755
+ * preset can tell composer-owned text from platform text;
2756
+ * - a composition WITHOUT a tool-skill row is returned unchanged with a
2757
+ * one-time warning (a renamed platform row must not brick the install,
2758
+ * but the missed cap must be observable).
2759
+ * install-layered.mjs ships the byte-identical `injectToolSkillCap`.
2760
+ */
2761
+ function injectCatalogDescriptionCap(composition) {
2762
+ const lines = composition.split("\n");
2763
+ let found = false;
2764
+ for (let i = 0; i < lines.length; i += 1) {
2765
+ if (!/^- id:\s*tool-skill\s*$/.test(lines[i] ?? "")) continue;
2766
+ found = true;
2767
+ let end = i;
2768
+ let hasConfig = false;
2769
+ for (let j = i + 1; j < lines.length; j += 1) {
2770
+ const next = lines[j] ?? "";
2771
+ if (next.trim() === "") break;
2772
+ if (!/^\s/.test(next)) break;
2773
+ if (/^\s+config:(\s|$)/.test(next)) hasConfig = true;
2774
+ end = j;
2775
+ }
2776
+ if (hasConfig) continue;
2777
+ lines.splice(end + 1, 0, " # V10-14: Hermes 60-char catalog cap — injected by the preset composer (P1-2);", " # this preset-scope row is the session-visible instance and no profile", " # patch can reach it. Remove only to run the platform default (500).", " config:", " catalogDescriptionMaxLength: 60");
2778
+ i = end + 5;
2779
+ }
2780
+ if (!found) console.warn("evolution preset composition: warning — no `- id: tool-skill` row in the composed preset; the 60-char catalog cap was NOT injected (platform renamed the row? reconcile with the delta)");
2781
+ return lines.join("\n");
2548
2782
  }
2549
2783
  function compositionRowIds(composition) {
2550
2784
  const ids = /* @__PURE__ */ new Set();
2551
2785
  for (const line of composition.split("\n")) {
2552
- const match = /^- id:\s*(\S+)/.exec(line);
2553
- if (match) ids.add(match[1] ?? "");
2786
+ const id = /^- id:\s*(\S+)/.exec(line)?.[1];
2787
+ if (id) ids.add(id);
2554
2788
  }
2555
2789
  return ids;
2556
2790
  }
@@ -2617,7 +2851,10 @@ function computeQualityScores(input) {
2617
2851
  function normalize(content) {
2618
2852
  return content.toLowerCase().replace(/\s+/g, " ").trim();
2619
2853
  }
2620
- function contentHash$1(content) {
2854
+ /** C-26 (v10 audit): renamed from `contentHash` — mutations.ts exports a
2855
+ * contentHash of RAW bytes, this one hashes the NORMALIZED text for dedup
2856
+ * grouping. Same private helper, unambiguous name. */
2857
+ function normalizedHash(content) {
2621
2858
  return createHash("sha256").update(normalize(content)).digest("hex");
2622
2859
  }
2623
2860
  function tokenize(content) {
@@ -2639,7 +2876,7 @@ function computeDedupGroups(input) {
2639
2876
  const names = [...input.contents.keys()];
2640
2877
  const hashes = /* @__PURE__ */ new Map();
2641
2878
  for (const name of names) {
2642
- const hash = contentHash$1(input.contents.get(name) ?? "");
2879
+ const hash = normalizedHash(input.contents.get(name) ?? "");
2643
2880
  const bucket = hashes.get(hash);
2644
2881
  if (bucket) bucket.push(name);
2645
2882
  else hashes.set(hash, [name]);
@@ -2771,6 +3008,12 @@ const DEFAULT_HEALTH_THRESHOLDS = {
2771
3008
  * (`defaced`, `feedback`) are not counted as commit shas; the ISO branch
2772
3009
  * accepts a UTC `Z`, a numeric UTC offset (`+08:00`), or no timezone at all —
2773
3010
  * non-UTC timestamps used to escape detection (log-like content missed).
3011
+ *
3012
+ * Known boundary, recorded not fixed (C-22, v10 audit): the hex branch also
3013
+ * matches 7-40 char PURE-DIGIT strings (order numbers etc.), so stamp density
3014
+ * can be over-reported for such content. The misjudgment direction is "extra
3015
+ * stamp hits" only — never a miss — and no real incident exists; revisit only
3016
+ * if curator decisions are actually distorted (v10 deferral ledger).
2774
3017
  */
2775
3018
  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");
2776
3019
  /**
@@ -2815,7 +3058,7 @@ function assessStructureHealth(snapshot, thresholds = DEFAULT_HEALTH_THRESHOLDS)
2815
3058
  * Deterministic review signal gate.
2816
3059
  *
2817
3060
  * Scans a DSH session event log for durable learning signals before any LLM
2818
- * is spent. `turn/end` calls `observeTurn`; the returned review kind is
3061
+ * is spent. `turn/end` calls `observeEvent`; the returned review kind is
2819
3062
  * accumulated until a configured interval fires.
2820
3063
  */
2821
3064
  const CORRECTION_PATTERNS = [
@@ -3035,7 +3278,7 @@ function computeDriftSignals(snapshots) {
3035
3278
  const narrow = narrowNameMatches(snapshot.name);
3036
3279
  signals.push(narrow.length === 0 ? sig("narrow_name", "pass", "none") : sig("narrow_name", "over", narrow.join(", "), void 0, `name=${snapshot.name}`));
3037
3280
  const description = snapshot.description;
3038
- signals.push(description === void 0 ? sig("description_chars", "unknown", "missing", "60") : sig("description_chars", description.length > 60 ? "over" : "pass", `${description.length}`, `60`));
3281
+ signals.push(description === void 0 ? sig("description_chars", "unknown", "missing", `60`) : sig("description_chars", description.length > 60 ? "over" : "pass", `${description.length}`, `60`));
3039
3282
  const quality = snapshot.quality;
3040
3283
  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}`));
3041
3284
  return {
@@ -3240,6 +3483,13 @@ function frontmatterYamlUnsafeValues(content) {
3240
3483
  * rewritten block no longer parses, or a rewritten value's parsed content
3241
3484
  * differs from the original, the rewrite is rolled back and reported in
3242
3485
  * `issues` (fail-loud, never a silent value corruption — P3-4).
3486
+ *
3487
+ * V10-02 (P2-3): the rewrite decision is PER LINE — each entry parses its own
3488
+ * value, so a duplicated key can never route one entry's unsafe value into a
3489
+ * different line's rewrite (the old key→Map lookup rewrote the FIRST (safe)
3490
+ * line with the SECOND line's quoted value, and the last-wins YAML reader
3491
+ * masked the damage). A duplicated key is itself invalid input and is
3492
+ * reported in `issues` (the write path refuses) instead of being rewritten.
3243
3493
  */
3244
3494
  function normalizeFrontmatter(content) {
3245
3495
  const block = frontmatterBlock(content);
@@ -3250,10 +3500,10 @@ function normalizeFrontmatter(content) {
3250
3500
  issues: []
3251
3501
  };
3252
3502
  const { lines, end, nl } = block;
3253
- const unsafe = new Map(frontmatterYamlUnsafeValues(content).map((entry) => [entry.key, entry.value]));
3254
- const originalValues = new Map(unsafe);
3255
3503
  const fields = [];
3256
3504
  const issues = [];
3505
+ const seen = /* @__PURE__ */ new Set();
3506
+ const originalValues = /* @__PURE__ */ new Map();
3257
3507
  let changed = false;
3258
3508
  for (let i = 1; i < end; i++) {
3259
3509
  const line = lines[i];
@@ -3261,16 +3511,29 @@ function normalizeFrontmatter(content) {
3261
3511
  const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
3262
3512
  if (!match) continue;
3263
3513
  const key = match[1];
3264
- const value = unsafe.get(key ?? "");
3265
- if (key === void 0 || value === void 0) continue;
3514
+ if (key === void 0) continue;
3515
+ const value = (match[2] ?? "").trim();
3516
+ if (seen.has(key)) {
3517
+ issues.push(`${key}: duplicate frontmatter key — remove the repeated entry and retry`);
3518
+ continue;
3519
+ }
3520
+ seen.add(key);
3521
+ if (!yamlPlainScalarNeedsQuotes(value)) continue;
3266
3522
  if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(value)) {
3267
3523
  issues.push(`${key}: value contains control characters — clean them manually`);
3268
3524
  continue;
3269
3525
  }
3270
3526
  lines[i] = `${key}: ${value.includes("\"") || value.includes("\\") ? `'${value.replace(/'/g, "''")}'` : `"${value}"`}`;
3527
+ originalValues.set(key, value);
3271
3528
  fields.push(key);
3272
3529
  changed = true;
3273
3530
  }
3531
+ if (issues.length > 0) return {
3532
+ content,
3533
+ changed: false,
3534
+ fields: [],
3535
+ issues
3536
+ };
3274
3537
  if (!changed) return {
3275
3538
  content,
3276
3539
  changed: false,
@@ -3320,7 +3583,7 @@ function validateFrontmatter(content, expectedName, limits = DEFAULT_SKILL_LIMIT
3320
3583
  const parsed = parseFrontmatter(content);
3321
3584
  if (!parsed) return "SKILL.md must start and end with YAML frontmatter and include a body.";
3322
3585
  if (!parsed.frontmatter.name) return "Frontmatter must include a name field.";
3323
- if (!/^[a-z0-9][a-z0-9-]*$/.test(parsed.frontmatter.name)) return `Invalid skill name "${parsed.frontmatter.name}" — use lowercase letters, digits, and hyphens.`;
3586
+ if (!SKILL_NAME_RE.test(parsed.frontmatter.name)) return `Invalid skill name "${parsed.frontmatter.name}" — use lowercase letters, digits, and hyphens.`;
3324
3587
  if (parsed.frontmatter.name.length > limits.maxNameLength) return `Skill name exceeds ${limits.maxNameLength} characters.`;
3325
3588
  if (expectedName && parsed.frontmatter.name !== expectedName) return `Frontmatter name "${parsed.frontmatter.name}" does not match target skill "${expectedName}".`;
3326
3589
  if (!parsed.frontmatter.description) return "Frontmatter must include a description field.";
@@ -3358,12 +3621,51 @@ async function listNames(root, io) {
3358
3621
  }
3359
3622
  return names.sort();
3360
3623
  }
3624
+ /** C-18: support file names are restricted to the
3625
+ * RESTRUCTURE_TARGET_RE character class (leading `[a-z0-9]`, then
3626
+ * `[a-z0-9._-]`) — drive-colon / odd-character / uppercase names can no
3627
+ * longer reach the filesystem through writeSupportFile / patch /
3628
+ * removeSupportFile. */
3629
+ const SUPPORT_FILE_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
3630
+ /** C-18: win32 reserves these stems with ANY extension (`nul.md` hits the
3631
+ * NUL device), and they are fully inside the charset above — so the reserved
3632
+ * set is checked on the first-dot prefix as well; the charset close alone
3633
+ * cannot refuse them. */
3634
+ const WIN32_RESERVED_DEVICE_NAMES = new Set([
3635
+ "con",
3636
+ "prn",
3637
+ "aux",
3638
+ "nul",
3639
+ "com1",
3640
+ "com2",
3641
+ "com3",
3642
+ "com4",
3643
+ "com5",
3644
+ "com6",
3645
+ "com7",
3646
+ "com8",
3647
+ "com9",
3648
+ "lpt1",
3649
+ "lpt2",
3650
+ "lpt3",
3651
+ "lpt4",
3652
+ "lpt5",
3653
+ "lpt6",
3654
+ "lpt7",
3655
+ "lpt8",
3656
+ "lpt9"
3657
+ ]);
3361
3658
  function validateSupportPath(filePath) {
3362
3659
  const normalized = filePath.replace(/\\/g, "/");
3363
3660
  if (normalized.includes("..")) return "Path traversal is not allowed.";
3364
3661
  const parts = normalized.split("/").filter(Boolean);
3365
3662
  if (parts.length === 0 || !SUPPORT_DIRS.includes(parts[0])) return `file_path must be under one of: ${SUPPORT_DIRS.join(", ")}.`;
3366
3663
  if (parts.length < 2) return "Provide a file name, not just a directory.";
3664
+ for (const part of parts.slice(1)) {
3665
+ if (!SUPPORT_FILE_NAME_RE.test(part)) return `Unsupported file name "${part}" — use lowercase letters, digits, dots, hyphens, and underscores (leading letter or digit).`;
3666
+ const stem = part.split(".")[0]?.toLowerCase() ?? "";
3667
+ if (WIN32_RESERVED_DEVICE_NAMES.has(stem)) return `Unsupported file name "${part}" — a Windows reserved device name.`;
3668
+ }
3367
3669
  return null;
3368
3670
  }
3369
3671
  /**
@@ -3537,11 +3839,15 @@ var SkillLibrary = class {
3537
3839
  * one skill never interleave their read-modify-write (the cross-process layer
3538
3840
  * is the IO backend's transact lock; this chain is the second layer). */
3539
3841
  serial;
3540
- constructor(root = skillsRoot(), io = nodeEvolutionIo(), limits = DEFAULT_SKILL_LIMITS, onMutation, transact) {
3842
+ /** V10-03 (P2-18): see the constructor's threatExemptLabels. Empty by
3843
+ * default — the strict ANY-hit-blocks policy is unchanged. */
3844
+ threatExemptLabels;
3845
+ constructor(root = skillsRoot(), io = nodeEvolutionIo(), limits = DEFAULT_SKILL_LIMITS, onMutation, transact, threatExemptLabels) {
3541
3846
  this.root = root;
3542
3847
  this.io = io;
3543
3848
  this.limits = limits;
3544
3849
  this.onMutation = onMutation;
3850
+ this.threatExemptLabels = threatExemptLabels ?? [];
3545
3851
  this.transact = transact ?? (io.transact ? (ioLike, path, task) => {
3546
3852
  const t = ioLike.transact;
3547
3853
  return t ? t(path, task) : transactIo(ioLike, path, task);
@@ -3585,12 +3891,24 @@ var SkillLibrary = class {
3585
3891
  this.onMutation?.(event);
3586
3892
  } catch {}
3587
3893
  }
3894
+ /** V10-03 (P2-18): ScanOptions shared by every write-path threat check —
3895
+ * the constructor's exempt labels, empty by default (behavior unchanged). */
3896
+ threatScanOptions() {
3897
+ return this.threatExemptLabels.length > 0 ? { excludeLabels: this.threatExemptLabels } : {};
3898
+ }
3899
+ /** V10-03 (P2-18): the strict-scan write gate. A block message names the hit
3900
+ * label (scanContentThreats already embeds it) plus the self-heal hint, so a
3901
+ * false-positive rewrite direction is actionable instead of a dead end. */
3902
+ contentThreatBlock(content) {
3903
+ const threat = scanContentThreats(content, void 0, this.threatScanOptions());
3904
+ return threat === null ? null : threat + THREAT_EXEMPT_HINT;
3905
+ }
3588
3906
  async list() {
3589
3907
  const summaries = [];
3590
3908
  for (const name of await listNames(this.root, this.io)) {
3591
3909
  const dir = this.dirOf(name);
3592
3910
  const md = await this.io.readText(join(dir, "SKILL.md"));
3593
- if (!md) continue;
3911
+ if (md === null) continue;
3594
3912
  const parsed = parseFrontmatter(md);
3595
3913
  let entries = [];
3596
3914
  try {
@@ -3598,9 +3916,10 @@ var SkillLibrary = class {
3598
3916
  } catch {}
3599
3917
  const has = (marker) => entries.includes(markerEntryName(marker));
3600
3918
  const protectedBy = has("bundled") ? "bundled" : has("hub-installed") ? "hub-installed" : has("pinned") ? "pinned" : null;
3919
+ const parsedDescription = parsed?.frontmatter.description;
3601
3920
  summaries.push({
3602
3921
  name,
3603
- description: parsed?.frontmatter.description ?? "",
3922
+ description: typeof parsedDescription === "string" ? parsedDescription : "",
3604
3923
  path: dir,
3605
3924
  protectedBy,
3606
3925
  managed: has("hermes-managed"),
@@ -3766,9 +4085,10 @@ var SkillLibrary = class {
3766
4085
  */
3767
4086
  async setPinned(name, pinned, origin = "foreground") {
3768
4087
  const normalized = name.trim();
3769
- if (!SKILL_NAME_RE.test(normalized) || normalized.length > this.limits.maxNameLength) return {
4088
+ const bad = this.badName(normalized);
4089
+ if (bad) return {
3770
4090
  ok: false,
3771
- message: `Invalid skill name "${normalized}". Use lowercase letters, digits, and hyphens (<= ${this.limits.maxNameLength}).`
4091
+ message: bad
3772
4092
  };
3773
4093
  if (origin === "background_review") return {
3774
4094
  ok: false,
@@ -3802,9 +4122,10 @@ var SkillLibrary = class {
3802
4122
  }
3803
4123
  async create(name, content, origin = "foreground") {
3804
4124
  const normalized = name.trim();
3805
- if (!SKILL_NAME_RE.test(normalized) || normalized.length > this.limits.maxNameLength) return {
4125
+ const bad = this.badName(normalized);
4126
+ if (bad) return {
3806
4127
  ok: false,
3807
- message: `Invalid skill name "${normalized}". Use lowercase letters, digits, and hyphens (<= ${this.limits.maxNameLength}).`
4128
+ message: bad
3808
4129
  };
3809
4130
  const validation = validateFrontmatter(content, normalized, this.limits);
3810
4131
  if (validation) return {
@@ -3824,7 +4145,7 @@ var SkillLibrary = class {
3824
4145
  message: revalidated
3825
4146
  };
3826
4147
  }
3827
- const threat = scanContentThreats(finalContent);
4148
+ const threat = this.contentThreatBlock(finalContent);
3828
4149
  if (threat) return {
3829
4150
  ok: false,
3830
4151
  message: threat
@@ -3834,6 +4155,11 @@ var SkillLibrary = class {
3834
4155
  ok: false,
3835
4156
  message: `Skill "${normalized}" already exists.`
3836
4157
  };
4158
+ const protection = await this.writeProtection(normalized, origin);
4159
+ if (protection) return {
4160
+ ok: false,
4161
+ message: `Skill "${normalized}" is protected (${protection}).`
4162
+ };
3837
4163
  const onDisk = finalContent.trimEnd() + "\n";
3838
4164
  await this.io.writeText(join(dir, "SKILL.md"), onDisk);
3839
4165
  if (origin !== "foreground") await this.io.writeText(markerPath(dir, "hermes-managed"), "");
@@ -3885,7 +4211,7 @@ var SkillLibrary = class {
3885
4211
  message: revalidated
3886
4212
  };
3887
4213
  }
3888
- const threat = scanContentThreats(finalContent);
4214
+ const threat = this.contentThreatBlock(finalContent);
3889
4215
  if (threat) return {
3890
4216
  ok: false,
3891
4217
  message: threat
@@ -4028,7 +4354,7 @@ var SkillLibrary = class {
4028
4354
  },
4029
4355
  write: null
4030
4356
  };
4031
- const threat = scanContentThreats(writeContent);
4357
+ const threat = this.contentThreatBlock(writeContent);
4032
4358
  if (threat) return {
4033
4359
  result: {
4034
4360
  ok: false,
@@ -4078,11 +4404,11 @@ var SkillLibrary = class {
4078
4404
  };
4079
4405
  const dir = this.dirOf(name);
4080
4406
  const md = await this.io.readText(join(dir, "SKILL.md"));
4081
- if (!md) return {
4407
+ if (md === null) return {
4082
4408
  ok: false,
4083
4409
  message: `Skill "${name}" not found.`
4084
4410
  };
4085
- const protection = await this.deleteProtection(name, options.allowBundled === void 0 ? {} : { allowBundled: options.allowBundled });
4411
+ const protection = await this.deleteProtection(name, options);
4086
4412
  if (protection) return {
4087
4413
  ok: false,
4088
4414
  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}).`
@@ -4179,10 +4505,13 @@ var SkillLibrary = class {
4179
4505
  ok: false,
4180
4506
  message: "Consolidation requires at least one distinct source skill."
4181
4507
  };
4182
- for (const name of [targetName, ...normalizedSources]) if (!SKILL_NAME_RE.test(name)) return {
4183
- ok: false,
4184
- message: `Invalid skill name "${name}". Use lowercase letters, digits, and hyphens.`
4185
- };
4508
+ for (const name of [targetName, ...normalizedSources]) {
4509
+ const bad = this.badName(name);
4510
+ if (bad) return {
4511
+ ok: false,
4512
+ message: bad
4513
+ };
4514
+ }
4186
4515
  const targetDir = this.dirOf(targetName);
4187
4516
  const targetProtection = await this.writeProtection(targetName, origin);
4188
4517
  if (targetProtection) return {
@@ -4262,7 +4591,14 @@ var SkillLibrary = class {
4262
4591
  ok: false,
4263
4592
  message: `Skill "${targetName}" not found.`
4264
4593
  };
4265
- const writes = [...referenceWrites];
4594
+ const writes = [];
4595
+ for (const reference of referenceWrites) {
4596
+ const base = (await this.io.readText(reference.target).catch(() => null))?.trimEnd() ?? "";
4597
+ writes.push({
4598
+ target: reference.target,
4599
+ content: base === "" ? reference.content : `${base}\n\n${reference.content}`
4600
+ });
4601
+ }
4266
4602
  if (mode === "append") {
4267
4603
  const merged = freshTargetMd.trimEnd() + parts.join("\n") + "\n";
4268
4604
  const validation = validateFrontmatter(merged, targetName, this.limits);
@@ -4469,7 +4805,7 @@ var SkillLibrary = class {
4469
4805
  ok: false,
4470
4806
  message: `Write exceeds ${this.limits.maxSkillFileBytes} bytes: ${write.target}`
4471
4807
  };
4472
- const threat = scanContentThreats(write.content);
4808
+ const threat = this.contentThreatBlock(write.content);
4473
4809
  if (threat) return {
4474
4810
  ok: false,
4475
4811
  message: threat
@@ -4613,7 +4949,7 @@ var SkillLibrary = class {
4613
4949
  ok: false,
4614
4950
  message: `Support file exceeds ${this.limits.maxSkillFileBytes} bytes.`
4615
4951
  };
4616
- const threat = scanContentThreats(content);
4952
+ const threat = this.contentThreatBlock(content);
4617
4953
  if (threat) return {
4618
4954
  ok: false,
4619
4955
  message: threat
@@ -4878,4 +5214,4 @@ var SkillLibrary = class {
4878
5214
  }
4879
5215
  };
4880
5216
  //#endregion
4881
- 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 };
5217
+ 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 };