@lmzhen/dsh-evolution-core 0.3.67 → 0.3.68

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
@@ -18,14 +18,24 @@ import { load } from "js-yaml";
18
18
  * node backend's V5-03 short-circuit).
19
19
  */
20
20
  async function transactIo(io, path, task) {
21
- if (io.transact) {
21
+ const committedOnly = (error) => isCommittedWarning(error);
22
+ if (io.transact) try {
22
23
  await io.transact(path, task);
23
24
  return;
25
+ } catch (error) {
26
+ if (!committedOnly(error)) throw error;
27
+ console.warn(`evolution-io: ${path} was written but its directory fsync failed — the bytes are visible, durability is unconfirmed: ${error instanceof Error ? error.message : String(error)}`);
28
+ return;
24
29
  }
25
30
  const current = await io.readText(path);
26
31
  const next = await task(current);
27
32
  if (next === null) await io.remove(path);
28
- else if (next !== current) await io.writeText(path, next);
33
+ else if (next !== current) try {
34
+ await io.writeText(path, next);
35
+ } catch (error) {
36
+ if (!committedOnly(error)) throw error;
37
+ console.warn(`evolution-io: ${path} was written but its directory fsync failed — the bytes are visible, durability is unconfirmed: ${error instanceof Error ? error.message : String(error)}`);
38
+ }
29
39
  }
30
40
  /** Lazy adapter over an IO provider registry, shared by every evolution consumer. */
31
41
  function evolutionIoAdapter(provider) {
@@ -214,6 +224,72 @@ function parseLockBody(body) {
214
224
  return match === null ? null : Number(match[1]);
215
225
  }
216
226
  /**
227
+ * V27 G0.2 (EVO-IO-01): the write lock named by this claim is no longer ours
228
+ * at the commit point — a takeover reclaimed it while we were inside the
229
+ * critical section. The lock layer converts this into a retry of the whole
230
+ * read-modify-write; it must never reach a caller as a successful write.
231
+ */
232
+ var LostWriteLock = class extends Error {
233
+ constructor() {
234
+ super("evolution-io: the write lock was reclaimed before the commit");
235
+ this.name = "LostWriteLock";
236
+ }
237
+ };
238
+ /** V27 G1.4: every quarantine-copy name this family has ever produced —
239
+ * `<file>.corrupt` (current fixed name), `<file>.corrupt.<epoch>`, and the
240
+ * v10-05 legacy `<file>.corrupt-<epoch>-<rand>` series. A user support file
241
+ * keeps a final extension (`.corrupt-backup.md`) and therefore stays. */
242
+ const CORRUPT_COPY_RE = /\.corrupt(\.\d+|-\d+-[0-9a-z]+)?$/;
243
+ /** V27 G1.1: the three takeover windows, as protocol constants at ONE place
244
+ * (the inline copies that used to live in the acquisition loop, plus the dead
245
+ * branch's bare `1000`, are gone). */
246
+ /** A named holder that is gone, past this age, is reclaimed. Fits inside the
247
+ * `lockAttempts * 50ms` retry budget so a dead holder's lock is reachable
248
+ * within one budget (v19 gate arithmetic: budget >= 2 x threshold). */
249
+ const DEAD_LOCK_TAKEOVER_MS = 1e3;
250
+ /** No body at all: nothing attributes the lock to a holder, so this is the one
251
+ * branch that cannot probe liveness — it must outlast any plausible stall
252
+ * between create and body write (V27 G0.2: 1s was below what a loaded machine
253
+ * actually took, which let a peer delete a live holder's lock). */
254
+ const EMPTY_LOCK_TAKEOVER_MS = 3e4;
255
+ /** A body with no parseable pid (crash mid-write): 1h, far above any legal hold
256
+ * and far below "forever". */
257
+ const LOCK_TEAR_TAKEOVER_MS = 36e5;
258
+ /**
259
+ * V27 G1.3: the error `commitTmp` throws when the rename landed but the parent
260
+ * directory fsync failed — the bytes ARE visible, only their durability is
261
+ * unconfirmed. A consumer that treats it as a plain failure reports "not
262
+ * written" for a write that happened (and a two-phase caller may try to roll
263
+ * back a visible write). Every transaction consumer must therefore treat this
264
+ * shape as SUCCESS-with-warning, never as a rejection.
265
+ */
266
+ function isCommittedWarning(error) {
267
+ return error?.committed === true;
268
+ }
269
+ /**
270
+ * V27 G1.1: the lock-takeover decision as ONE pure function, so the protocol is
271
+ * testable and exhaustive instead of being an inline expression inside the
272
+ * acquisition loop:
273
+ * - `none` the lock is fresh, or its holder is alive → wait, never steal;
274
+ * - `dead` a named holder that is gone, past the dead threshold;
275
+ * - `empty` no body at all: nothing attributes it to a holder, so only the
276
+ * wide `emptyAfterMs` window may reclaim it;
277
+ * - `corrupt` a body with no parseable pid (a crash mid-write), past the 1h
278
+ * tear threshold.
279
+ * The age thresholds compare against `nowMs - mtimeMs` with `>` so a lock whose
280
+ * age EQUALS the threshold is not yet reclaimed (the boundary the v19 gate fix
281
+ * pinned).
282
+ */
283
+ function decideTakeover(probe) {
284
+ const age = (probe.nowMs ?? Date.now()) - probe.mtimeMs;
285
+ const holder = Number(probe.body.split(":")[0] ?? "");
286
+ const namedHolder = Number.isInteger(holder) && holder > 0;
287
+ if (probe.body === "") return age > (probe.emptyAfterMs ?? 3e4) ? "empty" : "none";
288
+ if (!namedHolder) return age > (probe.corruptAfterMs ?? 36e5) ? "corrupt" : "none";
289
+ if (probe.alive(holder)) return "none";
290
+ return age > (probe.deadAfterMs ?? 1e3) ? "dead" : "none";
291
+ }
292
+ /**
217
293
  * Build the Node IO backend. `lockAttempts` scales the write-lock retry budget
218
294
  * (attempts × 50ms); the default 40 (~2s, rc.69) covers production contention,
219
295
  * while contention TESTS on a loaded runner may raise it (e.g. 240 ≈ 12s) —
@@ -228,14 +304,6 @@ function nodeEvolutionIo(lockAttempts = 40) {
228
304
  /** True when the pid is alive (single source: `isProcessAlive`). */
229
305
  const isAlive = isProcessAlive;
230
306
  /**
231
- * V10-07 (P2-1): age threshold for taking over a lock whose body is TORN
232
- * (non-empty, but the pid prefix does not parse to a positive integer). 1h:
233
- * a legal hold (the ~2s lock/rename retry budgets, a slow task) never
234
- * approaches minutes, so 1h fires only in the "torn write + creator long
235
- * dead" scenario — far above any legitimate hold, far below "forever".
236
- */
237
- const LOCK_TEAR_TAKEOVER_MS = 36e5;
238
- /**
239
307
  * V10-06 (P1-1 integration fix): a takeover TICKET must never be reclaimed
240
308
  * while its (live) holder can still be committing — the C-28 rename retry
241
309
  * budget is ~2.35s under AV/indexer pressure, so the former 1s ticket age
@@ -280,31 +348,41 @@ function nodeEvolutionIo(lockAttempts = 40) {
280
348
  * parseable pid) — is taken over after a wide 1h threshold with a
281
349
  * console.warn; before this branch such a lock blocked every future writer
282
350
  * forever.
351
+ * V27 G0.2 (EVO-IO-01): the CONTRACT for `task` is therefore: commit only
352
+ * after calling the `assertOwned` callback it receives, immediately before the
353
+ * rename (or delete) that publishes the result. A takeover may reclaim a lock
354
+ * whose holder is alive — an empty body cannot be attributed to a pid, and a
355
+ * peer can read a stale stat — and the holder cannot see it from the handle it
356
+ * opened (on POSIX its write lands on the UNLINKED inode). With the guard, a
357
+ * reclaimed claim aborts the RMW (`LostWriteLock`, converted into a retry
358
+ * here) instead of overwriting the current holder's result; without it, two
359
+ * writers commit and one update is silently lost.
283
360
  */
284
361
  const withWriteLock = async (path, task) => {
285
362
  const lock = `${path}${LOCK_SUFFIX}`;
286
363
  let myClaim = "";
287
364
  for (let attempt = 0; attempt < lockAttempts; attempt += 1) {
288
- let lockHandle = null;
289
365
  try {
290
366
  myClaim = `${process.pid}:${randomBytes(4).toString("hex")}`;
291
- lockHandle = await open(lock, "wx");
292
- await lockHandle.writeFile(myClaim);
293
- await lockHandle.close();
294
- lockHandle = null;
367
+ await writeFile(lock, myClaim, { flag: "wx" });
295
368
  } catch (error) {
296
369
  const code = error?.code;
297
- if (lockHandle) {
298
- await lockHandle.close().catch(() => {});
370
+ if (code === "EEXIST" || code === "EPERM") {} else {
299
371
  await rm(lock, { force: true }).catch(() => {});
300
372
  throw error;
301
373
  }
302
- if (code !== "EEXIST" && code !== "EPERM") throw error;
303
374
  try {
304
375
  const st = await stat(lock);
305
- const holderContent = await readFile(lock, "utf8").catch(() => "");
376
+ const holderRead = await readFile(lock, "utf8").then((body) => ({
377
+ ok: true,
378
+ body
379
+ }), () => ({
380
+ ok: false,
381
+ body: ""
382
+ }));
383
+ if (!holderRead.ok) continue;
384
+ const holderContent = holderRead.body;
306
385
  const holder = Number(holderContent.split(":")[0] ?? "");
307
- const holderAlive = Number.isInteger(holder) && holder > 0 && isAlive(holder);
308
386
  if (holder === process.pid && pendingSelfCleanup.has(lock)) {
309
387
  const token = pendingSelfCleanup.get(lock);
310
388
  if (await readFile(lock, "utf8").catch(() => "") === token) try {
@@ -313,11 +391,13 @@ function nodeEvolutionIo(lockAttempts = 40) {
313
391
  } catch {}
314
392
  continue;
315
393
  }
316
- const staleDead = Number.isInteger(holder) && holder > 0 && Date.now() - st.mtimeMs > 1e3 && !holderAlive;
317
- const staleEmpty = holderContent === "" && Date.now() - st.mtimeMs > 1e3;
318
- const staleCorrupt = holderContent !== "" && !(Number.isInteger(holder) && holder > 0) && Date.now() - st.mtimeMs > LOCK_TEAR_TAKEOVER_MS;
319
- if (staleDead || staleEmpty || staleCorrupt) {
320
- 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`);
394
+ const decision = decideTakeover({
395
+ body: holderContent,
396
+ mtimeMs: st.mtimeMs,
397
+ alive: isAlive
398
+ });
399
+ if (decision !== "none") {
400
+ console.warn(`evolution-io: taking over write lock ${lock} (branch=stale${decision[0]?.toUpperCase()}${decision.slice(1)}, body=${JSON.stringify(holderContent)}, ageMs=${Date.now() - st.mtimeMs}, holderPid=${Number.isInteger(holder) && holder > 0 ? holder : "none"})`);
321
401
  if (await readFile(lock, "utf8").catch(() => "") === holderContent) {
322
402
  const ticket = `${lock}.next`;
323
403
  try {
@@ -357,8 +437,18 @@ function nodeEvolutionIo(lockAttempts = 40) {
357
437
  await new Promise((resolve) => setTimeout(resolve, 50));
358
438
  continue;
359
439
  }
440
+ const assertOwned = async () => {
441
+ const body = await readFile(lock, "utf8").catch(() => null);
442
+ if (body !== myClaim) {
443
+ console.warn(`evolution-io: write lock ${lock} was reclaimed by another writer before the commit (on disk now: ${JSON.stringify(body)}, ours: ${JSON.stringify(myClaim)}) — aborting this read-modify-write and retrying under a fresh acquisition`);
444
+ throw new LostWriteLock();
445
+ }
446
+ };
360
447
  try {
361
- return await task();
448
+ return await task(assertOwned);
449
+ } catch (error) {
450
+ if (error instanceof LostWriteLock) continue;
451
+ throw error;
362
452
  } finally {
363
453
  if (await readFile(lock, "utf8").catch(() => null) === myClaim) await rm(lock, {
364
454
  force: true,
@@ -426,7 +516,7 @@ function nodeEvolutionIo(lockAttempts = 40) {
426
516
  } catch {}
427
517
  continue;
428
518
  }
429
- if (/\.corrupt(\.\d+)?$/.test(name)) {
519
+ if (CORRUPT_COPY_RE.test(name)) {
430
520
  const corruptPath = join(dir, name);
431
521
  try {
432
522
  const st = await stat(corruptPath);
@@ -446,14 +536,16 @@ function nodeEvolutionIo(lockAttempts = 40) {
446
536
  },
447
537
  async writeText(path, content) {
448
538
  await mkdir(dirname(path), { recursive: true });
449
- await withWriteLock(path, async () => {
539
+ await withWriteLock(path, async (assertOwned) => {
450
540
  await sweepStaleTmps(path);
451
- await commitTmp(await writeDurableTmp(path, content), path);
541
+ const tmp = await writeDurableTmp(path, content);
542
+ await assertOwned();
543
+ await commitTmp(tmp, path);
452
544
  });
453
545
  },
454
546
  async transact(path, task) {
455
547
  await mkdir(dirname(path), { recursive: true });
456
- await withWriteLock(path, async () => {
548
+ await withWriteLock(path, async (assertOwned) => {
457
549
  await sweepStaleTmps(path);
458
550
  let current;
459
551
  try {
@@ -464,11 +556,14 @@ function nodeEvolutionIo(lockAttempts = 40) {
464
556
  }
465
557
  const next = await task(current);
466
558
  if (next === null) {
559
+ await assertOwned();
467
560
  await rm(path, { force: true });
468
561
  return;
469
562
  }
470
563
  if (next === current) return;
471
- await commitTmp(await writeDurableTmp(path, next), path);
564
+ const tmp = await writeDurableTmp(path, next);
565
+ await assertOwned();
566
+ await commitTmp(tmp, path);
472
567
  });
473
568
  },
474
569
  async remove(path) {
@@ -935,6 +1030,20 @@ const EVOLUTION_WRITE_TOOLS = ["memory", "skill_manage"];
935
1030
  * 0.3.16 (T-4): moved here from skill-store.ts so drift-signals (pure, no IO)
936
1031
  * can reference it without importing the skill-store module. */
937
1032
  const AUTHORING_DESCRIPTION_BAR = 60;
1033
+ /** V27 G2.4: the largest millisecond delay a timer accepts. `AbortSignal.timeout`
1034
+ * (and `setTimeout`) coerce anything larger to 1ms after a Node warning, so a
1035
+ * timeout configured above this ceiling silently collapses to "immediately
1036
+ * aborted". The curator's review timeout and the review timeout each carried
1037
+ * their own copy of the literal; the bound is one protocol constant.
1038
+ * (v19 P2-10 corrected the value from 2^32-1 to Node's real 2^31-1 ceiling.) */
1039
+ const MAX_TIMER_DELAY_MS = 2147483647;
1040
+ /** V27 G2.4: the model each review/curation leg defaults to. The policy schema,
1041
+ * the policy resolver and the curator's LLM nomination pass each carried their
1042
+ * own copy of these strings — a deployment that changed the policy default used
1043
+ * to leave the curator passing a different model than the reviews. */
1044
+ const DEFAULT_MEMORY_REVIEW_MODEL = "deepseek-v4-flash";
1045
+ const DEFAULT_SKILL_REVIEW_MODEL = "deepseek-v4-pro";
1046
+ const DEFAULT_CURATOR_MODEL = "deepseek-v4-pro";
938
1047
  //#endregion
939
1048
  //#region lib/types/gates.js
940
1049
  /**
@@ -1002,6 +1111,8 @@ function buildCuratorRunReport(input) {
1002
1111
  archiveCandidates: [...input.archiveCandidates],
1003
1112
  archived: [...input.archived],
1004
1113
  failed: [...input.failed],
1114
+ ...input.aborted === void 0 ? {} : { aborted: input.aborted },
1115
+ ...input.unattributed === void 0 || input.unattributed.length === 0 ? {} : { unattributed: [...input.unattributed] },
1005
1116
  ...input.consolidated === void 0 ? {} : { consolidated: [...input.consolidated] },
1006
1117
  ...input.snapshotPath === void 0 ? {} : { snapshotPath: input.snapshotPath },
1007
1118
  ...input.llmReviewEnabled === void 0 ? {} : { llmReviewEnabled: input.llmReviewEnabled },
@@ -1023,6 +1134,8 @@ function renderCuratorReportMarkdown(report) {
1023
1134
  `- **LLM nominations**: ${report.llmNominations.length}`,
1024
1135
  `- **Archived**: ${report.archived.length}`,
1025
1136
  `- **Failed**: ${report.failed.length}`,
1137
+ ...report.aborted === void 0 ? [] : [`- **Aborted**: ${report.aborted}`],
1138
+ ...report.unattributed === void 0 || report.unattributed.length === 0 ? [] : [`- **Unattributed errors**: ${report.unattributed.length}`],
1026
1139
  ...report.snapshotPath === void 0 ? [] : [`- **Snapshot**: ${report.snapshotPath}`],
1027
1140
  ...report.llmReviewEnabled === void 0 ? [] : [`- **llmReview**: ${report.llmReviewEnabled}`],
1028
1141
  ...report.nominationsWarnings === void 0 || report.nominationsWarnings.length === 0 ? [] : [`- **Nomination warnings**: ${report.nominationsWarnings.join("; ")}`]
@@ -1037,6 +1150,7 @@ function renderCuratorReportMarkdown(report) {
1037
1150
  ...lines,
1038
1151
  ...section("Archived", report.archived.map((item) => `${item.name} (${item.reason})`)),
1039
1152
  ...section("Failed", report.failed.map((item) => `${item.name}: ${item.reason}`)),
1153
+ ...section("Unattributed", report.unattributed ?? []),
1040
1154
  ...section("Stale candidates", report.staleCandidates),
1041
1155
  ...section("LLM nominations", report.llmNominations),
1042
1156
  ""
@@ -1195,15 +1309,6 @@ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Da
1195
1309
  });
1196
1310
  result.markStale.push(name);
1197
1311
  }
1198
- } else if (idle < staleAfterDays) {
1199
- record.state = "active";
1200
- result.transitions.push({
1201
- name,
1202
- from: "stale",
1203
- to: "active",
1204
- reason: `recent activity ${Math.round(idle)}d`
1205
- });
1206
- result.reactivate.push(name);
1207
1312
  } else if (idle >= config.archiveAfterDays) {
1208
1313
  record.state = "archived";
1209
1314
  record.archived_at = now.toISOString();
@@ -1214,6 +1319,15 @@ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Da
1214
1319
  reason: `idle ${Math.round(idle)}d >= ${config.archiveAfterDays}d`
1215
1320
  });
1216
1321
  result.archive.push(name);
1322
+ } else if (idle < staleAfterDays) {
1323
+ record.state = "active";
1324
+ result.transitions.push({
1325
+ name,
1326
+ from: "stale",
1327
+ to: "active",
1328
+ reason: `recent activity ${Math.round(idle)}d`
1329
+ });
1330
+ result.reactivate.push(name);
1217
1331
  }
1218
1332
  }
1219
1333
  return result;
@@ -1230,12 +1344,15 @@ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Da
1230
1344
  *
1231
1345
  * Usage events (C semantics, rc.73+): `type:'usage'` records are the
1232
1346
  * OBSERVATION WINDOW ANCHOR — written once, when the library's first observed
1233
- * read (`view_count` 0 -> 1) happens. Before that anchor the usage sidecar
1234
- * has no read evidence (reads were invisible pre-A2), so churn-based health
1235
- * judgments are NOT trustworthy; the curator suppresses them (its
1236
- * `usageObserved()` gate) until the anchor exists. `counts` on the event is a
1237
- * cumulative library-wide snapshot (skills/views/use/patches) at that moment,
1238
- * and `window.opened` pins the window start for the timeline.
1347
+ * read (`view_count` 0 -> 1) happens. The anchor is the durable timeline record
1348
+ * of that moment: the churn-suppression gate itself (`usageObserved()`) reads
1349
+ * the usage SIDECAR's own first-view evidence, since reads were invisible to it
1350
+ * pre-A2 and no sidecar record can reach `view_count > 0` without the same
1351
+ * 0 -> 1 transition. `counts` on the event is a cumulative library-wide
1352
+ * snapshot (skills/views/use/patches) at that moment, and `window.opened` pins
1353
+ * the window start for the timeline. (V27 G3.3: `verify-event-pairing` requires
1354
+ * every persisted type to have a production reader or a declared external
1355
+ * contract — this one is the latter.)
1239
1356
  *
1240
1357
  * Rotation (rc.71, 007 design): when the active log reaches
1241
1358
  * `EVENT_LOG_ROTATE_AT` the older half is split into an archive
@@ -2248,7 +2365,8 @@ const PATTERNS = [
2248
2365
  regex: /(?:密钥|凭据|口令|密码|环境变量)[\s\S]{0,30}(?:发送|上传|传输|外传|泄露)[\s\S]{0,30}(?:到|至)\s*(?:https?:\/\/|[\w.-]+\.(?:com|net|org|io|cn|dev|xyz|ru)\b)/
2249
2366
  }
2250
2367
  ];
2251
- const ZERO_WIDTH_CHARS = new RegExp(`[\\u034f\\u200b\\u2060\\u2061\\u2062\\u2063\\u2064\\u206a-\\u206f\\ufeff\\u{e0000}-\\u{e007f}]`, "u");
2368
+ const INVISIBLE_CHAR_CLASS = "\\u034f\\u200b\\u2060\\u2061\\u2062\\u2063\\u2064\\u206a-\\u206f\\ufeff\\u{e0000}-\\u{e007f}";
2369
+ const ZERO_WIDTH_CHARS = new RegExp(`[${INVISIBLE_CHAR_CLASS}]`, "u");
2252
2370
  const ZWJ_OUTSIDE_EMOJI = /(?<!\p{Extended_Pictographic})\u200d(?!\p{Extended_Pictographic})/u;
2253
2371
  const TYPOGRAPHY_CHARS = /[\u00ad\u061c\u180e\u200c\ufe00-\ufe0f]/;
2254
2372
  const BIDI_CHARS = /[\u202a-\u202e\u2066-\u2069]/;
@@ -2295,7 +2413,8 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_
2295
2413
  });
2296
2414
  const normalized = text.normalize("NFKC");
2297
2415
  const SPACE_SPLITTERS = /[\u00ad\u061c\u180e\u200c\ufe00-\ufe0f]/gu;
2298
- const patternTexts = [normalized.replace(SPACE_SPLITTERS, " "), normalized.replace(SPACE_SPLITTERS, "")];
2416
+ const OBFUSCATION_SPLITTERS = new RegExp(`[${INVISIBLE_CHAR_CLASS}\\u200d]`, "gu");
2417
+ const patternTexts = [normalized.replace(SPACE_SPLITTERS, " ").replace(OBFUSCATION_SPLITTERS, " "), normalized.replace(SPACE_SPLITTERS, "").replace(OBFUSCATION_SPLITTERS, "")];
2299
2418
  const windows = [];
2300
2419
  for (const patternText of patternTexts) if (patternText.length <= windowSize) windows.push(patternText);
2301
2420
  else {
@@ -2563,25 +2682,25 @@ var MemoryStore = class {
2563
2682
  limit: this.limitFor(target)
2564
2683
  };
2565
2684
  }
2566
- async add(target, facts) {
2567
- return await this.serial(() => this.addChained(target, facts));
2568
- }
2569
- async addChained(target, facts) {
2570
- if (!facts.trim()) return {
2571
- ok: false,
2572
- message: "Content cannot be empty.",
2573
- entries: [],
2574
- chars: 0,
2575
- limit: this.limitFor(target)
2576
- };
2577
- const path = fileFor(this.root, target);
2685
+ /**
2686
+ * The ONE memory write skeleton (V27 G2.5): oversized read-guard pre-transact,
2687
+ * one transaction over the target path, and the C-01 structured refusal when a
2688
+ * backend never invokes the task. `addChained` and `applyBatchChained` supply
2689
+ * only their own in-transaction core, so the two write paths cannot drift in
2690
+ * their guard order, their missing-file handling or their error text.
2691
+ *
2692
+ * @param target - memory target being written
2693
+ * @param core - the in-transaction read-modify-write for the locked body
2694
+ * @returns the core's result, or the oversized / contract-violation refusal
2695
+ */
2696
+ async chainedWrite(target, core) {
2578
2697
  const refusal = await this.oversizedRefusal(target);
2579
2698
  if (refusal) return refusal;
2580
2699
  let outcome;
2581
- await transactIo(this.io, path, async (current) => {
2582
- const core = await this.addCore(target, facts, current ?? "");
2583
- outcome = core.result;
2584
- return core.write ?? current ?? null;
2700
+ await transactIo(this.io, fileFor(this.root, target), async (current) => {
2701
+ const step = await core(current ?? "");
2702
+ outcome = step.result;
2703
+ return step.write ?? current ?? null;
2585
2704
  });
2586
2705
  return outcome ?? {
2587
2706
  ok: false,
@@ -2591,6 +2710,19 @@ var MemoryStore = class {
2591
2710
  limit: this.limitFor(target)
2592
2711
  };
2593
2712
  }
2713
+ async add(target, facts) {
2714
+ return await this.serial(() => this.addChained(target, facts));
2715
+ }
2716
+ async addChained(target, facts) {
2717
+ if (!facts.trim()) return {
2718
+ ok: false,
2719
+ message: "Content cannot be empty.",
2720
+ entries: [],
2721
+ chars: 0,
2722
+ limit: this.limitFor(target)
2723
+ };
2724
+ return await this.chainedWrite(target, async (raw) => await this.addCore(target, facts, raw));
2725
+ }
2594
2726
  /**
2595
2727
  * Single-entry add inside the transaction: shared checks (oversized,
2596
2728
  * drift, threat) and the content computation. `raw` is the locked view
@@ -2608,19 +2740,11 @@ var MemoryStore = class {
2608
2740
  },
2609
2741
  write: null
2610
2742
  };
2611
- if (this.driftFromRaw(target, raw)) {
2612
- const backup = await this.backupFile(target);
2613
- return {
2614
- result: {
2615
- ok: false,
2616
- message: `External drift detected in memory file.${backup ? ` A backup was saved to ${basename(backup)}.` : ""} Resolve the drift before retrying.`,
2617
- entries: [],
2618
- chars: 0,
2619
- limit: this.limitFor(target)
2620
- },
2621
- write: null
2622
- };
2623
- }
2743
+ const refusal = await this.driftRefusal(target, raw);
2744
+ if (refusal) return {
2745
+ result: refusal,
2746
+ write: null
2747
+ };
2624
2748
  const threat = this.memoryThreatBlock(content);
2625
2749
  if (threat) return {
2626
2750
  result: {
@@ -2676,14 +2800,52 @@ var MemoryStore = class {
2676
2800
  write: render(next)
2677
2801
  };
2678
2802
  }
2679
- /** Canonical-form drift check derived from the locked view (same formula as `detectDrift`, no second read). */
2680
- driftFromRaw(target, raw) {
2681
- if (raw.trim() === "") return false;
2803
+ /**
2804
+ * The single drift predicate. `raw` is in canonical form when it byte-matches
2805
+ * `render(normalizeEntries(raw))`; anything else means it was edited outside
2806
+ * MemoryStore (empty/`§`-only entries, stray blank lines, leading or trailing
2807
+ * delimiters — structural anomalies the writer would quietly normalize away).
2808
+ * Both write paths derive this from their locked view and `detectDrift` from
2809
+ * a fresh read, so a write and a later read can never disagree about the same
2810
+ * bytes.
2811
+ *
2812
+ * An absent, empty, or whitespace-only body is the "never written" state
2813
+ * (rc.42 audit P1-6): it parses to zero entries, and the canonical form
2814
+ * `'\n'` can never byte-match it, so flagging it would permanently refuse
2815
+ * every write path — including the repairs the model would need to make.
2816
+ * Such files are adopted instead of flagged.
2817
+ *
2818
+ * @param target - memory target whose char limit bounds one parsed entry
2819
+ * @param raw - on-disk body, or `null` when the file does not exist
2820
+ * @returns whether these bytes count as externally drifted
2821
+ */
2822
+ drifted(target, raw) {
2823
+ if (raw === null || raw.trim() === "") return false;
2682
2824
  const entries = normalizeEntries(raw);
2683
2825
  const limit = this.limitFor(target);
2684
2826
  if (limit > 0 && entries.some((entry) => entry.length > limit)) return true;
2685
2827
  return render(entries) !== raw;
2686
2828
  }
2829
+ /**
2830
+ * Drift refusal for a body already read under the write lock, or `null` when
2831
+ * the body is canonical. Both write paths return this unchanged, so their
2832
+ * refusals stay byte-identical and each carries the same backup.
2833
+ *
2834
+ * @param target - memory target that owns the drifted file
2835
+ * @param raw - locked file body
2836
+ * @returns the refusal to hand back, or `null` to continue writing
2837
+ */
2838
+ async driftRefusal(target, raw) {
2839
+ if (!this.drifted(target, raw)) return null;
2840
+ const backup = await this.backupFile(target);
2841
+ return {
2842
+ ok: false,
2843
+ message: `External drift detected in memory file.${backup ? ` A backup was saved to ${basename(backup)}.` : ""} Resolve the drift before retrying.`,
2844
+ entries: [],
2845
+ chars: 0,
2846
+ limit: this.limitFor(target)
2847
+ };
2848
+ }
2687
2849
  async applyBatch(target, operations) {
2688
2850
  return await this.serial(() => this.applyBatchChained(target, operations));
2689
2851
  }
@@ -2695,38 +2857,15 @@ var MemoryStore = class {
2695
2857
  chars: 0,
2696
2858
  limit: this.limitFor(target)
2697
2859
  };
2698
- const path = fileFor(this.root, target);
2699
- const refusal = await this.oversizedRefusal(target);
2700
- if (refusal) return refusal;
2701
- let outcome;
2702
- await transactIo(this.io, path, async (current) => {
2703
- const core = await this.applyBatchCore(target, operations, current ?? "");
2704
- outcome = core.result;
2705
- return core.write ?? current ?? null;
2706
- });
2707
- return outcome ?? {
2708
- ok: false,
2709
- message: "internal error: the memory transaction did not invoke the task; no write was performed",
2710
- entries: [],
2711
- chars: 0,
2712
- limit: this.limitFor(target)
2713
- };
2860
+ return await this.chainedWrite(target, async (raw) => await this.applyBatchCore(target, operations, raw));
2714
2861
  }
2715
2862
  /** Batch RMW inside the transaction. `write: null` = failure/no-op, disk untouched. */
2716
2863
  async applyBatchCore(target, operations, raw) {
2717
- if (this.driftFromRaw(target, raw)) {
2718
- const backup = await this.backupFile(target);
2719
- return {
2720
- result: {
2721
- ok: false,
2722
- message: `External drift detected in memory file.${backup ? ` A backup was saved to ${basename(backup)}.` : ""} Resolve the drift before retrying.`,
2723
- entries: [],
2724
- chars: 0,
2725
- limit: this.limitFor(target)
2726
- },
2727
- write: null
2728
- };
2729
- }
2864
+ const refusal = await this.driftRefusal(target, raw);
2865
+ if (refusal) return {
2866
+ result: refusal,
2867
+ write: null
2868
+ };
2730
2869
  const entries = [...new Set(normalizeEntries(raw))];
2731
2870
  const working = [...entries];
2732
2871
  const datePrefix = this.addDatePrefix ? `## ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}\n` : "";
@@ -2896,27 +3035,17 @@ var MemoryStore = class {
2896
3035
  return parts.join("\n\n");
2897
3036
  }
2898
3037
  /**
2899
- * Detect on-disk drift: true when the file is not in the canonical
2900
- * `render(normalizeEntries(raw))` form. This catches structural anomalies
2901
- * the writer would quietly normalize away (empty/`§`-only entries, stray
2902
- * blank lines, leading/trailing delimiters) that indicate the file was
2903
- * edited outside MemoryStore. Purely single-canonical content reaches the
2904
- * same serialization and returns false, so a normal write is never flagged.
3038
+ * Detect on-disk drift for a caller that holds no locked view: `true` when the
3039
+ * file is not in canonical form, or when its size trips the read guard. The
3040
+ * write paths apply the same predicate (`drifted`) to the body they read under
3041
+ * the lock, so a write and a follow-up read agree about the same bytes.
2905
3042
  *
2906
- * An absent, empty, or whitespace-only file is the "never written" state
2907
- * (rc.42 audit P1-6): it parses to zero entries, so the canonical form
2908
- * `'\n'` can never byte-match it and every write path was permanently
2909
- * refused with "External drift detected" — including the repairs the model
2910
- * would need to make. Such files are adopted instead of flagged.
3043
+ * @param target - memory target to inspect
3044
+ * @returns whether the file on disk counts as externally drifted
2911
3045
  */
2912
3046
  async detectDrift(target) {
2913
3047
  if (await this.oversizedFile(target)) return true;
2914
- const raw = await this.io.readText(fileFor(this.root, target));
2915
- if (raw === null || raw.trim() === "") return false;
2916
- const entries = normalizeEntries(raw);
2917
- const limit = this.limitFor(target);
2918
- if (limit > 0 && entries.some((entry) => entry.length > limit)) return true;
2919
- return render(entries) !== raw;
3048
+ return this.drifted(target, await this.io.readText(fileFor(this.root, target)));
2920
3049
  }
2921
3050
  };
2922
3051
  //#endregion
@@ -3258,7 +3387,7 @@ const SECRET_PATTERNS = [
3258
3387
  ["google api key", /AIza[0-9A-Za-z_-]{30,}/g],
3259
3388
  ["bearer credential", /Bearer[\s]+[a-z0-9._~+/=\-]{16,}/gi]
3260
3389
  ];
3261
- const INLINE_ASSIGNMENT_PATTERN = /* @__PURE__ */ new RegExp("(^|[^\\w-])([\\w-]{0,64}[_\\-])?((?:token|api[_-]?key|secret|password|passwd)(?:[_\\-][\\w-]{0,64})?)\\b([\\t ]*[:=][\\t ]*)([^\\r\\n]+)", "gi");
3390
+ const INLINE_ASSIGNMENT_PATTERN = /* @__PURE__ */ new RegExp("(^|[^\\w-])([\\w-]{0,64}[_\\-])?((?:token|api[_-]?key|secret|password|passwd)(?:[_\\-][\\w-]{0,64})?)\\b([\"\\']?[\\t ]*[:=][\\t ]*)([^\\r\\n]+)", "gi");
3262
3391
  const URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s/@]+)@/gi;
3263
3392
  /**
3264
3393
  * Mask credential-shaped text before it crosses a session boundary.
@@ -3654,28 +3783,28 @@ function skillsRoot(env = process.env) {
3654
3783
  function resolveSkillsRoot(config = {}) {
3655
3784
  return (config.root ?? "").trim() || skillsRoot();
3656
3785
  }
3657
- /** E-7 (v18): every family row reads ONE root key. `root` is canonical;
3658
- * `skillsRoot` is a deprecated alias honoured only while `root` is empty (so a
3659
- * deployment that sets both keeps the canonical one) and removed after 0.3.65.
3660
- * Callers log their own deprecation warning.
3661
- * @param config - the raw plugin config, carrying `root` and/or `skillsRoot`.
3662
- * @returns the effective root (empty when neither key is set) and whether the
3663
- * deprecated alias supplied it.
3786
+ /** E-7 (v18) → V27 G2.4 (M-08): every family row reads ONE root key. `root` is
3787
+ * canonical; the `skillsRoot` alias was honoured for one minor version and its
3788
+ * window closed at 0.3.65 it is now two releases past expiry, so this
3789
+ * resolver no longer reads it at all. A deployment that still sets the alias
3790
+ * must fail LOUDLY at load (see {@link assertSkillsRootAliasRetired}): silently
3791
+ * ignoring a config key leaves the deployment pointing at a root nobody reads,
3792
+ * which is the worst form of compatibility.
3793
+ * @param config - the raw plugin config.
3794
+ * @returns the effective root (empty when the key is unset or blank).
3664
3795
  */
3665
3796
  function resolveRootConfig(config = {}) {
3666
- const root = (config.root ?? "").trim();
3667
- if (root !== "") return {
3668
- root,
3669
- usedDeprecatedAlias: false
3670
- };
3671
- const alias = (config.skillsRoot ?? "").trim();
3672
- return alias === "" ? {
3673
- root: "",
3674
- usedDeprecatedAlias: false
3675
- } : {
3676
- root: alias,
3677
- usedDeprecatedAlias: true
3678
- };
3797
+ return { root: (config.root ?? "").trim() };
3798
+ }
3799
+ /** V27 G2.4 (M-08): the retirement gate for the expired `skillsRoot` alias.
3800
+ * Called at each plugin's load boundary (before the root is resolved), it turns
3801
+ * a stale key into an explicit load error naming the replacement — the
3802
+ * fail-loud form the plan requires instead of a silent no-op.
3803
+ * @param config - the raw plugin config (the alias field stays DECLARED in each
3804
+ * schema so the loader can hand it here instead of dropping it).
3805
+ */
3806
+ function assertSkillsRootAliasRetired(config = {}) {
3807
+ if ((config.skillsRoot ?? "").trim() !== "") throw new Error("evolution: config \"skillsRoot\" was removed after 0.3.65 — rename the key to \"root\" (the alias is no longer honoured)");
3679
3808
  }
3680
3809
  /**
3681
3810
  * Map a requesting session onto the two origin surfaces (rc.44 plan M2-2.3):
@@ -3729,21 +3858,28 @@ function markerPath(dir, marker) {
3729
3858
  }
3730
3859
  /**
3731
3860
  * Shared frontmatter block detection (P3-3 single owner): opening line `---`
3732
- * and closing line exactly `---` (both trimmed). Used by `parseFrontmatter`,
3861
+ * and closing line exactly `---`. Used by `parseFrontmatter`,
3733
3862
  * `frontmatterYamlUnsafeValues` and `normalizeFrontmatter` so the three can
3734
3863
  * never disagree about where the block ends (the loose `indexOf('\n---')`
3735
3864
  * form matched `\n----` and was replaced by this strict line rule).
3865
+ *
3866
+ * V27 G2.1: both fence lines are matched EXACTLY, tolerating only a trailing
3867
+ * `\r` — the same rule the upstream filesystem catalog uses
3868
+ * (`skill-filesystem.parseFrontmatter`). The former `.trim()` comparison
3869
+ * accepted ` --- `, so an indented fence loaded in the family while the
3870
+ * platform ignored the file: family visibility split from platform visibility,
3871
+ * which is exactly what a strict-YAML frontmatter is supposed to prevent.
3736
3872
  */
3737
3873
  function frontmatterBlock(content) {
3738
3874
  if (!content.trimStart().startsWith("---")) return null;
3739
3875
  const nl = content.includes("\r\n") ? "\r\n" : "\n";
3740
3876
  const lines = content.split(nl);
3741
- if ((lines[0] ?? "").trim() !== "---") return null;
3877
+ if ((lines[0] ?? "").replace(/\r$/, "") !== "---") return null;
3742
3878
  let end = -1;
3743
3879
  for (let i = 1; i < lines.length; i++) {
3744
3880
  const line = lines[i];
3745
3881
  if (line === void 0) continue;
3746
- if (line.trim() === "---") {
3882
+ if (line.replace(/\r$/, "") === "---") {
3747
3883
  end = i;
3748
3884
  break;
3749
3885
  }
@@ -3756,24 +3892,176 @@ function frontmatterBlock(content) {
3756
3892
  nl
3757
3893
  };
3758
3894
  }
3759
- function parseFrontmatter(content) {
3895
+ /**
3896
+ * Raw-line scan of a frontmatter block: the single owner of "which entries the
3897
+ * strict catalog cannot load". `frontmatterYamlUnsafeValues` publishes it and
3898
+ * `normalizeFrontmatter` decides each rewrite with the same predicate
3899
+ * (`yamlPlainScalarNeedsQuotes`), so the audit verdict and the write path can
3900
+ * never disagree. Only single-line `key: value` entries are judged; a line with
3901
+ * embedded breaks is skipped.
3902
+ */
3903
+ function unsafeFrontmatterEntries(block, nl) {
3904
+ const found = [];
3905
+ for (const line of block.split(nl)) {
3906
+ if (line.includes("\n") || line.includes("\r")) continue;
3907
+ const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
3908
+ if (!match) continue;
3909
+ const key = match[1];
3910
+ if (key === void 0) continue;
3911
+ const value = (match[2] ?? "").trim();
3912
+ if (yamlPlainScalarNeedsQuotes(value)) found.push({
3913
+ key,
3914
+ value
3915
+ });
3916
+ }
3917
+ return found;
3918
+ }
3919
+ /**
3920
+ * Frontmatter values as the STRICT platform catalog reads them — js-yaml, the
3921
+ * parser `normalizeFrontmatter` also verifies rewrites with — or `null` when
3922
+ * the block is not loadable as a YAML mapping.
3923
+ *
3924
+ * Scalars publish their text (`name`, `description`, `whenToUse` are strings by
3925
+ * contract; a number/boolean-shaped value keeps the text the family always
3926
+ * published), a flow or block sequence publishes its inline `[a, b]` form
3927
+ * (`relatedSkillNames` scans names out of it), and a nested mapping publishes
3928
+ * nothing — no consumer in this family reads one, and a lossy string could be
3929
+ * picked up by a routing field. A string value is trimmed: YAML's block-scalar
3930
+ * chomping appends a newline that the family's single-line routing fields never
3931
+ * carried.
3932
+ */
3933
+ function strictFrontmatterValues(block) {
3934
+ if (block.trim() === "") return /* @__PURE__ */ new Map();
3935
+ let loaded;
3936
+ try {
3937
+ loaded = load(block);
3938
+ } catch {
3939
+ return null;
3940
+ }
3941
+ if (loaded === null || loaded === void 0) return /* @__PURE__ */ new Map();
3942
+ if (typeof loaded !== "object" || Array.isArray(loaded)) return null;
3943
+ const values = /* @__PURE__ */ new Map();
3944
+ for (const [key, value] of Object.entries(loaded)) {
3945
+ if (typeof value === "string") {
3946
+ values.set(key, value.trim());
3947
+ continue;
3948
+ }
3949
+ if (typeof value === "number" || typeof value === "boolean") {
3950
+ values.set(key, String(value));
3951
+ continue;
3952
+ }
3953
+ if (Array.isArray(value)) {
3954
+ values.set(key, `[${value.map((item) => String(item)).join(", ")}]`);
3955
+ continue;
3956
+ }
3957
+ }
3958
+ return values;
3959
+ }
3960
+ /**
3961
+ * Lenient line scan, used only for a block the strict parser rejects: the
3962
+ * family keeps routing a legacy file the platform refuses, and
3963
+ * `catalogInvalid` reports the split instead of hiding it. Values are trimmed
3964
+ * and unquoted exactly as before.
3965
+ */
3966
+ function lenientFrontmatterValues(block, nl) {
3967
+ const values = /* @__PURE__ */ new Map();
3968
+ const blockLines = block.split(nl);
3969
+ for (let i = 0; i < blockLines.length; i += 1) {
3970
+ const line = blockLines[i] ?? "";
3971
+ const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
3972
+ if (!match) continue;
3973
+ const [, key, value] = match;
3974
+ if (key === void 0 || value === void 0) continue;
3975
+ const header = /^([>|])[+-]?\d*$/.exec(value.trim());
3976
+ if (header !== null) {
3977
+ const fold = header[1] === ">";
3978
+ const parts = [];
3979
+ let scan = i + 1;
3980
+ for (; scan < blockLines.length; scan += 1) {
3981
+ const raw = blockLines[scan] ?? "";
3982
+ if (raw.trim() === "") {
3983
+ parts.push("");
3984
+ continue;
3985
+ }
3986
+ if (!/^\s/.test(raw)) break;
3987
+ parts.push(raw.trim());
3988
+ }
3989
+ while (parts.length > 0 && parts[parts.length - 1] === "") parts.pop();
3990
+ let folded = "";
3991
+ for (const part of parts) {
3992
+ if (part === "") {
3993
+ if (folded !== "" && !folded.endsWith("\n")) folded += "\n";
3994
+ continue;
3995
+ }
3996
+ if (folded === "" || folded.endsWith("\n")) folded += part;
3997
+ else folded += ` ${part}`;
3998
+ }
3999
+ values.set(key, (fold ? folded : parts.join("\n")).trim());
4000
+ i = scan - 1;
4001
+ continue;
4002
+ }
4003
+ values.set(key, value.trim().replace(/^["']|["']$/g, ""));
4004
+ }
4005
+ return values;
4006
+ }
4007
+ /**
4008
+ * The single read of a SKILL.md frontmatter block: values, body and every
4009
+ * strict-catalog signal, computed once. `null` only when the file has no
4010
+ * frontmatter block; the body may be empty. {@link parseFrontmatter} and
4011
+ * {@link frontmatterCatalogInvalid} are its two projections.
4012
+ *
4013
+ * @param content - the SKILL.md text.
4014
+ * @returns the block read, or `null` when there is no block.
4015
+ */
4016
+ function readFrontmatterBlock(content) {
3760
4017
  const found = frontmatterBlock(content);
3761
4018
  if (!found) return null;
3762
4019
  const body = found.lines.slice(found.end + 1).join(found.nl).trim();
3763
- if (!body) return null;
4020
+ const strict = strictFrontmatterValues(found.block);
4021
+ const values = strict ?? lenientFrontmatterValues(found.block, found.nl);
3764
4022
  const frontmatter = {};
3765
- for (const line of found.block.split(found.nl)) {
3766
- const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
3767
- if (match) {
3768
- const [, key, value] = match;
3769
- if (key && value !== void 0) frontmatter[key] = value.trim().replace(/^["']|["']$/g, "");
3770
- }
3771
- }
4023
+ for (const [key, value] of values) frontmatter[key] = value;
3772
4024
  return {
3773
4025
  frontmatter,
3774
- body
4026
+ body,
4027
+ unsafeValues: unsafeFrontmatterEntries(found.block, found.nl),
4028
+ strictFailed: strict === null
3775
4029
  };
3776
4030
  }
4031
+ /**
4032
+ * Parse a SKILL.md: its frontmatter values and body, or `null` when the file
4033
+ * has no frontmatter block or no body. Every consumer of frontmatter values
4034
+ * goes through here — the write path's validation, `list()`'s published
4035
+ * description, `relatedSkillNames` and the audit — so all of them read the same
4036
+ * bytes the same way.
4037
+ *
4038
+ * @param content - the SKILL.md text.
4039
+ * @returns the read, or `null` when there is no block or no body.
4040
+ */
4041
+ function parseFrontmatter(content) {
4042
+ const read = readFrontmatterBlock(content);
4043
+ if (!read || !read.body) return null;
4044
+ return {
4045
+ frontmatter: read.frontmatter,
4046
+ body: read.body,
4047
+ unsafeValues: read.unsafeValues,
4048
+ catalogInvalid: read.strictFailed || read.unsafeValues.length > 0
4049
+ };
4050
+ }
4051
+ /**
4052
+ * Whether this file's frontmatter is valid as written for the strict platform
4053
+ * catalog (see `FrontmatterRead.catalogInvalid`). Body-independent (a body-less
4054
+ * file is still judged), and derived from the same read as `parseFrontmatter` —
4055
+ * so the audit's verdict and the values the family publishes for one file can
4056
+ * never disagree (V27 G2.1).
4057
+ *
4058
+ * @param content - the SKILL.md text.
4059
+ * @returns `true` when the strict parser rejects the block or an unquoted value would read as something else.
4060
+ */
4061
+ function frontmatterCatalogInvalid(content) {
4062
+ const read = readFrontmatterBlock(content);
4063
+ return read === null ? false : read.strictFailed || read.unsafeValues.length > 0;
4064
+ }
3777
4065
  /** YAML plain-scalar hazards that make an UNQUOTED frontmatter value
3778
4066
  * unloadable to the platform catalog (strict YAML parser): `: ` (mapping
3779
4067
  * separator), ` #` (comment start), a trailing `:` (a mapping marker),
@@ -3792,6 +4080,7 @@ function yamlPlainScalarNeedsQuotes(value) {
3792
4080
  if (value.length === 0) return false;
3793
4081
  if (value.length >= 2 && (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'"))) return false;
3794
4082
  if (/^\[.*\]$/.test(value) || /^\{.*\}$/.test(value)) return false;
4083
+ if (/^[>|][+-]?\d*$/.test(value)) return false;
3795
4084
  if (value.includes(": ")) return true;
3796
4085
  if (value.includes(" #")) return true;
3797
4086
  if (value.endsWith(":")) return true;
@@ -3803,24 +4092,13 @@ function yamlPlainScalarNeedsQuotes(value) {
3803
4092
  * YAML-unsafe for the strict platform catalog. Operates on the ORIGINAL line
3804
4093
  * value (quotes included), so a value already wrapped by
3805
4094
  * `normalizeFrontmatter` is never re-flagged — one source with the write
3806
- * path. Single-line entries only; lines with embedded line breaks skip. */
4095
+ * path. V27 G2.1: delegates to the shared scan, which
4096
+ * `parseFrontmatter(...).catalogInvalid` also uses, so the audit view and the
4097
+ * read view of one file can never disagree. Independent of the body: a
4098
+ * body-less file is still reported here. */
3807
4099
  function frontmatterYamlUnsafeValues(content) {
3808
- const found = [];
3809
4100
  const block = frontmatterBlock(content);
3810
- if (!block) return found;
3811
- for (const line of block.block.split(block.nl)) {
3812
- if (line.includes("\n") || line.includes("\r")) continue;
3813
- const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
3814
- if (!match) continue;
3815
- const key = match[1];
3816
- const value = (match[2] ?? "").trim();
3817
- if (key === void 0) continue;
3818
- if (yamlPlainScalarNeedsQuotes(value)) found.push({
3819
- key,
3820
- value
3821
- });
3822
- }
3823
- return found;
4101
+ return block === null ? [] : unsafeFrontmatterEntries(block.block, block.nl);
3824
4102
  }
3825
4103
  /**
3826
4104
  * Normalize a SKILL.md frontmatter block into catalog-loadable YAML: values
@@ -3931,7 +4209,7 @@ function relatedSkillNames(content, exclude) {
3931
4209
  }
3932
4210
  function validateFrontmatter(content, expectedName, limits = DEFAULT_SKILL_LIMITS) {
3933
4211
  const parsed = parseFrontmatter(content);
3934
- if (!parsed) return "SKILL.md must start and end with YAML frontmatter and include a body.";
4212
+ if (!parsed) return "SKILL.md must start with a `---` line, close the frontmatter with another exact `---` line (only a trailing `\\r` is tolerated), and include a body below it.";
3935
4213
  if (!parsed.frontmatter.name) return "Frontmatter must include a name field.";
3936
4214
  if (!SKILL_NAME_RE.test(parsed.frontmatter.name)) return `Invalid skill name "${parsed.frontmatter.name}" — use lowercase letters, digits, and hyphens.`;
3937
4215
  if (parsed.frontmatter.name.length > limits.maxNameLength) return `Skill name exceeds ${limits.maxNameLength} characters.`;
@@ -4819,6 +5097,8 @@ var SkillLibrary = class {
4819
5097
  },
4820
5098
  write: null
4821
5099
  };
5100
+ const anchorCount = oldString === "" ? 0 : md.split(oldString).length - 1;
5101
+ const patchNote = !replaceAll && anchorCount > 1 ? ` Replaced the FIRST of ${anchorCount} occurrences; pass replace_all=true to change every one.` : "";
4822
5102
  let writeContent = patched;
4823
5103
  let normalizedFields;
4824
5104
  if (target === skillMd) {
@@ -4886,7 +5166,7 @@ var SkillLibrary = class {
4886
5166
  return {
4887
5167
  result: {
4888
5168
  ok: true,
4889
- message: `Skill "${name}" patched (${patchLabel}).`,
5169
+ message: `Skill "${name}" patched (${patchLabel}).${patchNote}`,
4890
5170
  path: dir,
4891
5171
  ...normalizedFields ? { normalizedFrontmatterFields: normalizedFields } : {}
4892
5172
  },
@@ -5100,6 +5380,9 @@ var SkillLibrary = class {
5100
5380
  try {
5101
5381
  await this.io.writeText(join(dest, ".archive-reason"), `${(/* @__PURE__ */ new Date()).toISOString()}: ${reason}\n`);
5102
5382
  } catch {}
5383
+ try {
5384
+ await this.io.writeText(join(dest, ".archive-name"), `${name}\n`);
5385
+ } catch {}
5103
5386
  await this.audit(name, "archive", md, null, reason);
5104
5387
  this.notifyMutation({
5105
5388
  action: "archive",
@@ -5491,10 +5774,21 @@ var SkillLibrary = class {
5491
5774
  });
5492
5775
  }
5493
5776
  } catch (error) {
5494
- for (const entry of written.reverse()) await (entry.previous === null ? this.io.remove(entry.target) : this.io.writeText(entry.target, entry.previous)).catch(() => {});
5777
+ const stuck = [];
5778
+ for (const entry of written.reverse()) try {
5779
+ if (entry.previous === null) await this.io.remove(entry.target);
5780
+ else await this.io.writeText(entry.target, entry.previous);
5781
+ } catch {
5782
+ stuck.push(entry.target);
5783
+ }
5784
+ const reason = error instanceof Error ? error.message : String(error);
5785
+ if (stuck.length === 0) return {
5786
+ ok: false,
5787
+ message: `Tree change failed and was rolled back: ${reason}`
5788
+ };
5495
5789
  return {
5496
5790
  ok: false,
5497
- message: `Tree change failed and was rolled back: ${error instanceof Error ? error.message : String(error)}`
5791
+ message: `Tree change failed: ${reason}. The rollback could not restore ${stuck.join(", ")} recover those targets from the .backups snapshot (or re-apply the change).`
5498
5792
  };
5499
5793
  }
5500
5794
  await this.audit(name, plan.auditAction, md, landing.find((entry) => entry.target.split(/[\\/]/).pop() === "SKILL.md")?.content ?? md, plan.auditSummary);
@@ -5538,14 +5832,33 @@ var SkillLibrary = class {
5538
5832
  }
5539
5833
  const candidates = entries.filter((entry) => entry === name || entry.startsWith(`${name}-`)).sort().reverse();
5540
5834
  let chosen;
5541
- for (const candidate of candidates) if (parseFrontmatter(await this.io.readText(join(archiveRoot, candidate, "SKILL.md")).catch(() => null) ?? "")?.frontmatter.name === name) {
5542
- chosen = candidate;
5543
- break;
5835
+ const unreadable = [];
5836
+ for (const candidate of candidates) {
5837
+ if ((await this.io.readText(join(archiveRoot, candidate, ".archive-name")).catch(() => null))?.trim() === name) {
5838
+ chosen = candidate;
5839
+ break;
5840
+ }
5841
+ const parsed = parseFrontmatter(await this.io.readText(join(archiveRoot, candidate, "SKILL.md")).catch(() => null) ?? "");
5842
+ if (parsed?.frontmatter.name === name) {
5843
+ chosen = candidate;
5844
+ break;
5845
+ }
5846
+ if (parsed === null && candidate === name) {
5847
+ chosen = candidate;
5848
+ break;
5849
+ }
5850
+ if (parsed === null) unreadable.push(candidate);
5851
+ }
5852
+ if (!chosen) {
5853
+ if (unreadable.length === 0) return {
5854
+ ok: false,
5855
+ message: `Skill "${name}" is not in .archive.`
5856
+ };
5857
+ return {
5858
+ ok: false,
5859
+ message: `No archived entry for "${name}" carries a matching frontmatter name, but .archive holds ${unreadable.length} entr(y/ies) named like it: ${unreadable.join(", ")}. Those entries have no readable SKILL.md (or none matching the name), so restoring by name cannot tell them apart — rename the directory to the skill name and restore again, or restore from a snapshot.`
5860
+ };
5544
5861
  }
5545
- if (!chosen) return {
5546
- ok: false,
5547
- message: `Skill "${name}" is not in .archive.`
5548
- };
5549
5862
  const source = join(archiveRoot, chosen);
5550
5863
  if (this.io.isSymlink) {
5551
5864
  if (await this.io.isSymlink(source) === true) return {
@@ -5563,7 +5876,7 @@ var SkillLibrary = class {
5563
5876
  message: `Restore of "${name}" from .archive failed: ${moveFailure}`
5564
5877
  };
5565
5878
  await this.deleteStrandedLocks(dest);
5566
- if (await this.io.exists(join(dest, ".archive-reason"))) await this.io.remove(join(dest, ".archive-reason"));
5879
+ for (const marker of [".archive-reason", ".archive-name"]) if (await this.io.exists(join(dest, marker))) await this.io.remove(join(dest, marker));
5567
5880
  await this.audit(name, "restore", null, await this.io.readText(join(dest, "SKILL.md")).catch(() => null), `restored from ${source}`);
5568
5881
  this.notifyMutation({
5569
5882
  action: "restore",
@@ -5799,6 +6112,27 @@ var SkillLibrary = class {
5799
6112
  }
5800
6113
  return out;
5801
6114
  }
6115
+ /**
6116
+ * V27 G0.4 (core-a-01): the per-entry gate `skipped` already has. A corrupted
6117
+ * or hand-edited manifest could carry `extras: [123]`: the array check passed,
6118
+ * `SNAPSHOT_EXTRA_NAME_RE.test(123)` coerced the number to the string "123"
6119
+ * and matched, and the value then threw `TypeError` inside `path.join` — which
6120
+ * `readSnapshotExtras` reached only AFTER a whole-tree restore had committed.
6121
+ * Extras are path components under `extras/`, so they take the entry gate too;
6122
+ * bounded like `skipped` so a hostile manifest cannot grow the read set.
6123
+ */
6124
+ sanitizeExtraNames(raw) {
6125
+ if (!Array.isArray(raw)) return [];
6126
+ const out = [];
6127
+ for (const entry of raw) {
6128
+ if (typeof entry !== "string") continue;
6129
+ if (!SNAPSHOT_EXTRA_NAME_RE.test(entry)) continue;
6130
+ if (!this.safeSnapshotEntryName(entry)) continue;
6131
+ out.push(entry);
6132
+ if (out.length >= 50) break;
6133
+ }
6134
+ return out;
6135
+ }
5802
6136
  async readSnapshotManifest(path) {
5803
6137
  const raw = await this.io.readText(join(path, "manifest.json"));
5804
6138
  if (raw === null) return null;
@@ -5812,7 +6146,7 @@ var SkillLibrary = class {
5812
6146
  skipped: this.sanitizeSkippedNames(manifest.skipped),
5813
6147
  sidecars: Array.isArray(manifest.sidecars) ? manifest.sidecars : [],
5814
6148
  ...typeof manifest.hasArchive === "boolean" ? { hasArchive: manifest.hasArchive } : {},
5815
- extras: Array.isArray(manifest.extras) ? manifest.extras : []
6149
+ extras: this.sanitizeExtraNames(manifest.extras)
5816
6150
  };
5817
6151
  } catch {
5818
6152
  return null;
@@ -5886,9 +6220,9 @@ var SkillLibrary = class {
5886
6220
  message: "No skill snapshot available."
5887
6221
  };
5888
6222
  const preRollbackPath = await this.snapshotAll("pre-rollback", extras);
5889
- let skipped = [];
6223
+ const snapshotExtras = await this.readSnapshotExtras(latest.path);
5890
6224
  try {
5891
- skipped = await this.restoreSnapshotIntoRoot(latest.path);
6225
+ await this.restoreSnapshotIntoRoot(latest.path);
5892
6226
  } catch (error) {
5893
6227
  const reason = error instanceof Error ? error.message : String(error);
5894
6228
  try {
@@ -5904,15 +6238,13 @@ var SkillLibrary = class {
5904
6238
  };
5905
6239
  }
5906
6240
  }
5907
- const snapshotExtras = await this.readSnapshotExtras(latest.path);
5908
6241
  this.notifyMutation({
5909
6242
  action: "restore",
5910
6243
  name: "snapshot"
5911
6244
  });
5912
- const skippedNote = skipped.length === 0 ? "" : ` NOTE: ${skipped.length} skill(s) were skipped when this snapshot was taken (a live writer held their lock) and are NOT restored: ${skipped.join(", ")} — recover them from .backups if a copy exists.`;
5913
6245
  return {
5914
6246
  ok: true,
5915
- message: `Restored skill tree from ${latest.path}.${skippedNote}`,
6247
+ message: `Restored skill tree from ${latest.path}`,
5916
6248
  path: latest.path,
5917
6249
  ...snapshotExtras.length === 0 ? {} : { extras: snapshotExtras }
5918
6250
  };
@@ -5927,6 +6259,7 @@ var SkillLibrary = class {
5927
6259
  const manifest = await this.readSnapshotManifest(snapshotPath);
5928
6260
  if (manifest === null) throw new Error(await this.io.exists(join(snapshotPath, "manifest.json")) ? `snapshot ${snapshotPath} has an unreadable manifest.json; refusing to clear the active tree` : `snapshot ${snapshotPath} has no readable manifest.json; refusing to restore`);
5929
6261
  for (const name of [...manifest.skills, ...manifest.sidecars]) if (!this.safeSnapshotEntryName(name)) throw new Error(`snapshot ${snapshotPath} declares an unsafe entry name ${JSON.stringify(name)}; refusing to restore`);
6262
+ if (manifest.skipped.length > 0) throw new Error(`snapshot ${snapshotPath} is incomplete: ${manifest.skipped.length} skill(s) were skipped when it was taken (a live writer held their write lock: ${manifest.skipped.join(", ")}); restoring it would clear the active tree without bringing them back — let the writer finish and take a fresh snapshot, then restore that one`);
5930
6263
  if (manifest.skills.length === 0) {
5931
6264
  const snapshotEntries = await this.io.list(snapshotPath);
5932
6265
  const declared = new Set([
@@ -5977,8 +6310,7 @@ var SkillLibrary = class {
5977
6310
  if (entry.startsWith(".")) continue;
5978
6311
  await this.deleteStrandedLocks(join(this.root, entry));
5979
6312
  }
5980
- return manifest.skipped;
5981
6313
  }
5982
6314
  };
5983
6315
  //#endregion
5984
- 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_BOOT_GRACE_SECONDS, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_CURATOR_REVIEW_MAX_TOKENS, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_MAX_OPS_PER_PLAN, DEFAULT_MEMORY_CHAR_LIMIT, DEFAULT_MIN_IDLE_HOURS, DEFAULT_MUTATION_CAP, DEFAULT_REVIEW_CONTEXT_MESSAGES, DEFAULT_REVIEW_MEMORY_INTERVAL, DEFAULT_REVIEW_MESSAGE_CHARS, DEFAULT_REVIEW_SKILL_INTERVAL, DEFAULT_REVIEW_TIMEOUT_MS, 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, LOCK_BODY_RE, LOCK_SUFFIX, 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_EXEMPTION_HINT, advanceReview, allowRowCollisions, 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, evolutionEventPayloadIssue, evolutionHome, evolutionIoAdapter, evolutionRoot, findDriftSignal, foldCuratorFields, foldTurn, frontmatterBlock, frontmatterYamlUnsafeValues, getRecord, isProcessAlive, latestActivityAt, lifecycleCandidate, listEventArchives, loadMutations, loadSuppressedNames, loadUsage, makeSerialQueue, markAgentCreated, markerEntryName, memoryRoot, missingSupportPointers, mutateUsage, mutationsFile, narrowNameMatches, nodeEvolutionIo, normalizeFrontmatter, normalizeUsageRecord, observeEvent, overlongLines, parseCuratorNominations, parseEvolutionEvents, parseFrontmatter, parseLockBody, pendingSelfCleanup, readEvolutionEvents, readEvolutionTimeline, recordMutation, redactSecrets, relatedSkillNames, renameWithRetry, renderCuratorReportMarkdown, resolveOrigins, resolveRootConfig, resolveSkillsRoot, retainEventArchives, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, suppressedFile, transactIo, updateSuppressedNames, usageFile, usageObserved, validateFrontmatter, validateRestructureTarget, verifyPromptBundle, writeDurableTmp, yamlPlainScalarNeedsQuotes };
6316
+ export { AUTHORING_DESCRIPTION_BAR, COMBINED_REVIEW_PLAN_PROMPT, COMBINED_REVIEW_PROMPT, COMPLETION_SKILL_REVIEW_PROMPT, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEAD_LOCK_TAKEOVER_MS, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_CONSOLIDATION_FAILURES, DEFAULT_CURATOR_BOOT_GRACE_SECONDS, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_CURATOR_MODEL, DEFAULT_CURATOR_REVIEW_MAX_TOKENS, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_MAX_OPS_PER_PLAN, DEFAULT_MEMORY_CHAR_LIMIT, DEFAULT_MEMORY_REVIEW_MODEL, DEFAULT_MIN_IDLE_HOURS, DEFAULT_MUTATION_CAP, DEFAULT_REVIEW_CONTEXT_MESSAGES, DEFAULT_REVIEW_MEMORY_INTERVAL, DEFAULT_REVIEW_MESSAGE_CHARS, DEFAULT_REVIEW_SKILL_INTERVAL, DEFAULT_REVIEW_TIMEOUT_MS, DEFAULT_SKILL_CONTENT_CHARS, DEFAULT_SKILL_LIMITS, DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS, DEFAULT_SKILL_REVIEW_MODEL, 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, EMPTY_LOCK_TAKEOVER_MS, 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, LOCK_BODY_RE, LOCK_SUFFIX, LOCK_TEAR_TAKEOVER_MS, LOW_QUALITY_THRESHOLD, LostWriteLock, MAINTAIN_OUTPUT_INSTRUCTION, MAINTAIN_PROMPT, MAX_DESCRIPTION_LENGTH, MAX_RESTRUCTURE_MOVES, MAX_SKILL_CONTENT_CHARS, MAX_SKILL_FILE_BYTES, MAX_SKILL_NAME_LENGTH, MAX_TIMER_DELAY_MS, 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_EXEMPTION_HINT, advanceReview, allowRowCollisions, appendEvolutionEvent, applyCuratorFields, applyCuratorLifecycleFields, applyCuratorMetaFields, assertSkillsRootAliasRetired, assessStructureHealth, authoringFeedback, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpUse, bumpView, clampedNumber, composePresetComposition, computeDedupGroups, computeDriftSignals, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, contentHash, createGateSet, decideTakeover, duplicateHeadings, emptyRecord, evaluateThreat, eventsFile, evolutionEventPayloadIssue, evolutionHome, evolutionIoAdapter, evolutionRoot, findDriftSignal, foldCuratorFields, foldTurn, frontmatterBlock, frontmatterCatalogInvalid, frontmatterYamlUnsafeValues, getRecord, isCommittedWarning, isProcessAlive, latestActivityAt, lifecycleCandidate, listEventArchives, loadMutations, loadSuppressedNames, loadUsage, makeSerialQueue, markAgentCreated, markerEntryName, memoryRoot, missingSupportPointers, mutateUsage, mutationsFile, narrowNameMatches, nodeEvolutionIo, normalizeFrontmatter, normalizeUsageRecord, observeEvent, overlongLines, parseCuratorNominations, parseEvolutionEvents, parseFrontmatter, parseLockBody, pendingSelfCleanup, readEvolutionEvents, readEvolutionTimeline, recordMutation, redactSecrets, relatedSkillNames, renameWithRetry, renderCuratorReportMarkdown, resolveOrigins, resolveRootConfig, resolveSkillsRoot, retainEventArchives, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, suppressedFile, transactIo, updateSuppressedNames, usageFile, usageObserved, validateFrontmatter, validateRestructureTarget, verifyPromptBundle, writeDurableTmp, yamlPlainScalarNeedsQuotes };