@lmzhen/dsh-evolution-core 0.3.74 → 0.3.76

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
@@ -2,6 +2,7 @@ import { basename, dirname, isAbsolute, 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
+ import { scopeOf } from "@deepseek-ai/dsh-scope";
5
6
  import { load } from "js-yaml";
6
7
  //#region lib/types/io.js
7
8
  /**
@@ -77,6 +78,25 @@ function evolutionIoAdapter(provider) {
77
78
  * so `io.spec.ts` can drive the self-heal path deterministically.
78
79
  */
79
80
  const pendingSelfCleanup = /* @__PURE__ */ new Map();
81
+ /** P2-7 (v11)/P3-10 (v14): record a failed release so the next write to this
82
+ * path self-heals it, keeping the map's 64-entry cap — a never-re-touched path
83
+ * must not pin it, while a path whose lock still EXISTS is the last to drop
84
+ * (dropping it disables the self-heal for a lock that is really there). */
85
+ const recordPendingSelfCleanup = async (lock, token) => {
86
+ if (pendingSelfCleanup.size >= 64) {
87
+ let droppable;
88
+ for (const candidate of pendingSelfCleanup.keys()) {
89
+ if (candidate === lock) continue;
90
+ if (await readFile(candidate, "utf8").then(() => false, () => true)) {
91
+ droppable = candidate;
92
+ break;
93
+ }
94
+ }
95
+ const victim = droppable ?? pendingSelfCleanup.keys().next().value;
96
+ if (victim !== void 0) pendingSelfCleanup.delete(victim);
97
+ }
98
+ pendingSelfCleanup.set(lock, token);
99
+ };
80
100
  const RENAME_RETRY_BASE_MS = 50;
81
101
  const RENAME_RETRY_MAX_DELAY_MS = 800;
82
102
  const RENAME_RETRY_MAX_ATTEMPTS = 6;
@@ -255,6 +275,11 @@ const EMPTY_LOCK_TAKEOVER_MS = 3e4;
255
275
  /** A body with no parseable pid (crash mid-write): 1h, far above any legal hold
256
276
  * and far below "forever". */
257
277
  const LOCK_TEAR_TAKEOVER_MS = 36e5;
278
+ /** P2-27 (v37): the commit-point ownership re-read. A transient read failure
279
+ * (EACCES/EMFILE/antivirus hold) must not abort a valid RMW, so the read is
280
+ * retried in place; only a still-unreadable lock fails the attempt. */
281
+ const OWNERSHIP_READ_ATTEMPTS = 3;
282
+ const OWNERSHIP_READ_RETRY_MS = 20;
258
283
  /**
259
284
  * V27 G1.3: the error `commitTmp` throws when the rename landed but the parent
260
285
  * directory fsync failed — the bytes ARE visible, only their durability is
@@ -438,7 +463,26 @@ function nodeEvolutionIo(lockAttempts = 40) {
438
463
  continue;
439
464
  }
440
465
  const assertOwned = async () => {
441
- const body = await readFile(lock, "utf8").catch(() => null);
466
+ let body = "";
467
+ let readable = false;
468
+ for (let read = 0; read < OWNERSHIP_READ_ATTEMPTS && !readable; read += 1) {
469
+ if (read > 0) await new Promise((resolve) => setTimeout(resolve, OWNERSHIP_READ_RETRY_MS));
470
+ const attempt = await readFile(lock, "utf8").then((value) => ({
471
+ ok: true,
472
+ value
473
+ }), () => ({
474
+ ok: false,
475
+ value: ""
476
+ }));
477
+ if (attempt.ok) {
478
+ body = attempt.value;
479
+ readable = true;
480
+ }
481
+ }
482
+ if (!readable) {
483
+ console.warn(`evolution-io: write lock ${lock} could not be read at the commit point (ownership unverified, ours: ${JSON.stringify(myClaim)}) — aborting this read-modify-write and retrying under a fresh acquisition`);
484
+ throw new LostWriteLock();
485
+ }
442
486
  if (body !== myClaim) {
443
487
  console.warn(`evolution-io: write lock ${lock} was reclaimed by another writer before the commit (on disk now: ${JSON.stringify(body)}, ours: ${JSON.stringify(myClaim)}) — aborting this read-modify-write and retrying under a fresh acquisition`);
444
488
  throw new LostWriteLock();
@@ -450,25 +494,21 @@ function nodeEvolutionIo(lockAttempts = 40) {
450
494
  if (error instanceof LostWriteLock) continue;
451
495
  throw error;
452
496
  } finally {
453
- if (await readFile(lock, "utf8").catch(() => null) === myClaim) await rm(lock, {
497
+ const mine = await readFile(lock, "utf8").then((body) => ({
498
+ ok: true,
499
+ body
500
+ }), (error) => ({
501
+ ok: false,
502
+ missing: isMissing(error)
503
+ }));
504
+ if (!mine.ok) {
505
+ if (!mine.missing) await recordPendingSelfCleanup(lock, myClaim);
506
+ } else if (mine.body === myClaim) await rm(lock, {
454
507
  force: true,
455
508
  maxRetries: 20,
456
509
  retryDelay: 100
457
510
  }).catch(async () => {
458
- const body = await readFile(lock, "utf8").catch(() => "");
459
- if (pendingSelfCleanup.size >= 64) {
460
- let droppable;
461
- for (const candidate of pendingSelfCleanup.keys()) {
462
- if (candidate === lock) continue;
463
- if (await readFile(candidate, "utf8").then(() => false, () => true)) {
464
- droppable = candidate;
465
- break;
466
- }
467
- }
468
- const victim = droppable ?? pendingSelfCleanup.keys().next().value;
469
- if (victim !== void 0) pendingSelfCleanup.delete(victim);
470
- }
471
- pendingSelfCleanup.set(lock, body);
511
+ await recordPendingSelfCleanup(lock, await readFile(lock, "utf8").catch(() => ""));
472
512
  });
473
513
  }
474
514
  }
@@ -750,21 +790,15 @@ async function mutateUsage(root, io, task, options = {}) {
750
790
  return JSON.stringify(Object.fromEntries(map.entries()), null, 2);
751
791
  });
752
792
  }
753
- /**
754
- * Curator-owned usage fields (rc.67 K-2): the curator writes ONLY this set —
793
+ /** Curator-owned usage fields (rc.67 K-2): the curator writes ONLY this set —
755
794
  * lifecycle state, archive stamp, the six-factor quality pair, and the
756
- * marker-mirrored pin flag. Counter and activity-stamp fields belong to the
757
- * tool-telemetry side (skill-usage / tool-skill-manage), which bumps them
758
- * through its own transact-backed RMW. A whole-record overwrite by either
759
- * side would clobber the other side's concurrent increment, so cross-side
760
- * folds copy this set only.
761
- */
762
- function applyCuratorFields(disk, curated) {
763
- applyCuratorMetaFields(disk, curated);
764
- applyCuratorLifecycleFields(disk, curated);
765
- }
795
+ * marker-mirrored pin flag; counters and activity stamps belong to the
796
+ * tool-telemetry side and are never copied by a fold. P2-12 (v39): the
797
+ * combined `applyCuratorFields` wrapper had no production caller (folds go
798
+ * through {@link foldCuratorFields}), so the two field copies below are the
799
+ * whole contract. */
766
800
  /** Copy only the lifecycle pair (state/archived_at) — see the ownership split
767
- * rationale on {@link applyCuratorMetaFields}. */
801
+ * rationale above. */
768
802
  function applyCuratorLifecycleFields(disk, curated) {
769
803
  disk.state = curated.state;
770
804
  disk.archived_at = curated.archived_at;
@@ -1049,13 +1083,17 @@ const DEFAULT_CURATOR_BOOT_GRACE_SECONDS = 10;
1049
1083
  const DEFAULT_CURATOR_REVIEW_MAX_TOKENS = 2048;
1050
1084
  /** 0.3.17 (S3.10, T-1): control-plane fields a model-facing write call may
1051
1085
  * never carry — single source for plan-validator, evolution-policy and the
1052
- * threat scanner (they used to each hardcode the list). */
1086
+ * threat scanner (they used to each hardcode the list).
1087
+ * P2-13 (v37): staged_from_sha256 joins the list — it is the replay's own
1088
+ * staleness anchor, and the tool-arguments root is an OPEN object, so a model
1089
+ * could otherwise choose the anchor that decides the write's outcome. */
1053
1090
  const FORBIDDEN_CONTROL_KEYS = [
1054
1091
  "policy",
1055
1092
  "threshold",
1056
1093
  "prompt_hash",
1057
1094
  "model_route",
1058
- "evolution_config"
1095
+ "evolution_config",
1096
+ "staged_from_sha256"
1059
1097
  ];
1060
1098
  /** 0.3.17 (S3.10): the model-facing write tools the policy guard and threat
1061
1099
  * scanner cover. */
@@ -1215,20 +1253,20 @@ function parseCuratorNominations(text) {
1215
1253
  section = header[1] === "consolidations" ? "consolidations" : "prunings";
1216
1254
  continue;
1217
1255
  }
1218
- const consolidated = /^\s*-\s*from:\s*([a-z0-9][a-z0-9-]*)\s*$/.exec(line);
1256
+ const consolidated = /^\s*-\s*from:\s*([a-z0-9][a-z0-9-]*)(?:\s*#.*)?\s*$/.exec(line);
1219
1257
  if (consolidated) {
1220
1258
  section = "consolidations";
1221
1259
  currentFrom = consolidated[1] ?? "";
1222
1260
  currentMode = void 0;
1223
1261
  continue;
1224
1262
  }
1225
- const mode = /^\s*mode:\s*(append|reference)\s*$/.exec(line);
1263
+ const mode = /^\s*mode:\s*(append|reference)(?:\s*#.*)?\s*$/.exec(line);
1226
1264
  if (mode) {
1227
1265
  if (currentFrom !== "") currentMode = mode[1] === "reference" ? "reference" : "append";
1228
1266
  else warnings.push(`mode: ${mode[1]} ignored — no preceding "- from:" entry (the consolidation falls back to append)`);
1229
1267
  continue;
1230
1268
  }
1231
- const into = /^\s*into:\s*([a-z0-9][a-z0-9-]*)\s*$/.exec(line);
1269
+ const into = /^\s*into:\s*([a-z0-9][a-z0-9-]*)(?:\s*#.*)?\s*$/.exec(line);
1232
1270
  if (into) {
1233
1271
  const intoName = into[1] ?? "";
1234
1272
  if (section === "consolidations" && currentFrom !== "" && currentFrom !== intoName) consolidations.push({
@@ -1240,13 +1278,15 @@ function parseCuratorNominations(text) {
1240
1278
  currentMode = void 0;
1241
1279
  continue;
1242
1280
  }
1243
- const pruned = /^\s*-\s*name:\s*([a-z0-9][a-z0-9-]*)\s*$/.exec(line);
1281
+ const pruned = /^\s*-\s*name:\s*([a-z0-9][a-z0-9-]*)(?:\s*#.*)?\s*$/.exec(line);
1244
1282
  if (pruned) {
1245
1283
  if (section === "consolidations") warnings.push("\"- name:\" inside the consolidations section flips the parse to prunings");
1246
1284
  section = "prunings";
1247
1285
  const name = pruned[1];
1248
1286
  if (name) prunings.push(name);
1287
+ continue;
1249
1288
  }
1289
+ if (/^\s*(?:-\s*(?:from|name)\s*:|(?:into|mode)\s*:)/.test(line)) warnings.push(`"${line.trim()}" ignored - not a usable nomination line (names are lowercase letters, digits and hyphens; one optional trailing "# comment" is allowed)`);
1250
1290
  }
1251
1291
  const valid = (name) => NOMINATION_NAME_RE.test(name);
1252
1292
  return {
@@ -1435,6 +1475,7 @@ function evolutionEventPayloadIssue(event) {
1435
1475
  case "feedback":
1436
1476
  if (event.kind !== "skill" && event.kind !== "session") return "feedback event requires kind skill|session";
1437
1477
  if (event.rating !== "positive" && event.rating !== "negative") return "feedback event requires rating positive|negative";
1478
+ if (typeof event.target !== "string" || event.target.trim() === "") return "feedback event requires a non-empty target";
1438
1479
  return null;
1439
1480
  case "maintain": return typeof event.runId === "string" ? null : "maintain event requires runId";
1440
1481
  case "learn":
@@ -1535,7 +1576,12 @@ async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE
1535
1576
  return current;
1536
1577
  }
1537
1578
  }
1538
- const events = await rotateIfDue(io, path, v1EventRecords(parsedBody), rotateAt);
1579
+ const rotated = await rotateIfDue(io, path, v1EventRecords(parsedBody), rotateAt);
1580
+ if (!rotated.ok) {
1581
+ refuseMessage = rotated.reason;
1582
+ return current;
1583
+ }
1584
+ const events = rotated.events;
1539
1585
  let maxSeq = events.reduce((max, entry) => Math.max(max, entry.seq), 0);
1540
1586
  for (const name of await listEventArchives(io, path)) maxSeq = Math.max(maxSeq, Number.parseInt(name.slice(7, name.length - 5), 10));
1541
1587
  const record = {
@@ -1552,6 +1598,16 @@ async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE
1552
1598
  if (assigned === 0) throw new Error(`${refuseMessage || "evolution event log is malformed and was not touched"}: ${path}`);
1553
1599
  return assigned;
1554
1600
  }
1601
+ /** The collision archive is mergeable only when this v1 writer can read it
1602
+ * (P2-11): a foreign `version` (F-338) or a body without an `events` array is
1603
+ * refused, so a merge can never downgrade it or empty it out.
1604
+ * @param parsed - the parsed collision archive body.
1605
+ * @returns the usable events, or null when the archive must not be rewritten. */
1606
+ function mergeableCollisionEvents(parsed) {
1607
+ if (parsed.version !== void 0 && parsed.version !== 1) return null;
1608
+ if (!Array.isArray(parsed.events)) return null;
1609
+ return parsed.events.filter(isEventRecord);
1610
+ }
1555
1611
  /**
1556
1612
  * Split the active log at its midpoint when due: the older half is written to
1557
1613
  * `events-<lastArchivedSeq>.json` (await — a failed archive write aborts the
@@ -1561,38 +1617,60 @@ async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE
1561
1617
  * one-event rotate would archive everything and restart seqs at 1).
1562
1618
  */
1563
1619
  async function rotateIfDue(io, path, events, rotateAt) {
1564
- if (!Number.isFinite(rotateAt) || rotateAt < 2 || events.length < rotateAt) return events;
1620
+ if (!Number.isFinite(rotateAt) || rotateAt < 2 || events.length < rotateAt) return {
1621
+ ok: true,
1622
+ events
1623
+ };
1565
1624
  const mid = Math.ceil(events.length / 2);
1566
1625
  const head = events.slice(0, mid);
1567
1626
  const tail = events.slice(mid);
1568
- if (tail.length === 0) return events;
1627
+ if (tail.length === 0) return {
1628
+ ok: true,
1629
+ events
1630
+ };
1569
1631
  const anchor = tail[0]?.seq ?? 0;
1570
1632
  const archivePath = join(dirname(path), `${EVENT_ARCHIVE_PREFIX}${anchor - 1}.json`);
1571
1633
  let archived = head;
1572
1634
  const existing = await io.readText(archivePath).catch(() => null);
1573
- if (existing !== null) try {
1574
- const parsed = JSON.parse(existing);
1575
- const usablePrior = (Array.isArray(parsed.events) ? parsed.events : []).filter(isEventRecord);
1576
- const bySeq = new Map(usablePrior.map((event) => [event.seq, event]));
1635
+ if (existing !== null) {
1636
+ let parsed = null;
1637
+ try {
1638
+ parsed = JSON.parse(existing);
1639
+ } catch {
1640
+ parsed = null;
1641
+ }
1642
+ if (parsed === null) {
1643
+ const shiftPath = `${archivePath}.${Date.now()}.collide`;
1644
+ await io.writeText(shiftPath, JSON.stringify({
1645
+ version: 1,
1646
+ events: head
1647
+ }, null, 2));
1648
+ console.warn(`evolution-events: rotation hit an unparsable archive collision — the rotated head band was preserved at ${shiftPath} but is OUTSIDE the logical timeline; inspect and merge it manually`);
1649
+ await retainEventArchives(io, path);
1650
+ await pruneCollideArchives(io, path);
1651
+ return {
1652
+ ok: true,
1653
+ events: tail
1654
+ };
1655
+ }
1656
+ const prior = mergeableCollisionEvents(parsed);
1657
+ if (prior === null) return {
1658
+ ok: false,
1659
+ reason: `evolution event archive collision at ${archivePath} is not a readable version-1 archive (future version, or no "events" array) and was not touched`
1660
+ };
1661
+ const bySeq = new Map(prior.map((event) => [event.seq, event]));
1577
1662
  for (const event of head) bySeq.set(event.seq, event);
1578
1663
  archived = [...bySeq.values()].sort((a, b) => a.seq - b.seq);
1579
- } catch {
1580
- const shiftPath = `${archivePath}.${Date.now()}.collide`;
1581
- await io.writeText(shiftPath, JSON.stringify({
1582
- version: 1,
1583
- events: head
1584
- }, null, 2));
1585
- console.warn(`evolution-events: rotation hit an unparsable archive collision — the rotated head band was preserved at ${shiftPath} but is OUTSIDE the logical timeline; inspect and merge it manually`);
1586
- await retainEventArchives(io, path);
1587
- await pruneCollideArchives(io, path);
1588
- return tail;
1589
1664
  }
1590
1665
  await io.writeText(archivePath, JSON.stringify({
1591
1666
  version: 1,
1592
1667
  events: archived
1593
1668
  }, null, 2));
1594
1669
  await retainEventArchives(io, path);
1595
- return tail;
1670
+ return {
1671
+ ok: true,
1672
+ events: tail
1673
+ };
1596
1674
  }
1597
1675
  /**
1598
1676
  * Prune old event archives (rc.71): keep the newest `EVENT_LOG_RETAIN_ARCHIVES`.
@@ -1726,496 +1804,111 @@ function allowRowCollisions(env = process.env) {
1726
1804
  return env.DSH_EVOLUTION_ALLOW_ROW_COLLISIONS === ALLOW_ROW_COLLISIONS;
1727
1805
  }
1728
1806
  //#endregion
1729
- //#region lib/types/prompts.js
1807
+ //#region lib/types/learn-prompt.js
1730
1808
  /**
1731
- * Review and curation prompts adapted from Hermes Agent
1732
- * `agent/background_review.py`, `agent/curator.py`, and
1733
- * `agent/learn_prompt.py`, with tool names translated to the DSH-native
1734
- * catalog (`memory`, `skill_manage`, `skill`, `bash`, `str_replace_editor`).
1809
+ * Open-ended `/evolution learn` prompt builder.
1735
1810
  *
1736
- * Alignment policy (2026-08-29): the OPERATIONAL steps and instructions the
1737
- * model follows mirror the Hermes originals structurally (signal list,
1738
- * preference order, support-file taxonomy, curator package integrity,
1739
- * consolidated/pruned reporting block). Tool and platform differences are
1740
- * DSH-adapted (native tool names, pinned-within-review semantics, this
1741
- * platform's index cap), and DSH-only additions are marked as such.
1811
+ * `learn` is open-ended: the user can name anything they can describe — a
1812
+ * directory of code, an API doc URL, a workflow they just walked the agent
1813
+ * through, or pasted notes. The prompt instructs the live agent to gather the
1814
+ * named sources with its existing tools, then author a single SKILL.md via
1815
+ * `skill_manage` following `DSH_AUTHORING_STANDARDS`. There is no separate
1816
+ * distillation engine and no model-tool footprint.
1817
+ */
1818
+ /**
1819
+ * Build the agent prompt for an open-ended `/evolution learn` request.
1742
1820
  *
1743
- * Every prompt is pinned in a versioned bundle. Review workers verify the
1744
- * bundle digest before spending a model call v31 PROMPT-01, stated
1745
- * precisely: THAT check proves internal coherence (id/version/digest agree)
1746
- * for a bundle assembled OUT of process and handed to `verifyPromptBundle`
1747
- * explicitly. It CANNOT detect in-process tampering (the digest is recomputed
1748
- * from the same module state it verifies) — catching a stale or partially
1749
- * patched default bundle is CI's version pin (tests/prompts.spec.ts), not
1750
- * this runtime gate.
1821
+ * @param userRequest free-text the user gave after `/evolution learn`; an
1822
+ * empty string falls back to "the workflow we just went through".
1823
+ * @returns a complete instruction the agent runs as a normal turn.
1751
1824
  */
1825
+ function buildLearnPrompt(userRequest) {
1826
+ return [
1827
+ "[/learn] The user wants you to learn a reusable skill from the request below, and save it.",
1828
+ "",
1829
+ "THE REQUEST:",
1830
+ userRequest.trim() || "the workflow we just went through in this conversation — review the steps taken and distill them into a reusable skill",
1831
+ "",
1832
+ "The request is open-ended and may mix two kinds of content, in any order: SOURCES to gather (directories, file paths, URLs, \"what we just did\", pasted notes) AND REQUIREMENTS that shape the skill (what to focus on, what to leave out, scope, naming, the angle to take). Treat EVERY part of the request as load-bearing. In particular, prose that comes after a path or link is NOT incidental — it is the user telling you what they want from that source. A request like `<url> focus on the auth flow, skip the deprecated endpoints` means: gather the URL AND honor \"focus on auth, skip deprecated\" as authoring requirements. Never fetch the first source and ignore the rest.",
1833
+ "",
1834
+ "Do this:",
1835
+ "1. Gather every source the user named, using the tools you already have — reads and searches for local files or directories, web access for URLs, this conversation history if they referred to something you just did, and the text they pasted as-is. If the request is ambiguous about scope, make a reasonable choice and note it; do not stall.",
1836
+ "2. Author ONE SKILL.md, applying every requirement, focus, and constraint in the request — these govern what the SKILL.md covers and emphasizes, not just which sources you read.",
1837
+ "3. Save it with the `skill_manage` tool (action=\"create\"). Pick a sensible category. If the procedure needs a non-trivial script, add it under the skill's `scripts/` with `skill_manage` write_file and reference it by relative path.",
1838
+ "",
1839
+ "Apply the skill-authoring standards (frontmatter, prose, file layout) carried by the `skill_manage` tool description — that is the single copy; do not restate them here.",
1840
+ "",
1841
+ "When done, tell the user the skill name, its category, and a one-line summary of what it captured."
1842
+ ].join("\n");
1843
+ }
1844
+ //#endregion
1845
+ //#region lib/types/state-store.js
1752
1846
  /**
1753
- * Prompt bundle identity. Bump both id and version whenever a prompt's text
1754
- * changes semantically: the bundle digest is the fail-closed signal for
1755
- * review workers, so a stale id across deployments must be distinguishable.
1847
+ * Evolution home path helpers: the DSH root and `$DSH_HOME/evolution` for
1848
+ * plugin-owned sidecar state (reports, activity store, feedback file,
1849
+ * state-domain data).
1850
+ *
1851
+ * C-10: PATH HELPERS ONLY — despite the file name there is no store
1852
+ * here. Durable evolution state lives in the state stack (evolution-state over
1853
+ * evolution-state-json / -domain); skills and memories live in skill-store.ts
1854
+ * / memory-store.ts. The file name is kept deliberately: renaming it would
1855
+ * touch every family import for zero behavior change, and the audit records
1856
+ * the mismatch as known naming debt.
1756
1857
  */
1757
- const PROMPT_BUNDLE_VERSION = 17;
1758
- const PROMPT_BUNDLE_ID = `dsh-evolution@17`;
1759
- const MEMORY_REVIEW_PROMPT = `[Auto-review — Memory]
1760
- Review the conversation above and consider saving to memory if appropriate.
1761
-
1762
- Focus on:
1763
- 1. Has the user revealed things about themselves — persona, desires, preferences, or personal details worth remembering?
1764
- 2. Has the user expressed expectations about how you should behave, their work style, or ways they want you to operate?
1765
-
1766
- If something stands out, save it using the memory tool.
1767
- If nothing is worth saving, just say "Nothing to save." and stop.`;
1768
- const SKILL_REVIEW_PROMPT = `[Auto-review — Skills]
1769
- Review the conversation above and update the skill library. Be ACTIVE — most sessions produce at least one skill update, even if small. A pass that does nothing is a missed learning opportunity, not a neutral outcome.
1770
-
1771
- Target shape of the library: CLASS-LEVEL skills, each with a rich SKILL.md and a references/ directory for session-specific detail. Not a long flat list of narrow one-session-one-skill entries. This shapes HOW you update, not WHETHER you update.
1772
-
1773
- Signals to look for (any one of these warrants action):
1774
- • User corrected your style, tone, format, legibility, or verbosity. Frustration signals like 'stop doing X', 'this is too verbose', 'don't format like this', 'why are you explaining', 'just give me the answer', 'you always do Y and I hate it', or an explicit 'remember this' are FIRST-CLASS skill signals, not just memory signals. Update the relevant skill(s) to embed the preference so the next session starts already knowing.
1775
- • User corrected your workflow, approach, or sequence of steps. Encode the correction as a pitfall or explicit step in the skill that governs that class of task.
1776
- • Non-trivial technique, fix, workaround, debugging path, or tool-usage pattern emerged that a future session would benefit from. Capture it.
1777
- • A skill that got loaded or consulted this session turned out to be wrong, missing a step, or outdated. Patch it NOW.
1778
-
1779
- Read-before-write: update, patch, delete, or write support files ONLY into skills you loaded or read in THIS session. On the plan channel ops on unread skills are rejected; direct writes have no such guard, so treat the rule as binding. CREATE of a brand-new umbrella is the only exception.
1780
-
1781
- Preference order — prefer the earliest action that fits, but do pick one when a signal above fired:
1782
- 1. UPDATE A CURRENTLY-LOADED SKILL. Look back through the conversation for skills the user loaded or you read. If any of them covers the territory of the new learning, PATCH that one first. It is the skill that was in play, so it's the right one to extend.
1783
- 2. UPDATE AN EXISTING UMBRELLA. If no loaded skill fits but an existing class-level skill does, patch it. Add a subsection, a pitfall, or broaden a trigger.
1784
- 3. ADD A SUPPORT FILE under an existing umbrella. Skills can be packaged with three kinds of support files — use the right directory per kind:
1785
- • references/<topic>.md — session-specific detail (error transcripts, reproduction recipes, provider quirks) AND condensed knowledge banks: quoted research, API docs, external authoritative excerpts, or domain notes you found while working on the problem. Write it concise and for the value of the task, not as a full mirror of upstream docs.
1786
- • templates/<name>.<ext> — starter files meant to be copied and modified (boilerplate configs, scaffolding, a known-good example the agent can reproduce with modifications).
1787
- • scripts/<name>.<ext> — statically re-runnable actions the skill can invoke directly (verification scripts, fixture generators, deterministic probes, anything the agent should run rather than hand-type each time).
1788
- Add support files via skill_manage action=write_file with file_path starting 'references/', 'templates/', or 'scripts/'. The umbrella's SKILL.md should gain a one-line pointer to any new support file so future agents know it exists.
1789
- 4. RESTRUCTURE a loaded skill whose body grew log-like — rc/sha/date-dense sections, session-detail spirals, or a fat body with no support files. Use skill_manage action=restructure with restructure: [{"heading": "<the exact ## heading text>", "to_file": "references/<topic>.md"}] — the ENTIRE ## section (from that heading to the next heading) moves into the support file and its position becomes a pointer line. The skill's name and directory never change. Only propose headings that exist verbatim in the body; never invent one, and never restructure a healthy small skill.
1790
- 5. CREATE A NEW CLASS-LEVEL UMBRELLA SKILL when no existing skill covers the class. The name MUST be at the class level. The name MUST NOT be a specific PR number, error string, feature codename, library-alone name, or 'fix-X / debug-Y / audit-Z-today' session artifact. If the proposed name only makes sense for today's task, it's wrong — fall back to (1), (2), or (3).
1791
-
1792
- User-preference embedding (important): when the user expressed a style/format/workflow preference, the update belongs in the SKILL.md body, not just in memory. Memory captures 'who the user is and what the current situation and state of your operations are'; skills capture 'how to do this class of task for this user'. When they complain about how you handled a task, the skill that governs that task needs to carry the lesson.
1793
-
1794
- If you notice two existing skills that overlap, note it in your reply — the background curator handles consolidation at scale.
1795
-
1796
- Two-tier deposition discipline (DSH addition, same spirit as the umbrella rule): before writing, classify the knowledge:
1797
- • PATTERN (reusable — symptom → mechanism → fix → verification, still valuable next session) belongs in the SKILL.md body.
1798
- • LOG (one-off — commit SHAs, npm/profile states, what this release changed, this session's process narrative) belongs in a references/ file, never the body. Body density IS reuse rate. Keep new entries tight: a pattern fits in 2-8 physical lines; prefer changing the current-state pointer over appending history.
1799
-
1800
- Protected skills (DO NOT edit these):
1801
- • Bundled skills (shipped with the platform).
1802
- • Hub-installed skills (installed from a hub).
1803
- Pinned skills are read-only to THIS background review pass — the pinned write guard refuses background changes, so this pass may not update them. They also cannot be archived by any writer (the foreground included): remove the .pinned marker first. Foreground and delegated-subagent update/patch writes to pinned skills remain allowed.
1804
- If the only skills that need updating are protected, say 'Nothing to save.' and stop.
1805
-
1806
- Do NOT capture (these become persistent self-imposed constraints that bite you later when the environment changes):
1807
- • Environment-dependent failures: missing binaries, fresh-install errors, post-migration path mismatches, 'command not found', unconfigured credentials, uninstalled packages. The user can fix these — they are not durable rules.
1808
- • Negative claims about tools or features ('browser tools do not work', 'X tool is broken', 'cannot use Y'). These harden into refusals the agent cites against itself for months after the actual problem was fixed.
1809
- • Session-specific transient errors that resolved before the conversation ended. If retrying worked, the lesson is the retry pattern, not the original failure.
1810
- • One-off task narratives. A user asking 'summarize today's market' or 'analyze this PR' is not a class of work that warrants a skill.
1811
-
1812
- If a tool failed because of setup state, capture the FIX (install command, config step, env var to set) under an existing setup or troubleshooting skill — never 'this tool does not work' as a standalone constraint.
1813
-
1814
- 'Nothing to save.' is a real option but should NOT be the default. If the session ran smoothly with no corrections and produced no new technique, just say 'Nothing to save.' and stop. Otherwise, act.`;
1815
- const COMBINED_REVIEW_PROMPT = `[Auto-review]
1816
- Review the conversation above and update two things:
1817
-
1818
- **Memory**: who the user is. Did the user reveal persona, desires, preferences, personal details, or expectations about how you should behave? Save facts about the user and durable preferences with the memory tool.
1819
-
1820
- **Skills**: how to do this class of task. Be ACTIVE — most sessions produce at least one skill update. A pass that does nothing is a missed learning opportunity, not a neutral outcome.
1821
-
1822
- Target shape of the skill library: CLASS-LEVEL skills with a rich SKILL.md and a references/ directory for session-specific detail. Not a long flat list of narrow one-session-one-skill entries.
1823
-
1824
- Signals that warrant a skill update (any one is enough):
1825
- • User corrected your style, tone, format, legibility, verbosity, or approach. Frustration is a FIRST-CLASS skill signal, not just a memory signal. 'stop doing X', 'don't format like this', 'I hate when you Y' — embed the lesson in the skill that governs that task so the next session starts fixed.
1826
- • Non-trivial technique, fix, workaround, or debugging path emerged.
1827
- • A skill that was loaded or consulted turned out wrong, missing, or outdated — patch it now.
1828
-
1829
- Read-before-write: update, patch, delete, or write support files ONLY into skills you loaded or read in THIS session. On the plan channel ops on unread skills are rejected; direct writes have no such guard, so treat the rule as binding. CREATE of a brand-new umbrella is the only exception.
1830
-
1831
- Preference order for skills — pick the earliest that fits:
1832
- 1. UPDATE A CURRENTLY-LOADED SKILL. Check what skills were loaded or read in the conversation. If one of them covers the learning, PATCH it first. It was in play; it's the right place.
1833
- 2. UPDATE AN EXISTING UMBRELLA. Patch it.
1834
- 3. ADD A SUPPORT FILE under an existing umbrella via skill_manage action=write_file. Three kinds: references/<topic>.md for session-specific detail OR condensed knowledge banks (quoted research, API docs excerpts, domain notes) written concise and task-focused; templates/<name>.<ext> for starter files meant to be copied and modified; scripts/<name>.<ext> for statically re-runnable actions (verification, fixture generators, probes). Add a one-line pointer in SKILL.md so future agents find them.
1835
- 4. RESTRUCTURE a loaded skill whose body grew log-like (rc/sha/date-dense sections, session-detail spirals, fat body with no support files) via skill_manage action=restructure with restructure: [{"heading": "<the exact ## heading text>", "to_file": "references/<topic>.md"}] — the ENTIRE ## section moves into the support file and its position becomes a pointer line; the skill's name and directory never change. Only propose headings that exist verbatim in the body.
1836
- 5. CREATE A NEW CLASS-LEVEL UMBRELLA when nothing exists. Name at the class level — NOT a PR number, error string, codename, library-alone name, or 'fix-X / debug-Y' session artifact. If the name only fits today's task, fall back to (1), (2), or (3).
1837
-
1838
- Two-tier deposition discipline (DSH addition): classify before writing — PATTERN (symptom → mechanism → fix → verification) goes in the SKILL.md body; LOG (commit SHAs, npm/profile states, this release's change list, this session's narrative) goes in a references/ file. Body density IS reuse rate; a pattern fits in 2-8 physical lines.
1839
-
1840
- User-preference embedding: when the user complains about how you handled a task, update the skill that governs that task — memory alone isn't enough. Memory says 'who the user is and what the current situation and state of your operations are'; skills say 'how to do this class of task for this user'. Both should carry user-preference lessons when relevant.
1841
-
1842
- If you notice overlapping existing skills, mention it — the background curator handles consolidation.
1843
-
1844
- Protected skills (DO NOT edit these):
1845
- • Bundled skills (shipped with the platform).
1846
- • Hub-installed skills (installed from a hub).
1847
- Pinned skills are read-only to THIS background review pass — the pinned write guard refuses background changes, so this pass may not update them. They also cannot be archived by any writer (the foreground included): remove the .pinned marker first. Foreground and delegated-subagent update/patch writes to pinned skills remain allowed.
1848
- If the only skills that need updating are protected, say 'Nothing to save.' and stop.
1849
-
1850
- Do NOT capture as skills (these become persistent self-imposed constraints that bite you later when the environment changes):
1851
- • Environment-dependent failures: missing binaries, fresh-install errors, post-migration path mismatches, 'command not found', unconfigured credentials, uninstalled packages. The user can fix these — they are not durable rules.
1852
- • Negative claims about tools or features ('browser tools do not work', 'X tool is broken', 'cannot use Y'). These harden into refusals the agent cites against itself for months after the actual problem was fixed.
1853
- • Session-specific transient errors that resolved before the conversation ended. If retrying worked, the lesson is the retry pattern, not the original failure.
1854
- • One-off task narratives. A user asking 'summarize today's market' or 'analyze this PR' is not a class of work that warrants a skill.
1855
-
1856
- If a tool failed because of setup state, capture the FIX (install command, config step, env var to set) under an existing setup or troubleshooting skill — never 'this tool does not work' as a standalone constraint.
1857
-
1858
- Act on whichever of the two dimensions has real signal. If genuinely nothing stands out on either, say 'Nothing to save.' and stop — but don't reach for that conclusion as a default.`;
1859
- const CURATOR_PROMPT = `You are the skill curator. Maintain a healthy, class-level skill library, not a flat pile of narrow one-session skills.
1860
-
1861
- This is an UMBRELLA-BUILDING consolidation pass, not a passive audit and not a duplicate-finder.
1862
-
1863
- The goal is a LIBRARY OF CLASS-LEVEL INSTRUCTIONS. A skill collection of many narrow skills where each captures one session's specific bug is a FAILURE of the library. An agent searching skills matches on descriptions, not exact names; one broad umbrella with labeled subsections beats five narrow siblings for discoverability.
1864
-
1865
- Right target shape: class-level skills with rich SKILL.md + references/, templates/, scripts/ support files for session-specific detail.
1866
-
1867
- Hard rules:
1868
- 1. NEVER hard-delete a skill. Archive (moving to .archive/) is the maximum destructive action; archives are recoverable, deletion is not.
1869
- 2. Do not touch bundled, hub-installed, pinned, or scheduled-task-referenced (referenced) skills. Referenced skills are fully protected — never consolidated, never pruned (there is no scheduled-task reference-rewriting pass; a referenced skill stays in place by design).
1870
- 3. Do not archive recently-created or never-used skills without strong evidence. "use=0" is NOT evidence either way — it only means the trigger has not come up yet. Never archive a never-used skill unless it is at least 30 days old AND its content is genuinely obsolete or fully absorbed elsewhere.
1871
- 4. Do NOT reject consolidation on the grounds that "each skill has a distinct trigger". The right bar is: would a human maintainer write this as N separate skills, or one skill with N labeled subsections? When the answer is the latter, merge.
1872
- 5. Judge overlap on CONTENT, not on usage counters.
1873
- 6. Before archiving a merged skill, ensure its unique content was preserved in the umbrella.
1874
-
1875
- How to work:
1876
- 1. Scan the candidate list. Identify PREFIX CLUSTERS — skills sharing a first word or domain keyword. Expected cluster count scales with the library: a large collection may show 10-25 prefix clusters, a small one often has none — a clean "nothing to consolidate" summary is the correct small-library outcome, not a shortage of ambition.
1877
- 2. For each cluster with 2+ members, ask "what is the UMBRELLA CLASS these skills serve?" and consolidate:
1878
- a. MERGE INTO AN EXISTING UMBRELLA (patch a labeled section for each sibling's unique insight, then archive the siblings).
1879
- b. CREATE A NEW UMBRELLA SKILL.md covering the shared workflow with short labeled subsections, then archive the absorbed siblings.
1880
- c. DEMOTE session-specific detail to references/, templates/, or scripts/ under the umbrella. Use the right directory per kind:
1881
- • references/<topic>.md — session-specific detail OR condensed knowledge banks (quoted research, API docs excerpts, domain notes, provider quirks, reproduction recipes) written concise and task-focused.
1882
- • templates/<name>.<ext> — starter files meant to be copied and modified.
1883
- • scripts/<name>.<ext> — statically re-runnable actions (verification scripts, fixture generators, probes).
1884
- 3. Package integrity — not optional: inspect each skill as a COMPLETE directory package, not just SKILL.md. A skill root may include references/, templates/, scripts/, and assets/. If the source skill has support files OR its SKILL.md contains relative links to them, DO NOT flatten only SKILL.md into <umbrella>/references/<old>.md. Choose one safe path instead: keep it as a standalone skill, OR fully merge by re-homing every needed support file into the umbrella's canonical directories AND rewriting the destination instructions to the new paths, OR archive the entire original skill package unchanged. Never leave demoted instructions pointing at files left behind under the old skill directory.
1885
- 4. Flag skills whose NAME is too narrow (contains a PR number, a feature codename, a specific error string, an 'audit'/'diagnosis'/'salvage' session artifact) — they almost always belong as a subsection or support file under a class-level umbrella.
1886
- 5. Iterate. After one consolidation round, scan the remaining set and look for the NEXT umbrella opportunity. Don't stop after 3 merges.
1887
-
1888
- You are a NOMINATOR, not an executor: this channel has NO tools. Your single deliverable is the structured YAML block below. Never narrate actions you did not take ("merged", "patched", "archived") — you are proposing, and the deterministic engine executes only names from the candidate pool it gave you. (A future execution view would expose skill_manage; today it does not.)
1889
-
1890
- 'keep' is a legitimate decision ONLY when the skill is already a class-level umbrella and none of the proposed merges would improve discoverability. 'This is narrow but distinct from its siblings' is NOT a reason to keep — it's a reason to move it under an umbrella as a subsection or support file.
1891
-
1892
- Expected output: real umbrella-ification. Process every obvious cluster. If you end the pass with obvious clusters still untouched, you stopped too early — go back and look at the clusters you left alone.
1893
-
1894
- Keep the umbrella body tight and scannable: exact commands, verbatim paths, ~100-200 lines; never invent flags or APIs.
1895
-
1896
- When done, write a human summary THEN the structured machine-readable block. The block is the contract: every skill you would move to .archive/ MUST appear in exactly one of the two lists. Return ONLY the YAML block after the summary — no post-block prose. Format EXACTLY:
1897
-
1898
- ## Structured summary (required)
1899
- \`\`\`yaml
1900
- consolidations:
1901
- - from: <old-skill-name>
1902
- mode: reference # optional — ONLY for a 'demote': source is narrow-but-valuable session detail, write it as references/<source>.md under the umbrella instead of appending to the body. Default is append. Place this line BEFORE into:. NEVER use reference when the source body links its own references/ templates/ scripts/ files.
1903
- into: <umbrella-skill-name>
1904
- reason: <one short sentence — why merged, not just 'similar'>
1905
- prunings:
1906
- - name: <skill-name>
1907
- reason: <one short sentence — why archived with no merge target>
1908
- \`\`\`
1909
-
1910
- Every skill you would move to .archive/ MUST appear in exactly one of the two lists. If you consolidated X into umbrella Y (patched Y, wrote a references file to Y, or created Y with X's content absorbed), X goes under consolidations with into: Y. If you archived X with no absorption — truly stale, irrelevant, or obsolete — X goes under prunings. Leave a list empty (consolidations: []) if none. Do not omit the block. The block comes AFTER your human-readable summary of clusters processed, patches made, and decisions left alone.`;
1911
- const CURATOR_DRY_RUN_BANNER = `═══════════════════════════════════════════════════════════════
1912
- DRY-RUN — REPORT ONLY. DO NOT MUTATE THE SKILL LIBRARY.
1913
- ═══════════════════════════════════════════════════════════════
1914
-
1915
- This is a PREVIEW pass. Follow every instruction above EXCEPT:
1916
- • Do NOT call skill_manage with action=create, update, patch, delete, write_file, or remove_file.
1917
- • Do NOT move, copy, or rewrite any file under the skills tree.
1918
-
1919
- Your output IS the deliverable: produce the exact same human-readable summary and YAML block you would on a live run, describing the actions you WOULD take. A reviewer will decide whether to approve a live run.
1920
-
1921
- If you accidentally take a mutating action, say so explicitly in the summary.`;
1922
- const COMPLETION_SKILL_REVIEW_PROMPT = `[Auto-review — Skills · task complete]
1923
- Your current task now appears complete. Before wrapping up, review the approach and update the skill library via skill_manage.
1924
-
1925
- Follow the skills review policy: be ACTIVE, prefer class-level umbrellas, patch ONLY skills loaded or read this session, and capture non-trivial techniques and user corrections. Do NOT capture environment-dependent failures, negative claims about tools, or one-off task narratives.
1926
-
1927
- Do NOT modify output files or re-run the task. If you are still mid-task, ignore this.`;
1928
- /**
1929
- * Maintenance-subagent persona (design 011 §6). Constants pin the persona
1930
- * with `{signal:id}` placeholders; the renderer substitutes names from the
1931
- * drift-signals module (single vocabulary). The model-facing contract is the
1932
- * persona + the mechanical-facts block; the signature head lets the model
1933
- * compare the two heads (011 mismatch protocol).
1934
- */
1935
- const MAINTAIN_PROMPT = `<<<MAINTAIN_PROMPT v={bundle_version} sig={joint_signature}>>>
1936
-
1937
- ## 角色
1938
- 你是技能库的**外部审计者**:只读、只输出计划、从不执行(执行由用户命令与审批完成)。
1939
-
1940
- ## 1. 输入契约(冲突时以此为准)
1941
- 机械事实块 <<<MECHANICAL_FACTS v={signals_version} sig={joint_signature}>>>(下方,以 <<<END FACTS>>> 闭合)是唯一证据来源。
1942
- - verdict 仅三值:pass=未越阈 / over=越阈 / unknown=未检测。
1943
- - over 是事实位置,不是违规结论;没有条款对应的事实,不产生建议。
1944
- - unknown ≠ pass;引用 unknown 信号的条目必须 needs_human:true。
1945
- - 事实只读:不改写、不补写、不把事实"翻译"成裁决。
1946
- - 两处 sig 不一致或任一缺失 → 只输出 MISMATCH + 两侧版本号,禁止输出计划。
1947
-
1948
- ## 2. 信号→条款映射(每条 over 必须落到条款;无一遗漏)
1949
- {signal:dedup_group}→A1 · {signal:narrow_name}→A2 · {signal:prefix_cluster}→A3 · {signal:stamp_density}与{signal:body_size}→B1 · {signal:pointer_missing}→B2 · {signal:dup_heading}→B3 · {signal:overlong_line}→B4 · {signal:description_chars}→B5 · {signal:usage_observed}/{signal:quality_low}→门控(校验器对 quality_low=unknown 的技能强制 needs_human,模板侧不重复)
1950
-
1951
- ## 3. 完整性契约(校验器机械执行)
1952
- 事实块中每条 over 信号必须满足其一:成为某条建议的 evidence,或在 notes 中说明"已审·无条款对应·不动作"。禁止静默省略;先逐信号核对再输出。
1953
-
1954
- ## 4. 工作流程(按序执行,不得跳步)
1955
- ① 通读事实块 → ② 对每个候选技能用 skill 读正文(B1/B2/B4 必读;**读取失败必须报告工具返回的事实**(错误信息/无对应条目),禁止用"无法读取"含糊绕过)→ ③ maintenance_probe 按需深挖 → ④ 逐信号过 §3 完整性 → ⑤ 输出计划。
1956
-
1957
- ## 5. 检查清单(信号 → 语义判定 → 输出形态)
1958
- A. 域·碎片化
1959
- - A1 {signal:dedup_group}=over:判近重复组是否同伞可合并;是→relationship-level consolidate;否→不输出。
1960
- - A2 {signal:narrow_name}=over:判否"仅对今日任务成立";成立→改名/归档建议;内部代号(格式合规语义窄)→conf≤0.4+needs_human。
1961
- - A3 {signal:prefix_cluster}=over:判簇内是否同伞;非同伞→notes 提域划分观察,不强制建伞。
1962
-
1963
- B. 层·分层错位
1964
- - B1 {signal:stamp_density}(阈值 {signal:stamp_density.threshold})或 {signal:body_size}(阈值 {signal:body_size.threshold})=over:按**三问判据**判锚/残留——① 该编号/时间戳是否被库内其他文件引用?② 除"何时产生/为何存在"外是否还承载信息?③ 删除是否影响任何跨文档检索?(①是且③是→锚;否则→残留候选,人审)。锚→允许保留 + needs_human + semantic_reasoning 写三问结果;**锚不使用 is_override**(is_override 仅用于 §7 申诉;锚是 B1 的正常裁决路径);残留→restructure 建议(movable headings 逐字引用)。**锚≠可读:单行 >4000 字符即使在锚类也必须拆分。**
1965
- - B2 {signal:pointer_missing}=over:读支持文件后判性质——可复用模式→上移正文;会话专属实录→保留+补指针;形态=patch 指引。**未读内容仅凭文件名 → conf≤0.4 且措辞"先人工确认再执行"。** **缺失指针=支持文件存在、正文无引用(单向语义),finding 表述勿反向。**
1966
- - B3 {signal:dup_heading}=over:删除多余标题行(保留一份),patch 指引。
1967
- - B4 {signal:overlong_line}=over:>1500 拆行;>4000 判定可读性危机(内容合法也拆);patch 指引。**finding 必须给全量口径:共 N 行超限,其中 >4000 的逐行列出。**
1968
- - B5 {signal:description_chars}=over:先判**性质**三分类——事件性承诺(单次故障/incident 写入元数据)→裁剪建议;叙事性自我描述→压缩建议;丰富但合规(完整用例边界)→保留 + is_override + override_reason="合法密度"。**分类特征**:含"恢复/修复某次事故、日期快照"类一次性措辞→事件性承诺;"动词+对象"式任务说明→叙事性;枚举完整用例边界且不可拆分→丰富合规。**第三类门槛(默认从严,半机械)**:先自行试写一个 ≤60 字压缩方案——能保留全部路由关键项(触发词+域)→ 不可判第三类(按压缩建议);只有试写失败(在 semantic_reasoning 列出试写方案与具体失败点)才可判丰富合规。描述文本可见(probe desc-text 或正文 frontmatter)时仍须三分类;仅长度可见 → conf≤0.4。semantic_reasoning 必写三分类之一。
1969
-
1970
- D. 库·整合纪律(计划形态约束)
1971
- - D1 同类问题多处出现→合成一条 relationship-level 建议,不逐项输出。
1972
- - D2 结构类优先级高于内容类;影响面 library-level > relationship-level > skill-level。
1973
-
1974
- ## 6. 输出契约(校验器机械执行)
1975
- {verdict: "issues" | "no_issues",
1976
- plan: [{ kind: "skill-level"|"relationship-level"|"library-level", names: [str],
1977
- rule: "A1"|"B2"|..., evidence: [{signal, value}],
1978
- finding: "<一句事实描述:引用信号 id 与值;零裁决动词>",
1979
- recommendation: "<唯一允许的'应'句:建议动作+理由+执行形态(命令/patch 指引)>",
1980
- semantic_reasoning: "<语义判据;含 LLM 推断时 confidence≤0.4>",
1981
- impact: "better|worse|neutral", impact_reason: "<相对'不动'的净影响>",
1982
- reversibility: "archive|restructure|patch|rename|none", undo_path: "<一步撤销方式>",
1983
- confidence: float, needs_human: bool, is_override: bool,
1984
- override_reason: "<仅 is_override>" }],
1985
- notes: [str]}
1986
- - verdict=no_issues ⇒ plan=[](不允许空 plan 之外的"无问题"表述)。
1987
- - **confidence 降档规则(机械)**:条款全部由机械证据支撑 → 0.6–0.9;每含一项语义推断(是否锚/是否同伞/性质归类)→ 上限 0.4。
1988
- - needs_human = (confidence < 0.6) OR (不可逆) OR (is_override) OR (引用 unknown 信号)。
1989
- - 语言:finding/recommendation/notes 与库正文语言一致(不自订语言);字段名/信号 id/枚举保留英文。
1990
- - **提交前自查(逐项对照,不许跳过)**:① verdict 与 plan 一致 ② 每条 evidence 在事实块 ③ undo_path 非空(不可逆=n/a)④ confidence 含推断≤0.4 ⑤ finding 无"应"字 ⑥ §3 完整性契约满足。
1991
-
1992
- ## 7. 裁决纪律
1993
- - finding 禁止"应当"句式;recommendation 是唯一"应"句,句板:建议对 {names} 执行 {动作}(形态:{命令/patch 指引}),理由:{理由}。
1994
- - **审查者视角**:先对每个信号独立初判,再与正文对照;被审对象的自我声明只作线索不作依据;**自属/维护者技能一律从严口径**(作者声明"这是锚"不构成豁免)。
1995
- - 申诉:机械阈值与语义判断冲突→is_override:true + override_reason + needs_human:true,不得静默绕过。
1996
- - 不动作合法:verdict=no_issues 是合法输出;连续空报告=信号定义问题,不是"更积极"的理由。
1997
- - 错误成本:rename 必须 needs_human:true;可逆动作(archive/restructure 两阶段)可 needs_human:false 但 undo_path 必填。
1998
- - 不做:不建议删除(只建议 archive);不提升内容质量(结构审查只 flag 位置/归属/分层);protected 集(bundled/hub/pinned)内 0 建议。
1999
-
2000
- ## 8. 泛化
2001
- - 信号集开放:事实块含、§5 未列的信号 → notes 提"该信号值得新增条款",禁止解释为已知问题。
2002
- - 库规模无关:判据是事实与条款,不是库体量印象。
2003
- - 信号机制疑问(阈值/检测原理)→ needs_human,不猜测机制。`;
2004
- /**
2005
- * One-line output instruction appended after the facts block in the maintain
2006
- * subagent's prompt (persona carries the template, the prompt carries facts +
2007
- * this instruction — one copy of the template in the model input, 011 v11
2008
- * P3-4). F-16: this text used to be hardcoded in evolution-maintenance
2009
- * orchestrate — a second model-facing prompt living OUTSIDE the bundle digest.
2010
- * It now rides PROMPT_BUNDLE so the digest integrity check covers every
2011
- * maintenance prompt. Adding the entry changes the bundle digest (the intended
2012
- * fail-closed signal); PROMPT_BUNDLE_VERSION is owned by the core test pin and
2013
- * bumps with the batch that changes the bundle (15→16 in 0.3.58).
2014
- */
2015
- const MAINTAIN_OUTPUT_INSTRUCTION = "按模板契约输出 JSON 维护计划(verdict/plan/notes);你有 skill 工具与维护模板;并在维护探针(maintenance_probe)挂载时可用它深挖细节。";
2016
1858
  /**
2017
- * System-prompt guidance section (Hermes `SKILLS_GUIDANCE`, DSH-adapted).
2018
- * Registered as a system-prompt section by tool-skill-manage (it mounts
2019
- * exactly when `skill_manage` is available the DSH analogue of Hermes'
2020
- * `if "skill_manage" in agent.valid_tool_names` condition). Instructs the
2021
- * model to save/repair skills on its own initiative.
1859
+ * DSH home root: `$DSH_HOME` or `~/.dsh`. Single source of the empty-string
1860
+ * fallback an EMPTY or WHITESPACE-ONLY DSH_HOME resolves to the default
1861
+ * home, never to a CWD-relative path (0.3.19 W1.3, 0.3.22 F-207);
1862
+ * V8-06 (0.3.47) extends the guard to whitespace (upstream home-paths:
1863
+ * `trim().length > 0` is the adoption test — `DSH_HOME=" "` must not produce
1864
+ * a sidecar under a relative "." path).
1865
+ * C-11: the adoption test and the RETURNED value now come from the
1866
+ * SAME trimmed source — the old form tested `trim()` but returned the raw
1867
+ * value, so `DSH_HOME=" /x "` was accepted AND persisted with literal spaces.
1868
+ * OPT-27 (2026-09, plan D5 — accepted): the v10-era "no `~` expansion, no
1869
+ * resolve" divergence from upstream `resolveDshHome` is RETIRED. It became
1870
+ * load-bearing when the skill-catalog shadow made "same tree as the upstream
1871
+ * `USER_DSH_RANK` provider" a hard contract: upstream watches the EXPANDED
1872
+ * absolute `<home>/skills` while this value fed a literal `~/x` (a directory
1873
+ * named `~` under the host CWD) or a CWD-relative path — split-brain skill
1874
+ * trees, preset installs the platform never reads, doctor probes of a
1875
+ * directory nothing serves. Behavior now matches upstream:
1876
+ * `resolve(expandHomePath(selected))`. Only `~`-prefixed and RELATIVE
1877
+ * DSH_HOME values change landing spot; absolute homes are byte-identical.
2022
1878
  */
2023
- const SKILLS_GUIDANCE = `Skills guidance:
2024
- After completing a complex task (5+ tool calls), fixing a tricky error, or discovering a non-trivial workflow, save the approach as a skill with skill_manage so you can reuse it next time.
2025
- When using a skill and finding it outdated, incomplete, or wrong, patch it immediately with skill_manage (action='patch') — don't wait to be asked. Skills that aren't maintained become liabilities.`;
2026
- const PLAN_CHANNEL_NOTE = `
2027
-
2028
- CHANNEL (subagent): this review channel mounts only the read-only \`skill\` tool — you have NO \`skill_manage\`, NO \`memory\`. Your deliverable is the structured JSON plan below (outputSchema). Describe the patches/creates you RECOMMEND in the plan; never narrate actions you took.`;
2029
- /** Subagent-channel variant: same review policy, channel-limited deliverable (M-2). */
2030
- const SKILL_REVIEW_PLAN_PROMPT = `${SKILL_REVIEW_PROMPT}${PLAN_CHANNEL_NOTE}`;
2031
- /** Subagent-channel variant of the combined review (M-2). */
2032
- const COMBINED_REVIEW_PLAN_PROMPT = `${COMBINED_REVIEW_PROMPT}${PLAN_CHANNEL_NOTE}`;
2033
- function reviewPrompt(kind, channel = "agent") {
2034
- if (channel === "plan") return kind === "skill" ? SKILL_REVIEW_PLAN_PROMPT : COMBINED_REVIEW_PLAN_PROMPT;
2035
- if (kind === "memory") return MEMORY_REVIEW_PROMPT;
2036
- if (kind === "skill") return SKILL_REVIEW_PROMPT;
2037
- return COMBINED_REVIEW_PROMPT;
2038
- }
2039
- function sha256(text) {
2040
- return createHash("sha256").update(text).digest("hex");
1879
+ function evolutionRoot(env = process.env) {
1880
+ const home = env.DSH_HOME?.trim();
1881
+ const selected = home ? home : join(homedir(), ".dsh");
1882
+ const expanded = selected === "~" ? homedir() : selected.startsWith("~/") || selected.startsWith("~\\") ? join(homedir(), selected.slice(2)) : selected;
1883
+ return isAbsolute(expanded) ? expanded : resolve(expanded);
2041
1884
  }
2042
- function createPromptBundle(prompts) {
2043
- const canonical = JSON.stringify({
2044
- id: PROMPT_BUNDLE_ID,
2045
- version: 17,
2046
- prompts: Object.fromEntries(Object.entries(prompts).sort())
2047
- });
2048
- return Object.freeze({
2049
- id: PROMPT_BUNDLE_ID,
2050
- version: 17,
2051
- prompts: Object.freeze({ ...prompts }),
2052
- sha256: sha256(canonical)
2053
- });
1885
+ /** Evolution home path helper: `$DSH_HOME/evolution` for plugin-owned sidecar
1886
+ * state (reports, activity store, feedback file, state-domain data). */
1887
+ function evolutionHome(env = process.env) {
1888
+ return join(evolutionRoot(env), "evolution");
2054
1889
  }
2055
- const PROMPT_BUNDLE = createPromptBundle({
2056
- memory: MEMORY_REVIEW_PROMPT,
2057
- skill: SKILL_REVIEW_PROMPT,
2058
- combined: COMBINED_REVIEW_PROMPT,
2059
- skillPlan: SKILL_REVIEW_PLAN_PROMPT,
2060
- combinedPlan: COMBINED_REVIEW_PLAN_PROMPT,
2061
- curator: CURATOR_PROMPT,
2062
- completion: COMPLETION_SKILL_REVIEW_PROMPT,
2063
- maintain: MAINTAIN_PROMPT,
2064
- maintainOutput: MAINTAIN_OUTPUT_INSTRUCTION,
2065
- skillsGuidance: SKILLS_GUIDANCE
2066
- });
2067
- function verifyPromptBundle(bundle = PROMPT_BUNDLE) {
2068
- if (bundle.id !== PROMPT_BUNDLE_ID || bundle.version !== 17) return false;
2069
- const canonical = JSON.stringify({
2070
- id: PROMPT_BUNDLE_ID,
2071
- version: 17,
2072
- prompts: Object.fromEntries(Object.entries(bundle.prompts).sort())
2073
- });
2074
- return bundle.sha256 === sha256(canonical);
1890
+ //#endregion
1891
+ //#region lib/types/serial.js
1892
+ /**
1893
+ * A process-local serial task queue: each task starts only after the previous
1894
+ * one settles (success or failure), so read-modify-write sequences that share
1895
+ * one file never interleave inside this process. The durable cross-process
1896
+ * serialization layer is the IO backend's transact lock; this chain is the
1897
+ * second layer (0.3.17 S2.8, T-1: the shape was duplicated in state-json and
1898
+ * memory-files — one factory now).
1899
+ */
1900
+ function makeSerialQueue() {
1901
+ let chain = Promise.resolve();
1902
+ return (task) => {
1903
+ const run = chain.then(task, task);
1904
+ chain = run.then(() => void 0, () => void 0);
1905
+ return run;
1906
+ };
2075
1907
  }
2076
- const DSH_AUTHORING_STANDARDS = `Follow the Hermes skill-authoring standards, translated to DSH tools.
2077
-
2078
- Frontmatter:
2079
- - name: lowercase-hyphenated, <=64 chars, no spaces.
2080
- - description: ONE sentence, <=60 characters, ends with a period. State the capability, not the implementation. No marketing words. Do NOT repeat the skill name. Count the characters before saving. If the description contains a colon, wrap the whole value in double quotes.
2081
- - version: 0.1.0
2082
- - author: always the literal value "Hermes". NEVER fill it from the environment, git config, or any identity you can probe — an environment-derived name is a privacy leak the user never opted into (skills get shared and published), and the skill names itself as Hermes.
2083
- - platforms: declare [macos], [linux], and/or [windows] only when the skill is genuinely OS-bound (osascript/apt/systemctl => the matching OS; /proc, signal.SIGKILL => linux; fcntl/termios => POSIX). Prefer fixing it cross-platform first (tempdir, pathlib, pure-Node); omit the field for portable skills.
2084
- - metadata.hermes.tags: a few Capitalized, Relevant, Tags.
2085
- - metadata.hermes.related_skills: [a, b] — name sibling skills this one builds on or is referenced by (optional; feeds the quality references factor).
2086
-
2087
- Body section order (omit only when empty):
2088
- 1. "# <Human Title>" then a 2-3 sentence intro: what it does, what it does NOT do, key dependency stance.
2089
- 2. "## When to Use" — concrete trigger phrases.
2090
- 3. "## Prerequisites" — exact env vars, install steps, credentials.
2091
- 4. "## How to Run" — canonical invocation framed through DSH tools.
2092
- 5. "## Quick Reference" — flat command/endpoint list.
2093
- 6. "## Procedure" — numbered steps with copy-paste-exact commands.
2094
- 7. "## Pitfalls" — known limits and rate limits.
2095
- 8. "## Verification" — one check proving the skill worked.
2096
-
2097
- DSH-tool framing:
2098
- - Reference DSH tools by name in backticks: \`bash\`, \`str_replace_editor\`, \`write\`, \`skill\`, \`skill_manage\`, \`memory\`.
2099
- - Do not name wrapped shell utilities when a DSH tool already covers them.
2100
- - Larger scripts belong under \`scripts/\` (written with \`skill_manage write_file\`) and are referenced from SKILL.md by relative path.
2101
-
2102
- Quality bar:
2103
- - Prefer verbatim flags, paths, and APIs from the source. Never invent them.
2104
- - Keep it tight: ~100 lines simple, ~200 complex.
2105
- - No router/index/hub skills that only point at other skills.
2106
- - References go in \`references/\`, templates in \`templates/\`.
2107
-
2108
- Learn workflow (when the user asks you to learn a reusable skill, or you decide to turn a source/request into one):
2109
- 1. Gather every source named (files, URLs, "what we just did", pasted notes) with the tools you already have — and treat prose after a source as authoring requirements, not noise.
2110
- 2. Apply every requirement and constraint from the request to the SKILL.md you author.
2111
- 3. Author exactly ONE SKILL.md and save it with \`skill_manage\` (action=create); non-trivial scripts go under \`scripts/\`.
2112
- 4. When done, tell the user the skill name, its category, and a one-line summary of what it captured.`;
2113
1908
  //#endregion
2114
- //#region lib/types/learn-prompt.js
1909
+ //#region lib/types/numeric.js
2115
1910
  /**
2116
- * Open-ended `/evolution learn` prompt builder.
2117
- *
2118
- * `learn` is open-ended: the user can name anything they can describe — a
2119
- * directory of code, an API doc URL, a workflow they just walked the agent
2120
- * through, or pasted notes. The prompt instructs the live agent to gather the
2121
- * named sources with its existing tools, then author a single SKILL.md via
2122
- * `skill_manage` following `DSH_AUTHORING_STANDARDS`. There is no separate
2123
- * distillation engine and no model-tool footprint.
2124
- */
2125
- /**
2126
- * Build the agent prompt for an open-ended `/evolution learn` request.
2127
- *
2128
- * @param userRequest free-text the user gave after `/evolution learn`; an
2129
- * empty string falls back to "the workflow we just went through".
2130
- * @returns a complete instruction the agent runs as a normal turn.
2131
- */
2132
- function buildLearnPrompt(userRequest) {
2133
- return [
2134
- "[/learn] The user wants you to learn a reusable skill from the request below, and save it.",
2135
- "",
2136
- "THE REQUEST:",
2137
- userRequest.trim() || "the workflow we just went through in this conversation — review the steps taken and distill them into a reusable skill",
2138
- "",
2139
- "The request is open-ended and may mix two kinds of content, in any order: SOURCES to gather (directories, file paths, URLs, \"what we just did\", pasted notes) AND REQUIREMENTS that shape the skill (what to focus on, what to leave out, scope, naming, the angle to take). Treat EVERY part of the request as load-bearing. In particular, prose that comes after a path or link is NOT incidental — it is the user telling you what they want from that source. A request like `<url> focus on the auth flow, skip the deprecated endpoints` means: gather the URL AND honor \"focus on auth, skip deprecated\" as authoring requirements. Never fetch the first source and ignore the rest.",
2140
- "",
2141
- "Do this:",
2142
- "1. Gather every source the user named, using the tools you already have — reads and searches for local files or directories, web access for URLs, this conversation history if they referred to something you just did, and the text they pasted as-is. If the request is ambiguous about scope, make a reasonable choice and note it; do not stall.",
2143
- "2. Author ONE SKILL.md, applying every requirement, focus, and constraint in the request — these govern what the SKILL.md covers and emphasizes, not just which sources you read.",
2144
- "3. Save it with the `skill_manage` tool (action=\"create\"). Pick a sensible category. If the procedure needs a non-trivial script, add it under the skill's `scripts/` with `skill_manage` write_file and reference it by relative path.",
2145
- "",
2146
- DSH_AUTHORING_STANDARDS,
2147
- "",
2148
- "When done, tell the user the skill name, its category, and a one-line summary of what it captured."
2149
- ].join("\n");
2150
- }
2151
- //#endregion
2152
- //#region lib/types/state-store.js
2153
- /**
2154
- * Evolution home path helpers: the DSH root and `$DSH_HOME/evolution` for
2155
- * plugin-owned sidecar state (reports, activity store, feedback file,
2156
- * state-domain data).
2157
- *
2158
- * C-10: PATH HELPERS ONLY — despite the file name there is no store
2159
- * here. Durable evolution state lives in the state stack (evolution-state over
2160
- * evolution-state-json / -domain); skills and memories live in skill-store.ts
2161
- * / memory-store.ts. The file name is kept deliberately: renaming it would
2162
- * touch every family import for zero behavior change, and the audit records
2163
- * the mismatch as known naming debt.
2164
- */
2165
- /**
2166
- * DSH home root: `$DSH_HOME` or `~/.dsh`. Single source of the empty-string
2167
- * fallback — an EMPTY or WHITESPACE-ONLY DSH_HOME resolves to the default
2168
- * home, never to a CWD-relative path (0.3.19 W1.3, 0.3.22 F-207);
2169
- * V8-06 (0.3.47) extends the guard to whitespace (upstream home-paths:
2170
- * `trim().length > 0` is the adoption test — `DSH_HOME=" "` must not produce
2171
- * a sidecar under a relative "." path).
2172
- * C-11: the adoption test and the RETURNED value now come from the
2173
- * SAME trimmed source — the old form tested `trim()` but returned the raw
2174
- * value, so `DSH_HOME=" /x "` was accepted AND persisted with literal spaces.
2175
- * OPT-27 (2026-09, plan D5 — accepted): the v10-era "no `~` expansion, no
2176
- * resolve" divergence from upstream `resolveDshHome` is RETIRED. It became
2177
- * load-bearing when the skill-catalog shadow made "same tree as the upstream
2178
- * `USER_DSH_RANK` provider" a hard contract: upstream watches the EXPANDED
2179
- * absolute `<home>/skills` while this value fed a literal `~/x` (a directory
2180
- * named `~` under the host CWD) or a CWD-relative path — split-brain skill
2181
- * trees, preset installs the platform never reads, doctor probes of a
2182
- * directory nothing serves. Behavior now matches upstream:
2183
- * `resolve(expandHomePath(selected))`. Only `~`-prefixed and RELATIVE
2184
- * DSH_HOME values change landing spot; absolute homes are byte-identical.
2185
- */
2186
- function evolutionRoot(env = process.env) {
2187
- const home = env.DSH_HOME?.trim();
2188
- const selected = home ? home : join(homedir(), ".dsh");
2189
- const expanded = selected === "~" ? homedir() : selected.startsWith("~/") || selected.startsWith("~\\") ? join(homedir(), selected.slice(2)) : selected;
2190
- return isAbsolute(expanded) ? expanded : resolve(expanded);
2191
- }
2192
- /** Evolution home path helper: `$DSH_HOME/evolution` for plugin-owned sidecar
2193
- * state (reports, activity store, feedback file, state-domain data). */
2194
- function evolutionHome(env = process.env) {
2195
- return join(evolutionRoot(env), "evolution");
2196
- }
2197
- //#endregion
2198
- //#region lib/types/serial.js
2199
- /**
2200
- * A process-local serial task queue: each task starts only after the previous
2201
- * one settles (success or failure), so read-modify-write sequences that share
2202
- * one file never interleave inside this process. The durable cross-process
2203
- * serialization layer is the IO backend's transact lock; this chain is the
2204
- * second layer (0.3.17 S2.8, T-1: the shape was duplicated in state-json and
2205
- * memory-files — one factory now).
2206
- */
2207
- function makeSerialQueue() {
2208
- let chain = Promise.resolve();
2209
- return (task) => {
2210
- const run = chain.then(task, task);
2211
- chain = run.then(() => void 0, () => void 0);
2212
- return run;
2213
- };
2214
- }
2215
- //#endregion
2216
- //#region lib/types/numeric.js
2217
- /**
2218
- * Numeric config clamping for the dsh-evolution plugin family.
1911
+ * Numeric config clamping for the dsh-evolution plugin family.
2219
1912
  *
2220
1913
  * G3.1 (0.3.23): numeric configuration values are normalised through a single
2221
1914
  * helper so 0 / negative / NaN / ±Infinity / out-of-range all fall back to the
@@ -2466,11 +2159,22 @@ const PATTERNS = [
2466
2159
  regex: /(?:密钥|凭据|口令|密码|环境变量)[\s\S]{0,30}(?:发送|上传|传输|外传|泄露)[\s\S]{0,30}(?:到|至)\s*(?:https?:\/\/|[\w.-]+\.(?:com|net|org|io|cn|dev|xyz|ru)\b)/
2467
2160
  }
2468
2161
  ];
2469
- const INVISIBLE_CHAR_CLASS = "\\u034f\\u200b\\u2060\\u2061\\u2062\\u2063\\u2064\\u206a-\\u206f\\ufeff\\u{e0000}-\\u{e007f}";
2470
- const ZERO_WIDTH_CHARS = new RegExp(`[${INVISIBLE_CHAR_CLASS}]`, "u");
2162
+ const ZERO_WIDTH_CHARS = new RegExp(`[\\u034f\\u200b\\u2060\\u2061\\u2062\\u2063\\u2064\\u206a-\\u206f\\ufeff\\u{e0000}-\\u{e007f}]`, "u");
2471
2163
  const ZWJ_OUTSIDE_EMOJI = /(?<!\p{Extended_Pictographic})\u200d(?!\p{Extended_Pictographic})/u;
2472
2164
  const TYPOGRAPHY_CHARS = /[\u00ad\u061c\u180e\u200c\ufe00-\ufe0f]/;
2473
2165
  const BIDI_CHARS = /[\u202a-\u202e\u2066-\u2069]/;
2166
+ const FORMAT_CONTROL_CLASS = "\\p{Cf}\\p{Zl}\\p{Zp}\\u0000\\u2065\\ufff0-\\ufff8\\u{e0080}-\\u{e00ff}";
2167
+ const FORMAT_CONTROL_TEST = new RegExp(`[${FORMAT_CONTROL_CLASS}]`, "u");
2168
+ /** S0.4 (v37 P0-3): a format control the three finding sets above do NOT report. */
2169
+ function hasUnreportedFormatControl(text) {
2170
+ for (const character of text) {
2171
+ if (!FORMAT_CONTROL_TEST.test(character)) continue;
2172
+ if (ZERO_WIDTH_CHARS.test(character) || TYPOGRAPHY_CHARS.test(character) || BIDI_CHARS.test(character)) continue;
2173
+ if (character === "\\u200d") continue;
2174
+ return true;
2175
+ }
2176
+ return false;
2177
+ }
2474
2178
  const SCOPE_ORDER = {
2475
2179
  all: 1,
2476
2180
  context: 2,
@@ -2512,9 +2216,15 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_
2512
2216
  category: "unicode_obfuscation",
2513
2217
  scope: "all"
2514
2218
  });
2219
+ if (!excluded.has("unicode_format_control") && hasUnreportedFormatControl(text)) findings.push({
2220
+ label: "unicode_format_control",
2221
+ category: "unicode_obfuscation",
2222
+ scope: "all",
2223
+ severity: "report"
2224
+ });
2515
2225
  const normalized = text.normalize("NFKC");
2516
2226
  const SPACE_SPLITTERS = /[\u00ad\u061c\u180e\u200c\ufe00-\ufe0f]/gu;
2517
- const OBFUSCATION_SPLITTERS = new RegExp(`[${INVISIBLE_CHAR_CLASS}\\u200d]`, "gu");
2227
+ const OBFUSCATION_SPLITTERS = new RegExp(`[${FORMAT_CONTROL_CLASS}]`, "gu");
2518
2228
  const patternTexts = [normalized.replace(SPACE_SPLITTERS, " ").replace(OBFUSCATION_SPLITTERS, " "), normalized.replace(SPACE_SPLITTERS, "").replace(OBFUSCATION_SPLITTERS, "")];
2519
2229
  const windows = [];
2520
2230
  for (const patternText of patternTexts) if (patternText.length <= windowSize) windows.push(patternText);
@@ -2629,6 +2339,24 @@ function render(entries) {
2629
2339
  function stripDatePrefix(entry) {
2630
2340
  return entry.replace(/^## \d{4}-\d{2}-\d{2}\n/, "");
2631
2341
  }
2342
+ /**
2343
+ * Neutralize the platform's `{{name}}` prompt-variable syntax in text that is
2344
+ * about to be registered as a `systemPrompt.context` contribution.
2345
+ *
2346
+ * The platform interpolates every context/section text once per model step and
2347
+ * THROWS on an unknown or malformed reference — so one stored memory entry
2348
+ * containing `{{...}}` (a CI expression, a Jinja/Helm/Vue template, a doc
2349
+ * placeholder) used to fail `assemble()` on EVERY later pre-step of every
2350
+ * session under this DSH_HOME, while a registered name such as `{{cwd}}` was
2351
+ * silently substituted into the "memory" the model reads. Only the INJECTED
2352
+ * text is neutralized: stored entries keep their original bytes.
2353
+ *
2354
+ * @param text - rendered context text about to leave for the prompt surface.
2355
+ * @returns the same text with every `{{` split so it cannot start a reference.
2356
+ */
2357
+ function neutralizePromptVariables(text) {
2358
+ return text.includes("{{") ? text.replaceAll("{{", "{ {") : text;
2359
+ }
2632
2360
  /** F-201: does `content` carry the on-disk entry delimiter or a trailing
2633
2361
  * `\n§` fragment that would combine with the render terminator into a real
2634
2362
  * delimiter boundary? Both split the fact into multiple entries on read-back
@@ -3196,7 +2924,7 @@ var MemoryStore = class {
3196
2924
  parts.push(`## ${label} (${safe.length} entries)${usage}${note}\n${body}`);
3197
2925
  } else if (entries.length > 0) parts.push(`## ${label} — ${entries.length} entries withheld by the security scan; none injected`);
3198
2926
  }
3199
- return parts.join("\n\n");
2927
+ return neutralizePromptVariables(parts.join("\n\n"));
3200
2928
  }
3201
2929
  /**
3202
2930
  * Detect on-disk drift for a caller that holds no locked view: `true` when the
@@ -3312,56 +3040,452 @@ function composePresetComposition(standardComposition, deltaComposition) {
3312
3040
  const collisions = [...compositionRowIds(deltaComposition)].filter((id) => standardIds.has(id)).sort();
3313
3041
  if (collisions.length > 0 && !allowRowCollisions()) throw new Error(`evolution preset composition: delta rows collide with runtime standard rows: ${collisions.join(", ")}`);
3314
3042
  if (collisions.length > 0) console.warn(`evolution preset composition: warning — delta rows collide with standard rows (${collisions.join(", ")}); keeping both (DSH_EVOLUTION_ALLOW_ROW_COLLISIONS=1)`);
3315
- return injectCatalogDescriptionCap(`${standardComposition.replace(/\s+$/, "")}\n\n${deltaComposition.trim()}\n`);
3043
+ return applyRowOverrides(`${standardComposition.replace(/\s+$/, "")}\n\n${deltaComposition.trim()}\n`);
3044
+ }
3045
+ /** The composer-owned overrides, as DATA: one place states what the generated
3046
+ * preset must carry beyond the platform composition. V10-14's cap is the first
3047
+ * entry; install-layered.mjs keeps a byte-identical copy of this table. */
3048
+ const ROW_OVERRIDES = [{
3049
+ row: "tool-skill",
3050
+ key: "config",
3051
+ lines: [
3052
+ " # V10-14: Hermes 60-char catalog cap — injected by the preset composer (P1-2);",
3053
+ " # this preset-scope row is the session-visible instance and no profile",
3054
+ " # patch can reach it. Remove only to run the platform default (500).",
3055
+ " config:",
3056
+ " catalogDescriptionMaxLength: 60"
3057
+ ],
3058
+ missing: "evolution preset composition: warning — no `- id: tool-skill` row in the composed preset; the 60-char catalog cap was NOT injected (platform renamed the row? reconcile with the delta)"
3059
+ }];
3060
+ /**
3061
+ * Ensure every {@link RowOverride} inside its target row.
3062
+ *
3063
+ * Key-level by construction: we INSERT the override's own lines and leave a row
3064
+ * that already carries the key byte-identical, so a re-run never doubles a key.
3065
+ * This is the deliberate opposite of the platform's patch layers, where an
3066
+ * override REPLACES `config` wholesale — the difference is why the composer can
3067
+ * add a key without erasing the platform's own config defaults.
3068
+ */
3069
+ function applyRowOverrides(composition, overrides = ROW_OVERRIDES) {
3070
+ let lines = composition.split("\n");
3071
+ for (const override of overrides) lines = applyOneOverride(lines, override);
3072
+ return lines.join("\n");
3073
+ }
3074
+ function applyOneOverride(lines, override) {
3075
+ const rowRe = new RegExp("^- id:\\s*" + override.row.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\s*$");
3076
+ let found = false;
3077
+ for (let i = 0; i < lines.length; i += 1) {
3078
+ if (!rowRe.test(lines[i] ?? "")) continue;
3079
+ found = true;
3080
+ let end = i;
3081
+ let hasConfig = false;
3082
+ for (let j = i + 1; j < lines.length; j += 1) {
3083
+ const next = lines[j] ?? "";
3084
+ if (next.trim() === "") break;
3085
+ if (!/^\s/.test(next)) break;
3086
+ if (new RegExp("^ {2}" + override.key + ":(\\s|$)").test(next)) hasConfig = true;
3087
+ end = j;
3088
+ }
3089
+ if (hasConfig) continue;
3090
+ lines.splice(end + 1, 0, ...override.lines);
3091
+ i = end + override.lines.length;
3092
+ }
3093
+ if (!found) console.warn(override.missing);
3094
+ return lines;
3095
+ }
3096
+ function compositionRowIds(composition) {
3097
+ const ids = /* @__PURE__ */ new Set();
3098
+ for (const line of composition.split("\n")) {
3099
+ const id = /^- id:\s*(\S+)/.exec(line)?.[1];
3100
+ if (id) ids.add(id);
3101
+ }
3102
+ return ids;
3103
+ }
3104
+ //#endregion
3105
+ //#region lib/types/prompts.js
3106
+ /**
3107
+ * Review and curation prompts adapted from Hermes Agent
3108
+ * `agent/background_review.py`, `agent/curator.py`, and
3109
+ * `agent/learn_prompt.py`, with tool names translated to the DSH-native
3110
+ * catalog (`memory`, `skill_manage`, `skill`, `bash`, `str_replace_editor`).
3111
+ *
3112
+ * Alignment policy (2026-08-29): the OPERATIONAL steps and instructions the
3113
+ * model follows mirror the Hermes originals structurally (signal list,
3114
+ * preference order, support-file taxonomy, curator package integrity,
3115
+ * consolidated/pruned reporting block). Tool and platform differences are
3116
+ * DSH-adapted (native tool names, pinned-within-review semantics, this
3117
+ * platform's index cap), and DSH-only additions are marked as such.
3118
+ *
3119
+ * Every prompt is pinned in a versioned bundle. Review workers verify the
3120
+ * bundle digest before spending a model call — v31 PROMPT-01, stated
3121
+ * precisely: THAT check proves internal coherence (id/version/digest agree)
3122
+ * for a bundle assembled OUT of process and handed to `verifyPromptBundle`
3123
+ * explicitly. It CANNOT detect in-process tampering (the digest is recomputed
3124
+ * from the same module state it verifies) — catching a stale or partially
3125
+ * patched default bundle is CI's version pin (tests/prompts.spec.ts), not
3126
+ * this runtime gate.
3127
+ */
3128
+ /**
3129
+ * Prompt bundle identity. Bump both id and version whenever a prompt's text
3130
+ * changes semantically: the bundle digest is the fail-closed signal for
3131
+ * review workers, so a stale id across deployments must be distinguishable.
3132
+ */
3133
+ const PROMPT_BUNDLE_VERSION = 17;
3134
+ const PROMPT_BUNDLE_ID = `dsh-evolution@17`;
3135
+ const MEMORY_REVIEW_PROMPT = `[Auto-review — Memory]
3136
+ Review the conversation above and consider saving to memory if appropriate.
3137
+
3138
+ Focus on:
3139
+ 1. Has the user revealed things about themselves — persona, desires, preferences, or personal details worth remembering?
3140
+ 2. Has the user expressed expectations about how you should behave, their work style, or ways they want you to operate?
3141
+
3142
+ If something stands out, save it using the memory tool.
3143
+ If nothing is worth saving, just say "Nothing to save." and stop.`;
3144
+ const SKILL_REVIEW_PROMPT = `[Auto-review — Skills]
3145
+ Review the conversation above and update the skill library. Be ACTIVE — most sessions produce at least one skill update, even if small. A pass that does nothing is a missed learning opportunity, not a neutral outcome.
3146
+
3147
+ Target shape of the library: CLASS-LEVEL skills, each with a rich SKILL.md and a references/ directory for session-specific detail. Not a long flat list of narrow one-session-one-skill entries. This shapes HOW you update, not WHETHER you update.
3148
+
3149
+ Signals to look for (any one of these warrants action):
3150
+ • User corrected your style, tone, format, legibility, or verbosity. Frustration signals like 'stop doing X', 'this is too verbose', 'don't format like this', 'why are you explaining', 'just give me the answer', 'you always do Y and I hate it', or an explicit 'remember this' are FIRST-CLASS skill signals, not just memory signals. Update the relevant skill(s) to embed the preference so the next session starts already knowing.
3151
+ • User corrected your workflow, approach, or sequence of steps. Encode the correction as a pitfall or explicit step in the skill that governs that class of task.
3152
+ • Non-trivial technique, fix, workaround, debugging path, or tool-usage pattern emerged that a future session would benefit from. Capture it.
3153
+ • A skill that got loaded or consulted this session turned out to be wrong, missing a step, or outdated. Patch it NOW.
3154
+
3155
+ Read-before-write: update, patch, delete, or write support files ONLY into skills you loaded or read in THIS session. On the plan channel ops on unread skills are rejected; direct writes have no such guard, so treat the rule as binding. CREATE of a brand-new umbrella is the only exception.
3156
+
3157
+ Preference order — prefer the earliest action that fits, but do pick one when a signal above fired:
3158
+ 1. UPDATE A CURRENTLY-LOADED SKILL. Look back through the conversation for skills the user loaded or you read. If any of them covers the territory of the new learning, PATCH that one first. It is the skill that was in play, so it's the right one to extend.
3159
+ 2. UPDATE AN EXISTING UMBRELLA. If no loaded skill fits but an existing class-level skill does, patch it. Add a subsection, a pitfall, or broaden a trigger.
3160
+ 3. ADD A SUPPORT FILE under an existing umbrella. Skills can be packaged with three kinds of support files — use the right directory per kind:
3161
+ • references/<topic>.md — session-specific detail (error transcripts, reproduction recipes, provider quirks) AND condensed knowledge banks: quoted research, API docs, external authoritative excerpts, or domain notes you found while working on the problem. Write it concise and for the value of the task, not as a full mirror of upstream docs.
3162
+ • templates/<name>.<ext> — starter files meant to be copied and modified (boilerplate configs, scaffolding, a known-good example the agent can reproduce with modifications).
3163
+ • scripts/<name>.<ext> — statically re-runnable actions the skill can invoke directly (verification scripts, fixture generators, deterministic probes, anything the agent should run rather than hand-type each time).
3164
+ Add support files via skill_manage action=write_file with file_path starting 'references/', 'templates/', or 'scripts/'. The umbrella's SKILL.md should gain a one-line pointer to any new support file so future agents know it exists.
3165
+ 4. RESTRUCTURE a loaded skill whose body grew log-like — rc/sha/date-dense sections, session-detail spirals, or a fat body with no support files. Use skill_manage action=restructure with restructure: [{"heading": "<the exact ## heading text>", "to_file": "references/<topic>.md"}] — the ENTIRE ## section (from that heading to the next heading) moves into the support file and its position becomes a pointer line. The skill's name and directory never change. Only propose headings that exist verbatim in the body; never invent one, and never restructure a healthy small skill.
3166
+ 5. CREATE A NEW CLASS-LEVEL UMBRELLA SKILL when no existing skill covers the class. The name MUST be at the class level. The name MUST NOT be a specific PR number, error string, feature codename, library-alone name, or 'fix-X / debug-Y / audit-Z-today' session artifact. If the proposed name only makes sense for today's task, it's wrong — fall back to (1), (2), or (3).
3167
+
3168
+ User-preference embedding (important): when the user expressed a style/format/workflow preference, the update belongs in the SKILL.md body, not just in memory. Memory captures 'who the user is and what the current situation and state of your operations are'; skills capture 'how to do this class of task for this user'. When they complain about how you handled a task, the skill that governs that task needs to carry the lesson.
3169
+
3170
+ If you notice two existing skills that overlap, note it in your reply — the background curator handles consolidation at scale.
3171
+
3172
+ Two-tier deposition discipline (DSH addition, same spirit as the umbrella rule): before writing, classify the knowledge:
3173
+ • PATTERN (reusable — symptom → mechanism → fix → verification, still valuable next session) belongs in the SKILL.md body.
3174
+ • LOG (one-off — commit SHAs, npm/profile states, what this release changed, this session's process narrative) belongs in a references/ file, never the body. Body density IS reuse rate. Keep new entries tight: a pattern fits in 2-8 physical lines; prefer changing the current-state pointer over appending history.
3175
+
3176
+ Protected skills (DO NOT edit these):
3177
+ • Bundled skills (shipped with the platform).
3178
+ • Hub-installed skills (installed from a hub).
3179
+ Pinned skills are read-only to THIS background review pass — the pinned write guard refuses background changes, so this pass may not update them. They also cannot be archived by any writer (the foreground included): remove the .pinned marker first. Foreground and delegated-subagent update/patch writes to pinned skills remain allowed.
3180
+ If the only skills that need updating are protected, say 'Nothing to save.' and stop.
3181
+
3182
+ Do NOT capture (these become persistent self-imposed constraints that bite you later when the environment changes):
3183
+ • Environment-dependent failures: missing binaries, fresh-install errors, post-migration path mismatches, 'command not found', unconfigured credentials, uninstalled packages. The user can fix these — they are not durable rules.
3184
+ • Negative claims about tools or features ('browser tools do not work', 'X tool is broken', 'cannot use Y'). These harden into refusals the agent cites against itself for months after the actual problem was fixed.
3185
+ • Session-specific transient errors that resolved before the conversation ended. If retrying worked, the lesson is the retry pattern, not the original failure.
3186
+ • One-off task narratives. A user asking 'summarize today's market' or 'analyze this PR' is not a class of work that warrants a skill.
3187
+
3188
+ If a tool failed because of setup state, capture the FIX (install command, config step, env var to set) under an existing setup or troubleshooting skill — never 'this tool does not work' as a standalone constraint.
3189
+
3190
+ 'Nothing to save.' is a real option but should NOT be the default. If the session ran smoothly with no corrections and produced no new technique, just say 'Nothing to save.' and stop. Otherwise, act.`;
3191
+ const COMBINED_REVIEW_PROMPT = `[Auto-review]
3192
+ Review the conversation above and update two things:
3193
+
3194
+ **Memory**: who the user is. Did the user reveal persona, desires, preferences, personal details, or expectations about how you should behave? Save facts about the user and durable preferences with the memory tool.
3195
+
3196
+ **Skills**: how to do this class of task. Be ACTIVE — most sessions produce at least one skill update. A pass that does nothing is a missed learning opportunity, not a neutral outcome.
3197
+
3198
+ Target shape of the skill library: CLASS-LEVEL skills with a rich SKILL.md and a references/ directory for session-specific detail. Not a long flat list of narrow one-session-one-skill entries.
3199
+
3200
+ Signals that warrant a skill update (any one is enough):
3201
+ • User corrected your style, tone, format, legibility, verbosity, or approach. Frustration is a FIRST-CLASS skill signal, not just a memory signal. 'stop doing X', 'don't format like this', 'I hate when you Y' — embed the lesson in the skill that governs that task so the next session starts fixed.
3202
+ • Non-trivial technique, fix, workaround, or debugging path emerged.
3203
+ • A skill that was loaded or consulted turned out wrong, missing, or outdated — patch it now.
3204
+
3205
+ Read-before-write: update, patch, delete, or write support files ONLY into skills you loaded or read in THIS session. On the plan channel ops on unread skills are rejected; direct writes have no such guard, so treat the rule as binding. CREATE of a brand-new umbrella is the only exception.
3206
+
3207
+ Preference order for skills — pick the earliest that fits:
3208
+ 1. UPDATE A CURRENTLY-LOADED SKILL. Check what skills were loaded or read in the conversation. If one of them covers the learning, PATCH it first. It was in play; it's the right place.
3209
+ 2. UPDATE AN EXISTING UMBRELLA. Patch it.
3210
+ 3. ADD A SUPPORT FILE under an existing umbrella via skill_manage action=write_file. Three kinds: references/<topic>.md for session-specific detail OR condensed knowledge banks (quoted research, API docs excerpts, domain notes) written concise and task-focused; templates/<name>.<ext> for starter files meant to be copied and modified; scripts/<name>.<ext> for statically re-runnable actions (verification, fixture generators, probes). Add a one-line pointer in SKILL.md so future agents find them.
3211
+ 4. RESTRUCTURE a loaded skill whose body grew log-like (rc/sha/date-dense sections, session-detail spirals, fat body with no support files) via skill_manage action=restructure with restructure: [{"heading": "<the exact ## heading text>", "to_file": "references/<topic>.md"}] — the ENTIRE ## section moves into the support file and its position becomes a pointer line; the skill's name and directory never change. Only propose headings that exist verbatim in the body.
3212
+ 5. CREATE A NEW CLASS-LEVEL UMBRELLA when nothing exists. Name at the class level — NOT a PR number, error string, codename, library-alone name, or 'fix-X / debug-Y' session artifact. If the name only fits today's task, fall back to (1), (2), or (3).
3213
+
3214
+ Two-tier deposition discipline (DSH addition): classify before writing — PATTERN (symptom → mechanism → fix → verification) goes in the SKILL.md body; LOG (commit SHAs, npm/profile states, this release's change list, this session's narrative) goes in a references/ file. Body density IS reuse rate; a pattern fits in 2-8 physical lines.
3215
+
3216
+ User-preference embedding: when the user complains about how you handled a task, update the skill that governs that task — memory alone isn't enough. Memory says 'who the user is and what the current situation and state of your operations are'; skills say 'how to do this class of task for this user'. Both should carry user-preference lessons when relevant.
3217
+
3218
+ If you notice overlapping existing skills, mention it — the background curator handles consolidation.
3219
+
3220
+ Protected skills (DO NOT edit these):
3221
+ • Bundled skills (shipped with the platform).
3222
+ • Hub-installed skills (installed from a hub).
3223
+ Pinned skills are read-only to THIS background review pass — the pinned write guard refuses background changes, so this pass may not update them. They also cannot be archived by any writer (the foreground included): remove the .pinned marker first. Foreground and delegated-subagent update/patch writes to pinned skills remain allowed.
3224
+ If the only skills that need updating are protected, say 'Nothing to save.' and stop.
3225
+
3226
+ Do NOT capture as skills (these become persistent self-imposed constraints that bite you later when the environment changes):
3227
+ • Environment-dependent failures: missing binaries, fresh-install errors, post-migration path mismatches, 'command not found', unconfigured credentials, uninstalled packages. The user can fix these — they are not durable rules.
3228
+ • Negative claims about tools or features ('browser tools do not work', 'X tool is broken', 'cannot use Y'). These harden into refusals the agent cites against itself for months after the actual problem was fixed.
3229
+ • Session-specific transient errors that resolved before the conversation ended. If retrying worked, the lesson is the retry pattern, not the original failure.
3230
+ • One-off task narratives. A user asking 'summarize today's market' or 'analyze this PR' is not a class of work that warrants a skill.
3231
+
3232
+ If a tool failed because of setup state, capture the FIX (install command, config step, env var to set) under an existing setup or troubleshooting skill — never 'this tool does not work' as a standalone constraint.
3233
+
3234
+ Act on whichever of the two dimensions has real signal. If genuinely nothing stands out on either, say 'Nothing to save.' and stop — but don't reach for that conclusion as a default.`;
3235
+ const CURATOR_PROMPT = `You are the skill curator. Maintain a healthy, class-level skill library, not a flat pile of narrow one-session skills.
3236
+
3237
+ This is an UMBRELLA-BUILDING consolidation pass, not a passive audit and not a duplicate-finder.
3238
+
3239
+ The goal is a LIBRARY OF CLASS-LEVEL INSTRUCTIONS. A skill collection of many narrow skills where each captures one session's specific bug is a FAILURE of the library. An agent searching skills matches on descriptions, not exact names; one broad umbrella with labeled subsections beats five narrow siblings for discoverability.
3240
+
3241
+ Right target shape: class-level skills with rich SKILL.md + references/, templates/, scripts/ support files for session-specific detail.
3242
+
3243
+ Hard rules:
3244
+ 1. NEVER hard-delete a skill. Archive (moving to .archive/) is the maximum destructive action; archives are recoverable, deletion is not.
3245
+ 2. Do not touch bundled, hub-installed, pinned, or scheduled-task-referenced (referenced) skills. Referenced skills are fully protected — never consolidated, never pruned (there is no scheduled-task reference-rewriting pass; a referenced skill stays in place by design).
3246
+ 3. Do not archive recently-created or never-used skills without strong evidence. "use=0" is NOT evidence either way — it only means the trigger has not come up yet. Never archive a never-used skill unless it is at least 30 days old AND its content is genuinely obsolete or fully absorbed elsewhere.
3247
+ 4. Do NOT reject consolidation on the grounds that "each skill has a distinct trigger". The right bar is: would a human maintainer write this as N separate skills, or one skill with N labeled subsections? When the answer is the latter, merge.
3248
+ 5. Judge overlap on CONTENT, not on usage counters.
3249
+ 6. Before archiving a merged skill, ensure its unique content was preserved in the umbrella.
3250
+
3251
+ How to work:
3252
+ 1. Scan the candidate list. Identify PREFIX CLUSTERS — skills sharing a first word or domain keyword. Expected cluster count scales with the library: a large collection may show 10-25 prefix clusters, a small one often has none — a clean "nothing to consolidate" summary is the correct small-library outcome, not a shortage of ambition.
3253
+ 2. For each cluster with 2+ members, ask "what is the UMBRELLA CLASS these skills serve?" and consolidate:
3254
+ a. MERGE INTO AN EXISTING UMBRELLA (patch a labeled section for each sibling's unique insight, then archive the siblings).
3255
+ b. CREATE A NEW UMBRELLA SKILL.md covering the shared workflow with short labeled subsections, then archive the absorbed siblings.
3256
+ c. DEMOTE session-specific detail to references/, templates/, or scripts/ under the umbrella. Use the right directory per kind:
3257
+ • references/<topic>.md — session-specific detail OR condensed knowledge banks (quoted research, API docs excerpts, domain notes, provider quirks, reproduction recipes) written concise and task-focused.
3258
+ • templates/<name>.<ext> — starter files meant to be copied and modified.
3259
+ • scripts/<name>.<ext> — statically re-runnable actions (verification scripts, fixture generators, probes).
3260
+ 3. Package integrity — not optional: inspect each skill as a COMPLETE directory package, not just SKILL.md. A skill root may include references/, templates/, scripts/, and assets/. If the source skill has support files OR its SKILL.md contains relative links to them, DO NOT flatten only SKILL.md into <umbrella>/references/<old>.md. Choose one safe path instead: keep it as a standalone skill, OR fully merge by re-homing every needed support file into the umbrella's canonical directories AND rewriting the destination instructions to the new paths, OR archive the entire original skill package unchanged. Never leave demoted instructions pointing at files left behind under the old skill directory.
3261
+ 4. Flag skills whose NAME is too narrow (contains a PR number, a feature codename, a specific error string, an 'audit'/'diagnosis'/'salvage' session artifact) — they almost always belong as a subsection or support file under a class-level umbrella.
3262
+ 5. Iterate. After one consolidation round, scan the remaining set and look for the NEXT umbrella opportunity. Don't stop after 3 merges.
3263
+
3264
+ You are a NOMINATOR, not an executor: this channel has NO tools. Your single deliverable is the structured YAML block below. Never narrate actions you did not take ("merged", "patched", "archived") — you are proposing, and the deterministic engine executes only names from the candidate pool it gave you. (A future execution view would expose skill_manage; today it does not.)
3265
+
3266
+ 'keep' is a legitimate decision ONLY when the skill is already a class-level umbrella and none of the proposed merges would improve discoverability. 'This is narrow but distinct from its siblings' is NOT a reason to keep — it's a reason to move it under an umbrella as a subsection or support file.
3267
+
3268
+ Expected output: real umbrella-ification. Process every obvious cluster. If you end the pass with obvious clusters still untouched, you stopped too early — go back and look at the clusters you left alone.
3269
+
3270
+ Keep the umbrella body tight and scannable: exact commands, verbatim paths, ~100-200 lines; never invent flags or APIs.
3271
+
3272
+ When done, write a human summary THEN the structured machine-readable block. The block is the contract: every skill you would move to .archive/ MUST appear in exactly one of the two lists. Return ONLY the YAML block after the summary — no post-block prose. Format EXACTLY:
3273
+
3274
+ ## Structured summary (required)
3275
+ \`\`\`yaml
3276
+ consolidations:
3277
+ - from: <old-skill-name>
3278
+ mode: reference # optional — ONLY for a 'demote': source is narrow-but-valuable session detail, write it as references/<source>.md under the umbrella instead of appending to the body. Default is append. Place this line BEFORE into:. NEVER use reference when the source body links its own references/ templates/ scripts/ files.
3279
+ into: <umbrella-skill-name>
3280
+ reason: <one short sentence — why merged, not just 'similar'>
3281
+ prunings:
3282
+ - name: <skill-name>
3283
+ reason: <one short sentence — why archived with no merge target>
3284
+ \`\`\`
3285
+
3286
+ Every skill you would move to .archive/ MUST appear in exactly one of the two lists. If you consolidated X into umbrella Y (patched Y, wrote a references file to Y, or created Y with X's content absorbed), X goes under consolidations with into: Y. If you archived X with no absorption — truly stale, irrelevant, or obsolete — X goes under prunings. Leave a list empty (consolidations: []) if none. Do not omit the block. The block comes AFTER your human-readable summary of clusters processed, patches made, and decisions left alone.`;
3287
+ const CURATOR_DRY_RUN_BANNER = `═══════════════════════════════════════════════════════════════
3288
+ DRY-RUN — REPORT ONLY. DO NOT MUTATE THE SKILL LIBRARY.
3289
+ ═══════════════════════════════════════════════════════════════
3290
+
3291
+ This is a PREVIEW pass. Follow every instruction above EXCEPT:
3292
+ • Do NOT call skill_manage with action=create, update, patch, delete, write_file, or remove_file.
3293
+ • Do NOT move, copy, or rewrite any file under the skills tree.
3294
+
3295
+ Your output IS the deliverable: produce the exact same human-readable summary and YAML block you would on a live run, describing the actions you WOULD take. A reviewer will decide whether to approve a live run.
3296
+
3297
+ If you accidentally take a mutating action, say so explicitly in the summary.`;
3298
+ const COMPLETION_SKILL_REVIEW_PROMPT = `[Auto-review — Skills · task complete]
3299
+ Your current task now appears complete. Before wrapping up, review the approach and update the skill library via skill_manage.
3300
+
3301
+ Follow the skills review policy: be ACTIVE, prefer class-level umbrellas, patch ONLY skills loaded or read this session, and capture non-trivial techniques and user corrections. Do NOT capture environment-dependent failures, negative claims about tools, or one-off task narratives.
3302
+
3303
+ Do NOT modify output files or re-run the task. If you are still mid-task, ignore this.`;
3304
+ /**
3305
+ * Maintenance-subagent persona (design 011 §6). Constants pin the persona
3306
+ * with `{signal:id}` placeholders; the renderer substitutes names from the
3307
+ * drift-signals module (single vocabulary). The model-facing contract is the
3308
+ * persona + the mechanical-facts block; the signature head lets the model
3309
+ * compare the two heads (011 mismatch protocol).
3310
+ */
3311
+ const MAINTAIN_PROMPT = `<<<MAINTAIN_PROMPT v={bundle_version} sig={joint_signature}>>>
3312
+
3313
+ ## 角色
3314
+ 你是技能库的**外部审计者**:只读、只输出计划、从不执行(执行由用户命令与审批完成)。
3315
+
3316
+ ## 1. 输入契约(冲突时以此为准)
3317
+ 机械事实块 <<<MECHANICAL_FACTS v={signals_version} sig={joint_signature}>>>(下方,以 <<<END FACTS>>> 闭合)是唯一证据来源。
3318
+ - verdict 仅三值:pass=未越阈 / over=越阈 / unknown=未检测。
3319
+ - over 是事实位置,不是违规结论;没有条款对应的事实,不产生建议。
3320
+ - unknown ≠ pass;引用 unknown 信号的条目必须 needs_human:true。
3321
+ - 事实只读:不改写、不补写、不把事实"翻译"成裁决。
3322
+ - 两处 sig 不一致或任一缺失 → 只输出 MISMATCH + 两侧版本号,禁止输出计划。
3323
+
3324
+ ## 2. 信号→条款映射(每条 over 必须落到条款;无一遗漏)
3325
+ {signal:dedup_group}→A1 · {signal:narrow_name}→A2 · {signal:prefix_cluster}→A3 · {signal:stamp_density}与{signal:body_size}→B1 · {signal:pointer_missing}→B2 · {signal:dup_heading}→B3 · {signal:overlong_line}→B4 · {signal:description_chars}→B5 · {signal:usage_observed}/{signal:quality_low}→门控(校验器对 quality_low=unknown 的技能强制 needs_human,模板侧不重复)
3326
+
3327
+ ## 3. 完整性契约(校验器机械执行)
3328
+ 事实块中每条 over 信号必须满足其一:成为某条建议的 evidence,或在 notes 中说明"已审·无条款对应·不动作"。禁止静默省略;先逐信号核对再输出。
3329
+
3330
+ ## 4. 工作流程(按序执行,不得跳步)
3331
+ ① 通读事实块 → ② 对每个候选技能用 skill 读正文(B1/B2/B4 必读;**读取失败必须报告工具返回的事实**(错误信息/无对应条目),禁止用"无法读取"含糊绕过)→ ③ maintenance_probe 按需深挖 → ④ 逐信号过 §3 完整性 → ⑤ 输出计划。
3332
+
3333
+ ## 5. 检查清单(信号 → 语义判定 → 输出形态)
3334
+ A. 域·碎片化
3335
+ - A1 {signal:dedup_group}=over:判近重复组是否同伞可合并;是→relationship-level consolidate;否→不输出。
3336
+ - A2 {signal:narrow_name}=over:判否"仅对今日任务成立";成立→改名/归档建议;内部代号(格式合规语义窄)→conf≤0.4+needs_human。
3337
+ - A3 {signal:prefix_cluster}=over:判簇内是否同伞;非同伞→notes 提域划分观察,不强制建伞。
3338
+
3339
+ B. 层·分层错位
3340
+ - B1 {signal:stamp_density}(阈值 {signal:stamp_density.threshold})或 {signal:body_size}(阈值 {signal:body_size.threshold})=over:按**三问判据**判锚/残留——① 该编号/时间戳是否被库内其他文件引用?② 除"何时产生/为何存在"外是否还承载信息?③ 删除是否影响任何跨文档检索?(①是且③是→锚;否则→残留候选,人审)。锚→允许保留 + needs_human + semantic_reasoning 写三问结果;**锚不使用 is_override**(is_override 仅用于 §7 申诉;锚是 B1 的正常裁决路径);残留→restructure 建议(movable headings 逐字引用)。**锚≠可读:单行 >4000 字符即使在锚类也必须拆分。**
3341
+ - B2 {signal:pointer_missing}=over:读支持文件后判性质——可复用模式→上移正文;会话专属实录→保留+补指针;形态=patch 指引。**未读内容仅凭文件名 → conf≤0.4 且措辞"先人工确认再执行"。** **缺失指针=支持文件存在、正文无引用(单向语义),finding 表述勿反向。**
3342
+ - B3 {signal:dup_heading}=over:删除多余标题行(保留一份),patch 指引。
3343
+ - B4 {signal:overlong_line}=over:>1500 拆行;>4000 判定可读性危机(内容合法也拆);patch 指引。**finding 必须给全量口径:共 N 行超限,其中 >4000 的逐行列出。**
3344
+ - B5 {signal:description_chars}=over:先判**性质**三分类——事件性承诺(单次故障/incident 写入元数据)→裁剪建议;叙事性自我描述→压缩建议;丰富但合规(完整用例边界)→保留 + is_override + override_reason="合法密度"。**分类特征**:含"恢复/修复某次事故、日期快照"类一次性措辞→事件性承诺;"动词+对象"式任务说明→叙事性;枚举完整用例边界且不可拆分→丰富合规。**第三类门槛(默认从严,半机械)**:先自行试写一个 ≤60 字压缩方案——能保留全部路由关键项(触发词+域)→ 不可判第三类(按压缩建议);只有试写失败(在 semantic_reasoning 列出试写方案与具体失败点)才可判丰富合规。描述文本可见(probe desc-text 或正文 frontmatter)时仍须三分类;仅长度可见 → conf≤0.4。semantic_reasoning 必写三分类之一。
3345
+
3346
+ D. 库·整合纪律(计划形态约束)
3347
+ - D1 同类问题多处出现→合成一条 relationship-level 建议,不逐项输出。
3348
+ - D2 结构类优先级高于内容类;影响面 library-level > relationship-level > skill-level。
3349
+
3350
+ ## 6. 输出契约(校验器机械执行)
3351
+ {verdict: "issues" | "no_issues",
3352
+ plan: [{ kind: "skill-level"|"relationship-level"|"library-level", names: [str],
3353
+ rule: "A1"|"B2"|..., evidence: [{signal, value}],
3354
+ finding: "<一句事实描述:引用信号 id 与值;零裁决动词>",
3355
+ recommendation: "<唯一允许的'应'句:建议动作+理由+执行形态(命令/patch 指引)>",
3356
+ semantic_reasoning: "<语义判据;含 LLM 推断时 confidence≤0.4>",
3357
+ impact: "better|worse|neutral", impact_reason: "<相对'不动'的净影响>",
3358
+ reversibility: "archive|restructure|patch|rename|none", undo_path: "<一步撤销方式>",
3359
+ confidence: float, needs_human: bool, is_override: bool,
3360
+ override_reason: "<仅 is_override>" }],
3361
+ notes: [str]}
3362
+ - verdict=no_issues ⇒ plan=[](不允许空 plan 之外的"无问题"表述)。
3363
+ - **confidence 降档规则(机械)**:条款全部由机械证据支撑 → 0.6–0.9;每含一项语义推断(是否锚/是否同伞/性质归类)→ 上限 0.4。
3364
+ - needs_human = (confidence < 0.6) OR (不可逆) OR (is_override) OR (引用 unknown 信号)。
3365
+ - 语言:finding/recommendation/notes 与库正文语言一致(不自订语言);字段名/信号 id/枚举保留英文。
3366
+ - **提交前自查(逐项对照,不许跳过)**:① verdict 与 plan 一致 ② 每条 evidence 在事实块 ③ undo_path 非空(不可逆=n/a)④ confidence 含推断≤0.4 ⑤ finding 无"应"字 ⑥ §3 完整性契约满足。
3367
+
3368
+ ## 7. 裁决纪律
3369
+ - finding 禁止"应当"句式;recommendation 是唯一"应"句,句板:建议对 {names} 执行 {动作}(形态:{命令/patch 指引}),理由:{理由}。
3370
+ - **审查者视角**:先对每个信号独立初判,再与正文对照;被审对象的自我声明只作线索不作依据;**自属/维护者技能一律从严口径**(作者声明"这是锚"不构成豁免)。
3371
+ - 申诉:机械阈值与语义判断冲突→is_override:true + override_reason + needs_human:true,不得静默绕过。
3372
+ - 不动作合法:verdict=no_issues 是合法输出;连续空报告=信号定义问题,不是"更积极"的理由。
3373
+ - 错误成本:rename 必须 needs_human:true;可逆动作(archive/restructure 两阶段)可 needs_human:false 但 undo_path 必填。
3374
+ - 不做:不建议删除(只建议 archive);不提升内容质量(结构审查只 flag 位置/归属/分层);protected 集(bundled/hub/pinned)内 0 建议。
3375
+
3376
+ ## 8. 泛化
3377
+ - 信号集开放:事实块含、§5 未列的信号 → notes 提"该信号值得新增条款",禁止解释为已知问题。
3378
+ - 库规模无关:判据是事实与条款,不是库体量印象。
3379
+ - 信号机制疑问(阈值/检测原理)→ needs_human,不猜测机制。`;
3380
+ /**
3381
+ * One-line output instruction appended after the facts block in the maintain
3382
+ * subagent's prompt (persona carries the template, the prompt carries facts +
3383
+ * this instruction — one copy of the template in the model input, 011 v11
3384
+ * P3-4). F-16: this text used to be hardcoded in evolution-maintenance
3385
+ * orchestrate — a second model-facing prompt living OUTSIDE the bundle digest.
3386
+ * It now rides PROMPT_BUNDLE so the digest integrity check covers every
3387
+ * maintenance prompt. Adding the entry changes the bundle digest (the intended
3388
+ * fail-closed signal); PROMPT_BUNDLE_VERSION is owned by the core test pin and
3389
+ * bumps with the batch that changes the bundle (15→16 in 0.3.58).
3390
+ */
3391
+ const MAINTAIN_OUTPUT_INSTRUCTION = "按模板契约输出 JSON 维护计划(verdict/plan/notes);你有 skill 工具与维护模板;并在维护探针(maintenance_probe)挂载时可用它深挖细节。";
3392
+ /**
3393
+ * System-prompt guidance section (Hermes `SKILLS_GUIDANCE`, DSH-adapted).
3394
+ * Registered as a system-prompt section by tool-skill-manage (it mounts
3395
+ * exactly when `skill_manage` is available — the DSH analogue of Hermes'
3396
+ * `if "skill_manage" in agent.valid_tool_names` condition). Instructs the
3397
+ * model to save/repair skills on its own initiative.
3398
+ */
3399
+ const SKILLS_GUIDANCE = `Skills guidance:
3400
+ • After completing a complex task (5+ tool calls), fixing a tricky error, or discovering a non-trivial workflow, save the approach as a skill with skill_manage so you can reuse it next time.
3401
+ • When using a skill and finding it outdated, incomplete, or wrong, patch it immediately with skill_manage (action='patch') — don't wait to be asked. Skills that aren't maintained become liabilities.`;
3402
+ const PLAN_CHANNEL_NOTE = `
3403
+
3404
+ CHANNEL (subagent): this review channel mounts only the read-only \`skill\` tool — you have NO \`skill_manage\`, NO \`memory\`. Your deliverable is the structured JSON plan below (outputSchema). Describe the patches/creates you RECOMMEND in the plan; never narrate actions you took.`;
3405
+ /** Subagent-channel variant: same review policy, channel-limited deliverable (M-2). */
3406
+ const SKILL_REVIEW_PLAN_PROMPT = `${SKILL_REVIEW_PROMPT}${PLAN_CHANNEL_NOTE}`;
3407
+ /** Subagent-channel variant of the combined review (M-2). */
3408
+ const COMBINED_REVIEW_PLAN_PROMPT = `${COMBINED_REVIEW_PROMPT}${PLAN_CHANNEL_NOTE}`;
3409
+ function reviewPrompt(kind, channel = "agent") {
3410
+ if (channel === "plan") return kind === "skill" ? SKILL_REVIEW_PLAN_PROMPT : COMBINED_REVIEW_PLAN_PROMPT;
3411
+ if (kind === "memory") return MEMORY_REVIEW_PROMPT;
3412
+ if (kind === "skill") return SKILL_REVIEW_PROMPT;
3413
+ return COMBINED_REVIEW_PROMPT;
3316
3414
  }
3317
- /**
3318
- * V10-14 (P1-2), 0.3.53: inject the Hermes 60-char catalog cap onto the
3319
- * standard-sourced `- id: tool-skill` row of a composed preset.
3320
- *
3321
- * The session-visible `tool-skill` instance mounts in the agent preset's own
3322
- * standing scope; a profile-root patch (evolution-host/cordis.patch.yml)
3323
- * cannot reach it, so without this injection the catalog's read side runs the
3324
- * platform default (500). Text-level rewrite in the same line-scan style as
3325
- * compositionRowIds (no YAML library):
3326
- * - idempotent: a tool-skill item that already carries a `config:` key is
3327
- * left byte-identical, so re-running the installer never doubles the key;
3328
- * - the injected block carries a marker comment so a diff of the generated
3329
- * preset can tell composer-owned text from platform text;
3330
- * - a composition WITHOUT a tool-skill row is returned unchanged with a
3331
- * one-time warning (a renamed platform row must not brick the install,
3332
- * but the missed cap must be observable).
3333
- * install-layered.mjs ships the byte-identical `injectToolSkillCap`.
3334
- */
3335
- function injectCatalogDescriptionCap(composition) {
3336
- const lines = composition.split("\n");
3337
- let found = false;
3338
- for (let i = 0; i < lines.length; i += 1) {
3339
- if (!/^- id:\s*tool-skill\s*$/.test(lines[i] ?? "")) continue;
3340
- found = true;
3341
- let end = i;
3342
- let hasConfig = false;
3343
- for (let j = i + 1; j < lines.length; j += 1) {
3344
- const next = lines[j] ?? "";
3345
- if (next.trim() === "") break;
3346
- if (!/^\s/.test(next)) break;
3347
- if (/^ {2}config:(\s|$)/.test(next)) hasConfig = true;
3348
- end = j;
3349
- }
3350
- if (hasConfig) continue;
3351
- lines.splice(end + 1, 0, " # V10-14: Hermes 60-char catalog cap — injected by the preset composer (P1-2);", " # this preset-scope row is the session-visible instance and no profile", " # patch can reach it. Remove only to run the platform default (500).", " config:", " catalogDescriptionMaxLength: 60");
3352
- i = end + 5;
3353
- }
3354
- if (!found) console.warn("evolution preset composition: warning — no `- id: tool-skill` row in the composed preset; the 60-char catalog cap was NOT injected (platform renamed the row? reconcile with the delta)");
3355
- return lines.join("\n");
3415
+ function sha256(text) {
3416
+ return createHash("sha256").update(text).digest("hex");
3356
3417
  }
3357
- function compositionRowIds(composition) {
3358
- const ids = /* @__PURE__ */ new Set();
3359
- for (const line of composition.split("\n")) {
3360
- const id = /^- id:\s*(\S+)/.exec(line)?.[1];
3361
- if (id) ids.add(id);
3362
- }
3363
- return ids;
3418
+ function createPromptBundle(prompts) {
3419
+ const canonical = JSON.stringify({
3420
+ id: PROMPT_BUNDLE_ID,
3421
+ version: 17,
3422
+ prompts: Object.fromEntries(Object.entries(prompts).sort())
3423
+ });
3424
+ return Object.freeze({
3425
+ id: PROMPT_BUNDLE_ID,
3426
+ version: 17,
3427
+ prompts: Object.freeze({ ...prompts }),
3428
+ sha256: sha256(canonical)
3429
+ });
3430
+ }
3431
+ const PROMPT_BUNDLE = createPromptBundle({
3432
+ memory: MEMORY_REVIEW_PROMPT,
3433
+ skill: SKILL_REVIEW_PROMPT,
3434
+ combined: COMBINED_REVIEW_PROMPT,
3435
+ skillPlan: SKILL_REVIEW_PLAN_PROMPT,
3436
+ combinedPlan: COMBINED_REVIEW_PLAN_PROMPT,
3437
+ curator: CURATOR_PROMPT,
3438
+ completion: COMPLETION_SKILL_REVIEW_PROMPT,
3439
+ maintain: MAINTAIN_PROMPT,
3440
+ maintainOutput: MAINTAIN_OUTPUT_INSTRUCTION,
3441
+ skillsGuidance: SKILLS_GUIDANCE
3442
+ });
3443
+ function verifyPromptBundle(bundle = PROMPT_BUNDLE) {
3444
+ if (bundle.id !== PROMPT_BUNDLE_ID || bundle.version !== 17) return false;
3445
+ const canonical = JSON.stringify({
3446
+ id: PROMPT_BUNDLE_ID,
3447
+ version: 17,
3448
+ prompts: Object.fromEntries(Object.entries(bundle.prompts).sort())
3449
+ });
3450
+ return bundle.sha256 === sha256(canonical);
3364
3451
  }
3452
+ const DSH_AUTHORING_STANDARDS = `Follow the Hermes skill-authoring standards, translated to DSH tools.
3453
+
3454
+ Frontmatter:
3455
+ - name: lowercase-hyphenated, <=64 chars, no spaces.
3456
+ - description: ONE sentence, <=60 characters, ends with a period. State the capability, not the implementation. No marketing words. Do NOT repeat the skill name. Count the characters before saving. If the description contains a colon, wrap the whole value in double quotes.
3457
+ - version: 0.1.0
3458
+ - author: always the literal value "Hermes". NEVER fill it from the environment, git config, or any identity you can probe — an environment-derived name is a privacy leak the user never opted into (skills get shared and published), and the skill names itself as Hermes.
3459
+ - platforms: declare [macos], [linux], and/or [windows] only when the skill is genuinely OS-bound (osascript/apt/systemctl => the matching OS; /proc, signal.SIGKILL => linux; fcntl/termios => POSIX). Prefer fixing it cross-platform first (tempdir, pathlib, pure-Node); omit the field for portable skills.
3460
+ - metadata.hermes.tags: a few Capitalized, Relevant, Tags.
3461
+ - metadata.hermes.related_skills: [a, b] — name sibling skills this one builds on or is referenced by (optional; feeds the quality references factor).
3462
+
3463
+ Body section order (omit only when empty):
3464
+ 1. "# <Human Title>" then a 2-3 sentence intro: what it does, what it does NOT do, key dependency stance.
3465
+ 2. "## When to Use" — concrete trigger phrases.
3466
+ 3. "## Prerequisites" — exact env vars, install steps, credentials.
3467
+ 4. "## How to Run" — canonical invocation framed through DSH tools.
3468
+ 5. "## Quick Reference" — flat command/endpoint list.
3469
+ 6. "## Procedure" — numbered steps with copy-paste-exact commands.
3470
+ 7. "## Pitfalls" — known limits and rate limits.
3471
+ 8. "## Verification" — one check proving the skill worked.
3472
+
3473
+ DSH-tool framing:
3474
+ - Reference DSH tools by name in backticks: \`bash\`, \`str_replace_editor\`, \`write\`, \`skill\`, \`skill_manage\`, \`memory\`.
3475
+ - Do not name wrapped shell utilities when a DSH tool already covers them.
3476
+ - Larger scripts belong under \`scripts/\` (written with \`skill_manage write_file\`) and are referenced from SKILL.md by relative path.
3477
+
3478
+ Quality bar:
3479
+ - Prefer verbatim flags, paths, and APIs from the source. Never invent them.
3480
+ - Keep it tight: ~100 lines simple, ~200 complex.
3481
+ - No router/index/hub skills that only point at other skills.
3482
+ - References go in \`references/\`, templates in \`templates/\`.
3483
+
3484
+ Learn workflow (when the user asks you to learn a reusable skill, or you decide to turn a source/request into one):
3485
+ 1. Gather every source named (files, URLs, "what we just did", pasted notes) with the tools you already have — and treat prose after a source as authoring requirements, not noise.
3486
+ 2. Apply every requirement and constraint from the request to the SKILL.md you author.
3487
+ 3. Author exactly ONE SKILL.md and save it with \`skill_manage\` (action=create); non-trivial scripts go under \`scripts/\`.
3488
+ 4. When done, tell the user the skill name, its category, and a one-line summary of what it captured.`;
3365
3489
  //#endregion
3366
3490
  //#region lib/types/quality.js
3367
3491
  /**
@@ -3551,10 +3675,12 @@ const SECRET_PATTERNS = [
3551
3675
  ["google api key", /AIza[0-9A-Za-z_-]{30,}/g],
3552
3676
  ["bearer credential", /Bearer[\s]+[a-z0-9._~+/=\-]{16,}/gi]
3553
3677
  ];
3554
- const INLINE_ASSIGNMENT_PATTERN = /* @__PURE__ */ new RegExp("(^|[^\\w-])([\\w-]{0,64}[_\\-])?((?:token|api[_-]?key|secret|password|passwd)(?:[_\\-][\\w-]{0,64})?)\\b([\"\\']?[\\t ]*[:=][\\t ]*)([^\\r\\n]+)", "gi");
3555
- const URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s/@]+)@/gi;
3678
+ const INLINE_ASSIGNMENT_PATTERN = /* @__PURE__ */ new RegExp("(^|[^\\w-])([\\w-]*[_\\-])?((?:token|api[_-]?key|secret|password|passwd)(?:[_\\-][\\w-]*)?)\\b([\"\\']?[\\t ]*[:=][\\t ]*)([^\\r\\n]+)", "gi");
3679
+ const URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]{0,63}:\/\/[^\s:/@]+:)([^\s/@]+)@/gi;
3556
3680
  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");
3557
- const BLOCK_KEY_ONLY_LINE = /(?:^|\s)([\w-]{0,64}[_\-])?((?:token|api[_-]?key|secret|password|passwd)(?:[_\-][\w-]{0,64})?)\s*:(?:\r)?$/i;
3681
+ const BLOCK_KEY_ONLY_LINE = /(?:^|\s)([\w-]*[_\-])?((?:token|api[_-]?key|secret|password|passwd)(?:[_\-][\w-]*)?)\s*:(?:\r)?$/i;
3682
+ const CREDENTIAL_KEY_RE = /(?:[Tt]oken|[Ss]ecret|[Pp]assword|[Pp]asswd|[Aa]pi[_-]?[Kk]ey)(?![a-z])/;
3683
+ const CANDIDATE_ASSIGNMENT_PATTERN = /(^|[^\w-])([\w-]+)(["']?[\t ]*[:=][\t ]*)([^\r\n]+)/g;
3558
3684
  /**
3559
3685
  * Mask credential-shaped text before it crosses a session boundary.
3560
3686
  * @param text - the text about to be sent to a model outside this session.
@@ -3566,10 +3692,16 @@ function redactSecrets(text) {
3566
3692
  for (const [, pattern] of SECRET_PATTERNS) out = out.replace(pattern, "<redacted>");
3567
3693
  out = out.replace(URL_CREDENTIALS_PATTERN, (_match, lead) => `${lead ?? ""}<redacted>@`);
3568
3694
  out = out.replace(INLINE_ASSIGNMENT_PATTERN, (_match, lead, prefix, key, separator) => `${lead ?? ""}${prefix ?? ""}${key ?? ""}${separator ?? ""}<redacted>`);
3695
+ out = out.replace(CANDIDATE_ASSIGNMENT_PATTERN, (match, lead, key, separator) => {
3696
+ if (typeof key !== "string" || !CREDENTIAL_KEY_RE.test(key)) return match;
3697
+ return `${lead ?? ""}${key}${separator ?? ""}<redacted>`;
3698
+ });
3569
3699
  const lines = out.split("\n");
3570
3700
  for (let i = 0; i < lines.length - 1; i++) {
3571
3701
  const line = lines[i];
3572
- if (line === void 0 || !BLOCK_KEY_ONLY_LINE.test(line)) continue;
3702
+ if (line === void 0) continue;
3703
+ const camelKey = /^([\w-]+)\s*:(?:\r)?$/.exec(line)?.[1];
3704
+ if (!(BLOCK_KEY_ONLY_LINE.test(line) || camelKey !== void 0 && CREDENTIAL_KEY_RE.test(camelKey))) continue;
3573
3705
  const next = lines[i + 1] ?? "";
3574
3706
  const [, indent, value, tail] = /^([ \t]+)(\S.*?)([ \t]*)(?:\r)?$/.exec(next) ?? [];
3575
3707
  if (indent === void 0 || value === void 0) continue;
@@ -3584,6 +3716,109 @@ function redactSecrets(text) {
3584
3716
  return out;
3585
3717
  }
3586
3718
  //#endregion
3719
+ //#region lib/types/review-channel.js
3720
+ /**
3721
+ * Family-internal mark for the review channel's INJECT delivery (v37 S2.2).
3722
+ *
3723
+ * With the default `reviewMode: 'inject'` the review prompt runs in the PARENT
3724
+ * session, so the parent model's own `skill_manage` calls carry no origin the
3725
+ * platform could attribute: `.pinned` and the `.hermes-managed` authorship mark
3726
+ * were skipped for exactly the autonomous writes they exist for. The delivery
3727
+ * marks the session here and the write path reads the mark.
3728
+ *
3729
+ * Window: after the review prompt, until the next REAL user message (a
3730
+ * `user/message` with `source.kind === 'user'` clears it; plugin-sourced notices
3731
+ * never do). Process-wide and keyed by session on purpose — the same
3732
+ * "restart is a fresh conversation boundary" discipline as the review plugin's
3733
+ * own per-session counters. This module is the ONE owner of the marker.
3734
+ * @module @lmzhen/dsh-evolution-core/review-channel
3735
+ */
3736
+ /** Session ids currently executing a review prompt, oldest delivery first. */
3737
+ const markedSessions = /* @__PURE__ */ new Set();
3738
+ /**
3739
+ * Mark one session as running the review channel's prompt; idempotent, and
3740
+ * keyed by session so a mark can never leak into another session.
3741
+ * @param sessionId - the session the review prompt was delivered to.
3742
+ */
3743
+ function markReviewChannel(sessionId) {
3744
+ markedSessions.delete(sessionId);
3745
+ markedSessions.add(sessionId);
3746
+ }
3747
+ /**
3748
+ * Clear the mark: the session's next prompt is human input again.
3749
+ * @param sessionId - the session whose mark is dropped.
3750
+ */
3751
+ function clearReviewChannel(sessionId) {
3752
+ markedSessions.delete(sessionId);
3753
+ }
3754
+ /**
3755
+ * Whether this session's current prompt came from the review channel. An
3756
+ * execution without a session is never the review channel.
3757
+ * @param sessionId - the executing session id, when the caller has one.
3758
+ * @returns true only for a marked session.
3759
+ */
3760
+ function isReviewChannelSession(sessionId) {
3761
+ return sessionId !== void 0 && markedSessions.has(sessionId);
3762
+ }
3763
+ /**
3764
+ * Drop marks whose session is gone — a dead session cannot execute a write.
3765
+ * @param isAlive - liveness probe for one session id.
3766
+ * @returns the number of removed marks.
3767
+ */
3768
+ function sweepReviewChannelSessions(isAlive) {
3769
+ let removed = 0;
3770
+ for (const sessionId of [...markedSessions]) {
3771
+ if (isAlive(sessionId)) continue;
3772
+ markedSessions.delete(sessionId);
3773
+ removed += 1;
3774
+ }
3775
+ return removed;
3776
+ }
3777
+ //#endregion
3778
+ //#region lib/types/probe.js
3779
+ function probePresent(value) {
3780
+ return {
3781
+ kind: "present",
3782
+ value
3783
+ };
3784
+ }
3785
+ function probeAbsent() {
3786
+ return { kind: "absent" };
3787
+ }
3788
+ function probeUnknown(reason) {
3789
+ return {
3790
+ kind: "unknown",
3791
+ reason
3792
+ };
3793
+ }
3794
+ function isPresent(probe) {
3795
+ return probe.kind === "present";
3796
+ }
3797
+ function isAbsent(probe) {
3798
+ return probe.kind === "absent";
3799
+ }
3800
+ function isUnknown(probe) {
3801
+ return probe.kind === "unknown";
3802
+ }
3803
+ /** Only a PRESENT probe yields a value; absent and unknown both fall back. */
3804
+ function valueOr(probe, fallback) {
3805
+ return probe.kind === "present" ? probe.value : fallback;
3806
+ }
3807
+ function mapProbe(probe, transform) {
3808
+ return probe.kind === "present" ? probePresent(transform(probe.value)) : probe;
3809
+ }
3810
+ //#endregion
3811
+ //#region lib/types/scope.js
3812
+ function callingScope(ctx, held) {
3813
+ if (held !== void 0) return held;
3814
+ return scopeOf(ctx);
3815
+ }
3816
+ /** True when a read is deliberately scope-less (global layer only). Callers
3817
+ * pass this to the register so the choice is reviewed, not accidental. */
3818
+ function isGlobalRead(scope) {
3819
+ return scope === void 0;
3820
+ }
3821
+ //#endregion
3587
3822
  //#region lib/types/skill-health.js
3588
3823
  /**
3589
3824
  * Skill structure-health domain (rc.73 A1, 008 design): a SECOND assessment
@@ -3653,6 +3888,320 @@ function assessStructureHealth(snapshot, thresholds = DEFAULT_HEALTH_THRESHOLDS)
3653
3888
  };
3654
3889
  }
3655
3890
  //#endregion
3891
+ //#region lib/types/tool-dispatch.js
3892
+ /**
3893
+ * The family's ONE reader of platform tool-dispatch events.
3894
+ *
3895
+ * The platform records a finished tool call under two different event
3896
+ * vocabularies and only one of them is written per dispatch, chosen by the
3897
+ * mounted tool runtime's mode:
3898
+ *
3899
+ * - native mode: \`tool/call\` (with \`callId\`) settled by \`tool/result\`, the
3900
+ * write sites being core/agent-loop/src/tool-calls.ts:264 and :282.
3901
+ * - PTC mode: \`tool/ptc-dispatch-start\` and \`tool/ptc-dispatch\` (with
3902
+ * \`subCallId\`), the write sites being core/tools/src/ptc.ts:534 and :509.
3903
+ *
3904
+ * A consumer that matches a vocabulary directly therefore goes blind in the
3905
+ * other mode while still appearing to work — the defect this module exists to
3906
+ * make impossible. Every family consumer reads dispatches through
3907
+ * \`ToolDispatchNormalizer\` (or the pure helpers below) and matches on
3908
+ * \`ToolDispatchSignal\` fields only; \`verify-arch-guards\` rule N11 rejects a
3909
+ * dispatch event-type comparison anywhere else.
3910
+ *
3911
+ * ## The one-dispatch invariant
3912
+ *
3913
+ * One dispatch produces exactly one \`ToolDispatchSignal\`, no matter how many
3914
+ * events carry it: a start/settle pair for a PTC sub-dispatch, a call/result
3915
+ * pair for a native call, or both vocabularies for the same call. Deduplication
3916
+ * and outcome folding are keyed by the platform's own per-vocabulary call
3917
+ * identity, which is stable across the pair (the platform's event JSDoc:
3918
+ * "the pairing ids (matching the \`tool/ptc-dispatch-start\` with the same
3919
+ * \`subCallId\`)").
3920
+ *
3921
+ * ## Layer
3922
+ *
3923
+ * Cross-cutting normalization: the reader half of the dispatch vocabulary,
3924
+ * next to \`signals.ts\` (which folds the signals this module emits). Both are
3925
+ * consumers; neither subscribes to the platform bus itself.
3926
+ * @module
3927
+ */
3928
+ /** The platform event type carrying a PTC sub-dispatch start. */
3929
+ const PTC_DISPATCH_START_EVENT = "tool/ptc-dispatch-start";
3930
+ /** The platform event type carrying a PTC sub-dispatch settle. */
3931
+ const PTC_DISPATCH_EVENT = "tool/ptc-dispatch";
3932
+ /** The platform event type carrying a native tool call. */
3933
+ const NATIVE_CALL_EVENT = "tool/call";
3934
+ /** The platform event type carrying a native tool result. */
3935
+ const NATIVE_RESULT_EVENT = "tool/result";
3936
+ /**
3937
+ * The dispatch vocabulary this module owns. A consumer must never compare
3938
+ * against these literals itself (rule N11); it compares \`kind\` instead.
3939
+ */
3940
+ const DISPATCH_EVENT_TYPES = [
3941
+ NATIVE_CALL_EVENT,
3942
+ NATIVE_RESULT_EVENT,
3943
+ PTC_DISPATCH_START_EVENT,
3944
+ PTC_DISPATCH_EVENT
3945
+ ];
3946
+ /**
3947
+ * \`TypeError\` thrown when a payload that claims to be a dispatch event
3948
+ * violates the platform's event declaration. Named so a deployment can tell this
3949
+ * refusal apart from a generic failure in a log line.
3950
+ */
3951
+ var ToolDispatchPayloadError = class extends TypeError {
3952
+ name = "ToolDispatchPayloadError";
3953
+ };
3954
+ /** The dispatch event types this module recognizes, as a set. */
3955
+ const DISPATCH_EVENT_TYPE_SET = new Set(DISPATCH_EVENT_TYPES);
3956
+ /** \`name\`-less payloads: an external emitter or a corrupt log can produce these. */
3957
+ const MALFORMED = "malformed";
3958
+ /** One tool-result block of a native result payload, or \`null\` when the payload carries none. */
3959
+ function firstResultBlock(data) {
3960
+ const content = data.message?.content;
3961
+ if (!Array.isArray(content)) return null;
3962
+ for (const entry of content) {
3963
+ if (entry === null || typeof entry !== "object") continue;
3964
+ const block = entry;
3965
+ if (block.type === "tool-result") return block;
3966
+ }
3967
+ return null;
3968
+ }
3969
+ /** Deterministic identity for a dispatch payload that carries no call id. */
3970
+ function payloadIdentity(data) {
3971
+ if (data === void 0) return "";
3972
+ try {
3973
+ return JSON.stringify(data);
3974
+ } catch {
3975
+ return "";
3976
+ }
3977
+ }
3978
+ /** Read the payload of a recognized dispatch event, or \`null\` for any other event. */
3979
+ function readDispatchRecord(event) {
3980
+ if (!DISPATCH_EVENT_TYPE_SET.has(event.type)) return null;
3981
+ const data = event.data ?? {};
3982
+ if (event.type === "tool/result") {
3983
+ const owner = data.message?.source?.callId;
3984
+ const block = firstResultBlock(data);
3985
+ const callId = typeof owner === "string" && owner !== "" ? owner : typeof block?.toolCallId === "string" && block.toolCallId !== "" ? block.toolCallId : void 0;
3986
+ if (callId === void 0) return null;
3987
+ return {
3988
+ kind: "native",
3989
+ callId,
3990
+ rootCallId: callId,
3991
+ name: "",
3992
+ arguments: void 0,
3993
+ outcome: { ok: data.isError !== true && data.error === void 0 && block?.isError !== true }
3994
+ };
3995
+ }
3996
+ const isPtc = event.type === "tool/ptc-dispatch-start" || event.type === "tool/ptc-dispatch";
3997
+ const identity = isPtc ? data.subCallId : data.callId;
3998
+ const callId = typeof identity === "string" && identity !== "" ? identity : `${event.type}:${payloadIdentity(event.data)}`;
3999
+ const parent = isPtc && typeof data.parentCallId === "string" && data.parentCallId !== "" ? data.parentCallId : void 0;
4000
+ const name = typeof data.name === "string" && data.name !== "" ? data.name : MALFORMED;
4001
+ return {
4002
+ kind: isPtc ? "program" : "native",
4003
+ callId,
4004
+ rootCallId: typeof data.rootCallId === "string" && data.rootCallId !== "" ? data.rootCallId : parent ?? callId,
4005
+ name,
4006
+ arguments: data.arguments,
4007
+ ...isPtc && event.type === "tool/ptc-dispatch" ? { outcome: { ok: data.isError !== true } } : {}
4008
+ };
4009
+ }
4010
+ /**
4011
+ * The family's single dispatch ledger: absorbs platform events in log order,
4012
+ * emits one \`ToolDispatchSignal\` per dispatch, and folds later events of the
4013
+ * same dispatch into the record it already emitted.
4014
+ *
4015
+ * Consumers keep the \`ToolDispatchSignal\` object they received and re-read it
4016
+ * later; no consumer needs to correlate events itself.
4017
+ */
4018
+ var ToolDispatchNormalizer = class {
4019
+ records = /* @__PURE__ */ new Map();
4020
+ /** Call ids of \`run_code\`-class calls, i.e. of dispatches whose sub-dispatches are \`program\`. */
4021
+ programRoots = /* @__PURE__ */ new Set();
4022
+ /**
4023
+ * Absorb one session event.
4024
+ * @param event - the event to absorb; any non-dispatch event is ignored.
4025
+ * @returns the dispatch's signal when this event FIRST reveals the dispatch,
4026
+ * otherwise \`null\` (the paired event of an already-emitted dispatch, or a
4027
+ * non-dispatch event). A \`null\` return is never a dispatch to count again.
4028
+ */
4029
+ advance(event) {
4030
+ const record = readDispatchRecord(event);
4031
+ if (record === null) return null;
4032
+ if (record.name === "") {
4033
+ const existing = this.records.get(record.callId);
4034
+ if (existing !== void 0 && record.outcome !== void 0) existing.ok = record.outcome.ok;
4035
+ return null;
4036
+ }
4037
+ const existing = this.records.get(record.callId);
4038
+ if (existing !== void 0) {
4039
+ if (record.outcome !== void 0) existing.ok = record.outcome.ok;
4040
+ return null;
4041
+ }
4042
+ const signal = {
4043
+ kind: record.kind === "native" && this.programRoots.has(record.callId) ? "program-root" : record.kind,
4044
+ callId: record.callId,
4045
+ rootCallId: record.rootCallId,
4046
+ name: record.name,
4047
+ arguments: record.arguments,
4048
+ ok: record.outcome?.ok
4049
+ };
4050
+ this.records.set(record.callId, signal);
4051
+ if (record.kind === "program" && record.rootCallId !== record.callId) this.programRoots.add(record.rootCallId);
4052
+ return signal;
4053
+ }
4054
+ /** Every emitted dispatch, in first-seen order. */
4055
+ get signals() {
4056
+ return [...this.records.values()];
4057
+ }
4058
+ /**
4059
+ * Fold every event of a session log, in order.
4060
+ * @param events - the log, oldest first.
4061
+ * @returns one signal per dispatch, in first-seen order.
4062
+ */
4063
+ foldAll(events) {
4064
+ const emitted = [];
4065
+ for (const event of events) {
4066
+ const signal = this.advance(event);
4067
+ if (signal !== null) emitted.push(signal);
4068
+ }
4069
+ return emitted;
4070
+ }
4071
+ };
4072
+ /**
4073
+ * Read one event into a dispatch record without a ledger (the pure half of the
4074
+ * fold). Unlike the ledger it does NOT dedupe: a caller that walks a log and
4075
+ * emits its own lines needs the call line on the event that opens the result.
4076
+ * @param event - one session event; an absent event (an over-advanced index)
4077
+ * answers \`null\` rather than throwing.
4078
+ * @returns the dispatch this event opens, or \`null\` for any other event.
4079
+ */
4080
+ function readDispatchSignal(event) {
4081
+ if (event?.type === void 0) return null;
4082
+ const record = readDispatchRecord({
4083
+ type: event.type,
4084
+ ...event.data === void 0 ? {} : { data: event.data }
4085
+ });
4086
+ if (record === null || record.name === "") return null;
4087
+ return {
4088
+ kind: record.kind,
4089
+ callId: record.callId,
4090
+ rootCallId: record.rootCallId,
4091
+ name: record.name,
4092
+ arguments: record.arguments
4093
+ };
4094
+ }
4095
+ /** Tool names that READ one skill. The single authority for skill-read detection. */
4096
+ const SKILL_READ_TOOL_NAMES = new Set(["skill"]);
4097
+ /**
4098
+ * Is this dispatched tool name a single-skill read?
4099
+ * @param name - the dispatched tool name.
4100
+ * @returns \`true\` only for the read tool whose arguments name one skill.
4101
+ */
4102
+ function isSkillReadToolName(name) {
4103
+ return SKILL_READ_TOOL_NAMES.has(name);
4104
+ }
4105
+ /** Tool names whose dispatch opens a code program, i.e. whose sub-dispatches are \`program\`. */
4106
+ const PROGRAM_TOOL_NAMES = new Set(["run_code"]);
4107
+ /**
4108
+ * Does this signal read one skill, and did it not fail?
4109
+ * @param signal - a normalized dispatch.
4110
+ * @returns the skill name the dispatch read, or \`undefined\` when the dispatch
4111
+ * is not a skill read or has failed. A dispatch whose outcome is still pending
4112
+ * counts as a read: the platform settles every started sub-dispatch, so pending
4113
+ * is a live-window state, not a failure.
4114
+ */
4115
+ function skillReadNameOf(signal) {
4116
+ if (!SKILL_READ_TOOL_NAMES.has(signal.name)) return void 0;
4117
+ if (signal.ok === false) return void 0;
4118
+ let parsed = signal.arguments;
4119
+ if (typeof parsed === "string") try {
4120
+ parsed = JSON.parse(parsed);
4121
+ } catch {
4122
+ return;
4123
+ }
4124
+ if (parsed === null || typeof parsed !== "object") return void 0;
4125
+ const candidate = parsed;
4126
+ const name = typeof candidate.name === "string" ? candidate.name : typeof candidate.skill === "string" ? candidate.skill : "";
4127
+ return name === "" ? void 0 : name;
4128
+ }
4129
+ /**
4130
+ * Fold one session log into its deduplicated dispatches.
4131
+ * @param events - the log, oldest first. Pass an array, not a live snapshot
4132
+ * iterator, when the log can grow while folding.
4133
+ * @returns one signal per dispatch, in first-seen order.
4134
+ */
4135
+ function foldToolDispatches(events) {
4136
+ return new ToolDispatchNormalizer().foldAll(events);
4137
+ }
4138
+ /**
4139
+ * Every skill name this log read through a dispatch, deduplicated by dispatch.
4140
+ * @param events - the log, oldest first.
4141
+ * @returns the names read by at least one non-failed skill dispatch.
4142
+ */
4143
+ function collectReadSkillNames(events) {
4144
+ const names = /* @__PURE__ */ new Set();
4145
+ for (const signal of foldToolDispatches(events)) {
4146
+ const name = skillReadNameOf(signal);
4147
+ if (name !== void 0) names.add(name);
4148
+ }
4149
+ return names;
4150
+ }
4151
+ /** Tool names that touch the skill library at all — the review's skill-signal set. */
4152
+ const SKILL_TOOL_NAMES = new Set([...SKILL_READ_TOOL_NAMES, "skill_manage"]);
4153
+ /**
4154
+ * Does this dispatched tool name touch the skill library (read or mutate)?
4155
+ * @param name - the dispatched tool name.
4156
+ * @returns \`true\` for any skill-library tool. This is the review cadence's
4157
+ * skill signal, which has always covered reads and writes alike.
4158
+ */
4159
+ function isSkillToolName(name) {
4160
+ return SKILL_TOOL_NAMES.has(name);
4161
+ }
4162
+ /**
4163
+ * Does this dispatched tool name open a code program?
4164
+ * @param name - the dispatched tool name.
4165
+ * @returns \`true\` for the tool whose sub-dispatches are \`program\` kind.
4166
+ */
4167
+ function isProgramToolName(name) {
4168
+ return PROGRAM_TOOL_NAMES.has(name);
4169
+ }
4170
+ /**
4171
+ * Assert that a payload really is the dispatch event it claims to be.
4172
+ *
4173
+ * The normalizer is deliberately lenient (it also folds persisted logs, where a
4174
+ * payload may predate the current declaration); this is the loud gate for the
4175
+ * live path, where a malformed payload means the producer is broken. It never
4176
+ * silently downgrades: an unrecognized event type or a payload missing a
4177
+ * declared field throws a named \`ToolDispatchPayloadError\`.
4178
+ * @param event - the session event to check.
4179
+ * @returns nothing; throws when the payload violates the platform declaration.
4180
+ */
4181
+ function assertDispatchPayload(event) {
4182
+ const data = event.data ?? {};
4183
+ if (event.type === "tool/call") {
4184
+ if (typeof data.name !== "string" || data.name === "" || typeof data.callId !== "string" || data.callId === "") throw new ToolDispatchPayloadError("tool/call carries no name/callId: the agent-loop write site is broken");
4185
+ return;
4186
+ }
4187
+ if (event.type === "tool/ptc-dispatch-start" || event.type === "tool/ptc-dispatch") {
4188
+ if (typeof data.subCallId !== "string" || data.subCallId === "" || typeof data.name !== "string" || data.name === "") throw new ToolDispatchPayloadError(`${event.type} carries no name/subCallId: the PTC bridge write site is broken`);
4189
+ if (event.type === "tool/ptc-dispatch" && typeof data.isError !== "boolean") throw new ToolDispatchPayloadError("tool/ptc-dispatch carries no boolean isError: the settle outcome is unusable");
4190
+ return;
4191
+ }
4192
+ if (event.type === "tool/result") return;
4193
+ throw new ToolDispatchPayloadError(`${event.type} is not a platform dispatch event; compare signal.kind instead of event types`);
4194
+ }
4195
+ /**
4196
+ * Total dispatches in a log, deduplicated.
4197
+ * @param events - the log, oldest first.
4198
+ * @returns the number of distinct dispatches, one per dispatch regardless of
4199
+ * how many events carried it.
4200
+ */
4201
+ function countDispatches(events) {
4202
+ return foldToolDispatches(events).length;
4203
+ }
4204
+ //#endregion
3656
4205
  //#region lib/types/signals.js
3657
4206
  /**
3658
4207
  * Deterministic review signal gate.
@@ -3684,6 +4233,23 @@ function textOfBlock(block) {
3684
4233
  const candidate = block;
3685
4234
  return candidate.type === "text" && typeof candidate.text === "string" ? candidate.text : "";
3686
4235
  }
4236
+ /**
4237
+ * Per-turn dispatch ledgers, keyed by the signal object they feed.
4238
+ *
4239
+ * `observeEvent` folds ONE event at a time, so the dedup state has to outlive
4240
+ * the call. One WeakMap entry per `TurnSignals`, so a finished fold's ledger is
4241
+ * collectable with the fold itself and two concurrent turns never share one.
4242
+ */
4243
+ const dispatchLedgers = /* @__PURE__ */ new WeakMap();
4244
+ /** The ledger folding this signal's turn, created on first use. */
4245
+ function normalizerForSignal(signal) {
4246
+ let normalizer = dispatchLedgers.get(signal);
4247
+ if (normalizer === void 0) {
4248
+ normalizer = new ToolDispatchNormalizer();
4249
+ dispatchLedgers.set(signal, normalizer);
4250
+ }
4251
+ return normalizer;
4252
+ }
3687
4253
  /** Fold one session event into the current turn observation. */
3688
4254
  function observeEvent(signal, event) {
3689
4255
  const data = event.data;
@@ -3704,11 +4270,10 @@ function observeEvent(signal, event) {
3704
4270
  signal.assistantChars += text.length;
3705
4271
  return;
3706
4272
  }
3707
- if (event.type === "tool/call") {
3708
- signal.toolCalls += 1;
3709
- const name = data.name;
3710
- if (name === "skill" || name === "skill_manage") signal.skillSignal = true;
3711
- }
4273
+ const dispatch = normalizerForSignal(signal).advance(event);
4274
+ if (dispatch === null) return;
4275
+ if (dispatch.kind !== "program") signal.toolCalls += 1;
4276
+ if (isSkillToolName(dispatch.name)) signal.skillSignal = true;
3712
4277
  }
3713
4278
  /** Compute review cadence after `turn/end`. */
3714
4279
  function advanceReview(state, turn, signal, config) {
@@ -3815,7 +4380,8 @@ function missingSupportPointers(body, supportFiles) {
3815
4380
  /** Duplicate `## heading` occurrences: singleton results default to head of the file. */
3816
4381
  function duplicateHeadings(body) {
3817
4382
  const counts = /* @__PURE__ */ new Map();
3818
- for (const line of body.split("\n")) {
4383
+ for (const raw of body.split("\n")) {
4384
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
3819
4385
  const m = /^##\s+(.+)$/.exec(line);
3820
4386
  if (m?.[1]) {
3821
4387
  const heading = m[1].trim();
@@ -3832,7 +4398,8 @@ function overlongLines(body, max = DRIFT_MAX_LINE_CHARS) {
3832
4398
  const out = [];
3833
4399
  const lines = body.split("\n");
3834
4400
  for (let index = 0; index < lines.length; index += 1) {
3835
- const length = (lines[index] ?? "").length;
4401
+ const raw = lines[index] ?? "";
4402
+ const length = raw.endsWith("\r") ? raw.length - 1 : raw.length;
3836
4403
  if (length > max) out.push({
3837
4404
  lineNo: index + 1,
3838
4405
  chars: length
@@ -3887,7 +4454,7 @@ function computeDriftSignals(snapshots) {
3887
4454
  bodyText: body,
3888
4455
  supportGroups: supportGroupCount(supportFiles)
3889
4456
  }, DEFAULT_HEALTH_THRESHOLDS).dims.stampDensityPerKb;
3890
- signals.push(density === null ? sig("stamp_density", "pass", body.length < 2e3 ? "below-min-body" : "not-assessed") : sig("stamp_density", density >= DEFAULT_HEALTH_THRESHOLDS.stampDensityPerKb ? "over" : "pass", `${density.toFixed(2)}/KB`, `${DEFAULT_HEALTH_THRESHOLDS.stampDensityPerKb}/KB`));
4457
+ signals.push(density === null ? sig("stamp_density", "pass", "below-min-body") : sig("stamp_density", density >= DEFAULT_HEALTH_THRESHOLDS.stampDensityPerKb ? "over" : "pass", `${density.toFixed(2)}/KB`, `${DEFAULT_HEALTH_THRESHOLDS.stampDensityPerKb}/KB`));
3891
4458
  signals.push(sig("body_size", body.length >= DEFAULT_HEALTH_THRESHOLDS.softBodyChars ? "over" : "pass", `${body.length}`, `${DEFAULT_HEALTH_THRESHOLDS.softBodyChars}`));
3892
4459
  const dupes = duplicateHeadings(body);
3893
4460
  signals.push(dupes.length === 0 ? sig("dup_heading", "pass", "none") : sig("dup_heading", "over", dupes.map((d) => `${d.heading}(${d.count})`).join(", "), "count >= 2"));
@@ -4134,23 +4701,35 @@ function markerPath(dir, marker) {
4134
4701
  * platform ignored the file: family visibility split from platform visibility,
4135
4702
  * which is exactly what a strict-YAML frontmatter is supposed to prevent.
4136
4703
  */
4704
+ /**
4705
+ * S1.1 (v37 P1-4): the frontmatter block is split on LF and every line keeps its
4706
+ * own `\r`, so the byte-preserving rebuild (`lines.join('\n')`) is exact. Line
4707
+ * MATCHERS must therefore look at a CR-stripped view: JS `.` does not match `\r`,
4708
+ * so `(.*)$` never reaches the end of a CRLF line.
4709
+ * @param line - one block line, possibly CR-terminated.
4710
+ * @returns the line without its trailing CR.
4711
+ */
4712
+ function withoutCr(line) {
4713
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
4714
+ }
4137
4715
  function frontmatterBlock(content) {
4138
4716
  if (!content.trimStart().startsWith("---")) return null;
4139
- const nl = content.includes("\r\n") ? "\r\n" : "\n";
4140
- const lines = content.split(nl);
4717
+ const firstBreak = content.indexOf("\n");
4718
+ const nl = firstBreak > 0 && content[firstBreak - 1] === "\r" ? "\r\n" : "\n";
4719
+ const lines = content.split("\n");
4141
4720
  if ((lines[0] ?? "").replace(/\r$/, "") !== "---") return null;
4142
4721
  let end = -1;
4143
4722
  for (let i = 1; i < lines.length; i++) {
4144
4723
  const line = lines[i];
4145
4724
  if (line === void 0) continue;
4146
- if (line.replace(/\r$/, "") === "---") {
4725
+ if (line === "---" || line === "---\r") {
4147
4726
  end = i;
4148
4727
  break;
4149
4728
  }
4150
4729
  }
4151
4730
  if (end < 0) return null;
4152
4731
  return {
4153
- block: lines.slice(1, end).join(nl),
4732
+ block: lines.slice(1, end).join("\n"),
4154
4733
  lines,
4155
4734
  end,
4156
4735
  nl
@@ -4167,8 +4746,9 @@ function frontmatterBlock(content) {
4167
4746
  function unsafeFrontmatterEntries(block, nl) {
4168
4747
  const found = [];
4169
4748
  for (const line of block.split(nl)) {
4170
- if (line.includes("\n") || line.includes("\r")) continue;
4171
- const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
4749
+ const clean = withoutCr(line);
4750
+ if (clean.includes("\n") || clean.includes("\r")) continue;
4751
+ const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(clean);
4172
4752
  if (!match) continue;
4173
4753
  const key = match[1];
4174
4754
  if (key === void 0) continue;
@@ -4181,9 +4761,22 @@ function unsafeFrontmatterEntries(block, nl) {
4181
4761
  return found;
4182
4762
  }
4183
4763
  /**
4764
+ * Frontmatter fields the platform catalog reads as STRINGS (`stringField` /
4765
+ * `optionalString` in `skill-filesystem`): any other YAML type reads as
4766
+ * ABSENT there, and an absent `name`/`description` makes the catalog ignore the
4767
+ * whole file. The family publishes text for these keys anyway (its lenient
4768
+ * read), which is the one split a write must not create.
4769
+ */
4770
+ const PLATFORM_STRING_FIELDS = [
4771
+ "name",
4772
+ "description",
4773
+ "whenToUse"
4774
+ ];
4775
+ /**
4184
4776
  * Frontmatter values as the STRICT platform catalog reads them — js-yaml, the
4185
4777
  * parser `normalizeFrontmatter` also verifies rewrites with — or `null` when
4186
- * the block is not loadable as a YAML mapping.
4778
+ * the block is not loadable as a YAML mapping. Also reports the platform string
4779
+ * fields whose value is not a string ({@link PlatformStringSplit}).
4187
4780
  *
4188
4781
  * Scalars publish their text (`name`, `description`, `whenToUse` are strings by
4189
4782
  * contract; a number/boolean-shaped value keeps the text the family always
@@ -4195,17 +4788,28 @@ function unsafeFrontmatterEntries(block, nl) {
4195
4788
  * carried.
4196
4789
  */
4197
4790
  function strictFrontmatterValues(block) {
4198
- if (block.trim() === "") return /* @__PURE__ */ new Map();
4791
+ if (block.trim() === "") return {
4792
+ values: /* @__PURE__ */ new Map(),
4793
+ split: []
4794
+ };
4199
4795
  let loaded;
4200
4796
  try {
4201
4797
  loaded = load(block);
4202
4798
  } catch {
4203
4799
  return null;
4204
4800
  }
4205
- if (loaded === null || loaded === void 0) return /* @__PURE__ */ new Map();
4801
+ if (loaded === null || loaded === void 0) return {
4802
+ values: /* @__PURE__ */ new Map(),
4803
+ split: []
4804
+ };
4206
4805
  if (typeof loaded !== "object" || Array.isArray(loaded)) return null;
4207
4806
  const values = /* @__PURE__ */ new Map();
4807
+ const split = [];
4208
4808
  for (const [key, value] of Object.entries(loaded)) {
4809
+ if (typeof value !== "string" && PLATFORM_STRING_FIELDS.includes(key)) split.push({
4810
+ key,
4811
+ kind: value === null || value === void 0 ? "null" : Array.isArray(value) ? "sequence" : typeof value === "object" ? "mapping" : "scalar"
4812
+ });
4209
4813
  if (typeof value === "string") {
4210
4814
  values.set(key, value.trim());
4211
4815
  continue;
@@ -4219,7 +4823,10 @@ function strictFrontmatterValues(block) {
4219
4823
  continue;
4220
4824
  }
4221
4825
  }
4222
- return values;
4826
+ return {
4827
+ values,
4828
+ split
4829
+ };
4223
4830
  }
4224
4831
  /**
4225
4832
  * Lenient line scan, used only for a block the strict parser rejects: the
@@ -4231,7 +4838,7 @@ function lenientFrontmatterValues(block, nl) {
4231
4838
  const values = /* @__PURE__ */ new Map();
4232
4839
  const blockLines = block.split(nl);
4233
4840
  for (let i = 0; i < blockLines.length; i += 1) {
4234
- const line = blockLines[i] ?? "";
4841
+ const line = withoutCr(blockLines[i] ?? "");
4235
4842
  const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
4236
4843
  if (!match) continue;
4237
4844
  const [, key, value] = match;
@@ -4280,16 +4887,17 @@ function lenientFrontmatterValues(block, nl) {
4280
4887
  function readFrontmatterBlock(content) {
4281
4888
  const found = frontmatterBlock(content);
4282
4889
  if (!found) return null;
4283
- const body = found.lines.slice(found.end + 1).join(found.nl).trim();
4890
+ const body = found.lines.slice(found.end + 1).join("\n").trim();
4284
4891
  const strict = strictFrontmatterValues(found.block);
4285
- const values = strict ?? lenientFrontmatterValues(found.block, found.nl);
4892
+ const values = strict?.values ?? lenientFrontmatterValues(found.block, found.nl);
4286
4893
  const frontmatter = {};
4287
4894
  for (const [key, value] of values) frontmatter[key] = value;
4288
4895
  return {
4289
4896
  frontmatter,
4290
4897
  body,
4291
4898
  unsafeValues: unsafeFrontmatterEntries(found.block, found.nl),
4292
- strictFailed: strict === null
4899
+ strictFailed: strict === null,
4900
+ platformStringSplit: strict?.split ?? []
4293
4901
  };
4294
4902
  }
4295
4903
  /**
@@ -4309,7 +4917,8 @@ function parseFrontmatter(content) {
4309
4917
  frontmatter: read.frontmatter,
4310
4918
  body: read.body,
4311
4919
  unsafeValues: read.unsafeValues,
4312
- catalogInvalid: read.strictFailed || read.unsafeValues.length > 0
4920
+ catalogInvalid: read.strictFailed || read.unsafeValues.length > 0 || read.platformStringSplit.length > 0,
4921
+ platformStringSplit: read.platformStringSplit
4313
4922
  };
4314
4923
  }
4315
4924
  /**
@@ -4324,7 +4933,7 @@ function parseFrontmatter(content) {
4324
4933
  */
4325
4934
  function frontmatterCatalogInvalid(content) {
4326
4935
  const read = readFrontmatterBlock(content);
4327
- if (read !== null) return read.strictFailed || read.unsafeValues.length > 0;
4936
+ if (read !== null) return read.strictFailed || read.unsafeValues.length > 0 || read.platformStringSplit.length > 0;
4328
4937
  return frontmatterBlock(content.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n")) !== null;
4329
4938
  }
4330
4939
  /** YAML plain-scalar hazards that make an UNQUOTED frontmatter value
@@ -4384,16 +4993,16 @@ function normalizeFrontmatter(content) {
4384
4993
  fields: [],
4385
4994
  issues: []
4386
4995
  };
4387
- const { lines, end, nl } = block;
4996
+ const { lines, end } = block;
4388
4997
  const fields = [];
4389
4998
  const issues = [];
4390
4999
  const seen = /* @__PURE__ */ new Set();
4391
5000
  const originalValues = /* @__PURE__ */ new Map();
4392
5001
  let changed = false;
4393
5002
  for (let i = 1; i < end; i++) {
4394
- const line = lines[i];
4395
- if (line === void 0) continue;
4396
- const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
5003
+ const raw = lines[i];
5004
+ if (raw === void 0) continue;
5005
+ const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(withoutCr(raw));
4397
5006
  if (!match) continue;
4398
5007
  const key = match[1];
4399
5008
  if (key === void 0) continue;
@@ -4408,7 +5017,7 @@ function normalizeFrontmatter(content) {
4408
5017
  issues.push(`${key}: value contains control characters — clean them manually`);
4409
5018
  continue;
4410
5019
  }
4411
- lines[i] = `${key}: ${value.includes("\"") || value.includes("\\") ? `'${value.replace(/'/g, "''")}'` : `"${value}"`}`;
5020
+ lines[i] = `${key}: ${value.includes("\"") || value.includes("\\") ? `'${value.replace(/'/g, "''")}'` : `"${value}"`}${raw.endsWith("\r") ? "\r" : ""}`;
4412
5021
  originalValues.set(key, value);
4413
5022
  fields.push(key);
4414
5023
  changed = true;
@@ -4425,12 +5034,12 @@ function normalizeFrontmatter(content) {
4425
5034
  fields: [],
4426
5035
  issues
4427
5036
  };
4428
- const rewrittenBlock = lines.slice(1, end).join(nl);
5037
+ const rewrittenBlock = lines.slice(1, end).join("\n");
4429
5038
  try {
4430
5039
  const parsed = load(rewrittenBlock);
4431
5040
  for (const key of fields) if (String(parsed[key]) !== originalValues.get(key)) throw new Error(`rewritten value for ${key} differs from the original`);
4432
5041
  return {
4433
- content: lines.join(nl),
5042
+ content: lines.join("\n"),
4434
5043
  changed: true,
4435
5044
  fields,
4436
5045
  issues
@@ -4464,16 +5073,36 @@ function relatedSkillNames(content, exclude) {
4464
5073
  }
4465
5074
  return [...names];
4466
5075
  }
4467
- function validateFrontmatter(content, expectedName, limits = DEFAULT_SKILL_LIMITS) {
5076
+ /** S1.2 (v37 P2-1): the content limit applies to the bytes that LAND ON DISK.
5077
+ * Every write normalizes with `trimEnd() + '\n'`, so judging the raw argument let
5078
+ * a 100_000-character body with no trailing newline land as 100_001 bytes — and
5079
+ * every later patch/update of that skill was then refused, which made it
5080
+ * unmaintainable through `skill_manage` with no repair path at all. */
5081
+ function skillMdOnDisk(content) {
5082
+ return content.trimEnd() + "\n";
5083
+ }
5084
+ /** Whether `content` would exceed `limit` once written. */
5085
+ function exceedsContentLimit(content, limit) {
5086
+ return skillMdOnDisk(content).length > limit;
5087
+ }
5088
+ /** S1.2: the repair path — a write that makes an already-over-limit file smaller.
5089
+ * Only a NET SHRINK is exempt; an equal or larger write stays refused. */
5090
+ function shrinksOverLimit(next, current, limit) {
5091
+ if (current === null || current === void 0 || !exceedsContentLimit(current, limit)) return false;
5092
+ return skillMdOnDisk(next).length < skillMdOnDisk(current).length;
5093
+ }
5094
+ function validateFrontmatter(content, expectedName, limits = DEFAULT_SKILL_LIMITS, current) {
4468
5095
  const parsed = parseFrontmatter(content);
4469
5096
  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.";
5097
+ const unreadable = parsed.platformStringSplit.filter((entry) => entry.kind === "sequence" || entry.kind === "mapping");
5098
+ 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.`;
4470
5099
  if (!parsed.frontmatter.name) return "Frontmatter must include a name field.";
4471
5100
  if (!SKILL_NAME_RE.test(parsed.frontmatter.name)) return `Invalid skill name "${parsed.frontmatter.name}" — use lowercase letters, digits, and hyphens.`;
4472
5101
  if (parsed.frontmatter.name.length > limits.maxNameLength) return `Skill name exceeds ${limits.maxNameLength} characters.`;
4473
5102
  if (expectedName && parsed.frontmatter.name !== expectedName) return `Frontmatter name "${parsed.frontmatter.name}" does not match target skill "${expectedName}".`;
4474
5103
  if (!parsed.frontmatter.description) return "Frontmatter must include a description field.";
4475
5104
  if (parsed.frontmatter.description.length > limits.maxDescriptionLength) return `Description exceeds ${limits.maxDescriptionLength} characters.`;
4476
- if (content.length > limits.maxSkillContentChars) return `SKILL.md content exceeds ${limits.maxSkillContentChars} characters. Consider splitting into a smaller SKILL.md with supporting files.`;
5105
+ 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.`;
4477
5106
  return null;
4478
5107
  }
4479
5108
  /**
@@ -5310,7 +5939,8 @@ var SkillLibrary = class {
5310
5939
  ok: false,
5311
5940
  message: `Skill "${name}" is protected (${protection}).`
5312
5941
  };
5313
- const validation = validateFrontmatter(content, name, this.limits);
5942
+ const currentForLimit = await this.io.readText(path).catch(() => null);
5943
+ const validation = validateFrontmatter(content, name, this.limits, currentForLimit);
5314
5944
  if (validation) return {
5315
5945
  ok: false,
5316
5946
  message: validation
@@ -5322,7 +5952,7 @@ var SkillLibrary = class {
5322
5952
  };
5323
5953
  const finalContent = norm.changed ? norm.content : content;
5324
5954
  if (norm.changed) {
5325
- const revalidated = validateFrontmatter(finalContent, name, this.limits);
5955
+ const revalidated = validateFrontmatter(finalContent, name, this.limits, currentForLimit);
5326
5956
  if (revalidated) return {
5327
5957
  ok: false,
5328
5958
  message: revalidated
@@ -5447,7 +6077,7 @@ var SkillLibrary = class {
5447
6077
  let writeContent = patched;
5448
6078
  let normalizedFields;
5449
6079
  if (target === skillMd) {
5450
- const validation = validateFrontmatter(patched, name, this.limits);
6080
+ const validation = validateFrontmatter(patched, name, this.limits, md);
5451
6081
  if (validation) return {
5452
6082
  result: {
5453
6083
  ok: false,
@@ -5466,7 +6096,7 @@ var SkillLibrary = class {
5466
6096
  if (norm.changed) {
5467
6097
  writeContent = norm.content;
5468
6098
  normalizedFields = norm.fields;
5469
- const revalidated = validateFrontmatter(writeContent, name, this.limits);
6099
+ const revalidated = validateFrontmatter(writeContent, name, this.limits, md);
5470
6100
  if (revalidated) return {
5471
6101
  result: {
5472
6102
  ok: false,
@@ -5483,7 +6113,9 @@ var SkillLibrary = class {
5483
6113
  },
5484
6114
  write: null
5485
6115
  };
5486
- if (writeContent.length > this.limits.maxSkillContentChars && target === skillMd) return {
6116
+ const overLimit = exceedsContentLimit(writeContent, this.limits.maxSkillContentChars);
6117
+ const repairing = shrinksOverLimit(writeContent, md, this.limits.maxSkillContentChars);
6118
+ if (overLimit && target === skillMd && !repairing) return {
5487
6119
  result: {
5488
6120
  ok: false,
5489
6121
  message: `Patched content exceeds ${this.limits.maxSkillContentChars} characters. Consider splitting into a smaller SKILL.md with supporting files.`
@@ -5577,8 +6209,8 @@ var SkillLibrary = class {
5577
6209
  * each support dir (write_file's lock sits next to its file). Residual:
5578
6210
  * NESTED support-subdir locks and the probe→rename TOCTOU itself remain
5579
6211
  * fail-safe (renameWithRetry rides the write out; the writer's locked
5580
- * re-read refuses on the moved-away file), and a residue `.lock` from a
5581
- * CRASHED writer also refusescorrect: inspect, don't archive.
6212
+ * re-read refuses on the moved-away file). v37 (P2-8): a residue `.lock` whose
6213
+ * holder is GONE is not a writer `decideTakeover` owns that verdict.
5582
6214
  */
5583
6215
  async hasWriteLock(dir) {
5584
6216
  const markerLocks = MARKER_LOCK_NAMES.map((name) => join(dir, name));
@@ -5597,11 +6229,12 @@ var SkillLibrary = class {
5597
6229
  }
5598
6230
  return false;
5599
6231
  }
5600
- /** P2 (v17): a file only counts as a writer lock when its body has the
5601
- * `pid:token` shape the io layer writes. User support files legitimately
5602
- * named `*.lock` (allowed by SUPPORT_FILE_NAME_RE) must not trip the probe
5603
- * or be swept as residue the v16 first cut matched on suffix alone,
5604
- * which permanently refused archiving and deleted user content on restore. */
6232
+ /** P2 (v17): a `*.lock` file counts as a writer lock only when its body
6233
+ * carries the io layer's protocol — `pid:token`, a bare pid, or an empty body
6234
+ * (a creator between create and body write); anything else is user content
6235
+ * (suffix-only matching refused archiving and deleted user files on restore).
6236
+ * v37 (P2-8): the verdict is `decideTakeover`'s, so the mover refuses exactly
6237
+ * the lock the io layer refuses to reclaim, the 30s empty window included. */
5605
6238
  async isWriterLock(lockPath) {
5606
6239
  let body;
5607
6240
  try {
@@ -5610,7 +6243,19 @@ var SkillLibrary = class {
5610
6243
  return true;
5611
6244
  }
5612
6245
  if (body === null) return false;
5613
- return LOCK_BODY_RE.test(body.trim());
6246
+ const trimmed = body.trim();
6247
+ if (trimmed !== "" && !/^\d+$/.test(trimmed) && !LOCK_BODY_RE.test(trimmed)) return false;
6248
+ let mtimeMs = null;
6249
+ try {
6250
+ mtimeMs = await this.io.mtime?.(lockPath) ?? null;
6251
+ } catch {
6252
+ mtimeMs = null;
6253
+ }
6254
+ return decideTakeover({
6255
+ body,
6256
+ mtimeMs: mtimeMs ?? Date.now(),
6257
+ alive: isProcessAlive
6258
+ }) === "none";
5614
6259
  }
5615
6260
  /** P2 (v16): best-effort removal of lock residue inside a RESTORED tree.
5616
6261
  * A1-2/A1-7 (v18): the sweep now covers the marker locks the probe checks
@@ -5984,7 +6629,7 @@ var SkillLibrary = class {
5984
6629
  ok: false,
5985
6630
  message: "SKILL.md has no valid frontmatter; refusing to restructure."
5986
6631
  };
5987
- const header = block.lines.slice(0, block.end + 1).join(block.nl);
6632
+ const header = block.lines.slice(0, block.end + 1).join("\n");
5988
6633
  const plan = planRestructureSections(md.slice(header.length).replace(/\r\n/g, "\n"), moves);
5989
6634
  if ("error" in plan) return {
5990
6635
  ok: false,
@@ -6105,13 +6750,16 @@ var SkillLibrary = class {
6105
6750
  if (this.transact) {
6106
6751
  const baseline = entry.expected === void 0 ? entry.previous : entry.expected;
6107
6752
  const drift = { seen: false };
6753
+ const ran = { done: false };
6108
6754
  await this.transact(this.io, entry.target, (current) => {
6755
+ ran.done = true;
6109
6756
  if (current !== baseline) {
6110
6757
  drift.seen = true;
6111
6758
  return current;
6112
6759
  }
6113
6760
  return entry.content;
6114
6761
  });
6762
+ if (!ran.done) throw new Error(`internal error: the write transaction for ${entry.target} did not invoke the task; nothing was written`);
6115
6763
  if (drift.seen) throw new Error(`concurrent modification detected: ${entry.target} changed after the plan was computed (a concurrent writer won the race); no further writes were performed`);
6116
6764
  } else await this.io.writeText(entry.target, entry.content);
6117
6765
  } catch (error) {
@@ -6359,11 +7007,18 @@ var SkillLibrary = class {
6359
7007
  message: `"${filePath}" is not a readable regular file — remove the files inside it one by one.`
6360
7008
  };
6361
7009
  let verdict = anchorVerdict(anchor, before);
6362
- if (verdict === "match" && this.transact) await this.transact(this.io, target, (current) => {
6363
- verdict = anchorVerdict(anchor, current);
6364
- return verdict === "match" ? null : current;
6365
- });
6366
- else if (verdict === "match") await this.io.remove(target);
7010
+ if (verdict === "match" && this.transact) {
7011
+ const ran = { done: false };
7012
+ await this.transact(this.io, target, (current) => {
7013
+ ran.done = true;
7014
+ verdict = anchorVerdict(anchor, current);
7015
+ return verdict === "match" ? null : current;
7016
+ });
7017
+ if (!ran.done) return {
7018
+ ok: false,
7019
+ message: "internal error: the delete transaction did not invoke the task; nothing was removed"
7020
+ };
7021
+ } else if (verdict === "match") await this.io.remove(target);
6367
7022
  if (verdict !== "match") return anchorRefusalFile(name, filePath, verdict);
6368
7023
  await this.audit(name, "remove_file", before, null, `removed ${filePath}`);
6369
7024
  this.notifyMutation({
@@ -6668,8 +7323,8 @@ var SkillLibrary = class {
6668
7323
  let rootEntries;
6669
7324
  try {
6670
7325
  rootEntries = await this.io.list(this.root);
6671
- } catch {
6672
- rootEntries = [];
7326
+ } catch (error) {
7327
+ throw new Error(`snapshot restore refused: the skill root could not be listed (${error instanceof Error ? error.message : String(error)}) — nothing was changed`);
6673
7328
  }
6674
7329
  for (const entry of rootEntries) {
6675
7330
  if (entry === ".archive" || entry === ".backups" || entry === ".mutations.json" || entry === ".curator-suppressed.json") continue;
@@ -6705,5 +7360,11 @@ var SkillLibrary = class {
6705
7360
  }
6706
7361
  }
6707
7362
  };
7363
+ function newSkillLibrary(options) {
7364
+ const { config, io, limits, ctx, transact, threatExemptLabels } = options;
7365
+ return new SkillLibrary(resolveSkillsRoot(config ?? {}), io, limits, ctx ? (event) => {
7366
+ ctx.emit("evolution/skill-mutated", event);
7367
+ } : void 0, transact, [...threatExemptLabels ?? []]);
7368
+ }
6708
7369
  //#endregion
6709
- export { AUTHORING_DESCRIPTION_BAR, COMBINED_REVIEW_PLAN_PROMPT, COMBINED_REVIEW_PROMPT, COMPLETION_SKILL_REVIEW_PROMPT, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEAD_LOCK_TAKEOVER_MS, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_CONSOLIDATION_FAILURES, DEFAULT_CURATOR_BOOT_GRACE_SECONDS, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_CURATOR_MODEL, DEFAULT_CURATOR_REVIEW_MAX_TOKENS, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_MAX_OPS_PER_PLAN, DEFAULT_MEMORY_CHAR_LIMIT, DEFAULT_MEMORY_REVIEW_MODEL, DEFAULT_MIN_IDLE_HOURS, DEFAULT_MUTATION_CAP, DEFAULT_REVIEW_CONTEXT_MESSAGES, DEFAULT_REVIEW_MEMORY_INTERVAL, DEFAULT_REVIEW_MESSAGE_CHARS, DEFAULT_REVIEW_SKILL_INTERVAL, DEFAULT_REVIEW_TIMEOUT_MS, DEFAULT_SKILL_CONTENT_CHARS, DEFAULT_SKILL_LIMITS, DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS, DEFAULT_SKILL_REVIEW_MODEL, DEFAULT_SKILL_REVIEW_TRIGGER, DEFAULT_STALE_AFTER_DAYS, DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS, DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS, DEFAULT_SUBSTANTIVE_MIN_USER_CHARS, DEFAULT_USER_CHAR_LIMIT, DRIFT_MAX_LINE_CHARS, DRIFT_SIGNALS_VERSION, DRIFT_SIGNAL_NOUNS, DSH_AUTHORING_STANDARDS, EMPTY_LOCK_TAKEOVER_MS, ENTRY_DELIMITER, EVENT_ARCHIVE_PREFIX, EVENT_LOG_RETAIN_ARCHIVES, EVENT_LOG_ROTATE_AT, EVENT_LOG_VERSION, EVOLUTION_WRITE_TOOLS, EvolutionGateSet, FORBIDDEN_CONTROL_KEYS, HEALTH_STAMP_RE, LOCK_BODY_RE, LOCK_SUFFIX, LOCK_TEAR_TAKEOVER_MS, LOW_QUALITY_THRESHOLD, LostWriteLock, MAINTAIN_OUTPUT_INSTRUCTION, MAINTAIN_PROMPT, MAX_DESCRIPTION_LENGTH, MAX_RESTRUCTURE_MOVES, MAX_SKILL_CONTENT_CHARS, MAX_SKILL_FILE_BYTES, MAX_SKILL_NAME_LENGTH, MAX_TIMER_DELAY_MS, MEMORY_GUIDANCE_SECTION_ORDER, MEMORY_REVIEW_PROMPT, MIN_STAMP_BODY_CHARS, MUTATIONS_FILE_VERSION, MemoryStore, PATTERN_OVERLAP, PROMPT_BUNDLE, PROMPT_BUNDLE_ID, PROMPT_BUNDLE_VERSION, PROTECTED_BUILTIN_SKILLS, QUALITY_WEIGHTS, RESTRUCTURE_TARGET_RE, SKILLS_GUIDANCE, 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, advanceReview, allowRowCollisions, appendEvolutionEvent, applyCuratorFields, applyCuratorLifecycleFields, applyCuratorMetaFields, assertSkillsRootAliasRetired, assessStructureHealth, authoringFeedback, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpUse, bumpView, clampedNumber, composePresetComposition, computeDedupGroups, computeDriftSignals, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, contentHash, createGateSet, decideTakeover, duplicateHeadings, emptyRecord, evaluateThreat, eventsFile, evolutionEventPayloadIssue, evolutionHome, evolutionIoAdapter, evolutionRoot, findDriftSignal, foldCuratorFields, foldTurn, frontmatterBlock, frontmatterCatalogInvalid, getRecord, isCommittedWarning, isProcessAlive, latestActivityAt, lifecycleCandidate, listEventArchives, loadMutations, loadSuppressedNames, loadUsage, makeSerialQueue, markAgentCreated, markerEntryName, memoryRoot, missingSupportPointers, mutateUsage, mutationsFile, narrowNameMatches, nodeEvolutionIo, normalizeFrontmatter, normalizeUsageRecord, observeEvent, overlongLines, parseCuratorNominations, parseEvolutionEvents, parseFrontmatter, parseLockBody, pendingSelfCleanup, readEvolutionEvents, readEvolutionTimeline, recordMutation, redactSecrets, relatedSkillNames, renameWithRetry, renderCuratorReportMarkdown, resolveOrigins, resolveRootConfig, resolveSkillsRoot, retainEventArchives, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, suppressedFile, transactIo, updateSuppressedNames, usageFile, usageObserved, validateFrontmatter, validateRestructureTarget, verifyPromptBundle, writeDurableTmp, yamlPlainScalarNeedsQuotes };
7370
+ 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, FORBIDDEN_CONTROL_KEYS, HEALTH_STAMP_RE, LOCK_BODY_RE, LOCK_SUFFIX, LOCK_TEAR_TAKEOVER_MS, LOW_QUALITY_THRESHOLD, LostWriteLock, MAINTAIN_OUTPUT_INSTRUCTION, MAINTAIN_PROMPT, MAX_DESCRIPTION_LENGTH, MAX_RESTRUCTURE_MOVES, MAX_SKILL_CONTENT_CHARS, MAX_SKILL_FILE_BYTES, MAX_SKILL_NAME_LENGTH, MAX_TIMER_DELAY_MS, MEMORY_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, 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, isAbsent, isCommittedWarning, isGlobalRead, isPresent, isProcessAlive, isProgramToolName, isReviewChannelSession, isSkillReadToolName, isSkillToolName, isUnknown, latestActivityAt, lifecycleCandidate, listEventArchives, loadMutations, loadSuppressedNames, loadUsage, makeSerialQueue, mapProbe, markAgentCreated, markReviewChannel, markerEntryName, memoryRoot, missingSupportPointers, mutateUsage, mutationsFile, narrowNameMatches, neutralizePromptVariables, newSkillLibrary, nodeEvolutionIo, normalizeFrontmatter, normalizeUsageRecord, observeEvent, overlongLines, parseCuratorNominations, parseEvolutionEvents, parseFrontmatter, parseLockBody, pendingSelfCleanup, probeAbsent, probePresent, probeUnknown, readDispatchSignal, readEvolutionEvents, readEvolutionTimeline, recordMutation, redactSecrets, relatedSkillNames, renameWithRetry, renderCuratorReportMarkdown, resolveOrigins, resolveRootConfig, resolveSkillsRoot, retainEventArchives, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillReadNameOf, skillsRoot, suppressedFile, sweepReviewChannelSessions, transactIo, updateSuppressedNames, usageFile, usageObserved, validateFrontmatter, validateRestructureTarget, valueOr, verifyPromptBundle, writeDurableTmp, yamlPlainScalarNeedsQuotes };