@lmzhen/dsh-evolution-core 0.3.81 → 0.3.82

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
@@ -6,6 +6,79 @@ import { readFileSync } from "node:fs";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { scopeOf } from "@deepseek-ai/dsh-scope";
8
8
  import { load } from "js-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]));
@@ -928,7 +1031,7 @@ function parseSuppressed(raw) {
928
1031
  }
929
1032
  }
930
1033
  async function saveSuppressedNames(root, names, io = nodeEvolutionIo()) {
931
- const current = await io.readText(suppressedFile(root)).catch(() => null);
1034
+ const current = await io.readText(suppressedFile(root));
932
1035
  if (current !== null) try {
933
1036
  const parsed = JSON.parse(current);
934
1037
  if (parsed !== null && typeof parsed.version === "number" && parsed.version > 1) {
@@ -1641,7 +1744,15 @@ async function rotateIfDue(io, path, events, rotateAt) {
1641
1744
  const anchor = tail[0]?.seq ?? 0;
1642
1745
  const archivePath = join(dirname(path), `${EVENT_ARCHIVE_PREFIX}${anchor - 1}.json`);
1643
1746
  let archived = head;
1644
- const existing = await io.readText(archivePath).catch(() => null);
1747
+ let existing;
1748
+ try {
1749
+ existing = await io.readText(archivePath);
1750
+ } catch (error) {
1751
+ return {
1752
+ ok: false,
1753
+ reason: `evolution event archive collision at ${archivePath} could not be read (${error instanceof Error ? error.message : String(error)}) and was not touched`
1754
+ };
1755
+ }
1645
1756
  if (existing !== null) {
1646
1757
  let parsed = null;
1647
1758
  try {
@@ -1716,18 +1827,20 @@ async function pruneCollideArchives(io, path) {
1716
1827
  if (stamp && now - Number(stamp[1]) < COLLIDE_AGE_MS) continue;
1717
1828
  if (!stamp) try {
1718
1829
  const mtime = await io.mtime?.(full);
1719
- if (typeof mtime === "number" && now - mtime < COLLIDE_AGE_MS) continue;
1830
+ if (typeof mtime !== "number" || now - mtime < COLLIDE_AGE_MS) continue;
1720
1831
  } catch {
1721
1832
  continue;
1722
1833
  }
1723
1834
  await io.remove(full).catch(() => {});
1724
1835
  }
1725
1836
  }
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). */
1837
+ /** Read the event log; a missing/whitespace-only file reads as empty, corrupt
1838
+ * content is flagged (and refused on append). A well-formed future-version body
1839
+ * is v1-incompatible: it reads as EMPTY and is now flagged malformed as well
1840
+ * (C-events-dispatch-1, v43). F-338's own guarantees are untouched the reader
1841
+ * never mis-shapes a newer format and the append path refuses it up front, so
1842
+ * the original bytes survive — while the flag reports what the old reader hid:
1843
+ * every record that body holds is dropped from this read. */
1731
1844
  async function readEvolutionEvents(io, path) {
1732
1845
  let raw;
1733
1846
  try {
@@ -1746,7 +1859,7 @@ async function readEvolutionEvents(io, path) {
1746
1859
  const parsed = JSON.parse(raw);
1747
1860
  if (parsed.version !== void 0 && parsed.version !== 1) return {
1748
1861
  events: [],
1749
- malformed: false
1862
+ malformed: true
1750
1863
  };
1751
1864
  if (!Array.isArray(parsed.events)) return {
1752
1865
  events: [],
@@ -1767,8 +1880,11 @@ async function readEvolutionEvents(io, path) {
1767
1880
  * Read the full timeline (rc.71): active log + all archives, merged by seq
1768
1881
  * (active copy wins, duplicates only arise from the rotation crash window),
1769
1882
  * 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.
1883
+ * flagged ARCHIVE (unreadable, damaged, or a future-version body this reader
1884
+ * cannot interpret) is SKIPPED — it never bricks the boot, the returned events
1885
+ * simply LACK that seq band, and `malformed` is the only signal that they do
1886
+ * (C-events-dispatch-1, v43: the flag is the consumer's contract; a truncated
1887
+ * timeline must never be folded back as if it were complete).
1772
1888
  */
1773
1889
  async function readEvolutionTimeline(io, path, archives) {
1774
1890
  const dir = dirname(path);
@@ -3811,79 +3927,6 @@ function sweepReviewChannelSessions(isAlive) {
3811
3927
  return removed;
3812
3928
  }
3813
3929
  //#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
3930
  //#region lib/types/instance-scope.js
3888
3931
  /**
3889
3932
  * B3 / G4 (0.3.78): the family's single-instance contract, made explicit.
@@ -3901,11 +3944,30 @@ async function probeMtime(io, path) {
3901
3944
  * the sidecar directory, so two instances resolving different homes (an
3902
3945
  * isolated test fixture, a second DSH_HOME) do not contend, while two rows on
3903
3946
  * one profile do.
3947
+ *
3948
+ * ## Scope (v43 FLOW2-1) — this registry is PER PROCESS
3949
+ *
3950
+ * `claims` below is a module-scope Map: two ROWS over one home in ONE process
3951
+ * contend, while the SAME home in another process gets its own Map and is
3952
+ * granted the key. That is by construction, not a gap to close here — the
3953
+ * cross-process half of the contract is the IO backend's write lock
3954
+ * (`transactIo`, core/io.ts), which serializes a per-target read-modify-write.
3955
+ * The FLOW2-1 finding was three call sites reading a GRANTED claim as "no other
3956
+ * process can be doing this work", so the caller contract is stated here:
3957
+ * - granted means "no other row OF THIS PROCESS owns the key";
3958
+ * - `instanceHolder()` answers "who holds it HERE"; `undefined` also covers
3959
+ * "held by another process";
3960
+ * - a foreign holder's LIVENESS cannot be decided from a claim at all (no pid
3961
+ * is recorded here): a consumer that needs that decision must carry a pid in
3962
+ * its own credential and probe it (`isProcessAlive`, core/io.ts), or state
3963
+ * that its action is destructive.
3904
3964
  * @module @lmzhen/dsh-evolution-core/src/instance-scope
3905
3965
  */
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). */
3966
+ /** home+key -> holder, IN THIS PROCESS ONLY. The Map is module-scope, so two
3967
+ * processes never share it: this half cannot exclude another process (v43
3968
+ * FLOW2-1) — the cross-process half is the write lock, see
3969
+ * persisted-write-inventory.json. It is still the whole point for the case it
3970
+ * was written for: two ROWS of one process writing one home. */
3909
3971
  const claims = /* @__PURE__ */ new Map();
3910
3972
  /** `<home> :: <key>` — the registry key, exported so diagnostics name the
3911
3973
  * same unit the claim does. */
@@ -3936,7 +3998,8 @@ function releaseInstance(home, key, owner) {
3936
3998
  const id = instanceClaimKey(home, key);
3937
3999
  if (claims.get(id) === owner) claims.delete(id);
3938
4000
  }
3939
- /** The current holder of `key` at `home`, or undefined. */
4001
+ /** The current holder of `key` at `home`, IN THIS PROCESS, or undefined
4002
+ * (which also covers "another process holds it" — v43 FLOW2-1). */
3940
4003
  function instanceHolder(home, key) {
3941
4004
  return claims.get(instanceClaimKey(home, key));
3942
4005
  }
@@ -4001,16 +4064,39 @@ function parseSites(raw) {
4001
4064
  const INSTANCE_KEYS = {
4002
4065
  /** The per-home curator: report writing + the retention sweep. */
4003
4066
  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")));
4067
+ let cachedSites;
4068
+ /**
4069
+ * The declared persisted write sites, in file order.
4070
+ *
4071
+ * v43 audit (S2-3 / P1-10): this used to be a module-scope readFileSync plus
4072
+ * parse, so an unshipped asset threw AT IMPORT — one absent file took the whole
4073
+ * family's load down (0.3.79 shipped a tarball without this asset and every
4074
+ * package failed to load). The read is lazy now: importing the package never
4075
+ * fails on this asset, while the first caller still gets a loud, descriptive
4076
+ * failure instead of an empty table ("no declared write sites" would silently
4077
+ * disable rule N20).
4078
+ * @returns the sites, in file order.
4079
+ */
4080
+ function persistedWriteSites() {
4081
+ if (cachedSites !== void 0) return cachedSites;
4082
+ let raw;
4083
+ try {
4084
+ raw = readFileSync(fileURLToPath(SITES_URL), "utf8");
4085
+ } catch (error) {
4086
+ const cause = error instanceof Error ? error.message : String(error);
4087
+ 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`);
4088
+ }
4089
+ cachedSites = parseSites(JSON.parse(raw));
4090
+ return cachedSites;
4091
+ }
4006
4092
  /** Sites serialized by the per-home instance claim, with their instance keys. */
4007
4093
  function instanceClaimedWriteSites() {
4008
- return PERSISTED_WRITE_SITES.filter((site) => site.serializedBy === "instance-claim");
4094
+ return persistedWriteSites().filter((site) => site.serializedBy === "instance-claim");
4009
4095
  }
4010
4096
  /** One declared site by id. An undeclared id throws — a stale caller must fail
4011
4097
  * loud rather than read "nothing is declared". */
4012
4098
  function persistedWriteSite(id) {
4013
- const site = PERSISTED_WRITE_SITES.find((candidate) => candidate.id === id);
4099
+ const site = persistedWriteSites().find((candidate) => candidate.id === id);
4014
4100
  if (site === void 0) throw new Error(`evolution-core: no persisted write site "${id}" in persisted-write-inventory.json`);
4015
4101
  return site;
4016
4102
  }
@@ -4238,21 +4324,34 @@ var ToolDispatchNormalizer = class {
4238
4324
  this.maxTracked = options.maxTracked ?? Number.POSITIVE_INFINITY;
4239
4325
  }
4240
4326
  /**
4327
+ * v43 audit (FLOW4-4): the ledger key. A normalizer shared by every session
4328
+ * (skill-usage's live listener holds ONE process-wide instance) used the bare
4329
+ * call id, so two sessions that produced the same id — PTC sub-call ids are
4330
+ * short, and an id-less payload falls back to a type+payload key that is not
4331
+ * unique by construction — collided: the second session's read was absorbed as
4332
+ * "already seen" and never counted, and a settle in one session flipped the
4333
+ * other's `ok`. Callers that span sessions pass the session id as `scope`.
4334
+ */
4335
+ keyOf(callId, scope) {
4336
+ return scope === "" ? callId : `${scope}:${callId}`;
4337
+ }
4338
+ /**
4241
4339
  * Absorb one session event.
4242
4340
  * @param event - the event to absorb; any non-dispatch event is ignored.
4243
4341
  * @returns the dispatch's signal when this event FIRST reveals the dispatch,
4244
4342
  * otherwise \`null\` (the paired event of an already-emitted dispatch, or a
4245
4343
  * non-dispatch event). A \`null\` return is never a dispatch to count again.
4246
4344
  */
4247
- advance(event) {
4345
+ advance(event, scope = "") {
4248
4346
  const record = readDispatchRecord(event);
4249
4347
  if (record === null) return null;
4348
+ const key = this.keyOf(record.callId, scope);
4250
4349
  if (record.name === "") {
4251
- const existing = this.records.get(record.callId);
4350
+ const existing = this.records.get(key);
4252
4351
  if (existing !== void 0 && record.outcome !== void 0) existing.ok = record.outcome.ok;
4253
4352
  return null;
4254
4353
  }
4255
- const existing = this.records.get(record.callId);
4354
+ const existing = this.records.get(key);
4256
4355
  if (existing !== void 0) {
4257
4356
  if (record.outcome !== void 0) existing.ok = record.outcome.ok;
4258
4357
  return null;
@@ -4265,7 +4364,7 @@ var ToolDispatchNormalizer = class {
4265
4364
  arguments: record.arguments,
4266
4365
  ok: record.outcome?.ok
4267
4366
  };
4268
- this.records.set(record.callId, signal);
4367
+ this.records.set(key, signal);
4269
4368
  this.evict();
4270
4369
  return signal;
4271
4370
  }
@@ -4279,13 +4378,14 @@ var ToolDispatchNormalizer = class {
4279
4378
  * @param event - the event already absorbed by \`advance\`.
4280
4379
  * @returns the dispatch this event settled, or \`null\`.
4281
4380
  */
4282
- settledSignalOf(event) {
4381
+ settledSignalOf(event, scope = "") {
4283
4382
  const record = readDispatchRecord(event);
4284
4383
  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);
4384
+ const key = this.keyOf(record.callId, scope);
4385
+ if (this.settledIds.has(key)) return null;
4386
+ const signal = this.records.get(key);
4287
4387
  if (signal === void 0) return null;
4288
- this.settledIds.add(record.callId);
4388
+ this.settledIds.add(key);
4289
4389
  this.evict();
4290
4390
  return signal;
4291
4391
  }
@@ -4738,236 +4838,42 @@ function findDriftSignal(signals, id) {
4738
4838
  return signals.find((signal) => signal.id === id);
4739
4839
  }
4740
4840
  //#endregion
4741
- //#region lib/types/skill-store.js
4841
+ //#region lib/types/limits.js
4742
4842
  /**
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.
4843
+ * Skill content limits: the byte/char budgets every write path validates against.
4770
4844
  *
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.
4845
+ * Split out of skill-store.ts (S2-1) so the frontmatter validators and the
4846
+ * store share one declaration site. Re-exported by skill-store.ts: the package
4847
+ * export surface is unchanged.
4775
4848
  */
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
4849
  const DEFAULT_SKILL_LIMITS = {
4781
4850
  maxNameLength: 64,
4782
4851
  maxDescriptionLength: MAX_DESCRIPTION_LENGTH,
4783
4852
  maxSkillContentChars: MAX_SKILL_CONTENT_CHARS,
4784
4853
  maxSkillFileBytes: MAX_SKILL_FILE_BYTES
4785
4854
  };
4855
+ //#endregion
4856
+ //#region lib/types/frontmatter.js
4786
4857
  /**
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.
4858
+ * Frontmatter parsing, normalization and validation for skill Markdown files.
4859
+ *
4860
+ * Split out of skill-store.ts (S2-1): pure functions over file text, no store
4861
+ * state. skill-store.ts re-exports the same names it exported before the split,
4862
+ * so the package export surface is unchanged.
4791
4863
  */
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
4864
  /**
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
- /**
4959
- * Shared frontmatter block detection (P3-3 single owner): opening line `---`
4960
- * and closing line exactly `---`. Used by `parseFrontmatter`,
4961
- * `frontmatterCatalogInvalid` and `normalizeFrontmatter` so the three can
4962
- * never disagree about where the block ends (the loose `indexOf('\n---')`
4963
- * form matched `\n----` and was replaced by this strict line rule).
4964
- *
4965
- * V27 G2.1: both fence lines are matched EXACTLY, tolerating only a trailing
4966
- * `\r` — the same rule the upstream filesystem catalog uses
4967
- * (`skill-filesystem.parseFrontmatter`). The former `.trim()` comparison
4968
- * accepted ` --- `, so an indented fence loaded in the family while the
4969
- * platform ignored the file: family visibility split from platform visibility,
4970
- * which is exactly what a strict-YAML frontmatter is supposed to prevent.
4865
+ * Shared frontmatter block detection (P3-3 single owner): opening line `---`
4866
+ * and closing line exactly `---`. Used by `parseFrontmatter`,
4867
+ * `frontmatterCatalogInvalid` and `normalizeFrontmatter` so the three can
4868
+ * never disagree about where the block ends (the loose `indexOf('\n---')`
4869
+ * form matched `\n----` and was replaced by this strict line rule).
4870
+ *
4871
+ * V27 G2.1: both fence lines are matched EXACTLY, tolerating only a trailing
4872
+ * `\r` the same rule the upstream filesystem catalog uses
4873
+ * (`skill-filesystem.parseFrontmatter`). The former `.trim()` comparison
4874
+ * accepted ` --- `, so an indented fence loaded in the family while the
4875
+ * platform ignored the file: family visibility split from platform visibility,
4876
+ * which is exactly what a strict-YAML frontmatter is supposed to prevent.
4971
4877
  */
4972
4878
  /**
4973
4879
  * S1.1 (v37 P1-4): the frontmatter block is split on LF and every line keeps its
@@ -5341,65 +5247,381 @@ function relatedSkillNames(content, exclude) {
5341
5247
  }
5342
5248
  return [...names];
5343
5249
  }
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";
5250
+ /** S1.2 (v37 P2-1): the content limit applies to the bytes that LAND ON DISK.
5251
+ * Every write normalizes with `trimEnd() + '\n'`, so judging the raw argument let
5252
+ * a 100_000-character body with no trailing newline land as 100_001 bytes — and
5253
+ * every later patch/update of that skill was then refused, which made it
5254
+ * unmaintainable through `skill_manage` with no repair path at all. */
5255
+ function skillMdOnDisk(content) {
5256
+ return content.trimEnd() + "\n";
5257
+ }
5258
+ /** Whether `content` would exceed `limit` once written. */
5259
+ function exceedsContentLimit(content, limit) {
5260
+ return skillMdOnDisk(content).length > limit;
5261
+ }
5262
+ /** S1.2: the repair path — a write that makes an already-over-limit file smaller.
5263
+ * Only a NET SHRINK is exempt; an equal or larger write stays refused. */
5264
+ function shrinksOverLimit(next, current, limit) {
5265
+ if (current === null || current === void 0 || !exceedsContentLimit(current, limit)) return false;
5266
+ return skillMdOnDisk(next).length < skillMdOnDisk(current).length;
5267
+ }
5268
+ function validateFrontmatter(content, expectedName, limits = DEFAULT_SKILL_LIMITS, current) {
5269
+ const parsed = parseFrontmatter(content);
5270
+ 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.";
5271
+ const unreadable = parsed.platformStringSplit.filter((entry) => entry.kind === "sequence" || entry.kind === "mapping");
5272
+ 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.`;
5273
+ if (!parsed.frontmatter.name) return "Frontmatter must include a name field.";
5274
+ if (!SKILL_NAME_RE.test(parsed.frontmatter.name)) return `Invalid skill name "${parsed.frontmatter.name}" — use lowercase letters, digits, and hyphens.`;
5275
+ if (parsed.frontmatter.name.length > limits.maxNameLength) return `Skill name exceeds ${limits.maxNameLength} characters.`;
5276
+ if (expectedName && parsed.frontmatter.name !== expectedName) return `Frontmatter name "${parsed.frontmatter.name}" does not match target skill "${expectedName}".`;
5277
+ if (!parsed.frontmatter.description) return "Frontmatter must include a description field.";
5278
+ if (parsed.frontmatter.description.length > limits.maxDescriptionLength) return `Description exceeds ${limits.maxDescriptionLength} characters.`;
5279
+ 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.`;
5280
+ return null;
5281
+ }
5282
+ /**
5283
+ * Advisory authoring feedback (P0): evaluate frontmatter against the
5284
+ * authoring bar WITHOUT changing platform validation semantics. The bar is
5285
+ * the quality target, `validateFrontmatter`'s limits are the compatibility
5286
+ * floor, and this bridge layer tells the model when its text would be
5287
+ * truncated or route-poor instead of silently shipping it.
5288
+ */
5289
+ function authoringFeedback(frontmatter) {
5290
+ const description = frontmatter.description ?? "";
5291
+ const over60 = description.length > 60;
5292
+ const hasColon = description.includes(":");
5293
+ const lines = [];
5294
+ 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.`);
5295
+ if (hasColon) lines.push("Description contains a colon — wrap the whole value in double quotes.");
5296
+ return {
5297
+ descriptionChars: description.length,
5298
+ over60,
5299
+ hasColon,
5300
+ lines
5301
+ };
5302
+ }
5303
+ /** A1-15 (v18) / P2-2 (v19): the io layer marks an error `committed: true` when
5304
+ * the rename landed and only the directory fsync failed. Every single-file
5305
+ * writer must treat that as "written, durability unconfirmed" — never as a
5306
+ * plain failure (which a caller would retry, or a two-phase caller roll back).
5307
+ * v28 G2.1 (EVO-IO-05): this is a delegation to the seam's own
5308
+ * `isCommittedWarning` — the marker predicate has exactly one definition. */
5309
+ //#endregion
5310
+ //#region lib/types/fuzzy-match.js
5311
+ /**
5312
+ * Fuzzy string matching and replacement for patch/restructure edits of skill files.
5313
+ *
5314
+ * Split out of skill-store.ts (S2-1): pure text functions with no store state.
5315
+ * The store imports the scan, the budgets and the replace entry points directly;
5316
+ * none of them is re-exported, so the package export surface is unchanged.
5317
+ */
5318
+ function fuzzyIndexOf(content, pattern, from = 0) {
5319
+ const isSpace = (char) => char !== void 0 && /[ \t]/.test(char);
5320
+ const escaped = (char) => {
5321
+ if (char === "n") return "\n";
5322
+ if (char === "t") return " ";
5323
+ if (char === "r") return "\r";
5324
+ return null;
5325
+ };
5326
+ for (let start = from; start < content.length; start += 1) {
5327
+ let contentIndex = start;
5328
+ let patternIndex = 0;
5329
+ while (patternIndex < pattern.length && contentIndex < content.length) {
5330
+ const patternChar = pattern[patternIndex];
5331
+ const contentChar = content[contentIndex];
5332
+ if (isSpace(patternChar)) {
5333
+ while (patternIndex < pattern.length && isSpace(pattern[patternIndex])) patternIndex += 1;
5334
+ while (contentIndex < content.length && isSpace(content[contentIndex])) contentIndex += 1;
5335
+ continue;
5336
+ }
5337
+ const escapedChar = patternChar === "\\" ? escaped(pattern[patternIndex + 1]) : null;
5338
+ if (escapedChar !== null && contentChar === escapedChar) {
5339
+ patternIndex += 2;
5340
+ contentIndex += 1;
5341
+ continue;
5342
+ }
5343
+ if (patternChar === contentChar) {
5344
+ contentIndex += 1;
5345
+ patternIndex += 1;
5346
+ continue;
5347
+ }
5348
+ break;
5349
+ }
5350
+ if (patternIndex === pattern.length) return [start, contentIndex];
5351
+ }
5352
+ return null;
5353
+ }
5354
+ /** Trim leading whitespace of the first line and trailing whitespace of the last line. */
5355
+ function trimPatternBoundaries(pattern) {
5356
+ const from = pattern.search(/\S/);
5357
+ const trimmed = from < 0 ? pattern : pattern.slice(from);
5358
+ const trailing = trimmed.search(/\s+$/);
5359
+ return trailing < 0 ? trimmed : trimmed.slice(0, trailing);
5360
+ }
5361
+ /** Replace only the fuzzy-matched span, preserving all surrounding bytes.
5362
+ * V7-11 (0.3.44): the replaceAll loop accumulates the per-scan cost — the
5363
+ * single-scan budget at the caller only bounded ONE fuzzyIndexOf, while an
5364
+ * unbounded number of matches × O(n·m) each could still stall the loop.
5365
+ * When the accumulated cost exceeds the budget the whole replace fails
5366
+ * (null) instead of partially applying an arbitrary prefix. */
5367
+ function fuzzyReplace(content, oldString, newString, replaceAll) {
5368
+ let current = content;
5369
+ let scanFrom = 0;
5370
+ let totalWork = 0;
5371
+ for (;;) {
5372
+ totalWork += current.length * oldString.length;
5373
+ if (totalWork > 8e6) return null;
5374
+ const match = fuzzyIndexOf(current, oldString, scanFrom);
5375
+ if (match === null) return current;
5376
+ const [start, end] = match;
5377
+ const next = current.slice(0, start) + newString + current.slice(end);
5378
+ if (!replaceAll) return next;
5379
+ current = next;
5380
+ scanFrom = start + newString.length;
5381
+ }
5382
+ }
5383
+ function fuzzyPatch(content, oldString, newString, replaceAll = false) {
5384
+ if (oldString === "") return null;
5385
+ if (content.includes(oldString)) return replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, () => newString);
5386
+ const boundary = trimPatternBoundaries(oldString);
5387
+ if (boundary === "") return null;
5388
+ if (boundary !== oldString) {
5389
+ if (fuzzyIndexOf(content, boundary) !== null) return fuzzyReplace(content, boundary, newString, replaceAll);
5390
+ }
5391
+ if (fuzzyIndexOf(content, oldString) !== null) return fuzzyReplace(content, oldString, newString, replaceAll);
5392
+ return null;
5393
+ }
5394
+ /** Deterministic section-extraction plan facts; the caller owns the IO and the append semantics. */
5395
+ //#endregion
5396
+ //#region lib/types/skill-store.js
5397
+ /**
5398
+ * Skill library management for the self-evolution plugin.
5399
+ *
5400
+ * Skills live under `$DSH_HOME/skills` (`~/.dsh/skills` by default), matching
5401
+ * the default dsh skill-filesystem user root. The plugin only manages skills
5402
+ * it created unless a `.hermes-managed` marker opts a skill in. Archival is a
5403
+ * move to `.archive/` — never a hard delete.
5404
+ *
5405
+ * ## Concurrency discipline (OPT-09, 2026-09) — read before adding a mutator
5406
+ *
5407
+ * Three primitives, three distinct jobs (they compose, they do not replace
5408
+ * each other):
5409
+ *
5410
+ * 1. **In-process serial queue** (`this.serial`, makeSerialQueue) — orders the
5411
+ * read→plan→commit phases of one skill's mutation against OTHER mutators
5412
+ * in this process. Used by: create/update/patch/setPinned/restructure/
5413
+ * writeSupportFile/removeSupportFile and (whole-mutation) consolidate.
5414
+ * NON-reentrant: a callback must never call a public method that wraps
5415
+ * itself in `this.serial` (archive/restoreFromArchive deliberately do not).
5416
+ * 2. **Per-directory write lock** (io.ts LOCK_*) — cross-process mutual
5417
+ * exclusion plus in-process crash ownership (tickets, takeover). Checked
5418
+ * with `hasWriteLock` before any destructive move (archive/restore/
5419
+ * snapshot); held inside transactIo by byte writers.
5420
+ * 3. **CAS baseline (`expected:`)** — any read whose bytes feed a later write
5421
+ * must either live inside the serial section that commits the write, or
5422
+ * carry its plan-time bytes as `expected` so the commit fails closed on
5423
+ * drift (V8-11 / V24-01). A read outside the serial section WITHOUT a
5424
+ * baseline is a lost-update bug; this file's history is the test suite.
5425
+ *
5426
+ * Known residuals (deliberate, documented at their sites): the archive commit
5427
+ * re-check narrows but does not close the pin race (OPT-06); snapshotAll
5428
+ * re-probes after its copies so a mid-copy writer demotes to `skipped`
5429
+ * (OPT-07); the movers' probe→rename window is owned by the io.ts protocol.
5430
+ */
5431
+ /** 0.3.16 (S1.13, T-6): the pointer-line prefix written into a body when a
5432
+ * section is moved to references/ — single literal, both restructure and
5433
+ * append-mode consolidation emit the same discoverability line. */
5434
+ const POINTER_LINE_PREFIX = "> 详见 references/";
5435
+ /**
5436
+ * Evaluate a stage-time anchor against the bytes a locked read observed.
5437
+ * @param anchor - the caller's anchor, or `undefined` for an unanchored write.
5438
+ * @param current - the bytes the write lock read (`null` = the target is absent).
5439
+ * @returns `match` when the write may proceed, otherwise the refusal verdict.
5440
+ */
5441
+ function anchorVerdict(anchor, current) {
5442
+ if (anchor === void 0) return "match";
5443
+ if ("absent" in anchor) return current === null ? "match" : "drift";
5444
+ if (current === null) return "missing";
5445
+ return contentHash(current) === anchor.sha256 ? "match" : "drift";
5446
+ }
5447
+ /**
5448
+ * Build the refusal for a skill write whose anchor did not hold. The wording is
5449
+ * the library's own; a caller with staged-replay wording (the skill tool, the
5450
+ * review plan) re-words it from {@link SkillActionResult.anchor}.
5451
+ * @param name - the skill name the refusal names.
5452
+ * @param verdict - the non-matching verdict.
5453
+ * @returns the refusal result (nothing was written).
5454
+ */
5455
+ function anchorRefusal(name, verdict) {
5456
+ return {
5457
+ ok: false,
5458
+ stale: true,
5459
+ anchor: verdict,
5460
+ message: verdict === "missing" ? `Skill "${name}" not found.` : `Skill "${name}" changed since it was read; the write was refused to avoid overwriting newer content.`
5461
+ };
5462
+ }
5463
+ /**
5464
+ * Build the refusal for a support-file write/remove whose anchor did not hold.
5465
+ * @param name - the owning skill name.
5466
+ * @param filePath - the support-file path inside the skill.
5467
+ * @param verdict - the non-matching verdict.
5468
+ * @returns the refusal result (nothing was written or removed).
5469
+ */
5470
+ /**
5471
+ * Refusal for a target the locked read could not verify at all (EISDIR, an
5472
+ * unreadable file). A staged replay reports "could not be verified" instead of
5473
+ * propagating an exception: nothing was read, so nothing can have been written.
5474
+ * @param name - the owning skill name.
5475
+ * @param filePath - the support-file path, or `null` for the skill body.
5476
+ * @returns the refusal result.
5477
+ */
5478
+ function anchorUnverifiable(name, filePath) {
5479
+ return {
5480
+ ok: false,
5481
+ stale: true,
5482
+ anchor: "drift",
5483
+ 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.`
5484
+ };
5485
+ }
5486
+ function anchorRefusalFile(name, filePath, verdict) {
5487
+ return {
5488
+ ok: false,
5489
+ stale: true,
5490
+ anchor: verdict,
5491
+ 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.`
5492
+ };
5493
+ }
5494
+ /**
5495
+ * v43 S2-14 (FLOW2-1/flow-3): the target could not be READ at all — EISDIR, an
5496
+ * unreadable file, a failing backend. `io.readText` returns null only for a
5497
+ * genuinely missing path and throws for everything else, so a raw errno used to
5498
+ * escape `update` / `patch` / `write_file` to the model while the remove path had
5499
+ * classified the same condition since A-5 (v15). Structured refusal, nothing
5500
+ * written.
5501
+ *
5502
+ * @param label - `name` or `name/filePath`, as the caller's surface names it.
5503
+ * @param error - the read failure.
5504
+ * @returns the refusal result.
5505
+ */
5506
+ function unreadableTarget(label, error) {
5507
+ return {
5508
+ ok: false,
5509
+ 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.`
5510
+ };
5511
+ }
5512
+ /** Upper bound of moves per restructure proposal (validator and core agree). */
5513
+ const MAX_RESTRUCTURE_MOVES = 5;
5514
+ /** Restructure targets are plain markdown files under references/ — no
5515
+ * subdirectories, no other support kind. V8-10 (0.3.47): the regex-level
5516
+ * `(?!.*\.\.)` keeps the restructure-created set EXACTLY the set
5517
+ * validateSupportPath can reopen — a `references/my..notes.md` target (double
5518
+ * dots) used to pass here while every later patch/write/remove on it was
5519
+ * refused as traversal (an orphan file the user could not touch). */
5520
+ const RESTRUCTURE_TARGET_RE = /^references\/[a-z0-9](?!.*\.\.)[a-z0-9._-]*\.md$/;
5521
+ /** F-20 (v18): the character rule shared by support-file names and snapshot
5522
+ * `extras/` entry names. The two exported names used to carry the same literal
5523
+ * independently; both now derive from this one. */
5524
+ const SUPPORT_ENTRY_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
5525
+ /** Extra file name carried inside a snapshot's `extras/` directory. */
5526
+ const SNAPSHOT_EXTRA_NAME_RE = SUPPORT_ENTRY_NAME_RE;
5527
+ function skillsRoot(env = process.env) {
5528
+ return join(evolutionRoot(env), "skills");
5351
5529
  }
5352
- /** Whether `content` would exceed `limit` once written. */
5353
- function exceedsContentLimit(content, limit) {
5354
- return skillMdOnDisk(content).length > limit;
5530
+ /** 0.3.18 (S4.1, E-30): the ONE root resolution for every member that reads
5531
+ * the skills tree — tool-skill-manage / evolution-skill-catalog / skill-usage
5532
+ * / evolution-learning-graph used to each resolve `config.root || skillsRoot()`
5533
+ * (and the graph ignored config entirely). Empty/whitespace config falls
5534
+ * through to the default; callers pass their raw Config. The optional field is
5535
+ * declared `| undefined` so a config object whose root field is explicitly
5536
+ * `string | undefined` still assignable under exactOptionalPropertyTypes. */
5537
+ function resolveSkillsRoot(config = {}) {
5538
+ return (config.root ?? "").trim() || skillsRoot();
5355
5539
  }
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;
5540
+ /** E-7 (v18) V27 G2.4 (M-08): every family row reads ONE root key. `root` is
5541
+ * canonical; the `skillsRoot` alias was honoured for one minor version and its
5542
+ * window closed at 0.3.65 — it is now two releases past expiry, so this
5543
+ * resolver no longer reads it at all. A deployment that still sets the alias
5544
+ * must fail LOUDLY at load (see {@link assertSkillsRootAliasRetired}): silently
5545
+ * ignoring a config key leaves the deployment pointing at a root nobody reads,
5546
+ * which is the worst form of compatibility.
5547
+ * @param config - the raw plugin config.
5548
+ * @returns the effective root (empty when the key is unset or blank).
5549
+ */
5550
+ function resolveRootConfig(config = {}) {
5551
+ return { root: (config.root ?? "").trim() };
5361
5552
  }
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;
5553
+ /** V27 G2.4 (M-08): the retirement gate for the expired `skillsRoot` alias.
5554
+ * Called at each plugin's load boundary (before the root is resolved), it turns
5555
+ * a stale key into an explicit load error naming the replacement the
5556
+ * fail-loud form the plan requires instead of a silent no-op.
5557
+ * @param config - the raw plugin config (the alias field stays DECLARED in each
5558
+ * schema so the loader can hand it here instead of dropping it).
5559
+ */
5560
+ function assertSkillsRootAliasRetired(config = {}) {
5561
+ 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
5562
  }
5376
5563
  /**
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.
5564
+ * Map a requesting session onto the two origin surfaces (rc.44 plan M2-2.3):
5565
+ * the APPROVAL surface treats every delegated subagent as the autonomous
5566
+ * review channel, while the LIBRARY surface keeps the Hermes distinction -
5567
+ * the review fork is 'background_review' (the pinned guard blocks its
5568
+ * writes) and any other subagent is 'subagent' (agent-authored, not
5569
+ * review-channel). `isReview` marks the caller as the background review
5570
+ * pipeline itself. Single source: the two tools and the review executor all
5571
+ * read this table instead of re-deriving it.
5382
5572
  */
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.");
5573
+ function resolveOrigins(headerOrigin, isReview = false) {
5574
+ if (isReview) return {
5575
+ approval: "background_review",
5576
+ library: "background_review"
5577
+ };
5578
+ if (headerOrigin === "subagent") return {
5579
+ approval: "background_review",
5580
+ library: "subagent"
5581
+ };
5390
5582
  return {
5391
- descriptionChars: description.length,
5392
- over60,
5393
- hasColon,
5394
- lines
5583
+ approval: "foreground",
5584
+ library: "foreground"
5395
5585
  };
5396
5586
  }
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. */
5587
+ /**
5588
+ * S1-E8 (0.3.80): ONE exec→origins resolution for the two write tools reads
5589
+ * the session header origin AND the v37 S2.2 review-channel session mark, so a
5590
+ * tool cannot forget the mark half (tool-memory shipped without it, which
5591
+ * mislabeled every inject-mode review memory write as `foreground` and let it
5592
+ * bypass staging under `stageForeground: false`).
5593
+ * Single source: both tools call this instead of re-deriving the pair.
5594
+ */
5595
+ function resolveExecOrigins(exec) {
5596
+ const session = exec?.agent?.session;
5597
+ return resolveOrigins(session?.header?.origin, isReviewChannelSession(typeof session?.id === "string" ? session.id : void 0));
5598
+ }
5599
+ function skillDir(root, name) {
5600
+ return join(root, name);
5601
+ }
5602
+ /** Dot-prefixed on-disk marker name. SINGLE source: `list()` matches directory
5603
+ * entries against this name, and path builders must never hardcode a marker
5604
+ * literal (N-1: the rc.49 exists()-probe convergence dropped the dot,
5605
+ * poisoning every protectedBy/managed report). Exported for cross-package
5606
+ * consumers that must probe markers without re-deriving the name (curator's
5607
+ * archive-copy bundled probe, 0.3.26 V4-02). */
5608
+ function markerEntryName(marker) {
5609
+ return `.${marker}`;
5610
+ }
5611
+ /** F-17 (v18): the root-level lock files a DESTRUCTIVE MOVER must treat as an
5612
+ * active writer (skill body + the two marker writers). Single source with
5613
+ * `markerEntryName`/`LOCK_SUFFIX` so a renamed marker cannot silently drop out
5614
+ * of the ghost-writer probe. */
5615
+ const MARKER_LOCK_NAMES = [
5616
+ `SKILL.md${LOCK_SUFFIX}`,
5617
+ `.pinned${LOCK_SUFFIX}`,
5618
+ `.hermes-managed${LOCK_SUFFIX}`
5619
+ ];
5620
+ /** v23 (ML-1): `.archive` retention window (see pruneExpiredArchives). */
5621
+ const ARCHIVE_RETENTION_DAYS = 365;
5622
+ function markerPath(dir, marker) {
5623
+ return join(dir, markerEntryName(marker));
5624
+ }
5403
5625
  function isCommittedOnly(error) {
5404
5626
  return isCommittedWarning(error);
5405
5627
  }
@@ -5481,99 +5703,6 @@ function validateSupportPath(filePath) {
5481
5703
  return null;
5482
5704
  }
5483
5705
  /**
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
5706
  * Support-directory references in a markdown body (009 kernel): `references/…`,
5578
5707
  * `templates/…`, `scripts/…`, `assets/…` relative links — any extension and
5579
5708
  * nested paths (v7 audit P3-1: `.md`-only matching missed `scripts/run.sh` and
@@ -5692,7 +5821,7 @@ var SkillLibrary = class {
5692
5821
  if (this.transact) try {
5693
5822
  await this.transact(this.io, path, run);
5694
5823
  } catch (error) {
5695
- if (!progress.entered && readFailure !== void 0) return readFailure;
5824
+ if (!progress.entered && readFailure !== void 0) return readFailure(error);
5696
5825
  if (!committedOnly(error)) throw error;
5697
5826
  durabilityWarning = error instanceof Error ? error.message : String(error);
5698
5827
  }
@@ -5701,7 +5830,7 @@ var SkillLibrary = class {
5701
5830
  try {
5702
5831
  current = await this.io.readText(path);
5703
5832
  } catch (error) {
5704
- if (readFailure !== void 0) return readFailure;
5833
+ if (readFailure !== void 0) return readFailure(error);
5705
5834
  throw error;
5706
5835
  }
5707
5836
  const next = await run(current);
@@ -6104,7 +6233,17 @@ var SkillLibrary = class {
6104
6233
  ok: false,
6105
6234
  message: `Skill "${normalized}" already exists.`
6106
6235
  };
6107
- for (const entry of await this.io.list(this.root).catch(() => [])) if (typeof entry === "string" && entry !== normalized && entry.toLowerCase() === normalized.toLowerCase()) return {
6236
+ let rootEntries;
6237
+ try {
6238
+ rootEntries = await this.io.list(this.root);
6239
+ } catch (error) {
6240
+ const cause = error instanceof Error ? error.message : String(error);
6241
+ return {
6242
+ ok: false,
6243
+ 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.`
6244
+ };
6245
+ }
6246
+ for (const entry of rootEntries) if (typeof entry === "string" && entry !== normalized && entry.toLowerCase() === normalized.toLowerCase()) return {
6108
6247
  ok: false,
6109
6248
  message: `Skill "${normalized}" collides with the existing case-variant directory "${entry}" (skill names are lowercase-only); rename one of them.`
6110
6249
  };
@@ -6120,6 +6259,7 @@ var SkillLibrary = class {
6120
6259
  let createDurabilityWarning = "";
6121
6260
  if (this.transact) try {
6122
6261
  await this.transact(this.io, createPath, (current) => {
6262
+ existsAtCommit = false;
6123
6263
  taskRan = true;
6124
6264
  if (current !== null) {
6125
6265
  existsAtCommit = true;
@@ -6267,7 +6407,7 @@ var SkillLibrary = class {
6267
6407
  skillDir: dir
6268
6408
  }
6269
6409
  };
6270
- }, anchor !== void 0 ? anchorUnverifiable(name, null) : void 0);
6410
+ }, anchor !== void 0 ? () => anchorUnverifiable(name, null) : (error) => unreadableTarget(name, error));
6271
6411
  }
6272
6412
  async patch(rawName, oldString, newString, filePath = "", replaceAll = false, origin = "foreground") {
6273
6413
  const name = rawName.trim();
@@ -6310,7 +6450,7 @@ var SkillLibrary = class {
6310
6450
  },
6311
6451
  write: null
6312
6452
  };
6313
- if (!md.includes(oldString) && (oldString.length > FUZZY_MAX_PATTERN_CHARS || md.length * oldString.length > FUZZY_MAX_WORK)) return {
6453
+ if (!md.includes(oldString) && (oldString.length > 4096 || md.length * oldString.length > 8e6)) return {
6314
6454
  result: {
6315
6455
  ok: false,
6316
6456
  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 +6561,7 @@ var SkillLibrary = class {
6421
6561
  skillDir: dir
6422
6562
  }
6423
6563
  };
6424
- });
6564
+ }, (error) => unreadableTarget(name + "/" + patchLabel, error));
6425
6565
  }
6426
6566
  /**
6427
6567
  * P2-9 (v15): the destructive directory move shared by archive and
@@ -7009,6 +7149,7 @@ var SkillLibrary = class {
7009
7149
  const drift = { seen: false };
7010
7150
  const ran = { done: false };
7011
7151
  await this.transact(this.io, entry.target, (current) => {
7152
+ drift.seen = false;
7012
7153
  ran.done = true;
7013
7154
  if (current !== baseline) {
7014
7155
  drift.seen = true;
@@ -7223,7 +7364,7 @@ var SkillLibrary = class {
7223
7364
  file: target
7224
7365
  }
7225
7366
  };
7226
- }, anchor !== void 0 ? anchorUnverifiable(name, filePath) : void 0);
7367
+ }, anchor !== void 0 ? () => anchorUnverifiable(name, filePath) : (error) => unreadableTarget(name + "/" + filePath, error));
7227
7368
  }
7228
7369
  async removeSupportFile(rawName, filePath, origin = "foreground", anchor) {
7229
7370
  const name = rawName.trim();
@@ -7651,6 +7792,35 @@ function newSkillLibrary(options) {
7651
7792
  */
7652
7793
  const FAMILY_SESSION_TOOL_NAMES = ["skill_manage", "memory"];
7653
7794
  /**
7795
+ * S0-4 (v43 J-1 / G-1): the process-wide witness behind the deployment
7796
+ * diagnostic. A scoped false is the CORRECT answer for a session that did not
7797
+ * opt in, so the miss alone proves nothing — "no session in this process ever
7798
+ * matched" is the shape both real faults share, and only a witness that outlives
7799
+ * one session can tell it from a healthy per-session skip.
7800
+ *
7801
+ * Module scope on purpose: the claim spans every session and every row of this
7802
+ * process, so no single row's fiber owns it. Monotone scalars with no per-key
7803
+ * lifecycle — the N12 registry covers module-scope Set/Map/WeakMap stores, which
7804
+ * carry entries that do need one.
7805
+ */
7806
+ let scopedProbeHits = 0;
7807
+ let scopedProbeMisses = 0;
7808
+ let scopedProbeWarned = false;
7809
+ /**
7810
+ * Read the witness for a diagnostic surface (`/evolution doctor`). Read-only: it
7811
+ * neither evaluates the probe nor consumes the one-time warn, so a report run
7812
+ * cannot change what the next miss would have logged.
7813
+ * @returns the verdict with both counts, zeroed in a process where the gate has
7814
+ * not run.
7815
+ */
7816
+ function scopedProbeReport() {
7817
+ return {
7818
+ verdict: scopedProbeHits > 0 ? "hit" : scopedProbeMisses > 0 ? "never-hit" : "idle",
7819
+ hits: scopedProbeHits,
7820
+ misses: scopedProbeMisses
7821
+ };
7822
+ }
7823
+ /**
7654
7824
  * Does this session's scope see the family's model tools?
7655
7825
  *
7656
7826
  * The scope is the live agent's — the platform's own addressing for "what does
@@ -7671,6 +7841,23 @@ function sessionSeesFamilyTools(ctx, sessionId) {
7671
7841
  return FAMILY_SESSION_TOOL_NAMES.some((name) => tools.get(name, scope) !== void 0);
7672
7842
  }
7673
7843
  /**
7844
+ * S0-4 (v43 J-1 / G-1): record a scoped rejection and leave the one-time
7845
+ * deployment diagnostic.
7846
+ *
7847
+ * Fires at most once per process, on the first miss, and only while nothing has
7848
+ * ever matched. The message names the check rather than guessing the deployment
7849
+ * form: a host-only install and a disabled model row are the two shapes that can
7850
+ * never match, while the layered variant form legitimately waits for a session
7851
+ * that selected the Evolution preset.
7852
+ * @param ctx - the asking row's context; the warn rides that row's own logger.
7853
+ */
7854
+ function noteScopedProbeMiss(ctx) {
7855
+ scopedProbeMisses += 1;
7856
+ if (scopedProbeHits > 0 || scopedProbeWarned) return;
7857
+ scopedProbeWarned = true;
7858
+ 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).");
7859
+ }
7860
+ /**
7674
7861
  * The one decision every cross-session consumer calls before it acts.
7675
7862
  * @param ctx - a context of the runtime.
7676
7863
  * @param sessionId - the session the event belongs to.
@@ -7678,11 +7865,17 @@ function sessionSeesFamilyTools(ctx, sessionId) {
7678
7865
  * @returns true when the consumer may act on this session. A deployment that did
7679
7866
  * not declare session scoping always answers true (the historical behavior);
7680
7867
  * a scoped one answers true only for a session that carries the family's model
7681
- * tools.
7868
+ * tools. Each scoped answer updates the process witness, and the first miss with
7869
+ * no match ever leaves one warn (see {@link noteScopedProbeMiss}).
7682
7870
  */
7683
7871
  function sessionAudited(ctx, sessionId, sessionScoped) {
7684
7872
  if (sessionScoped !== true) return true;
7685
- return sessionSeesFamilyTools(ctx, sessionId);
7873
+ if (sessionSeesFamilyTools(ctx, sessionId)) {
7874
+ scopedProbeHits += 1;
7875
+ return true;
7876
+ }
7877
+ noteScopedProbeMiss(ctx);
7878
+ return false;
7686
7879
  }
7687
7880
  //#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 };
7881
+ 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, 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, 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 };