@lmzhen/dsh-evolution-core 0.3.81 → 0.3.83

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,11 +1,84 @@
1
- import { basename, dirname, isAbsolute, join, resolve } from "node:path";
1
+ import { basename, dirname, join, resolve } from "node:path";
2
2
  import { cp, lstat, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { createHash, randomBytes } from "node:crypto";
4
4
  import { homedir } from "node:os";
5
5
  import { readFileSync } from "node:fs";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { scopeOf } from "@deepseek-ai/dsh-scope";
8
- import { load } from "js-yaml";
8
+ import { parse } from "yaml";
9
+ //#region lib/types/probe.js
10
+ function probePresent(value) {
11
+ return {
12
+ kind: "present",
13
+ value
14
+ };
15
+ }
16
+ function probeAbsent() {
17
+ return { kind: "absent" };
18
+ }
19
+ function probeUnknown(reason) {
20
+ return {
21
+ kind: "unknown",
22
+ reason
23
+ };
24
+ }
25
+ function isPresent(probe) {
26
+ return probe.kind === "present";
27
+ }
28
+ function isAbsent(probe) {
29
+ return probe.kind === "absent";
30
+ }
31
+ function isUnknown(probe) {
32
+ return probe.kind === "unknown";
33
+ }
34
+ /** Only a PRESENT probe yields a value; absent and unknown both fall back. */
35
+ function valueOr(probe, fallback) {
36
+ return probe.kind === "present" ? probe.value : fallback;
37
+ }
38
+ function mapProbe(probe, transform) {
39
+ return probe.kind === "present" ? probePresent(transform(probe.value)) : probe;
40
+ }
41
+ /** The reason an `unknown` probe carries (message, never a bare String(object)). */
42
+ function probeReason(error) {
43
+ return error instanceof Error ? error.message : String(error);
44
+ }
45
+ /**
46
+ * ENOENT/ENOTDIR are the ONE read failure that means "it is not there"; every
47
+ * other failure is an IO error and stays unknown (same split as the node
48
+ * backend's own isMissing, V8-23⑨ for the size probe).
49
+ */
50
+ function isMissingPath(error) {
51
+ const code = error?.code;
52
+ return code === "ENOENT" || code === "ENOTDIR";
53
+ }
54
+ /**
55
+ * Three-state directory listing. The node backend already answers `[]` for a
56
+ * MISSING directory (its own rc.50 P2-4 contract) and THROWS for an unreadable
57
+ * one, so the value this adds is the second half: a backend that throws ENOENT
58
+ * reads as absent, an EACCES/EIO reads as unknown — where a bare
59
+ * `catch { return [] }` served a broken store as an empty one (the N14 class).
60
+ */
61
+ async function probeList(io, dir) {
62
+ try {
63
+ return probePresent(await io.list(dir));
64
+ } catch (error) {
65
+ return isMissingPath(error) ? probeAbsent() : probeUnknown(probeReason(error));
66
+ }
67
+ }
68
+ /**
69
+ * Three-state mtime read. Absent covers both "the path is missing" and "this
70
+ * backend has no mtime probe" — the seam cannot tell those apart, so consumers
71
+ * that must know say so in their own log line. A stat that FAILS is unknown.
72
+ */
73
+ async function probeMtime(io, path) {
74
+ try {
75
+ const value = await io.mtime?.(path) ?? null;
76
+ return value === null ? probeAbsent() : probePresent(value);
77
+ } catch (error) {
78
+ return probeUnknown(probeReason(error));
79
+ }
80
+ }
81
+ //#endregion
9
82
  //#region lib/types/io.js
10
83
  /**
11
84
  * Structural IO seam for the evolution plugin family.
@@ -40,6 +113,21 @@ async function transactIo(io, path, task) {
40
113
  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)}`);
41
114
  }
42
115
  }
116
+ /** Build a {@link TransactTaskGuard} for one write path. See its doc. */
117
+ function transactTaskGuard(what) {
118
+ let invoked = false;
119
+ return {
120
+ wrap: (task) => (current) => {
121
+ invoked = true;
122
+ return task(current);
123
+ },
124
+ invoked: () => invoked,
125
+ assertInvoked: () => {
126
+ if (invoked) return;
127
+ throw new Error(`internal error: the write transaction for ${what} did not invoke the task; no write was performed`);
128
+ }
129
+ };
130
+ }
43
131
  /** Lazy adapter over an IO provider registry, shared by every evolution consumer. */
44
132
  function evolutionIoAdapter(provider) {
45
133
  return {
@@ -324,10 +412,17 @@ function decideTakeover(probe) {
324
412
  * exceed the default budget and fail loud.
325
413
  */
326
414
  function nodeEvolutionIo(lockAttempts = 40) {
327
- const isMissing = (error) => {
328
- const code = error?.code;
329
- return code === "ENOENT" || code === "ENOTDIR";
330
- };
415
+ /**
416
+ * v43 S1-4: delegates to the canonical `isMissingPath` (probe.ts) — one
417
+ * definition for the whole family instead of a second copy of the same two
418
+ * codes. EISDIR deliberately stays OUT of that predicate: a directory
419
+ * squatting on a file path is not "absent" — rotation and event reads must
420
+ * still see it as malformed (rc.72 G-2), while the SkillLibrary.read boundary
421
+ * absorbs EISDIR into "absent" for its own surface (E-43).
422
+ * @param error - the caught read failure.
423
+ * @returns true only for ENOENT/ENOTDIR.
424
+ */
425
+ const isMissing = (error) => isMissingPath(error);
331
426
  /** True when the pid is alive (single source: `isProcessAlive`). */
332
427
  const isAlive = isProcessAlive;
333
428
  /**
@@ -425,7 +520,15 @@ function nodeEvolutionIo(lockAttempts = 40) {
425
520
  });
426
521
  if (decision !== "none") {
427
522
  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"})`);
428
- if (await readFile(lock, "utf8").catch(() => "") === holderContent) {
523
+ const currentRead = await readFile(lock, "utf8").then((body) => ({
524
+ ok: true,
525
+ body
526
+ }), () => ({
527
+ ok: false,
528
+ body: ""
529
+ }));
530
+ if (!currentRead.ok) continue;
531
+ if (currentRead.body === holderContent) {
429
532
  const ticket = `${lock}.next`;
430
533
  try {
431
534
  const ticketBody = await readFile(ticket, "utf8").catch(() => "");
@@ -509,9 +612,7 @@ function nodeEvolutionIo(lockAttempts = 40) {
509
612
  force: true,
510
613
  maxRetries: 20,
511
614
  retryDelay: 100
512
- }).catch(async () => {
513
- await recordPendingSelfCleanup(lock, await readFile(lock, "utf8").catch(() => ""));
514
- });
615
+ }).catch(() => recordPendingSelfCleanup(lock, myClaim));
515
616
  }
516
617
  }
517
618
  throw new Error(`could not acquire write lock for ${path} after ${lockAttempts} attempts`);
@@ -773,7 +874,9 @@ async function mutateUsage(root, io, task, options = {}) {
773
874
  const record = parsed;
774
875
  const isMalformed = (value) => value === null || typeof value !== "object" || Array.isArray(value);
775
876
  if (typeof record.version === "number" && record.version > 1) {
776
- options.onQuarantine?.(`usage sidecar ${usageFile(root)} carries schema version ${String(record.version)} (> this runtime) — writes stay frozen and the bytes preserved until the runtime is upgraded`);
877
+ const message = `usage sidecar ${usageFile(root)} carries schema version ${String(record.version)} (> this runtime) — writes stay frozen and the bytes preserved until the runtime is upgraded`;
878
+ if (options.onQuarantine !== void 0) options.onQuarantine(message);
879
+ else console.warn(message);
777
880
  shapePreserved = true;
778
881
  } else if (Object.values(record).some(isMalformed)) {
779
882
  const bad = Object.keys(record).filter((key) => isMalformed(record[key]));
@@ -927,19 +1030,37 @@ function parseSuppressed(raw) {
927
1030
  return /* @__PURE__ */ new Set();
928
1031
  }
929
1032
  }
1033
+ /**
1034
+ * Plain wholesale write of the suppression sidecar (tests and fixture seeding).
1035
+ * @internal S2.4 (PLAN 2026-09-16, audit P2-31): NO production consumer
1036
+ * (verified by grep; production suppressions go through
1037
+ * {@link updateSuppressedNames}) — exported for the family's tests only. Do
1038
+ * not use it to write the sidecar in new code.
1039
+ *
1040
+ * The write rides the {@link transactIo} channel (the same lock the RMW
1041
+ * writer uses), not the former naked read → `io.writeText`: that form was an
1042
+ * unserialized read-modify-write — the last `saveSuppressedNames` in this file
1043
+ * with no lock, while its sibling `saveUsage` already carried the `@internal`
1044
+ * test-only note. Version discipline is unchanged (V24-09 / S2-14): a read
1045
+ * failure surfaces (an unreadable sidecar is never written over), a newer
1046
+ * on-disk schema is warned about and preserved byte-for-byte (the
1047
+ * byte-identical result short-circuits the write), and a malformed sidecar is
1048
+ * still overwritten with the v1 shape (the historical plain-writer posture).
1049
+ */
930
1050
  async function saveSuppressedNames(root, names, io = nodeEvolutionIo()) {
931
- const current = await io.readText(suppressedFile(root)).catch(() => null);
932
- if (current !== null) try {
933
- const parsed = JSON.parse(current);
934
- if (parsed !== null && typeof parsed.version === "number" && parsed.version > 1) {
935
- console.warn(`suppression sidecar ${suppressedFile(root)} declares version ${String(parsed.version)} (newer than 1); not overwritten`);
936
- return;
937
- }
938
- } catch {}
939
- await io.writeText(suppressedFile(root), JSON.stringify({
940
- version: 1,
941
- names: [...names].sort()
942
- }, null, 2));
1051
+ await transactIo(io, suppressedFile(root), (current) => {
1052
+ if (current !== null) try {
1053
+ const parsed = JSON.parse(current);
1054
+ if (parsed !== null && typeof parsed.version === "number" && parsed.version > 1) {
1055
+ console.warn(`suppression sidecar ${suppressedFile(root)} declares version ${String(parsed.version)} (newer than 1); not overwritten`);
1056
+ return current;
1057
+ }
1058
+ } catch {}
1059
+ return JSON.stringify({
1060
+ version: 1,
1061
+ names: [...names].sort()
1062
+ }, null, 2);
1063
+ });
943
1064
  }
944
1065
  /**
945
1066
  * Atomic read-modify-write on the suppression sidecar (rc.50 P2-2): `task`
@@ -1641,7 +1762,15 @@ async function rotateIfDue(io, path, events, rotateAt) {
1641
1762
  const anchor = tail[0]?.seq ?? 0;
1642
1763
  const archivePath = join(dirname(path), `${EVENT_ARCHIVE_PREFIX}${anchor - 1}.json`);
1643
1764
  let archived = head;
1644
- const existing = await io.readText(archivePath).catch(() => null);
1765
+ let existing;
1766
+ try {
1767
+ existing = await io.readText(archivePath);
1768
+ } catch (error) {
1769
+ return {
1770
+ ok: false,
1771
+ reason: `evolution event archive collision at ${archivePath} could not be read (${error instanceof Error ? error.message : String(error)}) and was not touched`
1772
+ };
1773
+ }
1645
1774
  if (existing !== null) {
1646
1775
  let parsed = null;
1647
1776
  try {
@@ -1716,18 +1845,20 @@ async function pruneCollideArchives(io, path) {
1716
1845
  if (stamp && now - Number(stamp[1]) < COLLIDE_AGE_MS) continue;
1717
1846
  if (!stamp) try {
1718
1847
  const mtime = await io.mtime?.(full);
1719
- if (typeof mtime === "number" && now - mtime < COLLIDE_AGE_MS) continue;
1848
+ if (typeof mtime !== "number" || now - mtime < COLLIDE_AGE_MS) continue;
1720
1849
  } catch {
1721
1850
  continue;
1722
1851
  }
1723
1852
  await io.remove(full).catch(() => {});
1724
1853
  }
1725
1854
  }
1726
- /** Read the event log; a missing/whitespace-only file reads as empty,
1727
- * corrupt content is flagged (and refused on append). A well-formed future-
1728
- * version body is v1-incompatible and reads as empty, NOT malformed (F-338:
1729
- * the reader must never mis-shape a newer format; the append path refuses it
1730
- * up front so the original bytes survive). */
1855
+ /** Read the event log; a missing/whitespace-only file reads as empty, corrupt
1856
+ * content is flagged (and refused on append). A well-formed future-version body
1857
+ * is v1-incompatible: it reads as EMPTY and is now flagged malformed as well
1858
+ * (C-events-dispatch-1, v43). F-338's own guarantees are untouched the reader
1859
+ * never mis-shapes a newer format and the append path refuses it up front, so
1860
+ * the original bytes survive — while the flag reports what the old reader hid:
1861
+ * every record that body holds is dropped from this read. */
1731
1862
  async function readEvolutionEvents(io, path) {
1732
1863
  let raw;
1733
1864
  try {
@@ -1746,7 +1877,7 @@ async function readEvolutionEvents(io, path) {
1746
1877
  const parsed = JSON.parse(raw);
1747
1878
  if (parsed.version !== void 0 && parsed.version !== 1) return {
1748
1879
  events: [],
1749
- malformed: false
1880
+ malformed: true
1750
1881
  };
1751
1882
  if (!Array.isArray(parsed.events)) return {
1752
1883
  events: [],
@@ -1767,8 +1898,11 @@ async function readEvolutionEvents(io, path) {
1767
1898
  * Read the full timeline (rc.71): active log + all archives, merged by seq
1768
1899
  * (active copy wins, duplicates only arise from the rotation crash window),
1769
1900
  * sorted ascending. Per-file malformed flag as in `readEvolutionEvents`; a
1770
- * malformed (or unreadable) ARCHIVE is skipped it never bricks the boot and
1771
- * it is still flagged.
1901
+ * flagged ARCHIVE (unreadable, damaged, or a future-version body this reader
1902
+ * cannot interpret) is SKIPPED — it never bricks the boot, the returned events
1903
+ * simply LACK that seq band, and `malformed` is the only signal that they do
1904
+ * (C-events-dispatch-1, v43: the flag is the consumer's contract; a truncated
1905
+ * timeline must never be folded back as if it were complete).
1772
1906
  */
1773
1907
  async function readEvolutionTimeline(io, path, archives) {
1774
1908
  const dir = dirname(path);
@@ -1869,12 +2003,14 @@ function buildLearnPrompt(userRequest) {
1869
2003
  * DSH home root: `$DSH_HOME` or `~/.dsh`. Single source of the empty-string
1870
2004
  * fallback — an EMPTY or WHITESPACE-ONLY DSH_HOME resolves to the default
1871
2005
  * home, never to a CWD-relative path (0.3.19 W1.3, 0.3.22 F-207);
1872
- * V8-06 (0.3.47) extends the guard to whitespace (upstream home-paths:
1873
- * `trim().length > 0` is the adoption test — `DSH_HOME=" "` must not produce
1874
- * a sidecar under a relative "." path).
1875
- * C-11: the adoption test and the RETURNED value now come from the
1876
- * SAME trimmed source the old form tested `trim()` but returned the raw
1877
- * value, so `DSH_HOME=" /x "` was accepted AND persisted with literal spaces.
2006
+ * V8-06 (0.3.47) extends the guard to whitespace.
2007
+ * C-11 correction (S2.2, PLAN 2026-09-16): upstream `resolveDshHome`
2008
+ * (util/home-paths) uses `trim()` ONLY as the ADOPTION test and then uses the
2009
+ * RAW env value the earlier "same trimmed source" form returned the trimmed
2010
+ * text, so `DSH_HOME=" /x "` produced `/x` where upstream produced the literal
2011
+ * padded path. The value semantics now match upstream line for line:
2012
+ * `const selected = configured ?? (fromEnv !== undefined &&
2013
+ * fromEnv.trim().length > 0 ? fromEnv : defaultDshHome())`.
1878
2014
  * OPT-27 (2026-09, plan D5 — accepted): the v10-era "no `~` expansion, no
1879
2015
  * resolve" divergence from upstream `resolveDshHome` is RETIRED. It became
1880
2016
  * load-bearing when the skill-catalog shadow made "same tree as the upstream
@@ -1882,15 +2018,18 @@ function buildLearnPrompt(userRequest) {
1882
2018
  * absolute `<home>/skills` while this value fed a literal `~/x` (a directory
1883
2019
  * named `~` under the host CWD) or a CWD-relative path — split-brain skill
1884
2020
  * trees, preset installs the platform never reads, doctor probes of a
1885
- * directory nothing serves. Behavior now matches upstream:
1886
- * `resolve(expandHomePath(selected))`. Only `~`-prefixed and RELATIVE
1887
- * DSH_HOME values change landing spot; absolute homes are byte-identical.
2021
+ * directory nothing serves.
2022
+ * S2.2 (PLAN 2026-09-16), final correction: the result is ALWAYS
2023
+ * `resolve(expandHomePath(selected))` the earlier form returned an
2024
+ * already-absolute value VERBATIM, so a trailing slash or `..` segment
2025
+ * landed unnormalized while upstream normalizes every value. Relative
2026
+ * `DSH_HOME` values change landing spot; a CLEAN absolute home stays
2027
+ * byte-identical (resolve is a no-op on it).
1888
2028
  */
1889
2029
  function evolutionRoot(env = process.env) {
1890
- const home = env.DSH_HOME?.trim();
1891
- const selected = home ? home : join(homedir(), ".dsh");
1892
- const expanded = selected === "~" ? homedir() : selected.startsWith("~/") || selected.startsWith("~\\") ? join(homedir(), selected.slice(2)) : selected;
1893
- return isAbsolute(expanded) ? expanded : resolve(expanded);
2030
+ const fromEnv = env.DSH_HOME;
2031
+ const selected = fromEnv !== void 0 && fromEnv.trim().length > 0 ? fromEnv : join(homedir(), ".dsh");
2032
+ return resolve(selected === "~" ? homedir() : selected.startsWith("~/") || selected.startsWith("~\\") ? join(homedir(), selected.slice(2)) : selected);
1894
2033
  }
1895
2034
  /** Evolution home path helper: `$DSH_HOME/evolution` for plugin-owned sidecar
1896
2035
  * state (reports, activity store, feedback file, state-domain data). */
@@ -2076,7 +2215,7 @@ const PATTERNS = [
2076
2215
  label: "read_secrets",
2077
2216
  category: "exfiltration",
2078
2217
  scope: "all",
2079
- regex: /\bcat\s+[^\n]{0,512}(?:\.env(?!\w)|(?:\bcredentials\b)|\.netrc|\.pgpass|\.npmrc|\.pypirc)/i
2218
+ regex: /\bcat\s+[^\n]{0,512}(?:\.env(?!\w)(?!\.(?:[\w-]+\.)*(?:example|sample)(?=$|[\s"']))|(?:\bcredentials\b)|\.netrc|\.pgpass|\.npmrc|\.pypirc)/i
2080
2219
  },
2081
2220
  {
2082
2221
  label: "ssh_backdoor",
@@ -2778,7 +2917,7 @@ var MemoryStore = class {
2778
2917
  for (const [index, op] of operations.entries()) {
2779
2918
  const position = index + 1;
2780
2919
  if (op.action === "add") {
2781
- const body = (op.facts ?? "").trim();
2920
+ const body = (op.facts ?? op.content ?? "").trim();
2782
2921
  if (!body) return {
2783
2922
  result: {
2784
2923
  ok: false,
@@ -2857,7 +2996,7 @@ var MemoryStore = class {
2857
2996
  const matchIndex = matches[0]?.matchIndex ?? -1;
2858
2997
  if (op.action === "remove") working.splice(matchIndex, 1);
2859
2998
  else {
2860
- const body = (op.facts ?? "").trim();
2999
+ const body = (op.facts ?? op.content ?? "").trim();
2861
3000
  if (!body) return {
2862
3001
  result: {
2863
3002
  ok: false,
@@ -3129,10 +3268,23 @@ function applyOneOverride(lines, override) {
3129
3268
  if (!found) console.warn("evolution preset composition: warning — " + override.missingReason);
3130
3269
  return lines;
3131
3270
  }
3271
+ /**
3272
+ * Row ids of a composition fragment (line scan, no YAML library).
3273
+ *
3274
+ * PLAN S5.9 (2026-09-16, audit P2-27): the id extraction accepts INDENTED
3275
+ * `- id:` rows too, so a collision hidden in a nested group is still caught —
3276
+ * the old `^- id:` anchored at column 0 and was blind to exactly the rows an
3277
+ * upstream group nesting would produce. Twin of `rowIds` in
3278
+ * `scripts/install-layered.mjs` (installer.spec pins detection parity).
3279
+ * Boundary (current, deliberate): DETECTION covers nested rows, while the
3280
+ * override INJECTION anchors (`applyOneOverride` below) still match top-level
3281
+ * rows only — the injection indent contract (`^ {2}key:`) is defined against a
3282
+ * column-0 row.
3283
+ */
3132
3284
  function compositionRowIds(composition) {
3133
3285
  const ids = /* @__PURE__ */ new Set();
3134
3286
  for (const line of composition.split("\n")) {
3135
- const id = /^- id:\s*(\S+)/.exec(line)?.[1];
3287
+ const id = /^\s*- id:\s*(\S+)/.exec(line)?.[1];
3136
3288
  if (id) ids.add(id);
3137
3289
  }
3138
3290
  return ids;
@@ -3602,13 +3754,29 @@ function jaccard(a, b) {
3602
3754
  for (const token of a) if (b.has(token)) intersection += 1;
3603
3755
  return intersection / (a.size + b.size - intersection);
3604
3756
  }
3757
+ /** PLAN-R2 P2-8 (2026-09-16): default cap on two-name comparisons in
3758
+ * {@link computeDedupGroups}. A 2000-skill library is ~2M pairs; the old
3759
+ * unbounded two-two Jaccard ran seconds to tens of seconds per
3760
+ * review/`dedup_group` probe. 250k comparisons bounds that to well under a
3761
+ * second while staying far above every real library's pair count. Only
3762
+ * pairwise comparisons are budgeted — materializing a name's token set
3763
+ * (memoized, once per name) and the exact-hash pre-union phase are not. */
3764
+ const DEDUP_MAX_PAIR_COMPARISONS = 25e4;
3605
3765
  /**
3606
3766
  * Two-phase near-duplicate clustering: exact normalized-hash groups first,
3607
3767
  * then token-Jaccard edges at {@link DEDUP_SIMILARITY_THRESHOLD} with a token
3608
3768
  * ratio guard, union-find across the whole set.
3769
+ *
3770
+ * PLAN-R2 P2-8 (2026-09-16): each name's token set is materialized once
3771
+ * (memoized map), each pair takes an O(1) size-ratio short-circuit before the
3772
+ * intersection, and the pairwise loop is bounded by
3773
+ * `maxPairComparisons` (default {@link DEDUP_MAX_PAIR_COMPARISONS}); hitting
3774
+ * the budget stops the scan and `truncated: true` says so. Small libraries
3775
+ * (below the budget) behave exactly as the unbounded scan did.
3609
3776
  */
3610
3777
  function computeDedupGroups(input) {
3611
3778
  const threshold = input.threshold ?? .95;
3779
+ const maxPairComparisons = input.maxPairComparisons ?? 25e4;
3612
3780
  const names = [...input.contents.keys()];
3613
3781
  const hashes = /* @__PURE__ */ new Map();
3614
3782
  for (const name of names) {
@@ -3644,12 +3812,19 @@ function computeDedupGroups(input) {
3644
3812
  }
3645
3813
  return set;
3646
3814
  };
3647
- for (let index = 0; index < names.length; index += 1) {
3815
+ let compared = 0;
3816
+ let truncated = false;
3817
+ for (let index = 0; index < names.length && !truncated; index += 1) {
3648
3818
  const a = names[index];
3649
3819
  if (a === void 0) continue;
3650
3820
  for (let other = index + 1; other < names.length; other += 1) {
3651
3821
  const b = names[other];
3652
3822
  if (b === void 0) continue;
3823
+ if (compared >= maxPairComparisons) {
3824
+ truncated = true;
3825
+ break;
3826
+ }
3827
+ compared += 1;
3653
3828
  const [ta, tb] = [tokenSet(a), tokenSet(b)];
3654
3829
  if (Math.max(ta.size, tb.size) / Math.max(1, Math.min(ta.size, tb.size)) > 5) continue;
3655
3830
  if (jaccard(ta, tb) >= threshold) union(a, b);
@@ -3662,7 +3837,10 @@ function computeDedupGroups(input) {
3662
3837
  if (group) group.push(name);
3663
3838
  else groups.set(root, [name]);
3664
3839
  }
3665
- return [...groups.values()].filter((group) => group.length > 1);
3840
+ return {
3841
+ groups: [...groups.values()].filter((group) => group.length > 1),
3842
+ truncated
3843
+ };
3666
3844
  }
3667
3845
  /**
3668
3846
  * Prefix-cluster index over a name set (rc.67 merge heuristic, input side):
@@ -3716,7 +3894,7 @@ const URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]{0,63}:\/\/[^\s:/@]+:)([^\s/@]
3716
3894
  const PEM_PRIVATE_KEY_PATTERN = new RegExp(`-----BEGIN\\s+(?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED|PGP)\\s+)?PRIVATE\\s+KEY(?:\\s+BLOCK)?-----[\\s\\S]*?-----END\\s+(?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED|PGP)\\s+)?PRIVATE\\s+KEY(?:\\s+BLOCK)?-----`, "g");
3717
3895
  const BLOCK_KEY_ONLY_LINE = /(?:^|\s)([\w-]*[_\-])?((?:token|api[_-]?key|secret|password|passwd)(?:[_\-][\w-]*)?)\s*:(?:\r)?$/i;
3718
3896
  const CREDENTIAL_KEY_RE = /(?:[Tt]oken|[Ss]ecret|[Pp]assword|[Pp]asswd|[Aa]pi[_-]?[Kk]ey)(?![a-z])/;
3719
- const CANDIDATE_ASSIGNMENT_PATTERN = /(^|[^\w-])([\w-]+)(["']?[\t ]*[:=][\t ]*)([^\r\n]+)/g;
3897
+ const CANDIDATE_ASSIGNMENT_PATTERN = /(^|[^\w-])([\w-]+)(["']?[\t ]*[:=][\t ]*)(?=[^\r\n])/g;
3720
3898
  /**
3721
3899
  * Mask credential-shaped text before it crosses a session boundary.
3722
3900
  * @param text - the text about to be sent to a model outside this session.
@@ -3728,21 +3906,39 @@ function redactSecrets(text) {
3728
3906
  for (const [, pattern] of SECRET_PATTERNS) out = out.replace(pattern, "<redacted>");
3729
3907
  out = out.replace(URL_CREDENTIALS_PATTERN, (_match, lead) => `${lead ?? ""}<redacted>@`);
3730
3908
  out = out.replace(INLINE_ASSIGNMENT_PATTERN, (_match, lead, prefix, key, separator) => `${lead ?? ""}${prefix ?? ""}${key ?? ""}${separator ?? ""}<redacted>`);
3731
- out = out.replace(CANDIDATE_ASSIGNMENT_PATTERN, (match, lead, key, separator) => {
3732
- if (typeof key !== "string" || !CREDENTIAL_KEY_RE.test(key)) return match;
3733
- return `${lead ?? ""}${key}${separator ?? ""}<redacted>`;
3734
- });
3909
+ const candidatePattern = new RegExp(CANDIDATE_ASSIGNMENT_PATTERN.source, "g");
3910
+ let candidateOut = "";
3911
+ let consumed = 0;
3912
+ for (let m = candidatePattern.exec(out); m !== null; m = candidatePattern.exec(out)) {
3913
+ const lead = m[1] ?? "";
3914
+ const key = m[2] ?? "";
3915
+ const separator = m[3] ?? "";
3916
+ if (!CREDENTIAL_KEY_RE.test(key)) continue;
3917
+ let valueEnd = m.index + m[0].length;
3918
+ while (valueEnd < out.length && out[valueEnd] !== "\n" && out[valueEnd] !== "\r") valueEnd += 1;
3919
+ candidateOut += out.slice(consumed, m.index) + lead + key + separator + "<redacted>";
3920
+ consumed = valueEnd;
3921
+ candidatePattern.lastIndex = valueEnd;
3922
+ }
3923
+ out = candidateOut + out.slice(consumed);
3735
3924
  const lines = out.split("\n");
3736
3925
  for (let i = 0; i < lines.length - 1; i++) {
3737
3926
  const line = lines[i];
3738
3927
  if (line === void 0) continue;
3739
3928
  const camelKey = /^([\w-]+)\s*:(?:\r)?$/.exec(line)?.[1];
3740
3929
  if (!(BLOCK_KEY_ONLY_LINE.test(line) || camelKey !== void 0 && CREDENTIAL_KEY_RE.test(camelKey))) continue;
3741
- const next = lines[i + 1] ?? "";
3930
+ let valueLine = -1;
3931
+ for (let j = i + 1; j < lines.length; j++) {
3932
+ if ((lines[j] ?? "").trim() === "") continue;
3933
+ valueLine = j;
3934
+ break;
3935
+ }
3936
+ if (valueLine < 0) continue;
3937
+ const next = lines[valueLine] ?? "";
3742
3938
  const [, indent, value, tail] = /^([ \t]+)(\S.*?)([ \t]*)(?:\r)?$/.exec(next) ?? [];
3743
3939
  if (indent === void 0 || value === void 0) continue;
3744
3940
  if (value.includes("<redacted>")) continue;
3745
- lines[i + 1] = `${indent}<redacted>${tail ?? ""}`;
3941
+ lines[valueLine] = `${indent}<redacted>${tail ?? ""}`;
3746
3942
  }
3747
3943
  out = lines.join("\n");
3748
3944
  out = out.split("\n").map((line) => {
@@ -3811,79 +4007,6 @@ function sweepReviewChannelSessions(isAlive) {
3811
4007
  return removed;
3812
4008
  }
3813
4009
  //#endregion
3814
- //#region lib/types/probe.js
3815
- function probePresent(value) {
3816
- return {
3817
- kind: "present",
3818
- value
3819
- };
3820
- }
3821
- function probeAbsent() {
3822
- return { kind: "absent" };
3823
- }
3824
- function probeUnknown(reason) {
3825
- return {
3826
- kind: "unknown",
3827
- reason
3828
- };
3829
- }
3830
- function isPresent(probe) {
3831
- return probe.kind === "present";
3832
- }
3833
- function isAbsent(probe) {
3834
- return probe.kind === "absent";
3835
- }
3836
- function isUnknown(probe) {
3837
- return probe.kind === "unknown";
3838
- }
3839
- /** Only a PRESENT probe yields a value; absent and unknown both fall back. */
3840
- function valueOr(probe, fallback) {
3841
- return probe.kind === "present" ? probe.value : fallback;
3842
- }
3843
- function mapProbe(probe, transform) {
3844
- return probe.kind === "present" ? probePresent(transform(probe.value)) : probe;
3845
- }
3846
- /** The reason an `unknown` probe carries (message, never a bare String(object)). */
3847
- function probeReason(error) {
3848
- return error instanceof Error ? error.message : String(error);
3849
- }
3850
- /**
3851
- * ENOENT/ENOTDIR are the ONE read failure that means "it is not there"; every
3852
- * other failure is an IO error and stays unknown (same split as the node
3853
- * backend's own isMissing, V8-23⑨ for the size probe).
3854
- */
3855
- function isMissingPath(error) {
3856
- const code = error?.code;
3857
- return code === "ENOENT" || code === "ENOTDIR";
3858
- }
3859
- /**
3860
- * Three-state directory listing. The node backend already answers `[]` for a
3861
- * MISSING directory (its own rc.50 P2-4 contract) and THROWS for an unreadable
3862
- * one, so the value this adds is the second half: a backend that throws ENOENT
3863
- * reads as absent, an EACCES/EIO reads as unknown — where a bare
3864
- * `catch { return [] }` served a broken store as an empty one (the N14 class).
3865
- */
3866
- async function probeList(io, dir) {
3867
- try {
3868
- return probePresent(await io.list(dir));
3869
- } catch (error) {
3870
- return isMissingPath(error) ? probeAbsent() : probeUnknown(probeReason(error));
3871
- }
3872
- }
3873
- /**
3874
- * Three-state mtime read. Absent covers both "the path is missing" and "this
3875
- * backend has no mtime probe" — the seam cannot tell those apart, so consumers
3876
- * that must know say so in their own log line. A stat that FAILS is unknown.
3877
- */
3878
- async function probeMtime(io, path) {
3879
- try {
3880
- const value = await io.mtime?.(path) ?? null;
3881
- return value === null ? probeAbsent() : probePresent(value);
3882
- } catch (error) {
3883
- return probeUnknown(probeReason(error));
3884
- }
3885
- }
3886
- //#endregion
3887
4010
  //#region lib/types/instance-scope.js
3888
4011
  /**
3889
4012
  * B3 / G4 (0.3.78): the family's single-instance contract, made explicit.
@@ -3901,11 +4024,30 @@ async function probeMtime(io, path) {
3901
4024
  * the sidecar directory, so two instances resolving different homes (an
3902
4025
  * isolated test fixture, a second DSH_HOME) do not contend, while two rows on
3903
4026
  * one profile do.
4027
+ *
4028
+ * ## Scope (v43 FLOW2-1) — this registry is PER PROCESS
4029
+ *
4030
+ * `claims` below is a module-scope Map: two ROWS over one home in ONE process
4031
+ * contend, while the SAME home in another process gets its own Map and is
4032
+ * granted the key. That is by construction, not a gap to close here — the
4033
+ * cross-process half of the contract is the IO backend's write lock
4034
+ * (`transactIo`, core/io.ts), which serializes a per-target read-modify-write.
4035
+ * The FLOW2-1 finding was three call sites reading a GRANTED claim as "no other
4036
+ * process can be doing this work", so the caller contract is stated here:
4037
+ * - granted means "no other row OF THIS PROCESS owns the key";
4038
+ * - `instanceHolder()` answers "who holds it HERE"; `undefined` also covers
4039
+ * "held by another process";
4040
+ * - a foreign holder's LIVENESS cannot be decided from a claim at all (no pid
4041
+ * is recorded here): a consumer that needs that decision must carry a pid in
4042
+ * its own credential and probe it (`isProcessAlive`, core/io.ts), or state
4043
+ * that its action is destructive.
3904
4044
  * @module @lmzhen/dsh-evolution-core/src/instance-scope
3905
4045
  */
3906
- /** home+key -> holder. Single-process by construction: a claim can only
3907
- * serialize instances inside this process, which is the whole point (the
3908
- * cross-process half is the write lock, see persisted-write-inventory.json). */
4046
+ /** home+key -> holder, IN THIS PROCESS ONLY. The Map is module-scope, so two
4047
+ * processes never share it: this half cannot exclude another process (v43
4048
+ * FLOW2-1) — the cross-process half is the write lock, see
4049
+ * persisted-write-inventory.json. It is still the whole point for the case it
4050
+ * was written for: two ROWS of one process writing one home. */
3909
4051
  const claims = /* @__PURE__ */ new Map();
3910
4052
  /** `<home> :: <key>` — the registry key, exported so diagnostics name the
3911
4053
  * same unit the claim does. */
@@ -3936,7 +4078,8 @@ function releaseInstance(home, key, owner) {
3936
4078
  const id = instanceClaimKey(home, key);
3937
4079
  if (claims.get(id) === owner) claims.delete(id);
3938
4080
  }
3939
- /** The current holder of `key` at `home`, or undefined. */
4081
+ /** The current holder of `key` at `home`, IN THIS PROCESS, or undefined
4082
+ * (which also covers "another process holds it" — v43 FLOW2-1). */
3940
4083
  function instanceHolder(home, key) {
3941
4084
  return claims.get(instanceClaimKey(home, key));
3942
4085
  }
@@ -4001,16 +4144,45 @@ function parseSites(raw) {
4001
4144
  const INSTANCE_KEYS = {
4002
4145
  /** The per-home curator: report writing + the retention sweep. */
4003
4146
  curator: "evolution-curator" };
4004
- /** The declared persisted write sites, in file order. */
4005
- const PERSISTED_WRITE_SITES = parseSites(JSON.parse(readFileSync(fileURLToPath(SITES_URL), "utf8")));
4006
- /** Sites serialized by the per-home instance claim, with their instance keys. */
4147
+ let cachedSites;
4148
+ /**
4149
+ * The declared persisted write sites, in file order.
4150
+ *
4151
+ * v43 audit (S2-3 / P1-10): this used to be a module-scope readFileSync plus
4152
+ * parse, so an unshipped asset threw AT IMPORT — one absent file took the whole
4153
+ * family's load down (0.3.79 shipped a tarball without this asset and every
4154
+ * package failed to load). The read is lazy now: importing the package never
4155
+ * fails on this asset, while the first caller still gets a loud, descriptive
4156
+ * failure instead of an empty table ("no declared write sites" would silently
4157
+ * disable rule N20).
4158
+ * @internal S2.4 (PLAN 2026-09-16): no production caller — the arch guard
4159
+ * reads the JSON asset directly; the tests are the only in-tree consumers.
4160
+ * @returns the sites, in file order.
4161
+ */
4162
+ function persistedWriteSites() {
4163
+ if (cachedSites !== void 0) return cachedSites;
4164
+ let raw;
4165
+ try {
4166
+ raw = readFileSync(fileURLToPath(SITES_URL), "utf8");
4167
+ } catch (error) {
4168
+ const cause = error instanceof Error ? error.message : String(error);
4169
+ throw new Error(`evolution-core: persisted-write-inventory.json is unreadable (${cause}) — the package asset is missing or was not shipped; the write-inventory rules cannot be evaluated without it`);
4170
+ }
4171
+ cachedSites = parseSites(JSON.parse(raw));
4172
+ return cachedSites;
4173
+ }
4174
+ /** Sites serialized by the per-home instance claim, with their instance keys.
4175
+ * @internal S2.4 (PLAN 2026-09-16): no production caller — the arch guard
4176
+ * reads the JSON asset directly; the tests are the only in-tree consumers. */
4007
4177
  function instanceClaimedWriteSites() {
4008
- return PERSISTED_WRITE_SITES.filter((site) => site.serializedBy === "instance-claim");
4178
+ return persistedWriteSites().filter((site) => site.serializedBy === "instance-claim");
4009
4179
  }
4010
4180
  /** One declared site by id. An undeclared id throws — a stale caller must fail
4011
- * loud rather than read "nothing is declared". */
4181
+ * loud rather than read "nothing is declared".
4182
+ * @internal S2.4 (PLAN 2026-09-16): no production caller — the arch guard
4183
+ * reads the JSON asset directly; the tests are the only in-tree consumers. */
4012
4184
  function persistedWriteSite(id) {
4013
- const site = PERSISTED_WRITE_SITES.find((candidate) => candidate.id === id);
4185
+ const site = persistedWriteSites().find((candidate) => candidate.id === id);
4014
4186
  if (site === void 0) throw new Error(`evolution-core: no persisted write site "${id}" in persisted-write-inventory.json`);
4015
4187
  return site;
4016
4188
  }
@@ -4020,11 +4192,6 @@ function callingScope(ctx, held) {
4020
4192
  if (held !== void 0) return held;
4021
4193
  return scopeOf(ctx);
4022
4194
  }
4023
- /** True when a read is deliberately scope-less (global layer only). Callers
4024
- * pass this to the register so the choice is reviewed, not accidental. */
4025
- function isGlobalRead(scope) {
4026
- return scope === void 0;
4027
- }
4028
4195
  //#endregion
4029
4196
  //#region lib/types/skill-health.js
4030
4197
  /**
@@ -4238,21 +4405,34 @@ var ToolDispatchNormalizer = class {
4238
4405
  this.maxTracked = options.maxTracked ?? Number.POSITIVE_INFINITY;
4239
4406
  }
4240
4407
  /**
4408
+ * v43 audit (FLOW4-4): the ledger key. A normalizer shared by every session
4409
+ * (skill-usage's live listener holds ONE process-wide instance) used the bare
4410
+ * call id, so two sessions that produced the same id — PTC sub-call ids are
4411
+ * short, and an id-less payload falls back to a type+payload key that is not
4412
+ * unique by construction — collided: the second session's read was absorbed as
4413
+ * "already seen" and never counted, and a settle in one session flipped the
4414
+ * other's `ok`. Callers that span sessions pass the session id as `scope`.
4415
+ */
4416
+ keyOf(callId, scope) {
4417
+ return scope === "" ? callId : `${scope}:${callId}`;
4418
+ }
4419
+ /**
4241
4420
  * Absorb one session event.
4242
4421
  * @param event - the event to absorb; any non-dispatch event is ignored.
4243
4422
  * @returns the dispatch's signal when this event FIRST reveals the dispatch,
4244
4423
  * otherwise \`null\` (the paired event of an already-emitted dispatch, or a
4245
4424
  * non-dispatch event). A \`null\` return is never a dispatch to count again.
4246
4425
  */
4247
- advance(event) {
4426
+ advance(event, scope = "") {
4248
4427
  const record = readDispatchRecord(event);
4249
4428
  if (record === null) return null;
4429
+ const key = this.keyOf(record.callId, scope);
4250
4430
  if (record.name === "") {
4251
- const existing = this.records.get(record.callId);
4431
+ const existing = this.records.get(key);
4252
4432
  if (existing !== void 0 && record.outcome !== void 0) existing.ok = record.outcome.ok;
4253
4433
  return null;
4254
4434
  }
4255
- const existing = this.records.get(record.callId);
4435
+ const existing = this.records.get(key);
4256
4436
  if (existing !== void 0) {
4257
4437
  if (record.outcome !== void 0) existing.ok = record.outcome.ok;
4258
4438
  return null;
@@ -4265,7 +4445,7 @@ var ToolDispatchNormalizer = class {
4265
4445
  arguments: record.arguments,
4266
4446
  ok: record.outcome?.ok
4267
4447
  };
4268
- this.records.set(record.callId, signal);
4448
+ this.records.set(key, signal);
4269
4449
  this.evict();
4270
4450
  return signal;
4271
4451
  }
@@ -4279,13 +4459,14 @@ var ToolDispatchNormalizer = class {
4279
4459
  * @param event - the event already absorbed by \`advance\`.
4280
4460
  * @returns the dispatch this event settled, or \`null\`.
4281
4461
  */
4282
- settledSignalOf(event) {
4462
+ settledSignalOf(event, scope = "") {
4283
4463
  const record = readDispatchRecord(event);
4284
4464
  if (record === null || record.outcome === void 0) return null;
4285
- if (this.settledIds.has(record.callId)) return null;
4286
- const signal = this.records.get(record.callId);
4465
+ const key = this.keyOf(record.callId, scope);
4466
+ if (this.settledIds.has(key)) return null;
4467
+ const signal = this.records.get(key);
4287
4468
  if (signal === void 0) return null;
4288
- this.settledIds.add(record.callId);
4469
+ this.settledIds.add(key);
4289
4470
  this.evict();
4290
4471
  return signal;
4291
4472
  }
@@ -4692,7 +4873,8 @@ function computeDriftSignals(snapshots) {
4692
4873
  const library = [];
4693
4874
  const names = snapshots.map((s) => s.name);
4694
4875
  const dedup = computeDedupGroups({ contents: new Map(snapshots.map((s) => [s.name, s.body])) });
4695
- library.push(dedup.length === 0 ? sig("dedup_group", "pass", "none", "size >= 2") : sig("dedup_group", "over", dedup.map((group) => group.join(", ")).join(" | "), "size >= 2", `members=${dedup.map((group) => group.join("|")).join(";")}`));
4876
+ const dedupTruncation = dedup.truncated ? " (dedup scan truncated at the pair-comparison budget)" : "";
4877
+ library.push(dedup.groups.length === 0 ? sig("dedup_group", "pass", `none${dedupTruncation}`, "size >= 2") : sig("dedup_group", "over", `${dedup.groups.map((group) => group.join(", ")).join(" | ")}${dedupTruncation}`, "size >= 2", `members=${dedup.groups.map((group) => group.join("|")).join(";")}`));
4696
4878
  const clusters = computePrefixClusters(names);
4697
4879
  library.push(clusters.length === 0 ? sig("prefix_cluster", "pass", "none", "size >= 2") : sig("prefix_cluster", "over", clusters.map((cluster) => cluster.members.join(", ")).join(" | "), "size >= 2", `key=${clusters.map((cluster) => cluster.key).join("|")}`));
4698
4880
  const allProvided = snapshots.length > 0 && snapshots.every((s) => s.usageObserved !== null && s.usageObserved !== void 0);
@@ -4738,223 +4920,29 @@ function findDriftSignal(signals, id) {
4738
4920
  return signals.find((signal) => signal.id === id);
4739
4921
  }
4740
4922
  //#endregion
4741
- //#region lib/types/skill-store.js
4923
+ //#region lib/types/limits.js
4742
4924
  /**
4743
- * Skill library management for the self-evolution plugin.
4744
- *
4745
- * Skills live under `$DSH_HOME/skills` (`~/.dsh/skills` by default), matching
4746
- * the default dsh skill-filesystem user root. The plugin only manages skills
4747
- * it created unless a `.hermes-managed` marker opts a skill in. Archival is a
4748
- * move to `.archive/` — never a hard delete.
4749
- *
4750
- * ## Concurrency discipline (OPT-09, 2026-09) — read before adding a mutator
4751
- *
4752
- * Three primitives, three distinct jobs (they compose, they do not replace
4753
- * each other):
4754
- *
4755
- * 1. **In-process serial queue** (`this.serial`, makeSerialQueue) — orders the
4756
- * read→plan→commit phases of one skill's mutation against OTHER mutators
4757
- * in this process. Used by: create/update/patch/setPinned/restructure/
4758
- * writeSupportFile/removeSupportFile and (whole-mutation) consolidate.
4759
- * NON-reentrant: a callback must never call a public method that wraps
4760
- * itself in `this.serial` (archive/restoreFromArchive deliberately do not).
4761
- * 2. **Per-directory write lock** (io.ts LOCK_*) — cross-process mutual
4762
- * exclusion plus in-process crash ownership (tickets, takeover). Checked
4763
- * with `hasWriteLock` before any destructive move (archive/restore/
4764
- * snapshot); held inside transactIo by byte writers.
4765
- * 3. **CAS baseline (`expected:`)** — any read whose bytes feed a later write
4766
- * must either live inside the serial section that commits the write, or
4767
- * carry its plan-time bytes as `expected` so the commit fails closed on
4768
- * drift (V8-11 / V24-01). A read outside the serial section WITHOUT a
4769
- * baseline is a lost-update bug; this file's history is the test suite.
4925
+ * Skill content limits: the byte/char budgets every write path validates against.
4770
4926
  *
4771
- * Known residuals (deliberate, documented at their sites): the archive commit
4772
- * re-check narrows but does not close the pin race (OPT-06); snapshotAll
4773
- * re-probes after its copies so a mid-copy writer demotes to `skipped`
4774
- * (OPT-07); the movers' probe→rename window is owned by the io.ts protocol.
4927
+ * Split out of skill-store.ts (S2-1) so the frontmatter validators and the
4928
+ * store share one declaration site. Re-exported by skill-store.ts: the package
4929
+ * export surface is unchanged.
4775
4930
  */
4776
- /** 0.3.16 (S1.13, T-6): the pointer-line prefix written into a body when a
4777
- * section is moved to references/ — single literal, both restructure and
4778
- * append-mode consolidation emit the same discoverability line. */
4779
- const POINTER_LINE_PREFIX = "> 详见 references/";
4780
4931
  const DEFAULT_SKILL_LIMITS = {
4781
4932
  maxNameLength: 64,
4782
4933
  maxDescriptionLength: MAX_DESCRIPTION_LENGTH,
4783
4934
  maxSkillContentChars: MAX_SKILL_CONTENT_CHARS,
4784
4935
  maxSkillFileBytes: MAX_SKILL_FILE_BYTES
4785
4936
  };
4937
+ //#endregion
4938
+ //#region lib/types/frontmatter.js
4786
4939
  /**
4787
- * Evaluate a stage-time anchor against the bytes a locked read observed.
4788
- * @param anchor - the caller's anchor, or `undefined` for an unanchored write.
4789
- * @param current - the bytes the write lock read (`null` = the target is absent).
4790
- * @returns `match` when the write may proceed, otherwise the refusal verdict.
4940
+ * Frontmatter parsing, normalization and validation for skill Markdown files.
4941
+ *
4942
+ * Split out of skill-store.ts (S2-1): pure functions over file text, no store
4943
+ * state. skill-store.ts re-exports the same names it exported before the split,
4944
+ * so the package export surface is unchanged.
4791
4945
  */
4792
- function anchorVerdict(anchor, current) {
4793
- if (anchor === void 0) return "match";
4794
- if ("absent" in anchor) return current === null ? "match" : "drift";
4795
- if (current === null) return "missing";
4796
- return contentHash(current) === anchor.sha256 ? "match" : "drift";
4797
- }
4798
- /**
4799
- * Build the refusal for a skill write whose anchor did not hold. The wording is
4800
- * the library's own; a caller with staged-replay wording (the skill tool, the
4801
- * review plan) re-words it from {@link SkillActionResult.anchor}.
4802
- * @param name - the skill name the refusal names.
4803
- * @param verdict - the non-matching verdict.
4804
- * @returns the refusal result (nothing was written).
4805
- */
4806
- function anchorRefusal(name, verdict) {
4807
- return {
4808
- ok: false,
4809
- stale: true,
4810
- anchor: verdict,
4811
- message: verdict === "missing" ? `Skill "${name}" not found.` : `Skill "${name}" changed since it was read; the write was refused to avoid overwriting newer content.`
4812
- };
4813
- }
4814
- /**
4815
- * Build the refusal for a support-file write/remove whose anchor did not hold.
4816
- * @param name - the owning skill name.
4817
- * @param filePath - the support-file path inside the skill.
4818
- * @param verdict - the non-matching verdict.
4819
- * @returns the refusal result (nothing was written or removed).
4820
- */
4821
- /**
4822
- * Refusal for a target the locked read could not verify at all (EISDIR, an
4823
- * unreadable file). A staged replay reports "could not be verified" instead of
4824
- * propagating an exception: nothing was read, so nothing can have been written.
4825
- * @param name - the owning skill name.
4826
- * @param filePath - the support-file path, or `null` for the skill body.
4827
- * @returns the refusal result.
4828
- */
4829
- function anchorUnverifiable(name, filePath) {
4830
- return {
4831
- ok: false,
4832
- stale: true,
4833
- anchor: "drift",
4834
- message: filePath === null ? `Skill "${name}" could not be read to verify the staged content.` : `Support file "${filePath}" of "${name}" could not be read to verify the staged content.`
4835
- };
4836
- }
4837
- function anchorRefusalFile(name, filePath, verdict) {
4838
- return {
4839
- ok: false,
4840
- stale: true,
4841
- anchor: verdict,
4842
- message: verdict === "missing" ? `File "${filePath}" not found in skill "${name}".` : `Support file "${filePath}" of "${name}" changed since it was read; the write was refused to avoid overwriting newer content.`
4843
- };
4844
- }
4845
- /** Upper bound of moves per restructure proposal (validator and core agree). */
4846
- const MAX_RESTRUCTURE_MOVES = 5;
4847
- /** Restructure targets are plain markdown files under references/ — no
4848
- * subdirectories, no other support kind. V8-10 (0.3.47): the regex-level
4849
- * `(?!.*\.\.)` keeps the restructure-created set EXACTLY the set
4850
- * validateSupportPath can reopen — a `references/my..notes.md` target (double
4851
- * dots) used to pass here while every later patch/write/remove on it was
4852
- * refused as traversal (an orphan file the user could not touch). */
4853
- const RESTRUCTURE_TARGET_RE = /^references\/[a-z0-9](?!.*\.\.)[a-z0-9._-]*\.md$/;
4854
- /** F-20 (v18): the character rule shared by support-file names and snapshot
4855
- * `extras/` entry names. The two exported names used to carry the same literal
4856
- * independently; both now derive from this one. */
4857
- const SUPPORT_ENTRY_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
4858
- /** Extra file name carried inside a snapshot's `extras/` directory. */
4859
- const SNAPSHOT_EXTRA_NAME_RE = SUPPORT_ENTRY_NAME_RE;
4860
- function skillsRoot(env = process.env) {
4861
- return join(evolutionRoot(env), "skills");
4862
- }
4863
- /** 0.3.18 (S4.1, E-30): the ONE root resolution for every member that reads
4864
- * the skills tree — tool-skill-manage / evolution-skill-catalog / skill-usage
4865
- * / evolution-learning-graph used to each resolve `config.root || skillsRoot()`
4866
- * (and the graph ignored config entirely). Empty/whitespace config falls
4867
- * through to the default; callers pass their raw Config. The optional field is
4868
- * declared `| undefined` so a config object whose root field is explicitly
4869
- * `string | undefined` still assignable under exactOptionalPropertyTypes. */
4870
- function resolveSkillsRoot(config = {}) {
4871
- return (config.root ?? "").trim() || skillsRoot();
4872
- }
4873
- /** E-7 (v18) → V27 G2.4 (M-08): every family row reads ONE root key. `root` is
4874
- * canonical; the `skillsRoot` alias was honoured for one minor version and its
4875
- * window closed at 0.3.65 — it is now two releases past expiry, so this
4876
- * resolver no longer reads it at all. A deployment that still sets the alias
4877
- * must fail LOUDLY at load (see {@link assertSkillsRootAliasRetired}): silently
4878
- * ignoring a config key leaves the deployment pointing at a root nobody reads,
4879
- * which is the worst form of compatibility.
4880
- * @param config - the raw plugin config.
4881
- * @returns the effective root (empty when the key is unset or blank).
4882
- */
4883
- function resolveRootConfig(config = {}) {
4884
- return { root: (config.root ?? "").trim() };
4885
- }
4886
- /** V27 G2.4 (M-08): the retirement gate for the expired `skillsRoot` alias.
4887
- * Called at each plugin's load boundary (before the root is resolved), it turns
4888
- * a stale key into an explicit load error naming the replacement — the
4889
- * fail-loud form the plan requires instead of a silent no-op.
4890
- * @param config - the raw plugin config (the alias field stays DECLARED in each
4891
- * schema so the loader can hand it here instead of dropping it).
4892
- */
4893
- function assertSkillsRootAliasRetired(config = {}) {
4894
- 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)");
4895
- }
4896
- /**
4897
- * Map a requesting session onto the two origin surfaces (rc.44 plan M2-2.3):
4898
- * the APPROVAL surface treats every delegated subagent as the autonomous
4899
- * review channel, while the LIBRARY surface keeps the Hermes distinction -
4900
- * the review fork is 'background_review' (the pinned guard blocks its
4901
- * writes) and any other subagent is 'subagent' (agent-authored, not
4902
- * review-channel). `isReview` marks the caller as the background review
4903
- * pipeline itself. Single source: the two tools and the review executor all
4904
- * read this table instead of re-deriving it.
4905
- */
4906
- function resolveOrigins(headerOrigin, isReview = false) {
4907
- if (isReview) return {
4908
- approval: "background_review",
4909
- library: "background_review"
4910
- };
4911
- if (headerOrigin === "subagent") return {
4912
- approval: "background_review",
4913
- library: "subagent"
4914
- };
4915
- return {
4916
- approval: "foreground",
4917
- library: "foreground"
4918
- };
4919
- }
4920
- /**
4921
- * S1-E8 (0.3.80): ONE exec→origins resolution for the two write tools — reads
4922
- * the session header origin AND the v37 S2.2 review-channel session mark, so a
4923
- * tool cannot forget the mark half (tool-memory shipped without it, which
4924
- * mislabeled every inject-mode review memory write as `foreground` and let it
4925
- * bypass staging under `stageForeground: false`).
4926
- * Single source: both tools call this instead of re-deriving the pair.
4927
- */
4928
- function resolveExecOrigins(exec) {
4929
- const session = exec?.agent?.session;
4930
- return resolveOrigins(session?.header?.origin, isReviewChannelSession(typeof session?.id === "string" ? session.id : void 0));
4931
- }
4932
- function skillDir(root, name) {
4933
- return join(root, name);
4934
- }
4935
- /** Dot-prefixed on-disk marker name. SINGLE source: `list()` matches directory
4936
- * entries against this name, and path builders must never hardcode a marker
4937
- * literal (N-1: the rc.49 exists()-probe convergence dropped the dot,
4938
- * poisoning every protectedBy/managed report). Exported for cross-package
4939
- * consumers that must probe markers without re-deriving the name (curator's
4940
- * archive-copy bundled probe, 0.3.26 V4-02). */
4941
- function markerEntryName(marker) {
4942
- return `.${marker}`;
4943
- }
4944
- /** F-17 (v18): the root-level lock files a DESTRUCTIVE MOVER must treat as an
4945
- * active writer (skill body + the two marker writers). Single source with
4946
- * `markerEntryName`/`LOCK_SUFFIX` so a renamed marker cannot silently drop out
4947
- * of the ghost-writer probe. */
4948
- const MARKER_LOCK_NAMES = [
4949
- `SKILL.md${LOCK_SUFFIX}`,
4950
- `.pinned${LOCK_SUFFIX}`,
4951
- `.hermes-managed${LOCK_SUFFIX}`
4952
- ];
4953
- /** v23 (ML-1): `.archive` retention window (see pruneExpiredArchives). */
4954
- const ARCHIVE_RETENTION_DAYS = 365;
4955
- function markerPath(dir, marker) {
4956
- return join(dir, markerEntryName(marker));
4957
- }
4958
4946
  /**
4959
4947
  * Shared frontmatter block detection (P3-3 single owner): opening line `---`
4960
4948
  * and closing line exactly `---`. Used by `parseFrontmatter`,
@@ -5041,10 +5029,12 @@ const PLATFORM_STRING_FIELDS = [
5041
5029
  "whenToUse"
5042
5030
  ];
5043
5031
  /**
5044
- * Frontmatter values as the STRICT platform catalog reads them — js-yaml, the
5045
- * parser `normalizeFrontmatter` also verifies rewrites with or `null` when
5046
- * the block is not loadable as a YAML mapping. Also reports the platform string
5047
- * fields whose value is not a string ({@link PlatformStringSplit}).
5032
+ * Frontmatter values as the STRICT platform catalog reads them — the `yaml`
5033
+ * package (YAML 1.2 core schema, the same dependency the platform's
5034
+ * skill-filesystem parses with see the import note above),
5035
+ * the parser `normalizeFrontmatter` also verifies rewrites with or `null`
5036
+ * when the block is not loadable as a YAML mapping. Also reports the platform
5037
+ * string fields whose value is not a string ({@link PlatformStringSplit}).
5048
5038
  *
5049
5039
  * Scalars publish their text (`name`, `description`, `whenToUse` are strings by
5050
5040
  * contract; a number/boolean-shaped value keeps the text the family always
@@ -5062,7 +5052,7 @@ function strictFrontmatterValues(block) {
5062
5052
  };
5063
5053
  let loaded;
5064
5054
  try {
5065
- loaded = load(block);
5055
+ loaded = parse(block);
5066
5056
  } catch {
5067
5057
  return null;
5068
5058
  }
@@ -5230,7 +5220,7 @@ function yamlPlainScalarNeedsQuotes(value) {
5230
5220
  if (/^[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?$/.test(value)) return true;
5231
5221
  if (/^0x[0-9a-f]+$/i.test(value)) return true;
5232
5222
  if (/^0o[0-7]+$/.test(value)) return true;
5233
- if (/^\.(?:inf|nan)$/i.test(value)) return true;
5223
+ if (/^(?:[-+]?\.inf|\.nan)$/i.test(value)) return true;
5234
5224
  if (/^[-?:,[\]{}#&*!|>'\"%@`\s]/.test(value)) return true;
5235
5225
  return false;
5236
5226
  }
@@ -5241,7 +5231,10 @@ function yamlPlainScalarNeedsQuotes(value) {
5241
5231
  * unescaped inside single-quoted YAML). Idempotent; only single-line
5242
5232
  * `key: value` entries are touched; body text is never modified; line-ending
5243
5233
  * style is preserved. **Every rewrite is re-verified with the real YAML
5244
- * parser** (js-yaml — the same parser the platform catalog uses): if the
5234
+ * parser** (`yaml` — the package the platform's skill-filesystem catalog parses
5235
+ * with, YAML 1.2 core schema; PLAN S2.1, 2026-09-16 — the former "js-yaml, the
5236
+ * same parser" claim was false: js-yaml speaks YAML 1.1 full and diverged on
5237
+ * dates, timestamps and 1.1 int forms): if the
5245
5238
  * rewritten block no longer parses, or a rewritten value's parsed content
5246
5239
  * differs from the original, the rewrite is rolled back and reported in
5247
5240
  * `issues` (fail-loud, never a silent value corruption — P3-4).
@@ -5304,7 +5297,7 @@ function normalizeFrontmatter(content) {
5304
5297
  };
5305
5298
  const rewrittenBlock = lines.slice(1, end).join("\n");
5306
5299
  try {
5307
- const parsed = load(rewrittenBlock);
5300
+ const parsed = parse(rewrittenBlock);
5308
5301
  for (const key of fields) if (String(parsed[key]) !== originalValues.get(key)) throw new Error(`rewritten value for ${key} differs from the original`);
5309
5302
  return {
5310
5303
  content: lines.join("\n"),
@@ -5341,65 +5334,391 @@ function relatedSkillNames(content, exclude) {
5341
5334
  }
5342
5335
  return [...names];
5343
5336
  }
5344
- /** S1.2 (v37 P2-1): the content limit applies to the bytes that LAND ON DISK.
5345
- * Every write normalizes with `trimEnd() + '\n'`, so judging the raw argument let
5346
- * a 100_000-character body with no trailing newline land as 100_001 bytes — and
5347
- * every later patch/update of that skill was then refused, which made it
5348
- * unmaintainable through `skill_manage` with no repair path at all. */
5349
- function skillMdOnDisk(content) {
5350
- return content.trimEnd() + "\n";
5337
+ /** S1.2 (v37 P2-1): the content limit applies to the bytes that LAND ON DISK.
5338
+ * Every write normalizes with `trimEnd() + '\n'`, so judging the raw argument let
5339
+ * a 100_000-character body with no trailing newline land as 100_001 bytes — and
5340
+ * every later patch/update of that skill was then refused, which made it
5341
+ * unmaintainable through `skill_manage` with no repair path at all. */
5342
+ function skillMdOnDisk(content) {
5343
+ return content.trimEnd() + "\n";
5344
+ }
5345
+ /** Whether `content` would exceed `limit` once written. */
5346
+ function exceedsContentLimit(content, limit) {
5347
+ return skillMdOnDisk(content).length > limit;
5348
+ }
5349
+ /** S1.2: the repair path — a write that makes an already-over-limit file smaller.
5350
+ * Only a NET SHRINK is exempt; an equal or larger write stays refused. */
5351
+ function shrinksOverLimit(next, current, limit) {
5352
+ if (current === null || current === void 0 || !exceedsContentLimit(current, limit)) return false;
5353
+ return skillMdOnDisk(next).length < skillMdOnDisk(current).length;
5354
+ }
5355
+ function validateFrontmatter(content, expectedName, limits = DEFAULT_SKILL_LIMITS, current) {
5356
+ const parsed = parseFrontmatter(content);
5357
+ 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.";
5358
+ const unreadable = parsed.platformStringSplit.filter((entry) => entry.kind === "sequence" || entry.kind === "mapping");
5359
+ if (unreadable.length > 0) return `Frontmatter field ${unreadable.map((entry) => `"${entry.key}" (a YAML ${entry.kind})`).join(", ")} must be a string: the platform skill catalog reads such a field as absent and ignores the whole file. Write plain text, or wrap the value in double quotes to keep it literally.`;
5360
+ if (!parsed.frontmatter.name) return "Frontmatter must include a name field.";
5361
+ if (!SKILL_NAME_RE.test(parsed.frontmatter.name)) return `Invalid skill name "${parsed.frontmatter.name}" — use lowercase letters, digits, and hyphens.`;
5362
+ if (parsed.frontmatter.name.length > limits.maxNameLength) return `Skill name exceeds ${limits.maxNameLength} characters.`;
5363
+ if (expectedName && parsed.frontmatter.name !== expectedName) return `Frontmatter name "${parsed.frontmatter.name}" does not match target skill "${expectedName}".`;
5364
+ if (!parsed.frontmatter.description) return "Frontmatter must include a description field.";
5365
+ if (parsed.frontmatter.description.length > limits.maxDescriptionLength) return `Description exceeds ${limits.maxDescriptionLength} characters.`;
5366
+ if (exceedsContentLimit(content, limits.maxSkillContentChars) && !shrinksOverLimit(content, current, limits.maxSkillContentChars)) return `SKILL.md content exceeds ${limits.maxSkillContentChars} characters. Consider splitting into a smaller SKILL.md with supporting files.`;
5367
+ return null;
5368
+ }
5369
+ /**
5370
+ * Advisory authoring feedback (P0): evaluate frontmatter against the
5371
+ * authoring bar WITHOUT changing platform validation semantics. The bar is
5372
+ * the quality target, `validateFrontmatter`'s limits are the compatibility
5373
+ * floor, and this bridge layer tells the model when its text would be
5374
+ * truncated or route-poor instead of silently shipping it.
5375
+ */
5376
+ function authoringFeedback(frontmatter) {
5377
+ const description = frontmatter.description ?? "";
5378
+ const over60 = description.length > 60;
5379
+ const hasColon = description.includes(":");
5380
+ const lines = [];
5381
+ lines.push(over60 ? `Description is ${description.length}/60 characters — exceeds the 60-char authoring bar (Hermes standard; the catalog truncates at the configured platform cap).` : `Description ${description.length}/60 characters — within the authoring bar.`);
5382
+ if (hasColon) lines.push("Description contains a colon — wrap the whole value in double quotes.");
5383
+ return {
5384
+ descriptionChars: description.length,
5385
+ over60,
5386
+ hasColon,
5387
+ lines
5388
+ };
5389
+ }
5390
+ /** A1-15 (v18) / P2-2 (v19): the io layer marks an error `committed: true` when
5391
+ * the rename landed and only the directory fsync failed. Every single-file
5392
+ * writer must treat that as "written, durability unconfirmed" — never as a
5393
+ * plain failure (which a caller would retry, or a two-phase caller roll back).
5394
+ * v28 G2.1 (EVO-IO-05): this is a delegation to the seam's own
5395
+ * `isCommittedWarning` — the marker predicate has exactly one definition. */
5396
+ //#endregion
5397
+ //#region lib/types/fuzzy-match.js
5398
+ /**
5399
+ * Fuzzy string matching and replacement for patch/restructure edits of skill files.
5400
+ *
5401
+ * Split out of skill-store.ts (S2-1): pure text functions with no store state.
5402
+ * The store imports the scan, the budgets and the replace entry points directly;
5403
+ * none of them is re-exported, so the package export surface is unchanged.
5404
+ */
5405
+ function fuzzyIndexOf(content, pattern, from = 0) {
5406
+ const isSpace = (char) => char !== void 0 && /[ \t]/.test(char);
5407
+ const escaped = (char) => {
5408
+ if (char === "n") return "\n";
5409
+ if (char === "t") return " ";
5410
+ if (char === "r") return "\r";
5411
+ return null;
5412
+ };
5413
+ for (let start = from; start < content.length; start += 1) {
5414
+ let contentIndex = start;
5415
+ let patternIndex = 0;
5416
+ while (patternIndex < pattern.length && contentIndex < content.length) {
5417
+ const patternChar = pattern[patternIndex];
5418
+ const contentChar = content[contentIndex];
5419
+ if (isSpace(patternChar)) {
5420
+ while (patternIndex < pattern.length && isSpace(pattern[patternIndex])) patternIndex += 1;
5421
+ while (contentIndex < content.length && isSpace(content[contentIndex])) contentIndex += 1;
5422
+ continue;
5423
+ }
5424
+ const escapedChar = patternChar === "\\" ? escaped(pattern[patternIndex + 1]) : null;
5425
+ if (escapedChar !== null && contentChar === escapedChar) {
5426
+ patternIndex += 2;
5427
+ contentIndex += 1;
5428
+ continue;
5429
+ }
5430
+ if (patternChar === contentChar) {
5431
+ contentIndex += 1;
5432
+ patternIndex += 1;
5433
+ continue;
5434
+ }
5435
+ break;
5436
+ }
5437
+ if (patternIndex === pattern.length) return [start, contentIndex];
5438
+ }
5439
+ return null;
5440
+ }
5441
+ /** Trim leading whitespace of the first line and trailing whitespace of the last line. */
5442
+ function trimPatternBoundaries(pattern) {
5443
+ const from = pattern.search(/\S/);
5444
+ const trimmed = from < 0 ? pattern : pattern.slice(from);
5445
+ const trailing = trimmed.search(/\s+$/);
5446
+ return trailing < 0 ? trimmed : trimmed.slice(0, trailing);
5447
+ }
5448
+ /** Replace only the fuzzy-matched span, preserving all surrounding bytes.
5449
+ * V7-11 (0.3.44): the replaceAll loop accumulates the per-scan cost — the
5450
+ * single-scan budget at the caller only bounded ONE fuzzyIndexOf, while an
5451
+ * unbounded number of matches × O(n·m) each could still stall the loop.
5452
+ * When the accumulated cost exceeds the budget the whole replace fails
5453
+ * (null) instead of partially applying an arbitrary prefix. */
5454
+ function fuzzyReplace(content, oldString, newString, replaceAll) {
5455
+ let current = content;
5456
+ let scanFrom = 0;
5457
+ let totalWork = 0;
5458
+ for (;;) {
5459
+ totalWork += current.length * oldString.length;
5460
+ if (totalWork > 8e6) return null;
5461
+ const match = fuzzyIndexOf(current, oldString, scanFrom);
5462
+ if (match === null) return current;
5463
+ const [start, end] = match;
5464
+ const next = current.slice(0, start) + newString + current.slice(end);
5465
+ if (!replaceAll) return next;
5466
+ current = next;
5467
+ scanFrom = start + newString.length;
5468
+ }
5469
+ }
5470
+ function fuzzyPatch(content, oldString, newString, replaceAll = false) {
5471
+ if (oldString === "") return null;
5472
+ if (content.includes(oldString)) return replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, () => newString);
5473
+ const boundary = trimPatternBoundaries(oldString);
5474
+ if (boundary === "") return null;
5475
+ if (boundary !== oldString) {
5476
+ if (fuzzyIndexOf(content, boundary) !== null) return fuzzyReplace(content, boundary, newString, replaceAll);
5477
+ }
5478
+ if (fuzzyIndexOf(content, oldString) !== null) return fuzzyReplace(content, oldString, newString, replaceAll);
5479
+ return null;
5480
+ }
5481
+ /** Deterministic section-extraction plan facts; the caller owns the IO and the append semantics. */
5482
+ //#endregion
5483
+ //#region lib/types/skill-store.js
5484
+ /**
5485
+ * Skill library management for the self-evolution plugin.
5486
+ *
5487
+ * Skills live under `$DSH_HOME/skills` (`~/.dsh/skills` by default), matching
5488
+ * the default dsh skill-filesystem user root. The plugin only manages skills
5489
+ * it created unless a `.hermes-managed` marker opts a skill in. Archival is a
5490
+ * move to `.archive/` — never a hard delete.
5491
+ *
5492
+ * ## Concurrency discipline (OPT-09, 2026-09) — read before adding a mutator
5493
+ *
5494
+ * Three primitives, three distinct jobs (they compose, they do not replace
5495
+ * each other):
5496
+ *
5497
+ * 1. **In-process serial queue** (`this.serial`, makeSerialQueue) — orders the
5498
+ * read→plan→commit phases of one skill's mutation against OTHER mutators
5499
+ * in this process. Used by: create/update/patch/setPinned/restructure/
5500
+ * writeSupportFile/removeSupportFile and (whole-mutation) consolidate.
5501
+ * NON-reentrant: a callback must never call a public method that wraps
5502
+ * itself in `this.serial` (archive/restoreFromArchive deliberately do not).
5503
+ * 2. **Per-directory write lock** (io.ts LOCK_*) — cross-process mutual
5504
+ * exclusion plus in-process crash ownership (tickets, takeover). Checked
5505
+ * with `hasWriteLock` before any destructive move (archive/restore/
5506
+ * snapshot); held inside transactIo by byte writers.
5507
+ * 3. **CAS baseline (`expected:`)** — any read whose bytes feed a later write
5508
+ * must either live inside the serial section that commits the write, or
5509
+ * carry its plan-time bytes as `expected` so the commit fails closed on
5510
+ * drift (V8-11 / V24-01). A read outside the serial section WITHOUT a
5511
+ * baseline is a lost-update bug; this file's history is the test suite.
5512
+ *
5513
+ * Known residuals (deliberate, documented at their sites): the archive commit
5514
+ * re-check narrows but does not close the pin race (OPT-06); snapshotAll
5515
+ * re-probes after its copies so a mid-copy writer demotes to `skipped`
5516
+ * (OPT-07); the movers' probe→rename window is owned by the io.ts protocol.
5517
+ */
5518
+ /** 0.3.16 (S1.13, T-6): the pointer-line prefix written into a body when a
5519
+ * section is moved to references/ — single literal, both restructure and
5520
+ * append-mode consolidation emit the same discoverability line. */
5521
+ const POINTER_LINE_PREFIX = "> 详见 references/";
5522
+ /**
5523
+ * Evaluate a stage-time anchor against the bytes a locked read observed.
5524
+ * @param anchor - the caller's anchor, or `undefined` for an unanchored write.
5525
+ * @param current - the bytes the write lock read (`null` = the target is absent).
5526
+ * @returns `match` when the write may proceed, otherwise the refusal verdict.
5527
+ */
5528
+ function anchorVerdict(anchor, current) {
5529
+ if (anchor === void 0) return "match";
5530
+ if ("absent" in anchor) return current === null ? "match" : "drift";
5531
+ if (current === null) return "missing";
5532
+ return contentHash(current) === anchor.sha256 ? "match" : "drift";
5533
+ }
5534
+ /**
5535
+ * Build the refusal for a skill write whose anchor did not hold. The wording is
5536
+ * the library's own; a caller with staged-replay wording (the skill tool, the
5537
+ * review plan) re-words it from {@link SkillActionResult.anchor}.
5538
+ * @param name - the skill name the refusal names.
5539
+ * @param verdict - the non-matching verdict.
5540
+ * @returns the refusal result (nothing was written).
5541
+ */
5542
+ function anchorRefusal(name, verdict) {
5543
+ return {
5544
+ ok: false,
5545
+ stale: true,
5546
+ anchor: verdict,
5547
+ message: verdict === "missing" ? `Skill "${name}" not found.` : `Skill "${name}" changed since it was read; the write was refused to avoid overwriting newer content.`
5548
+ };
5549
+ }
5550
+ /**
5551
+ * Build the refusal for a support-file write/remove whose anchor did not hold.
5552
+ * @param name - the owning skill name.
5553
+ * @param filePath - the support-file path inside the skill.
5554
+ * @param verdict - the non-matching verdict.
5555
+ * @returns the refusal result (nothing was written or removed).
5556
+ */
5557
+ /**
5558
+ * Refusal for a target the locked read could not verify at all (EISDIR, an
5559
+ * unreadable file). A staged replay reports "could not be verified" instead of
5560
+ * propagating an exception: nothing was read, so nothing can have been written.
5561
+ * @param name - the owning skill name.
5562
+ * @param filePath - the support-file path, or `null` for the skill body.
5563
+ * @returns the refusal result.
5564
+ */
5565
+ function anchorUnverifiable(name, filePath) {
5566
+ return {
5567
+ ok: false,
5568
+ stale: true,
5569
+ anchor: "drift",
5570
+ message: filePath === null ? `Skill "${name}" could not be read to verify the staged content.` : `Support file "${filePath}" of "${name}" could not be read to verify the staged content.`
5571
+ };
5572
+ }
5573
+ function anchorRefusalFile(name, filePath, verdict) {
5574
+ return {
5575
+ ok: false,
5576
+ stale: true,
5577
+ anchor: verdict,
5578
+ message: verdict === "missing" ? `File "${filePath}" not found in skill "${name}".` : `Support file "${filePath}" of "${name}" changed since it was read; the write was refused to avoid overwriting newer content.`
5579
+ };
5580
+ }
5581
+ /**
5582
+ * v43 S2-14 (FLOW2-1/flow-3): the target could not be READ at all — EISDIR, an
5583
+ * unreadable file, a failing backend. `io.readText` returns null only for a
5584
+ * genuinely missing path and throws for everything else, so a raw errno used to
5585
+ * escape `update` / `patch` / `write_file` to the model while the remove path had
5586
+ * classified the same condition since A-5 (v15). Structured refusal, nothing
5587
+ * written.
5588
+ *
5589
+ * @param label - `name` or `name/filePath`, as the caller's surface names it.
5590
+ * @param error - the read failure.
5591
+ * @returns the refusal result.
5592
+ */
5593
+ function unreadableTarget(label, error) {
5594
+ return {
5595
+ ok: false,
5596
+ message: `Could not read "${label}": ${error?.code === "EISDIR" ? "the path is a DIRECTORY, not a file" : error instanceof Error ? error.message : String(error)}. Nothing was written.`
5597
+ };
5598
+ }
5599
+ /** Upper bound of moves per restructure proposal (validator and core agree). */
5600
+ const MAX_RESTRUCTURE_MOVES = 5;
5601
+ /** Restructure targets are plain markdown files under references/ — no
5602
+ * subdirectories, no other support kind. V8-10 (0.3.47): the regex-level
5603
+ * `(?!.*\.\.)` keeps the restructure-created set EXACTLY the set
5604
+ * validateSupportPath can reopen — a `references/my..notes.md` target (double
5605
+ * dots) used to pass here while every later patch/write/remove on it was
5606
+ * refused as traversal (an orphan file the user could not touch). */
5607
+ const RESTRUCTURE_TARGET_RE = /^references\/[a-z0-9](?!.*\.\.)[a-z0-9._-]*\.md$/;
5608
+ /** F-20 (v18): the character rule shared by support-file names and snapshot
5609
+ * `extras/` entry names. The two exported names used to carry the same literal
5610
+ * independently; both now derive from this one. */
5611
+ const SUPPORT_ENTRY_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
5612
+ /** Extra file name carried inside a snapshot's `extras/` directory. */
5613
+ const SNAPSHOT_EXTRA_NAME_RE = SUPPORT_ENTRY_NAME_RE;
5614
+ function skillsRoot(env = process.env) {
5615
+ return join(evolutionRoot(env), "skills");
5351
5616
  }
5352
- /** Whether `content` would exceed `limit` once written. */
5353
- function exceedsContentLimit(content, limit) {
5354
- return skillMdOnDisk(content).length > limit;
5617
+ /** 0.3.18 (S4.1, E-30): the ONE root resolution for every member that reads
5618
+ * the skills tree — tool-skill-manage / evolution-skill-catalog / skill-usage
5619
+ * / evolution-learning-graph used to each resolve `config.root || skillsRoot()`
5620
+ * (and the graph ignored config entirely). Empty/whitespace config falls
5621
+ * through to the default; callers pass their raw Config. The optional field is
5622
+ * declared `| undefined` so a config object whose root field is explicitly
5623
+ * `string | undefined` still assignable under exactOptionalPropertyTypes.
5624
+ * P2-31 core half (S2.2 batch, PLAN 2026-09-16): an explicit non-empty root
5625
+ * is `resolve()`d — the same normalization `evolutionRoot` applies and the
5626
+ * same one upstream skill-filesystem applies to `customSkillDirs`
5627
+ * (`(config.customSkillDirs ?? []).map(root => resolve(root))`). The former
5628
+ * verbatim return left a RELATIVE config root CWD-relative, so the family's
5629
+ * skill tree moved with the host process's launch directory while every
5630
+ * absolute consumer (watchers, the platform catalog) resolved it — a
5631
+ * split-brain tree; a trailing slash or `..` segment likewise landed
5632
+ * unnormalized. A clean absolute root is byte-identical (resolve is a no-op). */
5633
+ function resolveSkillsRoot(config = {}) {
5634
+ const explicit = (config.root ?? "").trim();
5635
+ return explicit ? resolve(explicit) : skillsRoot();
5355
5636
  }
5356
- /** S1.2: the repair path a write that makes an already-over-limit file smaller.
5357
- * Only a NET SHRINK is exempt; an equal or larger write stays refused. */
5358
- function shrinksOverLimit(next, current, limit) {
5359
- if (current === null || current === void 0 || !exceedsContentLimit(current, limit)) return false;
5360
- return skillMdOnDisk(next).length < skillMdOnDisk(current).length;
5637
+ /** E-7 (v18) V27 G2.4 (M-08): every family row reads ONE root key. `root` is
5638
+ * canonical; the `skillsRoot` alias was honoured for one minor version and its
5639
+ * window closed at 0.3.65 — it is now two releases past expiry, so this
5640
+ * resolver no longer reads it at all. A deployment that still sets the alias
5641
+ * must fail LOUDLY at load (see {@link assertSkillsRootAliasRetired}): silently
5642
+ * ignoring a config key leaves the deployment pointing at a root nobody reads,
5643
+ * which is the worst form of compatibility.
5644
+ * @param config - the raw plugin config.
5645
+ * @returns the effective root (empty when the key is unset or blank).
5646
+ */
5647
+ function resolveRootConfig(config = {}) {
5648
+ return { root: (config.root ?? "").trim() };
5361
5649
  }
5362
- function validateFrontmatter(content, expectedName, limits = DEFAULT_SKILL_LIMITS, current) {
5363
- const parsed = parseFrontmatter(content);
5364
- 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.";
5365
- const unreadable = parsed.platformStringSplit.filter((entry) => entry.kind === "sequence" || entry.kind === "mapping");
5366
- if (unreadable.length > 0) return `Frontmatter field ${unreadable.map((entry) => `"${entry.key}" (a YAML ${entry.kind})`).join(", ")} must be a string: the platform skill catalog reads such a field as absent and ignores the whole file. Write plain text, or wrap the value in double quotes to keep it literally.`;
5367
- if (!parsed.frontmatter.name) return "Frontmatter must include a name field.";
5368
- if (!SKILL_NAME_RE.test(parsed.frontmatter.name)) return `Invalid skill name "${parsed.frontmatter.name}" — use lowercase letters, digits, and hyphens.`;
5369
- if (parsed.frontmatter.name.length > limits.maxNameLength) return `Skill name exceeds ${limits.maxNameLength} characters.`;
5370
- if (expectedName && parsed.frontmatter.name !== expectedName) return `Frontmatter name "${parsed.frontmatter.name}" does not match target skill "${expectedName}".`;
5371
- if (!parsed.frontmatter.description) return "Frontmatter must include a description field.";
5372
- if (parsed.frontmatter.description.length > limits.maxDescriptionLength) return `Description exceeds ${limits.maxDescriptionLength} characters.`;
5373
- if (exceedsContentLimit(content, limits.maxSkillContentChars) && !shrinksOverLimit(content, current, limits.maxSkillContentChars)) return `SKILL.md content exceeds ${limits.maxSkillContentChars} characters. Consider splitting into a smaller SKILL.md with supporting files.`;
5374
- return null;
5650
+ /** V27 G2.4 (M-08): the retirement gate for the expired `skillsRoot` alias.
5651
+ * Called at each plugin's load boundary (before the root is resolved), it turns
5652
+ * a stale key into an explicit load error naming the replacement the
5653
+ * fail-loud form the plan requires instead of a silent no-op.
5654
+ * @param config - the raw plugin config (the alias field stays DECLARED in each
5655
+ * schema so the loader can hand it here instead of dropping it).
5656
+ */
5657
+ function assertSkillsRootAliasRetired(config = {}) {
5658
+ 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)");
5375
5659
  }
5376
5660
  /**
5377
- * Advisory authoring feedback (P0): evaluate frontmatter against the
5378
- * authoring bar WITHOUT changing platform validation semantics. The bar is
5379
- * the quality target, `validateFrontmatter`'s limits are the compatibility
5380
- * floor, and this bridge layer tells the model when its text would be
5381
- * truncated or route-poor instead of silently shipping it.
5661
+ * Map a requesting session onto the two origin surfaces (rc.44 plan M2-2.3):
5662
+ * the APPROVAL surface treats every delegated subagent as the autonomous
5663
+ * review channel, while the LIBRARY surface keeps the Hermes distinction -
5664
+ * the review fork is 'background_review' (the pinned guard blocks its
5665
+ * writes) and any other subagent is 'subagent' (agent-authored, not
5666
+ * review-channel). `isReview` marks the caller as the background review
5667
+ * pipeline itself. Single source: the two tools and the review executor all
5668
+ * read this table instead of re-deriving it.
5382
5669
  */
5383
- function authoringFeedback(frontmatter) {
5384
- const description = frontmatter.description ?? "";
5385
- const over60 = description.length > 60;
5386
- const hasColon = description.includes(":");
5387
- const lines = [];
5388
- lines.push(over60 ? `Description is ${description.length}/60 characters — exceeds the 60-char authoring bar (Hermes standard; the catalog truncates at the configured platform cap).` : `Description ${description.length}/60 characters — within the authoring bar.`);
5389
- if (hasColon) lines.push("Description contains a colon — wrap the whole value in double quotes.");
5670
+ function resolveOrigins(headerOrigin, isReview = false) {
5671
+ if (isReview) return {
5672
+ approval: "background_review",
5673
+ library: "background_review"
5674
+ };
5675
+ if (headerOrigin === "subagent") return {
5676
+ approval: "background_review",
5677
+ library: "subagent"
5678
+ };
5390
5679
  return {
5391
- descriptionChars: description.length,
5392
- over60,
5393
- hasColon,
5394
- lines
5680
+ approval: "foreground",
5681
+ library: "foreground"
5395
5682
  };
5396
5683
  }
5397
- /** A1-15 (v18) / P2-2 (v19): the io layer marks an error `committed: true` when
5398
- * the rename landed and only the directory fsync failed. Every single-file
5399
- * writer must treat that as "written, durability unconfirmed" never as a
5400
- * plain failure (which a caller would retry, or a two-phase caller roll back).
5401
- * v28 G2.1 (EVO-IO-05): this is a delegation to the seam's own
5402
- * `isCommittedWarning` the marker predicate has exactly one definition. */
5684
+ /**
5685
+ * S1-E8 (0.3.80): ONE exec→origins resolution for the two write tools reads
5686
+ * the session header origin AND the v37 S2.2 review-channel session mark, so a
5687
+ * tool cannot forget the mark half (tool-memory shipped without it, which
5688
+ * mislabeled every inject-mode review memory write as `foreground` and let it
5689
+ * bypass staging under `stageForeground: false`).
5690
+ * Single source: both tools call this instead of re-deriving the pair.
5691
+ */
5692
+ function resolveExecOrigins(exec) {
5693
+ const session = exec?.agent?.session;
5694
+ return resolveOrigins(session?.header?.origin, isReviewChannelSession(typeof session?.id === "string" ? session.id : void 0));
5695
+ }
5696
+ function skillDir(root, name) {
5697
+ return join(root, name);
5698
+ }
5699
+ /** Dot-prefixed on-disk marker name. SINGLE source: `list()` matches directory
5700
+ * entries against this name, and path builders must never hardcode a marker
5701
+ * literal (N-1: the rc.49 exists()-probe convergence dropped the dot,
5702
+ * poisoning every protectedBy/managed report). Exported for cross-package
5703
+ * consumers that must probe markers without re-deriving the name (curator's
5704
+ * archive-copy bundled probe, 0.3.26 V4-02). */
5705
+ function markerEntryName(marker) {
5706
+ return `.${marker}`;
5707
+ }
5708
+ /** F-17 (v18): the root-level lock files a DESTRUCTIVE MOVER must treat as an
5709
+ * active writer (skill body + the two marker writers). Single source with
5710
+ * `markerEntryName`/`LOCK_SUFFIX` so a renamed marker cannot silently drop out
5711
+ * of the ghost-writer probe. */
5712
+ const MARKER_LOCK_NAMES = [
5713
+ `SKILL.md${LOCK_SUFFIX}`,
5714
+ `.pinned${LOCK_SUFFIX}`,
5715
+ `.hermes-managed${LOCK_SUFFIX}`
5716
+ ];
5717
+ /** v23 (ML-1): `.archive` retention window (see pruneExpiredArchives). */
5718
+ const ARCHIVE_RETENTION_DAYS = 365;
5719
+ function markerPath(dir, marker) {
5720
+ return join(dir, markerEntryName(marker));
5721
+ }
5403
5722
  function isCommittedOnly(error) {
5404
5723
  return isCommittedWarning(error);
5405
5724
  }
@@ -5481,99 +5800,6 @@ function validateSupportPath(filePath) {
5481
5800
  return null;
5482
5801
  }
5483
5802
  /**
5484
- * Index of `pattern` inside `content`, treating whitespace runs (spaces/tabs)
5485
- * as flexible and literal escape sequences (`\n`, `\t`, `\r`) as their real
5486
- * characters: a PATTERN whitespace run matches any content run of any length
5487
- * (even empty), while extra whitespace that only exists in the content is not
5488
- * skipped — the flexibility is one-sided on the pattern, and a backslash-
5489
- * escaped char in the pattern matches the real char in the content
5490
- * (model-copy drift). Returns the [start, end) range in the ORIGINAL content
5491
- * so a patch can replace exactly the matched span and keep every other byte
5492
- * intact. Returns null when no fuzzy match exists.
5493
- */
5494
- function fuzzyIndexOf(content, pattern, from = 0) {
5495
- const isSpace = (char) => char !== void 0 && /[ \t]/.test(char);
5496
- const escaped = (char) => {
5497
- if (char === "n") return "\n";
5498
- if (char === "t") return " ";
5499
- if (char === "r") return "\r";
5500
- return null;
5501
- };
5502
- for (let start = from; start < content.length; start += 1) {
5503
- let contentIndex = start;
5504
- let patternIndex = 0;
5505
- while (patternIndex < pattern.length && contentIndex < content.length) {
5506
- const patternChar = pattern[patternIndex];
5507
- const contentChar = content[contentIndex];
5508
- if (isSpace(patternChar)) {
5509
- while (patternIndex < pattern.length && isSpace(pattern[patternIndex])) patternIndex += 1;
5510
- while (contentIndex < content.length && isSpace(content[contentIndex])) contentIndex += 1;
5511
- continue;
5512
- }
5513
- const escapedChar = patternChar === "\\" ? escaped(pattern[patternIndex + 1]) : null;
5514
- if (escapedChar !== null && contentChar === escapedChar) {
5515
- patternIndex += 2;
5516
- contentIndex += 1;
5517
- continue;
5518
- }
5519
- if (patternChar === contentChar) {
5520
- contentIndex += 1;
5521
- patternIndex += 1;
5522
- continue;
5523
- }
5524
- break;
5525
- }
5526
- if (patternIndex === pattern.length) return [start, contentIndex];
5527
- }
5528
- return null;
5529
- }
5530
- /** V6-17 (0.3.37): the fuzzy-patch scan is O(n·m) with no input bound; a
5531
- * non-exact anchor past these budgets would block the event loop (measured
5532
- * ~6s at 20k×20k). Exact matches go through the fast `includes` path and stay
5533
- * allowed regardless of size. */
5534
- const FUZZY_MAX_PATTERN_CHARS = 4096;
5535
- const FUZZY_MAX_WORK = 8e6;
5536
- /** Trim leading whitespace of the first line and trailing whitespace of the last line. */
5537
- function trimPatternBoundaries(pattern) {
5538
- const from = pattern.search(/\S/);
5539
- const trimmed = from < 0 ? pattern : pattern.slice(from);
5540
- const trailing = trimmed.search(/\s+$/);
5541
- return trailing < 0 ? trimmed : trimmed.slice(0, trailing);
5542
- }
5543
- /** Replace only the fuzzy-matched span, preserving all surrounding bytes.
5544
- * V7-11 (0.3.44): the replaceAll loop accumulates the per-scan cost — the
5545
- * single-scan budget at the caller only bounded ONE fuzzyIndexOf, while an
5546
- * unbounded number of matches × O(n·m) each could still stall the loop.
5547
- * When the accumulated cost exceeds the budget the whole replace fails
5548
- * (null) instead of partially applying an arbitrary prefix. */
5549
- function fuzzyReplace(content, oldString, newString, replaceAll) {
5550
- let current = content;
5551
- let scanFrom = 0;
5552
- let totalWork = 0;
5553
- for (;;) {
5554
- totalWork += current.length * oldString.length;
5555
- if (totalWork > FUZZY_MAX_WORK) return null;
5556
- const match = fuzzyIndexOf(current, oldString, scanFrom);
5557
- if (match === null) return current;
5558
- const [start, end] = match;
5559
- const next = current.slice(0, start) + newString + current.slice(end);
5560
- if (!replaceAll) return next;
5561
- current = next;
5562
- scanFrom = start + newString.length;
5563
- }
5564
- }
5565
- function fuzzyPatch(content, oldString, newString, replaceAll = false) {
5566
- if (oldString === "") return null;
5567
- if (content.includes(oldString)) return replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, () => newString);
5568
- const boundary = trimPatternBoundaries(oldString);
5569
- if (boundary === "") return null;
5570
- if (boundary !== oldString) {
5571
- if (fuzzyIndexOf(content, boundary) !== null) return fuzzyReplace(content, boundary, newString, replaceAll);
5572
- }
5573
- if (fuzzyIndexOf(content, oldString) !== null) return fuzzyReplace(content, oldString, newString, replaceAll);
5574
- return null;
5575
- }
5576
- /**
5577
5803
  * Support-directory references in a markdown body (009 kernel): `references/…`,
5578
5804
  * `templates/…`, `scripts/…`, `assets/…` relative links — any extension and
5579
5805
  * nested paths (v7 audit P3-1: `.md`-only matching missed `scripts/run.sh` and
@@ -5692,7 +5918,7 @@ var SkillLibrary = class {
5692
5918
  if (this.transact) try {
5693
5919
  await this.transact(this.io, path, run);
5694
5920
  } catch (error) {
5695
- if (!progress.entered && readFailure !== void 0) return readFailure;
5921
+ if (!progress.entered && readFailure !== void 0) return readFailure(error);
5696
5922
  if (!committedOnly(error)) throw error;
5697
5923
  durabilityWarning = error instanceof Error ? error.message : String(error);
5698
5924
  }
@@ -5701,7 +5927,7 @@ var SkillLibrary = class {
5701
5927
  try {
5702
5928
  current = await this.io.readText(path);
5703
5929
  } catch (error) {
5704
- if (readFailure !== void 0) return readFailure;
5930
+ if (readFailure !== void 0) return readFailure(error);
5705
5931
  throw error;
5706
5932
  }
5707
5933
  const next = await run(current);
@@ -6104,7 +6330,17 @@ var SkillLibrary = class {
6104
6330
  ok: false,
6105
6331
  message: `Skill "${normalized}" already exists.`
6106
6332
  };
6107
- for (const entry of await this.io.list(this.root).catch(() => [])) if (typeof entry === "string" && entry !== normalized && entry.toLowerCase() === normalized.toLowerCase()) return {
6333
+ let rootEntries;
6334
+ try {
6335
+ rootEntries = await this.io.list(this.root);
6336
+ } catch (error) {
6337
+ const cause = error instanceof Error ? error.message : String(error);
6338
+ return {
6339
+ ok: false,
6340
+ message: `Skill "${normalized}" could not be checked for a case-variant collision: listing ${this.root} failed (${cause}). Refusing the create — a second directory differing only in case cannot be undone across platforms.`
6341
+ };
6342
+ }
6343
+ for (const entry of rootEntries) if (typeof entry === "string" && entry !== normalized && entry.toLowerCase() === normalized.toLowerCase()) return {
6108
6344
  ok: false,
6109
6345
  message: `Skill "${normalized}" collides with the existing case-variant directory "${entry}" (skill names are lowercase-only); rename one of them.`
6110
6346
  };
@@ -6120,6 +6356,7 @@ var SkillLibrary = class {
6120
6356
  let createDurabilityWarning = "";
6121
6357
  if (this.transact) try {
6122
6358
  await this.transact(this.io, createPath, (current) => {
6359
+ existsAtCommit = false;
6123
6360
  taskRan = true;
6124
6361
  if (current !== null) {
6125
6362
  existsAtCommit = true;
@@ -6267,7 +6504,7 @@ var SkillLibrary = class {
6267
6504
  skillDir: dir
6268
6505
  }
6269
6506
  };
6270
- }, anchor !== void 0 ? anchorUnverifiable(name, null) : void 0);
6507
+ }, anchor !== void 0 ? () => anchorUnverifiable(name, null) : (error) => unreadableTarget(name, error));
6271
6508
  }
6272
6509
  async patch(rawName, oldString, newString, filePath = "", replaceAll = false, origin = "foreground") {
6273
6510
  const name = rawName.trim();
@@ -6310,7 +6547,7 @@ var SkillLibrary = class {
6310
6547
  },
6311
6548
  write: null
6312
6549
  };
6313
- if (!md.includes(oldString) && (oldString.length > FUZZY_MAX_PATTERN_CHARS || md.length * oldString.length > FUZZY_MAX_WORK)) return {
6550
+ if (!md.includes(oldString) && (oldString.length > 4096 || md.length * oldString.length > 8e6)) return {
6314
6551
  result: {
6315
6552
  ok: false,
6316
6553
  message: `old_string too large for fuzzy match (${oldString.length} chars in ${patchLabel}); use update for a full rewrite or a narrower anchor.`
@@ -6421,7 +6658,7 @@ var SkillLibrary = class {
6421
6658
  skillDir: dir
6422
6659
  }
6423
6660
  };
6424
- });
6661
+ }, (error) => unreadableTarget(name + "/" + patchLabel, error));
6425
6662
  }
6426
6663
  /**
6427
6664
  * P2-9 (v15): the destructive directory move shared by archive and
@@ -7009,6 +7246,7 @@ var SkillLibrary = class {
7009
7246
  const drift = { seen: false };
7010
7247
  const ran = { done: false };
7011
7248
  await this.transact(this.io, entry.target, (current) => {
7249
+ drift.seen = false;
7012
7250
  ran.done = true;
7013
7251
  if (current !== baseline) {
7014
7252
  drift.seen = true;
@@ -7223,7 +7461,7 @@ var SkillLibrary = class {
7223
7461
  file: target
7224
7462
  }
7225
7463
  };
7226
- }, anchor !== void 0 ? anchorUnverifiable(name, filePath) : void 0);
7464
+ }, anchor !== void 0 ? () => anchorUnverifiable(name, filePath) : (error) => unreadableTarget(name + "/" + filePath, error));
7227
7465
  }
7228
7466
  async removeSupportFile(rawName, filePath, origin = "foreground", anchor) {
7229
7467
  const name = rawName.trim();
@@ -7651,6 +7889,35 @@ function newSkillLibrary(options) {
7651
7889
  */
7652
7890
  const FAMILY_SESSION_TOOL_NAMES = ["skill_manage", "memory"];
7653
7891
  /**
7892
+ * S0-4 (v43 J-1 / G-1): the process-wide witness behind the deployment
7893
+ * diagnostic. A scoped false is the CORRECT answer for a session that did not
7894
+ * opt in, so the miss alone proves nothing — "no session in this process ever
7895
+ * matched" is the shape both real faults share, and only a witness that outlives
7896
+ * one session can tell it from a healthy per-session skip.
7897
+ *
7898
+ * Module scope on purpose: the claim spans every session and every row of this
7899
+ * process, so no single row's fiber owns it. Monotone scalars with no per-key
7900
+ * lifecycle — the N12 registry covers module-scope Set/Map/WeakMap stores, which
7901
+ * carry entries that do need one.
7902
+ */
7903
+ let scopedProbeHits = 0;
7904
+ let scopedProbeMisses = 0;
7905
+ let scopedProbeWarned = false;
7906
+ /**
7907
+ * Read the witness for a diagnostic surface (`/evolution doctor`). Read-only: it
7908
+ * neither evaluates the probe nor consumes the one-time warn, so a report run
7909
+ * cannot change what the next miss would have logged.
7910
+ * @returns the verdict with both counts, zeroed in a process where the gate has
7911
+ * not run.
7912
+ */
7913
+ function scopedProbeReport() {
7914
+ return {
7915
+ verdict: scopedProbeHits > 0 ? "hit" : scopedProbeMisses > 0 ? "never-hit" : "idle",
7916
+ hits: scopedProbeHits,
7917
+ misses: scopedProbeMisses
7918
+ };
7919
+ }
7920
+ /**
7654
7921
  * Does this session's scope see the family's model tools?
7655
7922
  *
7656
7923
  * The scope is the live agent's — the platform's own addressing for "what does
@@ -7671,6 +7938,23 @@ function sessionSeesFamilyTools(ctx, sessionId) {
7671
7938
  return FAMILY_SESSION_TOOL_NAMES.some((name) => tools.get(name, scope) !== void 0);
7672
7939
  }
7673
7940
  /**
7941
+ * S0-4 (v43 J-1 / G-1): record a scoped rejection and leave the one-time
7942
+ * deployment diagnostic.
7943
+ *
7944
+ * Fires at most once per process, on the first miss, and only while nothing has
7945
+ * ever matched. The message names the check rather than guessing the deployment
7946
+ * form: a host-only install and a disabled model row are the two shapes that can
7947
+ * never match, while the layered variant form legitimately waits for a session
7948
+ * that selected the Evolution preset.
7949
+ * @param ctx - the asking row's context; the warn rides that row's own logger.
7950
+ */
7951
+ function noteScopedProbeMiss(ctx) {
7952
+ scopedProbeMisses += 1;
7953
+ if (scopedProbeHits > 0 || scopedProbeWarned) return;
7954
+ scopedProbeWarned = true;
7955
+ ctx.logger.warn("dsh-evolution: sessionScoped is on but the family-tool probe has never matched a session in this process — review injection and skill-usage telemetry are inert for every session seen so far. Check whether the family's model rows are mounted at all: tool-memory / tool-skill-manage are devDependencies of @lmzhen/dsh-evolution-host, so a HOST-ONLY install can never match, and a profile overlay that disables either row has the same effect. Under the layered VARIANT form only a session on the Evolution preset matches, so a miss there just means none has run yet. Next: /evolution doctor reports the scoped rows and this verdict (see INSTALL.md).");
7956
+ }
7957
+ /**
7674
7958
  * The one decision every cross-session consumer calls before it acts.
7675
7959
  * @param ctx - a context of the runtime.
7676
7960
  * @param sessionId - the session the event belongs to.
@@ -7678,11 +7962,17 @@ function sessionSeesFamilyTools(ctx, sessionId) {
7678
7962
  * @returns true when the consumer may act on this session. A deployment that did
7679
7963
  * not declare session scoping always answers true (the historical behavior);
7680
7964
  * a scoped one answers true only for a session that carries the family's model
7681
- * tools.
7965
+ * tools. Each scoped answer updates the process witness, and the first miss with
7966
+ * no match ever leaves one warn (see {@link noteScopedProbeMiss}).
7682
7967
  */
7683
7968
  function sessionAudited(ctx, sessionId, sessionScoped) {
7684
7969
  if (sessionScoped !== true) return true;
7685
- return sessionSeesFamilyTools(ctx, sessionId);
7970
+ if (sessionSeesFamilyTools(ctx, sessionId)) {
7971
+ scopedProbeHits += 1;
7972
+ return true;
7973
+ }
7974
+ noteScopedProbeMiss(ctx);
7975
+ return false;
7686
7976
  }
7687
7977
  //#endregion
7688
- 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, DISPATCH_EVENT_TYPES, 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, FAMILY_SESSION_TOOL_NAMES, FORBIDDEN_CONTROL_KEYS, HEALTH_STAMP_RE, INSTANCE_KEYS, 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_GUIDANCE_SECTION_ORDER, MEMORY_REVIEW_PROMPT, MIN_STAMP_BODY_CHARS, MUTATIONS_FILE_VERSION, MemoryStore, NATIVE_CALL_EVENT, NATIVE_RESULT_EVENT, PATTERN_OVERLAP, PERSISTED_WRITE_SITES, PROMPT_BUNDLE, PROMPT_BUNDLE_ID, PROMPT_BUNDLE_VERSION, PROTECTED_BUILTIN_SKILLS, PTC_DISPATCH_EVENT, PTC_DISPATCH_START_EVENT, QUALITY_WEIGHTS, RESTRUCTURE_TARGET_RE, SKILLS_GUIDANCE, SKILLS_GUIDANCE_SECTION_ORDER, SKILL_ACTION_REQUIRED_FIELDS, SKILL_NAME_RE, SKILL_REVIEW_PLAN_PROMPT, SKILL_REVIEW_PROMPT, SNAPSHOT_EXTRA_NAME_RE, SUPPORT_DIRS, SUPPRESSED_FILE_VERSION, SkillLibrary, THREAT_EXEMPTION_HINT, ToolDispatchNormalizer, ToolDispatchPayloadError, advanceReview, allowRowCollisions, appendEvolutionEvent, applyCuratorLifecycleFields, applyCuratorMetaFields, assertDispatchPayload, assertSkillsRootAliasRetired, assessStructureHealth, authoringFeedback, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpUse, bumpView, callingScope, claimInstance, clampedNumber, clearReviewChannel, collectReadSkillNames, composePresetComposition, computeDedupGroups, computeDriftSignals, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, contentHash, countDispatches, createGateSet, decideTakeover, duplicateHeadings, emptyRecord, evaluateThreat, eventsFile, evolutionEventPayloadIssue, evolutionHome, evolutionIoAdapter, evolutionRoot, findDriftSignal, foldCuratorFields, foldToolDispatches, foldTurn, frontmatterBlock, frontmatterCatalogInvalid, getRecord, instanceClaimKey, instanceClaimedWriteSites, instanceHolder, isAbsent, isCommittedWarning, isGlobalRead, isMissingPath, isPresent, isProcessAlive, isProgramToolName, isReviewChannelSession, isSkillReadToolName, isSkillToolName, isUnknown, latestActivityAt, lifecycleCandidate, listEventArchives, loadMutations, loadRowOverrides, loadSuppressedNames, loadUsage, makeSerialQueue, mapProbe, markAgentCreated, markReviewChannel, markerEntryName, memoryRoot, missingSupportPointers, mutateUsage, mutationsFile, narrowNameMatches, neutralizePromptVariables, newSkillLibrary, nodeEvolutionIo, normalizeFrontmatter, normalizeUsageRecord, observeEvent, overlongLines, parseCuratorNominations, parseEvolutionEvents, parseFrontmatter, parseLockBody, pendingSelfCleanup, persistedWriteSite, probeAbsent, probeList, probeMtime, probePresent, probeReason, probeUnknown, readDispatchSignal, readEvolutionEvents, readEvolutionTimeline, recordMutation, redactSecrets, relatedSkillNames, releaseInstance, renameWithRetry, renderCuratorReportMarkdown, resolveExecOrigins, resolveOrigins, resolveRootConfig, resolveSkillsRoot, retainEventArchives, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, sessionAudited, sessionSeesFamilyTools, skillReadNameOf, skillsRoot, suppressedFile, sweepReviewChannelSessions, transactIo, updateSuppressedNames, usageFile, usageObserved, validateFrontmatter, validateRestructureTarget, valueOr, verifyPromptBundle, writeDurableTmp, yamlPlainScalarNeedsQuotes };
7978
+ 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, DEDUP_MAX_PAIR_COMPARISONS, 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, DISPATCH_EVENT_TYPES, 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, FAMILY_SESSION_TOOL_NAMES, FORBIDDEN_CONTROL_KEYS, HEALTH_STAMP_RE, INSTANCE_KEYS, 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_GUIDANCE_SECTION_ORDER, MEMORY_REVIEW_PROMPT, MIN_STAMP_BODY_CHARS, MUTATIONS_FILE_VERSION, MemoryStore, NATIVE_CALL_EVENT, NATIVE_RESULT_EVENT, PATTERN_OVERLAP, PROMPT_BUNDLE, PROMPT_BUNDLE_ID, PROMPT_BUNDLE_VERSION, PROTECTED_BUILTIN_SKILLS, PTC_DISPATCH_EVENT, PTC_DISPATCH_START_EVENT, QUALITY_WEIGHTS, RESTRUCTURE_TARGET_RE, SKILLS_GUIDANCE, SKILLS_GUIDANCE_SECTION_ORDER, SKILL_ACTION_REQUIRED_FIELDS, SKILL_NAME_RE, SKILL_REVIEW_PLAN_PROMPT, SKILL_REVIEW_PROMPT, SNAPSHOT_EXTRA_NAME_RE, SUPPORT_DIRS, SUPPRESSED_FILE_VERSION, SkillLibrary, THREAT_EXEMPTION_HINT, ToolDispatchNormalizer, ToolDispatchPayloadError, advanceReview, allowRowCollisions, appendEvolutionEvent, applyCuratorLifecycleFields, applyCuratorMetaFields, assertDispatchPayload, assertSkillsRootAliasRetired, assessStructureHealth, authoringFeedback, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpUse, bumpView, callingScope, claimInstance, clampedNumber, clearReviewChannel, collectReadSkillNames, composePresetComposition, computeDedupGroups, computeDriftSignals, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, contentHash, countDispatches, createGateSet, decideTakeover, duplicateHeadings, emptyRecord, evaluateThreat, eventsFile, evolutionEventPayloadIssue, evolutionHome, evolutionIoAdapter, evolutionRoot, findDriftSignal, foldCuratorFields, foldToolDispatches, foldTurn, frontmatterBlock, frontmatterCatalogInvalid, getRecord, instanceClaimKey, instanceClaimedWriteSites, instanceHolder, isAbsent, isCommittedWarning, isMissingPath, isPresent, isProcessAlive, isProgramToolName, isReviewChannelSession, isSkillReadToolName, isSkillToolName, isUnknown, latestActivityAt, lifecycleCandidate, listEventArchives, loadMutations, loadRowOverrides, loadSuppressedNames, loadUsage, makeSerialQueue, mapProbe, markAgentCreated, markReviewChannel, markerEntryName, memoryRoot, missingSupportPointers, mutateUsage, mutationsFile, narrowNameMatches, neutralizePromptVariables, newSkillLibrary, nodeEvolutionIo, normalizeFrontmatter, normalizeUsageRecord, observeEvent, overlongLines, parseCuratorNominations, parseEvolutionEvents, parseFrontmatter, parseLockBody, pendingSelfCleanup, persistedWriteSite, persistedWriteSites, probeAbsent, probeList, probeMtime, probePresent, probeReason, probeUnknown, readDispatchSignal, readEvolutionEvents, readEvolutionTimeline, recordMutation, redactSecrets, relatedSkillNames, releaseInstance, renameWithRetry, renderCuratorReportMarkdown, resolveExecOrigins, resolveOrigins, resolveRootConfig, resolveSkillsRoot, retainEventArchives, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, scopedProbeReport, sessionAudited, sessionSeesFamilyTools, skillReadNameOf, skillsRoot, suppressedFile, sweepReviewChannelSessions, transactIo, transactTaskGuard, updateSuppressedNames, usageFile, usageObserved, validateFrontmatter, validateRestructureTarget, valueOr, verifyPromptBundle, writeDurableTmp, yamlPlainScalarNeedsQuotes };