@lmzhen/dsh-evolution-core 0.9.0 → 0.11.1

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/README.md CHANGED
@@ -1,8 +1,6 @@
1
1
  # @lmzhen/dsh-evolution-core
2
2
 
3
- Shared pure library for the family: `SkillLibrary`/`MemoryStore`, the prompts and guidance text,
4
- the threat/quality/drift signals and the IO primitives (`nodeEvolutionIo`, `transactIo`,
5
- `evolutionIoAdapter`). It is **not a Cordis row** — it registers no service, tool or prompt
3
+ Shared pure library for the family: `SkillLibrary`/`MemoryStore`, the prompts and guidance text, the threat/quality/drift signals, the skill CONTENT history (`skill-history.ts`: content-addressed versions behind every write) and the frame classification the plan path reports over (`evidence.ts`), plus the IO primitives (`nodeEvolutionIo`, `transactIo`, `evolutionIoAdapter`). It is **not a Cordis row** — it registers no service, tool or prompt
6
4
  section, and importing the package root is its only entry.
7
5
 
8
6
  ## Model surface
@@ -15,12 +13,24 @@ section, and importing the package root is its only entry.
15
13
 
16
14
  - This package is a library, not a Cordis row; do not mount it as a plugin.
17
15
  - 数值配置已在消费方 Config 面钳制(`min 1`/各字段域);`MemoryStore` 内部对 `limit <= 0` 仍按 unbounded 防御处理——那是库内部防御,不构成"0 = 禁用"的配置语义。
16
+ - **Content-history storage grows with the number of DISTINCT bodies a skill has ever been written
17
+ with, not with the retained count.** Trimming drops index entries; the blobs they point at stay on
18
+ disk (they are shared by content, so a reference count would have to span every skill's index, and no
19
+ sweeper exists yet). Measured on a 3.6 KB skill written 41 times: a 5.7 KB index over 20 entries and
20
+ 41 blob files totalling 150 KB (41x the live body); unchanged writes add nothing. Plan disk accordingly
21
+ or remove `.history/blobs` entries by hand.
18
22
  - Skill-library writes are serialized in-process; cross-process writers to the SAME skill file must go through the single-file paths (`update`, `patch`, `writeSupportFile`). The full concurrency model, its two-phase exceptions and their provenance are in Notes and history.
19
23
 
20
24
  **Runtime invariant:** No companion is published. The platform auto-assembles nothing and the family mounts no `<pkg>/invariant` cordis row, so a companion here would never execute (v37 S2.1 / I-3).
21
25
 
22
26
  ## Notes and history
23
27
 
28
+ - Similarity is TWO NAMED QUESTIONS plus a projection the caller declares (`quality.ts`): `identity(text)` ("is it the same text?", a normalized-content hash) and `affinity(a, b)` ("how much vocabulary do these two share?", Jaccard over two `vocabulary()` sets), asked over `projectionText(projection, input)` — `body` for the library-wide scan, `summary` (name + description) for the create-time hint. `computePrefixClusters` stays a name-side STRUCTURAL index, not a score. Adding a projection is one entry in that table; a caller that wants a fourth ruler should name the question it is really asking instead.
29
+ - A whole-body replacement that keeps less than half of the previous body appends one line to its own result
30
+ (`contentRetentionFeedback`, design §4 item 3): the ratio, both character counts, and the fact that the
31
+ replaced version is still in the history. Computed in the write funnel where both bodies are in hand, feedback
32
+ only — no ratio refuses a write, and the function owns no configuration.
33
+ - Content history is HISTORY ONLY: the skill tree is the single truth for current content, and no judgement reads the version index (design invariant I1). Recording is best-effort but happens inside the write lock, so the index never describes content that did not land; a failed record warns once per instance instead of failing the write.
24
34
  - Skill-library mutations are read-modify-write on one file, so `SkillLibrary` serializes them in-process with a `makeSerialQueue` chain: `update`, `patch`, `restructure`, `writeSupportFile` and (since 0.3.46) `consolidate`'s target read→merge→commit run their whole read→validate→write under one serial task, so two concurrent mutators on one skill never interleave in this process.
25
35
  - `create` is INSIDE the serial chain and, when a transact backend is bound, its exists check runs inside the same per-file transact (v18); `archive`/`consolidate` are rename-based two-phase paths and stay outside the single-file serial chain.
26
36
  - **v18 residual (updated):** `create`, `removeSupportFile` and `setPinned` now run on the serial chain (`removeSupportFile`'s delete also goes through the per-file transact), so the remaining single-file residual is the protection TOCTOU (a marker check outside the transact) and the multi-file two-phase paths `archive`/`consolidate`/`restructure`.
package/lib/index.js CHANGED
@@ -429,6 +429,10 @@ const PROTECTED_BUILTIN_SKILLS = new Set(["plan"]);
429
429
  const MAX_SKILL_NAME_LENGTH = 64;
430
430
  const MAX_DESCRIPTION_LENGTH = 1024;
431
431
  const MAX_SKILL_CONTENT_CHARS = 1e5;
432
+ /** How many content versions to retain per skill (skill-history.ts). 20 mirrors the count
433
+ * KiroCrew settled on for the same job (its `MAX_SKILL_VERSIONS`), and is an E2 deployment
434
+ * parameter — the value is a retention policy, not a protocol constant. */
435
+ const DEFAULT_SKILL_VERSION_KEEP = 20;
432
436
  /** The authoring DISCIPLINE band, taken from the upstream standard (archive §5):
433
437
  * peer skills sit at 8-14k characters and a body pushing past 20k belongs in
434
438
  * `references/*.md`. Deliberately separate from `MAX_SKILL_CONTENT_CHARS`: the
@@ -600,7 +604,8 @@ const DEFAULT_SKILL_LIMITS = {
600
604
  maxNameLength: 64,
601
605
  maxDescriptionLength: MAX_DESCRIPTION_LENGTH,
602
606
  maxSkillContentChars: MAX_SKILL_CONTENT_CHARS,
603
- maxSkillFileBytes: MAX_SKILL_FILE_BYTES
607
+ maxSkillFileBytes: MAX_SKILL_FILE_BYTES,
608
+ versionKeep: 20
604
609
  };
605
610
  //#endregion
606
611
  //#region lib/types/frontmatter.js
@@ -4766,6 +4771,12 @@ Learn workflow (when the user asks you to learn a reusable skill, or you decide
4766
4771
  * mutation maturity is a documented DSH approximation (single per-month patch
4767
4772
  * trend ratio replaces the claw timestamp-trend formula, since DSH usage
4768
4773
  * records only carry the last patched timestamp).
4774
+ *
4775
+ * Batch C (2026-09-27): the similarity half of this module is now TWO NAMED
4776
+ * QUESTIONS plus a declared projection — {@link identity} ("is it the same
4777
+ * text?") and {@link affinity} ("how much vocabulary do these two share?") asked
4778
+ * over {@link projectionText}. Callers name the question and the projection
4779
+ * instead of growing a fourth unnamed ruler (design §3C / §1 I2).
4769
4780
  * @module @lmzhen/dsh-evolution-core
4770
4781
  */
4771
4782
  const QUALITY_WEIGHTS = {
@@ -4820,21 +4831,55 @@ function computeQualityScores(input) {
4820
4831
  function normalize(content) {
4821
4832
  return content.toLowerCase().replace(/\s+/g, " ").trim();
4822
4833
  }
4823
- /** C-26 (v10 audit): renamed from `contentHash` — mutations.ts exports a
4834
+ /** Named similarity question 1 — "is it the same text?" — as a normalized
4835
+ * content hash: case- and whitespace-insensitive, so two texts that differ only
4836
+ * in formatting are the SAME text here. Invariant under which projection the
4837
+ * caller read the text from.
4838
+ *
4839
+ * C-26 (v10 audit): NOT named `contentHash` — mutations.ts exports a
4824
4840
  * contentHash of RAW bytes, this one hashes the NORMALIZED text for dedup
4825
- * grouping. Same private helper, unambiguous name. */
4826
- function normalizedHash(content) {
4827
- return createHash("sha256").update(normalize(content)).digest("hex");
4841
+ * grouping. Same helper as that rename, now a named question. */
4842
+ function identity(text) {
4843
+ return createHash("sha256").update(normalize(text)).digest("hex");
4828
4844
  }
4829
- function tokenize(content) {
4830
- return new Set(normalize(content).split(/[^a-z0-9\u4e00-\u9fff]+/).filter(Boolean));
4831
- }
4832
- function jaccard(a, b) {
4845
+ /**
4846
+ * The declared projection: WHICH text a comparison weighs.
4847
+ *
4848
+ * `body` is the library-wide scan's projection (whole SKILL.md, read by the
4849
+ * caller). `summary` is name + description in one string — the projection a
4850
+ * listing already publishes (`SkillSummary` carries both fields, and a body is
4851
+ * attached only when the caller asks for it) — so a summary comparison never
4852
+ * reads a body OF ITS OWN: the create-time hint is fed by the listing pass the
4853
+ * tool already pays for its frontmatter, and calls no read of its own.
4854
+ */
4855
+ function projectionText(projection, input) {
4856
+ switch (projection) {
4857
+ case "body": return input.content ?? "";
4858
+ case "summary": return `${input.name ?? ""} ${input.description ?? ""}`;
4859
+ }
4860
+ }
4861
+ /** The comparable vocabulary of one text: lowercase alphanumeric runs, plus
4862
+ * whole CJK runs (a Chinese description tokenizes by punctuation, not by word —
4863
+ * deliberately: the ruler is one home, and a real tokenizer is a different
4864
+ * question than "do these two share vocabulary?"). */
4865
+ function vocabulary(text) {
4866
+ return new Set(normalize(text).split(/[^a-z0-9\u4e00-\u9fff]+/).filter(Boolean));
4867
+ }
4868
+ /** Named similarity question 2 — "how much vocabulary do these two share?" —
4869
+ * Jaccard over two {@link vocabulary} sets, 0 (nothing in common, or either side
4870
+ * empty) to 1 (identical vocabulary). Set-based on purpose: the bulk scan
4871
+ * memoizes each name's set once and only the comparisons are budgeted. */
4872
+ function affinity(a, b) {
4833
4873
  if (a.size === 0 || b.size === 0) return 0;
4834
4874
  let intersection = 0;
4835
4875
  for (const token of a) if (b.has(token)) intersection += 1;
4836
4876
  return intersection / (a.size + b.size - intersection);
4837
4877
  }
4878
+ /** The `affinity` level at which two bodies are treated as the same skill
4879
+ * (design §3C: the default is unchanged at 0.95 — it is now a NAME, because the
4880
+ * docblock below has linked it since PLAN-R2 while the code carried a bare
4881
+ * literal). Callers may still pass their own level. */
4882
+ const DEDUP_SIMILARITY_THRESHOLD = .95;
4838
4883
  /** PLAN-R2 P2-8 (2026-09-16): default cap on two-name comparisons in
4839
4884
  * {@link computeDedupGroups}. A 2000-skill library is ~2M pairs; the old
4840
4885
  * unbounded two-two Jaccard ran seconds to tens of seconds per
@@ -4861,7 +4906,7 @@ function computeDedupGroups(input) {
4861
4906
  const names = [...input.contents.keys()];
4862
4907
  const hashes = /* @__PURE__ */ new Map();
4863
4908
  for (const name of names) {
4864
- const hash = normalizedHash(input.contents.get(name) ?? "");
4909
+ const hash = identity(projectionText("body", { content: input.contents.get(name) ?? "" }));
4865
4910
  const bucket = hashes.get(hash);
4866
4911
  if (bucket) bucket.push(name);
4867
4912
  else hashes.set(hash, [name]);
@@ -4888,7 +4933,7 @@ function computeDedupGroups(input) {
4888
4933
  const tokenSet = (name) => {
4889
4934
  let set = tokens.get(name);
4890
4935
  if (!set) {
4891
- set = tokenize(input.contents.get(name) ?? "");
4936
+ set = vocabulary(projectionText("body", { content: input.contents.get(name) ?? "" }));
4892
4937
  tokens.set(name, set);
4893
4938
  }
4894
4939
  return set;
@@ -4908,7 +4953,7 @@ function computeDedupGroups(input) {
4908
4953
  compared += 1;
4909
4954
  const [ta, tb] = [tokenSet(a), tokenSet(b)];
4910
4955
  if (Math.max(ta.size, tb.size) / Math.max(1, Math.min(ta.size, tb.size)) > 5) continue;
4911
- if (jaccard(ta, tb) >= threshold) union(a, b);
4956
+ if (affinity(ta, tb) >= threshold) union(a, b);
4912
4957
  }
4913
4958
  }
4914
4959
  const groups = /* @__PURE__ */ new Map();
@@ -4924,6 +4969,40 @@ function computeDedupGroups(input) {
4924
4969
  };
4925
4970
  }
4926
4971
  /**
4972
+ * The create-time near-duplicate question (batch C, design §3C-2): WHICH
4973
+ * existing skills is this authoring candidate about to near-copy?
4974
+ *
4975
+ * It is the SAME {@link affinity} question as the library-wide scan, asked over
4976
+ * the `summary` projection — the one a listing already publishes
4977
+ * (`SkillSummary` carries `name` and `description`; nothing here can see a
4978
+ * body, which is the point: a create must not pay a SECOND whole-tree pass — the
4979
+ * listing's own frontmatter read is what feeds this comparison). Deliberately hint-only: the caller writes regardless, and the
4980
+ * matches are news, not a gate (G4 — the create-time foreground behavior is
4981
+ * unchanged).
4982
+ *
4983
+ * The default level is far below the scan's {@link DEDUP_SIMILARITY_THRESHOLD}
4984
+ * because the projection is thinner: a name plus one sentence shares far fewer
4985
+ * tokens than two bodies, and a hint that fires on every new skill is noise.
4986
+ * Exact same-name candidates are skipped — the library's own create refuses
4987
+ * those, and this line must not repeat that refusal.
4988
+ */
4989
+ const SUMMARY_DUPLICATE_HINT_THRESHOLD = .5;
4990
+ function nearDuplicateSummaries(input) {
4991
+ const threshold = input.threshold ?? .5;
4992
+ const limit = input.limit ?? 3;
4993
+ const candidateTokens = vocabulary(projectionText("summary", input.candidate));
4994
+ const matches = [];
4995
+ for (const existing of input.existing) {
4996
+ if (existing.name === input.candidate.name) continue;
4997
+ const score = affinity(candidateTokens, vocabulary(projectionText("summary", existing)));
4998
+ if (score >= threshold) matches.push({
4999
+ name: existing.name,
5000
+ score
5001
+ });
5002
+ }
5003
+ return matches.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)).slice(0, limit);
5004
+ }
5005
+ /**
4927
5006
  * Prefix-cluster index over a name set (rc.67 merge heuristic, input side):
4928
5007
  * the curator prompt asks the model to identify "prefix clusters — skills
4929
5008
  * sharing a first word or domain keyword"; the deterministic index supplies
@@ -5995,6 +6074,58 @@ function filterUnreadSkillOps(ops, readNames) {
5995
6074
  return dropped;
5996
6075
  }
5997
6076
  //#endregion
6077
+ //#region lib/types/evidence.js
6078
+ /**
6079
+ * Which session frames are EVIDENCE, and which are bookkeeping.
6080
+ *
6081
+ * A plan op must cite `evidence` seqs, and the validator's range rule
6082
+ * (`evolution-plan-validator`, rule `EVIDENCE_RANGE`) answers only "is this an integer inside
6083
+ * the window the plan was authored against". A boundary frame satisfies that while carrying
6084
+ * nothing a claim can rest on: the `turn/start` seq is the frame a claim about that turn was
6085
+ * most likely read out of, and citing it is indistinguishable from citing a real exchange. This
6086
+ * module owns the ONE classification of a session frame by what it CARRIES, so the plan path can
6087
+ * report an op whose whole citation list is bookkeeping (design
6088
+ * `dsh-evolution-skill-history-design.md` §3D, phase 1: report only — no op is refused here).
6089
+ *
6090
+ * The classification is a DENY list of the pure bookends, not an allow list of content frames.
6091
+ * The platform's own vocabulary (`KNOWN_SESSION_EVENT_TYPES`, 57 members at 0.1.5-rc.2) mixes
6092
+ * content frames with state-change frames, and out-of-repo plugins append types the family cannot
6093
+ * enumerate; an unknown frame is therefore treated as carrying content, because a report that
6094
+ * fires on an unclassifiable frame is a false positive and phase 1 exists to measure how many TRUE
6095
+ * ones there are. Tightening — a narrower definition, or an allow list built from the platform's
6096
+ * vocabulary — changes this table and nothing else (`evidenceKindIndex` is the only reader).
6097
+ * @module @lmzhen/dsh-evolution-core
6098
+ */
6099
+ /** The frame types that record a boundary and carry no content: the turn and step bookends. */
6100
+ const BOOKKEEPING_FRAME_TYPES = new Set([
6101
+ "turn/start",
6102
+ "turn/end",
6103
+ "step/start",
6104
+ "step/end"
6105
+ ]);
6106
+ /**
6107
+ * The seqs of the frames that carry content.
6108
+ *
6109
+ * @param events - the session's frames, oldest first (any iterable; nothing is retained).
6110
+ * @returns the substantive seqs, or `undefined` when NO frame carried a usable seq. The two
6111
+ * answers are different facts: an empty SET says "every sequenced frame is a boundary", while
6112
+ * `undefined` says "this log cannot tell a boundary from a message" (a stub session in a test,
6113
+ * a composition whose events carry no seq). A caller that cannot classify must stay silent
6114
+ * rather than report every op — the same three-state posture as `sessionReadSkillNames`.
6115
+ */
6116
+ function evidenceKindIndex(events) {
6117
+ const substantive = /* @__PURE__ */ new Set();
6118
+ let sequenced = 0;
6119
+ for (const event of events) {
6120
+ const seq = event.seq;
6121
+ if (typeof seq !== "number" || !Number.isInteger(seq)) continue;
6122
+ sequenced += 1;
6123
+ if (typeof event.type === "string" && BOOKKEEPING_FRAME_TYPES.has(event.type)) continue;
6124
+ substantive.add(seq);
6125
+ }
6126
+ return sequenced === 0 ? void 0 : substantive;
6127
+ }
6128
+ //#endregion
5998
6129
  //#region lib/types/drift-signals.js
5999
6130
  /**
6000
6131
  * Library-level drift signals for the maintenance subagent (design 011).
@@ -6260,6 +6391,337 @@ function findDriftSignal(signals, id) {
6260
6391
  return signals.find((signal) => signal.id === id);
6261
6392
  }
6262
6393
  //#endregion
6394
+ //#region lib/types/skill-history.js
6395
+ /**
6396
+ * Skill content history: content-addressed blobs plus a per-skill version index.
6397
+ *
6398
+ * Why it exists: `.mutations.json` records before/after HASHES and calls every automated edit
6399
+ * "reviewable and replayable" — but a hash cannot be replayed. With no content store, the only way
6400
+ * back was a whole-tree snapshot (`.backups`, keep=5) or `.archive` (removed skills only), so
6401
+ * reverting one skill cost the collateral of every other change in that window. This module keeps
6402
+ * the content, keyed by skill and version.
6403
+ *
6404
+ * Invariants (design `dsh-evolution-skill-history-design.md` §1):
6405
+ * - HISTORY IS HISTORY: the skill tree stays the single truth for "current content"; nothing here
6406
+ * feeds a judgment — callers read back content and version NUMBERS only.
6407
+ * - BEST-EFFORT, like the mutation audit: a failed history write warns and never fails the write
6408
+ * that already landed.
6409
+ * - Blobs are content-addressed, so identical bodies are stored once (across skills too).
6410
+ * - Versions are numbered by `v` (max+1), NOT by the clock: a moved system clock must not reorder
6411
+ * history (KiroCrew's `v<N>` choice, independently arrived at). Numbering and the index read-modify-write run
6412
+ * under the INDEX's own io lock, so two writers of one skill cannot mint the same number.
6413
+ * - The array's ORDER is append order, and the index is appended after the content lock is released, so two
6414
+ * writers of ONE skill can interleave their appends. Each entry therefore carries `beforeHash` — the content
6415
+ * it replaced, read under that writer's lock — and that link, not the position, is what `undo` follows
6416
+ * ({@link orderVersions} rebuilds the content order for display when the chain is complete).
6417
+ *
6418
+ * NOT here: blob garbage collection. Trimming drops INDEX entries; orphan blobs stay until a
6419
+ * sweeper collects them, because a reference count would have to span every skill's index and that
6420
+ * belongs to the curator's whole-library pass, not to the write path.
6421
+ * @module @lmzhen/dsh-evolution-core/skill-history
6422
+ */
6423
+ /** Root-level history directory. Dot-prefixed so skill discovery skips it. */
6424
+ const HISTORY_DIR = ".history";
6425
+ /** Versioned index shape, so a future field can migrate without guessing. */
6426
+ const HISTORY_INDEX_VERSION = 1;
6427
+ /** The action label for a predecessor the index had never seen (content found on disk before this
6428
+ * feature existed, or a hand-edited tree): it says "this is what was there", not what wrote it. */
6429
+ const BASELINE_ACTION = "baseline";
6430
+ /** The root-level history directory. */
6431
+ function historyRoot(root) {
6432
+ return join(root, HISTORY_DIR);
6433
+ }
6434
+ /** Content-addressed blob path: identical bodies share one file, across skills too. */
6435
+ function blobPath(root, hash) {
6436
+ return join(historyRoot(root), "blobs", hash);
6437
+ }
6438
+ /** One skill's index file. */
6439
+ function historyIndexFile(root, name) {
6440
+ return join(historyRoot(root), "skills", name, "index.json");
6441
+ }
6442
+ /**
6443
+ * The versions in CONTENT order (oldest first), rebuilt from the chain links, for display.
6444
+ *
6445
+ * The stored array is append order, which interleaves when two writers of one skill audit out of
6446
+ * order; the links say what actually replaced what. The rebuild runs only when it can account for
6447
+ * EVERY entry (exactly one start and a forward link for each step) — otherwise the stored order is
6448
+ * returned unchanged, because a partial reconstruction would be a worse answer than the honest one.
6449
+ * @param versions - the stored index, oldest first as recorded.
6450
+ * @returns the same entries, in content order when the chain is complete.
6451
+ */
6452
+ function orderVersions(versions) {
6453
+ if (versions.length < 2) return [...versions];
6454
+ const replaced = /* @__PURE__ */ new Set();
6455
+ for (const entry of versions) if (entry.beforeHash !== void 0) replaced.add(entry.beforeHash);
6456
+ const ends = versions.filter((entry) => !replaced.has(entry.hash));
6457
+ if (ends.length !== 1) return [...versions];
6458
+ const used = /* @__PURE__ */ new Set();
6459
+ const backward = [];
6460
+ let cursor = ends[0];
6461
+ while (cursor !== void 0 && !used.has(cursor)) {
6462
+ used.add(cursor);
6463
+ backward.push(cursor);
6464
+ const before = cursor.beforeHash;
6465
+ cursor = before === void 0 ? void 0 : [...versions].reverse().find((entry) => !used.has(entry) && entry.hash === before);
6466
+ }
6467
+ return backward.length === versions.length ? backward.reverse() : [...versions];
6468
+ }
6469
+ /** The actions that record a SUPPORT FILE's bytes (they share the skill's version index). */
6470
+ const SUPPORT_ACTIONS = ["write_file", "remove_file"];
6471
+ /** The actions that record the skill's own SKILL.md body. */
6472
+ const CONTENT_ACTIONS = [
6473
+ "create",
6474
+ "update",
6475
+ "patch",
6476
+ "consolidate",
6477
+ "restructure",
6478
+ "restore"
6479
+ ];
6480
+ /**
6481
+ * Which artifact one recorded version holds, from the action that produced it.
6482
+ * @param action - the entry's action label.
6483
+ * @returns the artifact class; `other` when the label does not say.
6484
+ */
6485
+ function versionTarget(action) {
6486
+ if (SUPPORT_ACTIONS.includes(action)) return "support";
6487
+ if (CONTENT_ACTIONS.includes(action)) return "content";
6488
+ return "other";
6489
+ }
6490
+ /**
6491
+ * Split one index into the skill body's chain and the support files' versions.
6492
+ *
6493
+ * Both share ONE index, so a skill that ever wrote a support file has two chains and
6494
+ * {@link orderVersions} cannot account for every entry (it falls back to the stored order). Splitting
6495
+ * first lets each group rebuild its own order, and keeps the body's chain intact by leaving every
6496
+ * entry the index cannot classify in it — a `baseline` predecessor is usually the body's first link.
6497
+ * @param versions - the stored index, oldest first as recorded.
6498
+ * @returns the two groups, each in content order when its chain is complete.
6499
+ */
6500
+ function partitionVersions(versions) {
6501
+ const content = [];
6502
+ const support = [];
6503
+ for (const entry of versions) if (versionTarget(entry.action) === "support") support.push(entry);
6504
+ else content.push(entry);
6505
+ return {
6506
+ content: orderVersions(content),
6507
+ support: orderVersions(support)
6508
+ };
6509
+ }
6510
+ /** Keep nothing less than one version, whatever the deployment asks for. */
6511
+ function clampKeep(keep) {
6512
+ return Number.isFinite(keep) ? Math.max(1, Math.floor(keep)) : 1;
6513
+ }
6514
+ /** The next version number: max+1, never a timestamp. */
6515
+ function nextVersion(versions) {
6516
+ let max = 0;
6517
+ for (const entry of versions) if (entry.v > max) max = entry.v;
6518
+ return max + 1;
6519
+ }
6520
+ /** The last entry carrying this hash, or undefined (a hash may repeat after an undo). */
6521
+ function lastVersionOf(versions, hash) {
6522
+ for (let index = versions.length - 1; index >= 0; index -= 1) {
6523
+ const entry = versions[index];
6524
+ if (entry !== void 0 && entry.hash === hash) return entry.v;
6525
+ }
6526
+ }
6527
+ /** One entry as this reader accepts it, or null when the entry cannot be understood. */
6528
+ function readVersionEntry(entry) {
6529
+ if (typeof entry !== "object" || entry === null) return null;
6530
+ const candidate = entry;
6531
+ if (typeof candidate.v !== "number" || !Number.isInteger(candidate.v) || candidate.v <= 0) return null;
6532
+ if (typeof candidate.hash !== "string" || candidate.hash === "") return null;
6533
+ if (typeof candidate.at !== "string") return null;
6534
+ if (typeof candidate.action !== "string") return null;
6535
+ if (typeof candidate.chars !== "number") return null;
6536
+ return typeof candidate.beforeHash === "string" && candidate.beforeHash !== "" ? {
6537
+ ...candidate,
6538
+ beforeHash: candidate.beforeHash
6539
+ } : {
6540
+ v: candidate.v,
6541
+ at: candidate.at,
6542
+ action: candidate.action,
6543
+ hash: candidate.hash,
6544
+ chars: candidate.chars
6545
+ };
6546
+ }
6547
+ /**
6548
+ * Read one index body for a RECORDER: "understood", "no file", or "cannot be understood".
6549
+ * A file whose shape is right but which carries an entry this reader would have to drop counts as
6550
+ * unreadable — dropping it silently would be the overwrite the audit posture forbids.
6551
+ * @param raw - the index file's bytes, or null when absent.
6552
+ * @returns the three-state answer.
6553
+ */
6554
+ function readHistoryIndex(raw) {
6555
+ if (raw === null) return { kind: "absent" };
6556
+ if (raw.trim() === "") return { kind: "unreadable" };
6557
+ let parsed;
6558
+ try {
6559
+ parsed = JSON.parse(raw);
6560
+ } catch {
6561
+ return { kind: "unreadable" };
6562
+ }
6563
+ if (typeof parsed !== "object" || parsed === null) return { kind: "unreadable" };
6564
+ const holder = parsed;
6565
+ if (typeof holder.version === "number" && holder.version > 1) return { kind: "unreadable" };
6566
+ if (!Array.isArray(holder.versions)) return { kind: "unreadable" };
6567
+ const versions = [];
6568
+ for (const entry of holder.versions) {
6569
+ const version = readVersionEntry(entry);
6570
+ if (version === null) return { kind: "unreadable" };
6571
+ versions.push(version);
6572
+ }
6573
+ return {
6574
+ kind: "ok",
6575
+ versions
6576
+ };
6577
+ }
6578
+ /**
6579
+ * Parse one index body for a READER. Malformed content, a foreign shape, or a version NEWER than this
6580
+ * reader all read as empty — the audit posture: never guess. A recorder must use
6581
+ * {@link readHistoryIndex} instead, because it has to PRESERVE bytes it cannot understand.
6582
+ * @param raw - the index file's bytes, or null when absent.
6583
+ * @returns the versions, oldest first.
6584
+ */
6585
+ function parseHistoryIndex(raw) {
6586
+ const state = readHistoryIndex(raw);
6587
+ return state.kind === "ok" ? state.versions : [];
6588
+ }
6589
+ /**
6590
+ * The index after this write, and the version numbers it produced. Pure, so the versioning rules
6591
+ * are testable without io.
6592
+ *
6593
+ * Two rules matter here: a predecessor the index has never seen gets a BASELINE entry (so the state
6594
+ * before this mutation is recoverable even for a skill that predates the feature), and re-writing
6595
+ * identical content mints NO new version (the library's no-op updates must not grow history).
6596
+ * @param versions - the current index, oldest first.
6597
+ * @param input - the write being recorded.
6598
+ * @param keep - how many versions to retain per skill.
6599
+ * @returns the trimmed index and the recorded numbers.
6600
+ */
6601
+ function nextHistoryIndex(versions, input, keep) {
6602
+ const beforeHash = input.before === null ? null : contentHash(input.before);
6603
+ const afterHash = input.after === null ? null : contentHash(input.after);
6604
+ const grown = [...versions];
6605
+ if (beforeHash !== null && !grown.some((entry) => entry.hash === beforeHash)) grown.push({
6606
+ v: nextVersion(grown),
6607
+ at: input.at,
6608
+ action: BASELINE_ACTION,
6609
+ hash: beforeHash,
6610
+ chars: input.before?.length ?? 0
6611
+ });
6612
+ if (afterHash !== null) {
6613
+ if (grown[grown.length - 1]?.hash !== afterHash) grown.push({
6614
+ v: nextVersion(grown),
6615
+ at: input.at,
6616
+ action: input.action,
6617
+ hash: afterHash,
6618
+ chars: input.after?.length ?? 0,
6619
+ ...beforeHash === null ? {} : { beforeHash }
6620
+ });
6621
+ }
6622
+ const trimmed = grown.slice(Math.max(0, grown.length - clampKeep(keep)));
6623
+ const recorded = {};
6624
+ if (beforeHash !== null) {
6625
+ const v = lastVersionOf(trimmed, beforeHash);
6626
+ if (v !== void 0) recorded.beforeVersion = v;
6627
+ }
6628
+ if (afterHash !== null) {
6629
+ const v = lastVersionOf(trimmed, afterHash);
6630
+ if (v !== void 0) recorded.afterVersion = v;
6631
+ }
6632
+ return {
6633
+ versions: trimmed,
6634
+ recorded
6635
+ };
6636
+ }
6637
+ /** Shrink level at which the retention line speaks: a body that keeps less than this share of the
6638
+ * previous one is a rewrite, not an edit. */
6639
+ const RETENTION_FEEDBACK_KEEP_RATIO = .5;
6640
+ /** Floor on the REPLACED body: below this a ratio is noise (a 40-character skill cut in half is not
6641
+ * news), so the line stays off for small skills. */
6642
+ const RETENTION_FEEDBACK_MIN_CHARS = 200;
6643
+ /**
6644
+ * The anti-overwrite feedback line (design §4, item 3): says how much of the previous body a
6645
+ * replacement kept, so a destructive rewrite is visible in the same message that reports the write.
6646
+ *
6647
+ * Computed where the replaced bytes are still in hand — the write path's own in-lock read — instead of
6648
+ * re-reading the file from a caller (that second read would race the writers this library serializes).
6649
+ * It is FEEDBACK, never a gate: no ratio refuses a write, and the function owns no configuration
6650
+ * (a toggle nobody reads would be configuration that does nothing).
6651
+ *
6652
+ * @param input - the write being described.
6653
+ * @returns the line, or null when the write is not a significant whole-body replacement.
6654
+ */
6655
+ function contentRetentionFeedback(input) {
6656
+ if (input.action !== "update") return null;
6657
+ const before = input.before;
6658
+ const after = input.after;
6659
+ if (before === null || after === null) return null;
6660
+ if (before.length < 200) return null;
6661
+ if (after.length >= before.length * .5) return null;
6662
+ return `Content kept ${Math.round(after.length / before.length * 100)}% of the previous body (${after.length} of ${before.length} characters); the replaced version is preserved in this skill's history.`;
6663
+ }
6664
+ /**
6665
+ * Read one skill's versions, oldest first.
6666
+ * @param root - the skills root.
6667
+ * @param io - the io seam.
6668
+ * @param name - the skill name.
6669
+ * @returns the versions; an absent or unreadable index reads as empty (never a throw).
6670
+ */
6671
+ async function loadVersions(root, io, name) {
6672
+ return parseHistoryIndex(await io.readText(historyIndexFile(root, name)).catch(() => null));
6673
+ }
6674
+ /**
6675
+ * One version's content.
6676
+ * @param root - the skills root.
6677
+ * @param io - the io seam.
6678
+ * @param name - the skill name.
6679
+ * @param v - the version number.
6680
+ * @returns the content, or null when the version or its blob cannot be read (never '').
6681
+ */
6682
+ async function loadVersionContent(root, io, name, v) {
6683
+ const entry = (await loadVersions(root, io, name)).find((candidate) => candidate.v === v);
6684
+ if (entry === void 0) return null;
6685
+ return await io.readText(blobPath(root, entry.hash)).catch(() => null);
6686
+ }
6687
+ /**
6688
+ * Record the content one mutation produced, best-effort.
6689
+ *
6690
+ * Blobs are written FIRST: the index must never reference content that is not on disk, and a failed
6691
+ * blob write aborts the whole record (the caller warns; the mutation itself stands).
6692
+ * @param root - the skills root.
6693
+ * @param io - the io seam.
6694
+ * @param input - the write being recorded.
6695
+ * @param keep - how many versions to retain per skill.
6696
+ * @returns the version numbers, or null when there was no content to record.
6697
+ */
6698
+ async function recordVersions(root, io, input, keep) {
6699
+ if (input.before === null && input.after === null) return null;
6700
+ for (const side of [input.before, input.after]) {
6701
+ if (side === null) continue;
6702
+ const path = blobPath(root, contentHash(side));
6703
+ if (!await io.exists(path).catch(() => false)) await io.writeText(path, side);
6704
+ }
6705
+ const file = historyIndexFile(root, input.skillName);
6706
+ if (readHistoryIndex(await io.readText(file).catch(() => null)).kind === "unreadable") {
6707
+ const quarantine = `${file}.corrupt`;
6708
+ await io.copy(file, quarantine);
6709
+ console.warn(`skill-store: ${file} could not be understood (malformed, foreign, or written by a newer reader); its bytes were copied to ${quarantine} and a fresh index starts from this write`);
6710
+ }
6711
+ const recorded = {};
6712
+ await transactIo(io, file, (current) => {
6713
+ const state = readHistoryIndex(current);
6714
+ const { versions, recorded: numbers } = nextHistoryIndex(state.kind === "ok" ? state.versions : [], input, keep);
6715
+ recorded.beforeVersion = numbers.beforeVersion;
6716
+ recorded.afterVersion = numbers.afterVersion;
6717
+ return JSON.stringify({
6718
+ version: 1,
6719
+ versions
6720
+ }, null, 2);
6721
+ });
6722
+ return recorded;
6723
+ }
6724
+ //#endregion
6263
6725
  //#region lib/types/fuzzy-match.js
6264
6726
  /**
6265
6727
  * Fuzzy string matching and replacement for patch/restructure edits of skill files.
@@ -6780,6 +7242,9 @@ var SkillLibrary = class {
6780
7242
  /** V10-03 (P2-18): see the constructor's threatExemptLabels. Empty by
6781
7243
  * default — the strict ANY-hit-blocks policy is unchanged. */
6782
7244
  threatExemptLabels;
7245
+ /** skill-history.ts: the content history is best-effort, so a failure must not be SILENT —
7246
+ * but it is a deployment condition, not a per-write event, so it warns once per instance. */
7247
+ historyWarned = false;
6783
7248
  constructor(root = skillsRoot(), io = nodeEvolutionIo(), limits = DEFAULT_SKILL_LIMITS, onMutation, transact, threatExemptLabels) {
6784
7249
  this.root = root;
6785
7250
  this.io = io;
@@ -6844,11 +7309,24 @@ var SkillLibrary = class {
6844
7309
  message: "internal error: the write transaction did not invoke the task; no write was performed"
6845
7310
  };
6846
7311
  if (o.ghostDir === true) await this.cleanupGhostDir(path);
6847
- if (o.write !== null && o.audit) await this.audit(o.audit.skillName, o.audit.action, o.audit.before, o.audit.after, o.audit.summary);
7312
+ let result = o.result;
7313
+ if (o.write !== null && o.audit) {
7314
+ await this.audit(o.audit.skillName, o.audit.action, o.audit.before, o.audit.after, o.audit.summary);
7315
+ const retention = contentRetentionFeedback({
7316
+ name: o.audit.skillName,
7317
+ action: o.audit.action,
7318
+ before: o.audit.before,
7319
+ after: o.audit.after
7320
+ });
7321
+ if (retention !== null && result.ok) result = {
7322
+ ...result,
7323
+ message: `${result.message}\n${retention}`
7324
+ };
7325
+ }
6848
7326
  if (o.write !== null && o.event) this.notifyMutation(o.event);
6849
- return durabilityWarning === "" || !o.result.ok ? o.result : {
6850
- ...o.result,
6851
- message: `${o.result.message} (warning: the write landed but the directory fsync failed — durability unconfirmed: ${durabilityWarning})`
7327
+ return durabilityWarning === "" || !result.ok ? result : {
7328
+ ...result,
7329
+ message: `${result.message} (warning: the write landed but the directory fsync failed — durability unconfirmed: ${durabilityWarning})`
6852
7330
  };
6853
7331
  }
6854
7332
  /**
@@ -7151,16 +7629,41 @@ var SkillLibrary = class {
7151
7629
  return null;
7152
7630
  }
7153
7631
  }
7154
- /** Best-effort audit trail entry; never blocks the mutation. */
7632
+ /** Best-effort audit trail entry; never blocks the mutation.
7633
+ *
7634
+ * Two independent best-effort writes run here, because either one failing must not cost the
7635
+ * other: the CONTENT history (skill-history.ts, so a version can be read back at all) and the
7636
+ * mutation ledger that points at it. The history failure is not silent — a deployment whose
7637
+ * history stopped growing is a degradation worth seeing once (the ledger keeps hashes either
7638
+ * way). */
7155
7639
  async audit(skillName, action, before, after, summary) {
7640
+ const at = (/* @__PURE__ */ new Date()).toISOString();
7641
+ let versions = null;
7642
+ try {
7643
+ const keep = this.limits.versionKeep ?? 20;
7644
+ versions = await recordVersions(this.root, this.io, {
7645
+ skillName,
7646
+ action,
7647
+ before,
7648
+ after,
7649
+ at
7650
+ }, keep);
7651
+ } catch (error) {
7652
+ if (!this.historyWarned) {
7653
+ this.historyWarned = true;
7654
+ console.warn(`skill-store: content history not recorded for "${skillName}" (${error instanceof Error ? error.message : String(error)}); the mutation itself landed and the audit ledger keeps its hashes`);
7655
+ }
7656
+ }
7156
7657
  try {
7157
7658
  await recordMutation(this.root, this.io, {
7158
7659
  skillName,
7159
7660
  action,
7160
7661
  ...before === null ? {} : { beforeHash: contentHash(before) },
7161
7662
  ...after === null ? {} : { afterHash: contentHash(after) },
7663
+ ...versions?.beforeVersion === void 0 ? {} : { beforeVersion: versions.beforeVersion },
7664
+ ...versions?.afterVersion === void 0 ? {} : { afterVersion: versions.afterVersion },
7162
7665
  summary,
7163
- at: (/* @__PURE__ */ new Date()).toISOString()
7666
+ at
7164
7667
  });
7165
7668
  } catch {}
7166
7669
  }
@@ -7169,6 +7672,32 @@ var SkillLibrary = class {
7169
7672
  return await loadMutations(this.root, this.io);
7170
7673
  }
7171
7674
  /**
7675
+ * Content versions recorded for one skill, oldest first (skill-history.ts, batch A).
7676
+ *
7677
+ * Read-only: the version seam never feeds a judgment — callers use it to LIST and to read content
7678
+ * back. A malformed or unreadable index reads as empty, and a bad name reads as empty too (the
7679
+ * caller is a human command, so "no versions" is the honest answer for a name that cannot exist).
7680
+ * @param rawName - the skill's name.
7681
+ * @returns the versions, oldest first.
7682
+ */
7683
+ async listVersions(rawName) {
7684
+ const name = rawName.trim();
7685
+ if (this.badName(name) !== null) return [];
7686
+ return await loadVersions(this.root, this.io, name);
7687
+ }
7688
+ /**
7689
+ * One version's content, read back from the content-addressed blob store.
7690
+ * @param rawName - the skill's name.
7691
+ * @param v - the version number (see {@link SkillLibrary.listVersions}).
7692
+ * @returns the content, or null when the version does not exist, the name is unusable, or the
7693
+ * blob cannot be read — never an empty string.
7694
+ */
7695
+ async readVersion(rawName, v) {
7696
+ const name = rawName.trim();
7697
+ if (this.badName(name) !== null) return null;
7698
+ return await loadVersionContent(this.root, this.io, name, v);
7699
+ }
7700
+ /**
7172
7701
  * Pin or unpin a skill (`.pinned` marker). Pinned skills are protected from
7173
7702
  * deletion, from background-review writes, and from the lifecycle — a
7174
7703
  * protective mutation, so the autonomous pipeline may never call it. The
@@ -8941,7 +9470,7 @@ var SkillLibrary = class {
8941
9470
  throw new Error(`snapshot restore refused: the skill root could not be listed (${error instanceof Error ? error.message : String(error)}) — nothing was changed`);
8942
9471
  }
8943
9472
  for (const entry of rootEntries) {
8944
- if (entry === ".archive" || entry === ".backups" || entry === ".mutations.json" || entry === ".curator-suppressed.json") continue;
9473
+ if (entry === ".archive" || entry === ".backups" || entry === ".mutations.json" || entry === ".curator-suppressed.json" || entry === ".history") continue;
8945
9474
  if (entry.endsWith(".lock") || entry.endsWith(`.lock.next`)) {
8946
9475
  await this.refuseLiveLockOrSweep(join(this.root, entry), entry);
8947
9476
  continue;
@@ -8955,6 +9484,7 @@ var SkillLibrary = class {
8955
9484
  const restoresSuppressed = manifest.sidecars.includes(".curator-suppressed.json");
8956
9485
  for (const entry of rootEntries) {
8957
9486
  if (entry === ".archive" || entry === ".backups" || entry === ".mutations.json") continue;
9487
+ if (entry === ".history") continue;
8958
9488
  if (entry === ".curator-suppressed.json" && restoresSuppressed) continue;
8959
9489
  if (entry.endsWith(".lock") || entry.endsWith(`.lock.next`)) continue;
8960
9490
  await this.io.remove(join(this.root, entry));
@@ -9365,6 +9895,16 @@ const PARAM_EXPOSURE = Object.freeze([
9365
9895
  docAnchor: "PARAMETERS.md#write-caps",
9366
9896
  summary: "Report expired archives, or prune them."
9367
9897
  },
9898
+ {
9899
+ id: "skillVersionKeep",
9900
+ group: "write-caps",
9901
+ tier: "E2",
9902
+ authority: "cordis",
9903
+ owner: "tool-skill-manage",
9904
+ applies: "none",
9905
+ docAnchor: "PARAMETERS.md#write-caps",
9906
+ summary: "Content versions retained per skill; older ones leave the index. Their stored blobs stay on disk — no automatic sweep exists yet, so a long-lived library grows by the distinct bodies it has written."
9907
+ },
9368
9908
  {
9369
9909
  id: "supportFileCharPolicy",
9370
9910
  group: "write-caps",
@@ -10414,4 +10954,4 @@ function sessionAudited(ctx, sessionId, sessionScoped) {
10414
10954
  return false;
10415
10955
  }
10416
10956
  //#endregion
10417
- export { ALIVE_LOCK_TAKEOVER_MS, AUTHORING_DESCRIPTION_BAR, AUTHORING_SPLIT_LINE_CHARS, CITATION_SUPPORT_DIRS, COMBINED_REVIEW_PLAN_PROMPT, COMBINED_REVIEW_PROMPT, COMPLETION_SKILL_REVIEW_PROMPT, CONTENT_SPLIT_HINT, COST_ASCII_TOKEN_HIGH, COST_ASCII_TOKEN_LOW, COST_CJK_TOKEN_HIGH, COST_CJK_TOKEN_LOW, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEAD_LOCK_TAKEOVER_MS, DEDUP_MAX_PAIR_COMPARISONS, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_ARCHIVE_RETENTION_POLICY, DEFAULT_CITATION_POLICY, DEFAULT_CITATION_REF_BUDGET, 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_REFERENCE_REWRITE_POLICY, 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_SUPPORT_FILE_CHAR_POLICY, DEFAULT_SUPPORT_READ_TOOL_NAMES, DEFAULT_THREAT_MAX_SCAN_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_ERRORS, EVOLUTION_WRITE_TOOLS, EvolutionGateSet, FAMILY_SESSION_TOOL_NAMES, FORBIDDEN_CONTROL_KEYS, HEALTH_STAMP_RE, HOOK_LINE_RE, INSTANCE_KEYS, KEEP_LINE_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_SUPPORT_READ_PATHS, 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, PARAM_ALIASES, PARAM_EXPOSURE, PARAM_GROUP_LABELS, PARAM_NAMESPACES, PATTERN_OVERLAP, PLATFORM_CATALOG_DESCRIPTION_DEFAULT, PLATFORM_ESTIMATE_CHARS_PER_TOKEN, POLICY_STAGE_DEFAULTS, PROMPT_BUNDLE, PROMPT_BUNDLE_ID, PROMPT_BUNDLE_VERSION, PROTECTED_BUILTIN_SKILLS, PTC_DISPATCH_EVENT, PTC_DISPATCH_START_EVENT, QUALITY_WEIGHTS, READ_REQUIRED_ACTIONS, 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, TOKEN_ASCII_WEIGHT, TOKEN_CJK_WEIGHT, ToolDispatchNormalizer, ToolDispatchPayloadError, UPSTREAM_LIMIT_CHARS_PER_TOKEN, advanceReview, allowRowCollisions, appendEvolutionEvent, applyCuratorLifecycleFields, applyCuratorMetaFields, applyReferenceRewrite, assertDispatchPayload, assertSkillsRootAliasRetired, assessStructureHealth, authoringFeedback, bodyCost, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpSupportRead, bumpUse, bumpView, callingScope, canonicalWriteId, claimInstance, clampedNumber, clearReviewChannel, collectReadSkillNames, composePresetComposition, computeDedupGroups, computeDriftSignals, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, contentHash, countDispatches, createGateSet, daysSinceIso, decideTakeover, describeReferenceRewrite, duplicateHeadings, emptyRecord, errorText, evaluateThreat, eventsFile, evolutionEventPayloadIssue, evolutionHome, evolutionIoAdapter, evolutionRoot, filterUnreadSkillOps, findDriftSignal, foldCuratorFields, foldToolDispatches, foldTurn, frontmatterBlock, frontmatterCatalogInvalid, getRecord, idleDays, installParamSection, instanceClaimKey, instanceClaimedWriteSites, instanceHolder, isAbsent, isCommittedWarning, isDeprecatedParamId, isFileShapedPath, isMissingPath, isPresent, isProcessAlive, isProgramToolName, isReviewChannelSession, isSkillReadToolName, isSkillToolName, isUnknown, isUnreadWrite, latestActivityAt, lifecycleCandidate, listEventArchives, loadMutations, loadRowOverrides, loadSuppressedNames, loadUsage, makeSerialQueue, mapProbe, markAgentCreated, markReviewChannel, markerEntryName, memoryRoot, missingSupportPointers, mutateUsage, mutationsFile, narrowNameMatches, neutralizePromptVariables, newSkillLibrary, nodeEvolutionIo, normalizeFrontmatter, normalizeUsageRecord, observeEvent, overlongLines, paramNamespace, paramSectionOverrides, parseCuratorNominations, parseEvolutionEvents, parseFrontmatter, parseLockBody, pendingSelfCleanup, persistedWriteSite, persistedWriteSites, planReferenceRewrite, planRehoming, policyStageLimits, probeAbsent, probeList, probeMtime, probePresent, probeReason, probeUnknown, readDispatchSignal, readEvolutionEvents, readEvolutionTimeline, readNumberParam, readParam, recordMutation, redactSecrets, relatedSkillNames, releaseInstance, renameWithRetry, renderCuratorReportMarkdown, resolveCitations, resolveExecOrigins, resolveOrigins, resolveParamId, resolveRootConfig, resolveSkillsRoot, retainEventArchives, retirementReport, reviewPrompt, saveSuppressedNames, saveUsage, scanBodyHooks, scanContentThreats, scanMemoryThreats, scanThreats, scopedProbeReport, sessionAudited, sessionReadSkillNames, sessionSeesFamilyTools, skillReadNameOf, skillsRoot, supportFileReadOf, suppressedFile, sweepReviewChannelSessions, tokenLineFor, transactIo, transactTaskGuard, unhookedSupportPointers, updateSuppressedNames, usageFile, usageObserved, validateFrontmatter, validateRestructureTarget, valueOr, verifyPromptBundle, writeDurableTmp, yamlPlainScalarNeedsQuotes };
10957
+ export { ALIVE_LOCK_TAKEOVER_MS, AUTHORING_DESCRIPTION_BAR, AUTHORING_SPLIT_LINE_CHARS, BASELINE_ACTION, CITATION_SUPPORT_DIRS, COMBINED_REVIEW_PLAN_PROMPT, COMBINED_REVIEW_PROMPT, COMPLETION_SKILL_REVIEW_PROMPT, CONTENT_SPLIT_HINT, COST_ASCII_TOKEN_HIGH, COST_ASCII_TOKEN_LOW, COST_CJK_TOKEN_HIGH, COST_CJK_TOKEN_LOW, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEAD_LOCK_TAKEOVER_MS, DEDUP_MAX_PAIR_COMPARISONS, DEDUP_SIMILARITY_THRESHOLD, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_ARCHIVE_RETENTION_POLICY, DEFAULT_CITATION_POLICY, DEFAULT_CITATION_REF_BUDGET, 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_REFERENCE_REWRITE_POLICY, 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_SKILL_VERSION_KEEP, DEFAULT_STALE_AFTER_DAYS, DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS, DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS, DEFAULT_SUBSTANTIVE_MIN_USER_CHARS, DEFAULT_SUPPORT_FILE_CHAR_POLICY, DEFAULT_SUPPORT_READ_TOOL_NAMES, DEFAULT_THREAT_MAX_SCAN_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_ERRORS, EVOLUTION_WRITE_TOOLS, EvolutionGateSet, FAMILY_SESSION_TOOL_NAMES, FORBIDDEN_CONTROL_KEYS, HEALTH_STAMP_RE, HISTORY_DIR, HISTORY_INDEX_VERSION, HOOK_LINE_RE, INSTANCE_KEYS, KEEP_LINE_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_SUPPORT_READ_PATHS, 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, PARAM_ALIASES, PARAM_EXPOSURE, PARAM_GROUP_LABELS, PARAM_NAMESPACES, PATTERN_OVERLAP, PLATFORM_CATALOG_DESCRIPTION_DEFAULT, PLATFORM_ESTIMATE_CHARS_PER_TOKEN, POLICY_STAGE_DEFAULTS, PROMPT_BUNDLE, PROMPT_BUNDLE_ID, PROMPT_BUNDLE_VERSION, PROTECTED_BUILTIN_SKILLS, PTC_DISPATCH_EVENT, PTC_DISPATCH_START_EVENT, QUALITY_WEIGHTS, READ_REQUIRED_ACTIONS, RESTRUCTURE_TARGET_RE, RETENTION_FEEDBACK_KEEP_RATIO, RETENTION_FEEDBACK_MIN_CHARS, SKILLS_GUIDANCE, SKILLS_GUIDANCE_SECTION_ORDER, SKILL_ACTION_REQUIRED_FIELDS, SKILL_NAME_RE, SKILL_REVIEW_PLAN_PROMPT, SKILL_REVIEW_PROMPT, SNAPSHOT_EXTRA_NAME_RE, SUMMARY_DUPLICATE_HINT_THRESHOLD, SUPPORT_DIRS, SUPPRESSED_FILE_VERSION, SkillLibrary, THREAT_EXEMPTION_HINT, TOKEN_ASCII_WEIGHT, TOKEN_CJK_WEIGHT, ToolDispatchNormalizer, ToolDispatchPayloadError, UPSTREAM_LIMIT_CHARS_PER_TOKEN, advanceReview, affinity, allowRowCollisions, appendEvolutionEvent, applyCuratorLifecycleFields, applyCuratorMetaFields, applyReferenceRewrite, assertDispatchPayload, assertSkillsRootAliasRetired, assessStructureHealth, authoringFeedback, blobPath, bodyCost, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpSupportRead, bumpUse, bumpView, callingScope, canonicalWriteId, claimInstance, clampedNumber, clearReviewChannel, collectReadSkillNames, composePresetComposition, computeDedupGroups, computeDriftSignals, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, contentHash, contentRetentionFeedback, countDispatches, createGateSet, daysSinceIso, decideTakeover, describeReferenceRewrite, duplicateHeadings, emptyRecord, errorText, evaluateThreat, eventsFile, evidenceKindIndex, evolutionEventPayloadIssue, evolutionHome, evolutionIoAdapter, evolutionRoot, filterUnreadSkillOps, findDriftSignal, foldCuratorFields, foldToolDispatches, foldTurn, frontmatterBlock, frontmatterCatalogInvalid, getRecord, historyIndexFile, historyRoot, identity, idleDays, installParamSection, instanceClaimKey, instanceClaimedWriteSites, instanceHolder, isAbsent, isCommittedWarning, isDeprecatedParamId, isFileShapedPath, isMissingPath, isPresent, isProcessAlive, isProgramToolName, isReviewChannelSession, isSkillReadToolName, isSkillToolName, isUnknown, isUnreadWrite, latestActivityAt, lifecycleCandidate, listEventArchives, loadMutations, loadRowOverrides, loadSuppressedNames, loadUsage, loadVersionContent, loadVersions, makeSerialQueue, mapProbe, markAgentCreated, markReviewChannel, markerEntryName, memoryRoot, missingSupportPointers, mutateUsage, mutationsFile, narrowNameMatches, nearDuplicateSummaries, neutralizePromptVariables, newSkillLibrary, nextHistoryIndex, nodeEvolutionIo, normalizeFrontmatter, normalizeUsageRecord, observeEvent, orderVersions, overlongLines, paramNamespace, paramSectionOverrides, parseCuratorNominations, parseEvolutionEvents, parseFrontmatter, parseHistoryIndex, parseLockBody, partitionVersions, pendingSelfCleanup, persistedWriteSite, persistedWriteSites, planReferenceRewrite, planRehoming, policyStageLimits, probeAbsent, probeList, probeMtime, probePresent, probeReason, probeUnknown, projectionText, readDispatchSignal, readEvolutionEvents, readEvolutionTimeline, readHistoryIndex, readNumberParam, readParam, recordMutation, recordVersions, redactSecrets, relatedSkillNames, releaseInstance, renameWithRetry, renderCuratorReportMarkdown, resolveCitations, resolveExecOrigins, resolveOrigins, resolveParamId, resolveRootConfig, resolveSkillsRoot, retainEventArchives, retirementReport, reviewPrompt, saveSuppressedNames, saveUsage, scanBodyHooks, scanContentThreats, scanMemoryThreats, scanThreats, scopedProbeReport, sessionAudited, sessionReadSkillNames, sessionSeesFamilyTools, skillReadNameOf, skillsRoot, supportFileReadOf, suppressedFile, sweepReviewChannelSessions, tokenLineFor, transactIo, transactTaskGuard, unhookedSupportPointers, updateSuppressedNames, usageFile, usageObserved, validateFrontmatter, validateRestructureTarget, valueOr, verifyPromptBundle, versionTarget, vocabulary, writeDurableTmp, yamlPlainScalarNeedsQuotes };
@@ -52,6 +52,10 @@ export declare const PROTECTED_BUILTIN_SKILLS: ReadonlySet<string>;
52
52
  export declare const MAX_SKILL_NAME_LENGTH = 64;
53
53
  export declare const MAX_DESCRIPTION_LENGTH = 1024;
54
54
  export declare const MAX_SKILL_CONTENT_CHARS = 100000;
55
+ /** How many content versions to retain per skill (skill-history.ts). 20 mirrors the count
56
+ * KiroCrew settled on for the same job (its `MAX_SKILL_VERSIONS`), and is an E2 deployment
57
+ * parameter — the value is a retention policy, not a protocol constant. */
58
+ export declare const DEFAULT_SKILL_VERSION_KEEP = 20;
55
59
  /** The authoring DISCIPLINE band, taken from the upstream standard (archive §5):
56
60
  * peer skills sit at 8-14k characters and a body pushing past 20k belongs in
57
61
  * `references/*.md`. Deliberately separate from `MAX_SKILL_CONTENT_CHARS`: the
@@ -57,6 +57,13 @@ export interface EvolutionPlanAppliedEvent {
57
57
  skippedUnread?: number | undefined;
58
58
  /** First execution-layer failure message (abort reason or op failure). */
59
59
  executionError?: string | undefined;
60
+ /** Batch D (2026-09-27): ops whose ENTIRE evidence list cites bookkeeping frames
61
+ * (a turn/step boundary) — the report-only phase of the plan path's
62
+ * `EVIDENCE_CLASS` rule. Not a refusal and not a "not done" cause: the ops in
63
+ * this count executed. It rides the same payload as the other dimensions so the
64
+ * observation window has a durable record; the model's notice says nothing about
65
+ * it. */
66
+ evidenceClassReports?: number | undefined;
60
67
  evidenceQuotes?: number | undefined;
61
68
  estimatedInputChars?: number | undefined;
62
69
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Which session frames are EVIDENCE, and which are bookkeeping.
3
+ *
4
+ * A plan op must cite `evidence` seqs, and the validator's range rule
5
+ * (`evolution-plan-validator`, rule `EVIDENCE_RANGE`) answers only "is this an integer inside
6
+ * the window the plan was authored against". A boundary frame satisfies that while carrying
7
+ * nothing a claim can rest on: the `turn/start` seq is the frame a claim about that turn was
8
+ * most likely read out of, and citing it is indistinguishable from citing a real exchange. This
9
+ * module owns the ONE classification of a session frame by what it CARRIES, so the plan path can
10
+ * report an op whose whole citation list is bookkeeping (design
11
+ * `dsh-evolution-skill-history-design.md` §3D, phase 1: report only — no op is refused here).
12
+ *
13
+ * The classification is a DENY list of the pure bookends, not an allow list of content frames.
14
+ * The platform's own vocabulary (`KNOWN_SESSION_EVENT_TYPES`, 57 members at 0.1.5-rc.2) mixes
15
+ * content frames with state-change frames, and out-of-repo plugins append types the family cannot
16
+ * enumerate; an unknown frame is therefore treated as carrying content, because a report that
17
+ * fires on an unclassifiable frame is a false positive and phase 1 exists to measure how many TRUE
18
+ * ones there are. Tightening — a narrower definition, or an allow list built from the platform's
19
+ * vocabulary — changes this table and nothing else (`evidenceKindIndex` is the only reader).
20
+ * @module @lmzhen/dsh-evolution-core
21
+ */
22
+ /** One session frame, as far as the classification reads it. A platform `SessionEvent` is
23
+ * assignable: both fields are optional here so a stub log can be classified without pretending
24
+ * to be a full event. */
25
+ export interface EvidenceFrame {
26
+ /** The platform event type. An unknown value counts as content (see the module docblock). */
27
+ type?: unknown;
28
+ /** The durable sequence number a plan op cites. */
29
+ seq?: unknown;
30
+ }
31
+ /**
32
+ * The seqs of the frames that carry content.
33
+ *
34
+ * @param events - the session's frames, oldest first (any iterable; nothing is retained).
35
+ * @returns the substantive seqs, or `undefined` when NO frame carried a usable seq. The two
36
+ * answers are different facts: an empty SET says "every sequenced frame is a boundary", while
37
+ * `undefined` says "this log cannot tell a boundary from a message" (a stub session in a test,
38
+ * a composition whose events carry no seq). A caller that cannot classify must stay silent
39
+ * rather than report every op — the same three-state posture as `sessionReadSkillNames`.
40
+ */
41
+ export declare function evidenceKindIndex(events: Iterable<EvidenceFrame>): ReadonlySet<number> | undefined;
42
+ //# sourceMappingURL=evidence.d.ts.map
@@ -23,7 +23,8 @@
23
23
  * `quality.ts`, `signals.ts`, `skill-reads.ts` (the read-before-write rule, shared by the
24
24
  * tool path and the review's plan path), `drift-signals.ts`, `skill-health.ts`,
25
25
  * `preset-composition.ts`, `prompts.ts`, `learn-prompt.ts`,
26
- * `evolution-events.ts`, `io.ts` (the ctx.evolutionIo seam itself).
26
+ * `evolution-events.ts`, `evidence.ts` (which session frames count as content, for the plan
27
+ * path's evidence-class report), `io.ts` (the ctx.evolutionIo seam itself).
27
28
  * @module @lmzhen/dsh-evolution-core
28
29
  */
29
30
  export * from './citations.ts';
@@ -52,7 +53,9 @@ export * from './scope.ts';
52
53
  export * from './skill-health.ts';
53
54
  export * from './signals.ts';
54
55
  export * from './skill-reads.ts';
56
+ export * from './evidence.ts';
55
57
  export * from './drift-signals.ts';
58
+ export * from './skill-history.ts';
56
59
  export * from './skill-store.ts';
57
60
  export * from './state-store.ts';
58
61
  export * from './threats.ts';
@@ -78,6 +78,9 @@ export interface SkillLimits {
78
78
  archiveRetention?: ArchiveRetentionPolicy | undefined;
79
79
  /** See SupportFileCharPolicy; absent means the default (`report`). */
80
80
  supportFileCharPolicy?: SupportFileCharPolicy | undefined;
81
+ /** Content versions retained per skill (skill-history.ts); absent means
82
+ * `DEFAULT_SKILL_VERSION_KEEP`. */
83
+ versionKeep?: number | undefined;
81
84
  }
82
85
  export declare const DEFAULT_SKILL_LIMITS: SkillLimits;
83
86
  //# sourceMappingURL=limits.d.ts.map
@@ -10,6 +10,11 @@ export interface MutationRecord {
10
10
  action: string;
11
11
  beforeHash?: string;
12
12
  afterHash?: string;
13
+ /** The skill-history version the content on each side of this mutation belongs to
14
+ * (skill-history.ts). Optional: records written before content history existed carry none, and
15
+ * the hashes above stay the authority for "what changed". */
16
+ beforeVersion?: number;
17
+ afterVersion?: number;
13
18
  summary: string;
14
19
  at: string;
15
20
  }
@@ -7,6 +7,12 @@
7
7
  * mutation maturity is a documented DSH approximation (single per-month patch
8
8
  * trend ratio replaces the claw timestamp-trend formula, since DSH usage
9
9
  * records only carry the last patched timestamp).
10
+ *
11
+ * Batch C (2026-09-27): the similarity half of this module is now TWO NAMED
12
+ * QUESTIONS plus a declared projection — {@link identity} ("is it the same
13
+ * text?") and {@link affinity} ("how much vocabulary do these two share?") asked
14
+ * over {@link projectionText}. Callers name the question and the projection
15
+ * instead of growing a fourth unnamed ruler (design §3C / §1 I2).
10
16
  * @module @lmzhen/dsh-evolution-core
11
17
  */
12
18
  import type { UsageMap } from './usage.ts';
@@ -45,6 +51,52 @@ export declare function computeQualityScores(input: {
45
51
  supportDirs?: ReadonlyMap<string, number>;
46
52
  now?: Date;
47
53
  }): Map<string, QualityScore>;
54
+ /** Named similarity question 1 — "is it the same text?" — as a normalized
55
+ * content hash: case- and whitespace-insensitive, so two texts that differ only
56
+ * in formatting are the SAME text here. Invariant under which projection the
57
+ * caller read the text from.
58
+ *
59
+ * C-26 (v10 audit): NOT named `contentHash` — mutations.ts exports a
60
+ * contentHash of RAW bytes, this one hashes the NORMALIZED text for dedup
61
+ * grouping. Same helper as that rename, now a named question. */
62
+ export declare function identity(text: string): string;
63
+ /** Which text a similarity question is asked about. Declared by the caller. */
64
+ export type SimilarityProjection = 'body' | 'summary';
65
+ /** The one input shape both projections read from: a caller passes what it has. */
66
+ export interface SimilarityInput {
67
+ /** The whole SKILL.md — the `body` projection. */
68
+ content?: string;
69
+ /** The skill name — half of the `summary` projection. */
70
+ name?: string;
71
+ /** The frontmatter description — half of the `summary` projection. */
72
+ description?: string;
73
+ }
74
+ /**
75
+ * The declared projection: WHICH text a comparison weighs.
76
+ *
77
+ * `body` is the library-wide scan's projection (whole SKILL.md, read by the
78
+ * caller). `summary` is name + description in one string — the projection a
79
+ * listing already publishes (`SkillSummary` carries both fields, and a body is
80
+ * attached only when the caller asks for it) — so a summary comparison never
81
+ * reads a body OF ITS OWN: the create-time hint is fed by the listing pass the
82
+ * tool already pays for its frontmatter, and calls no read of its own.
83
+ */
84
+ export declare function projectionText(projection: SimilarityProjection, input: SimilarityInput): string;
85
+ /** The comparable vocabulary of one text: lowercase alphanumeric runs, plus
86
+ * whole CJK runs (a Chinese description tokenizes by punctuation, not by word —
87
+ * deliberately: the ruler is one home, and a real tokenizer is a different
88
+ * question than "do these two share vocabulary?"). */
89
+ export declare function vocabulary(text: string): ReadonlySet<string>;
90
+ /** Named similarity question 2 — "how much vocabulary do these two share?" —
91
+ * Jaccard over two {@link vocabulary} sets, 0 (nothing in common, or either side
92
+ * empty) to 1 (identical vocabulary). Set-based on purpose: the bulk scan
93
+ * memoizes each name's set once and only the comparisons are budgeted. */
94
+ export declare function affinity(a: ReadonlySet<string>, b: ReadonlySet<string>): number;
95
+ /** The `affinity` level at which two bodies are treated as the same skill
96
+ * (design §3C: the default is unchanged at 0.95 — it is now a NAME, because the
97
+ * docblock below has linked it since PLAN-R2 while the code carried a bare
98
+ * literal). Callers may still pass their own level. */
99
+ export declare const DEDUP_SIMILARITY_THRESHOLD = 0.95;
48
100
  /** PLAN-R2 P2-8 (2026-09-16): default cap on two-name comparisons in
49
101
  * {@link computeDedupGroups}. A 2000-skill library is ~2M pairs; the old
50
102
  * unbounded two-two Jaccard ran seconds to tens of seconds per
@@ -79,6 +131,45 @@ export declare function computeDedupGroups(input: {
79
131
  * {@link DEDUP_MAX_PAIR_COMPARISONS}; a smaller value truncates earlier. */
80
132
  maxPairComparisons?: number;
81
133
  }): DedupScanResult;
134
+ /**
135
+ * The create-time near-duplicate question (batch C, design §3C-2): WHICH
136
+ * existing skills is this authoring candidate about to near-copy?
137
+ *
138
+ * It is the SAME {@link affinity} question as the library-wide scan, asked over
139
+ * the `summary` projection — the one a listing already publishes
140
+ * (`SkillSummary` carries `name` and `description`; nothing here can see a
141
+ * body, which is the point: a create must not pay a SECOND whole-tree pass — the
142
+ * listing's own frontmatter read is what feeds this comparison). Deliberately hint-only: the caller writes regardless, and the
143
+ * matches are news, not a gate (G4 — the create-time foreground behavior is
144
+ * unchanged).
145
+ *
146
+ * The default level is far below the scan's {@link DEDUP_SIMILARITY_THRESHOLD}
147
+ * because the projection is thinner: a name plus one sentence shares far fewer
148
+ * tokens than two bodies, and a hint that fires on every new skill is noise.
149
+ * Exact same-name candidates are skipped — the library's own create refuses
150
+ * those, and this line must not repeat that refusal.
151
+ */
152
+ export declare const SUMMARY_DUPLICATE_HINT_THRESHOLD = 0.5;
153
+ /** The fields {@link nearDuplicateSummaries} weighs — a structural subset of
154
+ * `SkillSummary`, so a listing feeds it without a conversion. */
155
+ export interface SummaryCandidate {
156
+ name: string;
157
+ description?: string;
158
+ }
159
+ export interface SummaryMatch {
160
+ name: string;
161
+ /** The `summary`-projection {@link affinity} in 0..1. */
162
+ score: number;
163
+ }
164
+ export declare function nearDuplicateSummaries(input: {
165
+ candidate: SummaryCandidate;
166
+ existing: ReadonlyArray<SummaryCandidate>;
167
+ /** Level at which a match is reported. Defaults to
168
+ * {@link SUMMARY_DUPLICATE_HINT_THRESHOLD}. */
169
+ threshold?: number;
170
+ /** Cap on returned matches, closest first. Defaults to 3. */
171
+ limit?: number;
172
+ }): SummaryMatch[];
82
173
  /**
83
174
  * Prefix-cluster index over a name set (rc.67 merge heuristic, input side):
84
175
  * the curator prompt asks the model to identify "prefix clusters — skills
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Skill content history: content-addressed blobs plus a per-skill version index.
3
+ *
4
+ * Why it exists: `.mutations.json` records before/after HASHES and calls every automated edit
5
+ * "reviewable and replayable" — but a hash cannot be replayed. With no content store, the only way
6
+ * back was a whole-tree snapshot (`.backups`, keep=5) or `.archive` (removed skills only), so
7
+ * reverting one skill cost the collateral of every other change in that window. This module keeps
8
+ * the content, keyed by skill and version.
9
+ *
10
+ * Invariants (design `dsh-evolution-skill-history-design.md` §1):
11
+ * - HISTORY IS HISTORY: the skill tree stays the single truth for "current content"; nothing here
12
+ * feeds a judgment — callers read back content and version NUMBERS only.
13
+ * - BEST-EFFORT, like the mutation audit: a failed history write warns and never fails the write
14
+ * that already landed.
15
+ * - Blobs are content-addressed, so identical bodies are stored once (across skills too).
16
+ * - Versions are numbered by `v` (max+1), NOT by the clock: a moved system clock must not reorder
17
+ * history (KiroCrew's `v<N>` choice, independently arrived at). Numbering and the index read-modify-write run
18
+ * under the INDEX's own io lock, so two writers of one skill cannot mint the same number.
19
+ * - The array's ORDER is append order, and the index is appended after the content lock is released, so two
20
+ * writers of ONE skill can interleave their appends. Each entry therefore carries `beforeHash` — the content
21
+ * it replaced, read under that writer's lock — and that link, not the position, is what `undo` follows
22
+ * ({@link orderVersions} rebuilds the content order for display when the chain is complete).
23
+ *
24
+ * NOT here: blob garbage collection. Trimming drops INDEX entries; orphan blobs stay until a
25
+ * sweeper collects them, because a reference count would have to span every skill's index and that
26
+ * belongs to the curator's whole-library pass, not to the write path.
27
+ * @module @lmzhen/dsh-evolution-core/skill-history
28
+ */
29
+ import { type EvolutionIoLike } from './io.ts';
30
+ /** Root-level history directory. Dot-prefixed so skill discovery skips it. */
31
+ export declare const HISTORY_DIR = ".history";
32
+ /** Versioned index shape, so a future field can migrate without guessing. */
33
+ export declare const HISTORY_INDEX_VERSION = 1;
34
+ /** The action label for a predecessor the index had never seen (content found on disk before this
35
+ * feature existed, or a hand-edited tree): it says "this is what was there", not what wrote it. */
36
+ export declare const BASELINE_ACTION = "baseline";
37
+ /** One recorded version of one skill. */
38
+ export interface SkillVersion {
39
+ /** Monotonic per skill; the identity, unlike {@link SkillVersion.at}. */
40
+ v: number;
41
+ at: string;
42
+ /** The mutation that produced it (`create`/`update`/`patch`/…), or {@link BASELINE_ACTION}. */
43
+ action: string;
44
+ /** sha256 of the stored content. */
45
+ hash: string;
46
+ /** Content length in characters, so a listing needs no blob read. */
47
+ chars: number;
48
+ /** The content this write REPLACED, as its hash — absent on a create, on a baseline, and on any
49
+ * entry written before 0.10.0. The array's ORDER is append order, which two writers of one skill
50
+ * can interleave (the index is appended after the content lock is released); this link is computed
51
+ * from the bytes a write lock actually read, so it is the authoritative predecessor. */
52
+ beforeHash?: string;
53
+ }
54
+ /** One history write, carrying the content the caller already holds. */
55
+ export interface VersionRecordInput {
56
+ skillName: string;
57
+ action: string;
58
+ /** Content before the mutation, or null when there was none (create/restore). */
59
+ before: string | null;
60
+ /** Content after the mutation, or null when there is none (archive/remove_file). */
61
+ after: string | null;
62
+ at: string;
63
+ }
64
+ /** The version numbers one write produced; a side with no content contributes nothing. */
65
+ export interface RecordedVersions {
66
+ beforeVersion?: number | undefined;
67
+ afterVersion?: number | undefined;
68
+ }
69
+ /** The root-level history directory. */
70
+ export declare function historyRoot(root: string): string;
71
+ /** Content-addressed blob path: identical bodies share one file, across skills too. */
72
+ export declare function blobPath(root: string, hash: string): string;
73
+ /** One skill's index file. */
74
+ export declare function historyIndexFile(root: string, name: string): string;
75
+ /**
76
+ * The versions in CONTENT order (oldest first), rebuilt from the chain links, for display.
77
+ *
78
+ * The stored array is append order, which interleaves when two writers of one skill audit out of
79
+ * order; the links say what actually replaced what. The rebuild runs only when it can account for
80
+ * EVERY entry (exactly one start and a forward link for each step) — otherwise the stored order is
81
+ * returned unchanged, because a partial reconstruction would be a worse answer than the honest one.
82
+ * @param versions - the stored index, oldest first as recorded.
83
+ * @returns the same entries, in content order when the chain is complete.
84
+ */
85
+ export declare function orderVersions(versions: readonly SkillVersion[]): SkillVersion[];
86
+ /** Which artifact one recorded version holds.
87
+ *
88
+ * `content` is the skill's own SKILL.md body. `support` is a support file's bytes: a `write_file`
89
+ * or `remove_file` mutation stores them in the SAME index as the body's versions, so a skill that
90
+ * ever wrote a support file has two chains in one index. `other` is an entry whose action does not say
91
+ * which artifact the bytes belong to — a `baseline` predecessor found on disk, or an action that
92
+ * records no content of its own (`pin`/`unpin`/`archive` record only predecessors).
93
+ *
94
+ * The split is FAIL-CLOSED: an action this module does not know reads as `other` rather than being
95
+ * assumed to be the body, so a caller that offers "restore this version" never advertises bytes the
96
+ * index cannot vouch for. `tests/skill-history.spec.ts` pins every action the library can write, so a
97
+ * new one cannot land unclassified. */
98
+ export type VersionTarget = 'content' | 'support' | 'other';
99
+ /**
100
+ * Which artifact one recorded version holds, from the action that produced it.
101
+ * @param action - the entry's action label.
102
+ * @returns the artifact class; `other` when the label does not say.
103
+ */
104
+ export declare function versionTarget(action: string): VersionTarget;
105
+ /** The versions of one skill, split by the artifact they hold. */
106
+ export interface VersionGroups {
107
+ /** The body's versions, plus predecessors whose artifact the index cannot name. */
108
+ readonly content: SkillVersion[];
109
+ /** Support-file versions: real history, but not restorable over SKILL.md. */
110
+ readonly support: SkillVersion[];
111
+ }
112
+ /**
113
+ * Split one index into the skill body's chain and the support files' versions.
114
+ *
115
+ * Both share ONE index, so a skill that ever wrote a support file has two chains and
116
+ * {@link orderVersions} cannot account for every entry (it falls back to the stored order). Splitting
117
+ * first lets each group rebuild its own order, and keeps the body's chain intact by leaving every
118
+ * entry the index cannot classify in it — a `baseline` predecessor is usually the body's first link.
119
+ * @param versions - the stored index, oldest first as recorded.
120
+ * @returns the two groups, each in content order when its chain is complete.
121
+ */
122
+ export declare function partitionVersions(versions: readonly SkillVersion[]): VersionGroups;
123
+ /** What one index file's bytes said. `absent` (no file yet) and `unreadable` (bytes that cannot be
124
+ * understood: malformed JSON, a foreign shape, an entry this reader would have to drop, or a writer
125
+ * NEWER than this reader) are DIFFERENT facts — the recorder must never replace the second with an
126
+ * index derived from "nothing", so it preserves those bytes instead. */
127
+ export type HistoryIndexState = {
128
+ readonly kind: 'ok';
129
+ readonly versions: SkillVersion[];
130
+ } | {
131
+ readonly kind: 'absent';
132
+ } | {
133
+ readonly kind: 'unreadable';
134
+ };
135
+ /**
136
+ * Read one index body for a RECORDER: "understood", "no file", or "cannot be understood".
137
+ * A file whose shape is right but which carries an entry this reader would have to drop counts as
138
+ * unreadable — dropping it silently would be the overwrite the audit posture forbids.
139
+ * @param raw - the index file's bytes, or null when absent.
140
+ * @returns the three-state answer.
141
+ */
142
+ export declare function readHistoryIndex(raw: string | null): HistoryIndexState;
143
+ /**
144
+ * Parse one index body for a READER. Malformed content, a foreign shape, or a version NEWER than this
145
+ * reader all read as empty — the audit posture: never guess. A recorder must use
146
+ * {@link readHistoryIndex} instead, because it has to PRESERVE bytes it cannot understand.
147
+ * @param raw - the index file's bytes, or null when absent.
148
+ * @returns the versions, oldest first.
149
+ */
150
+ export declare function parseHistoryIndex(raw: string | null): SkillVersion[];
151
+ /**
152
+ * The index after this write, and the version numbers it produced. Pure, so the versioning rules
153
+ * are testable without io.
154
+ *
155
+ * Two rules matter here: a predecessor the index has never seen gets a BASELINE entry (so the state
156
+ * before this mutation is recoverable even for a skill that predates the feature), and re-writing
157
+ * identical content mints NO new version (the library's no-op updates must not grow history).
158
+ * @param versions - the current index, oldest first.
159
+ * @param input - the write being recorded.
160
+ * @param keep - how many versions to retain per skill.
161
+ * @returns the trimmed index and the recorded numbers.
162
+ */
163
+ export declare function nextHistoryIndex(versions: readonly SkillVersion[], input: VersionRecordInput, keep: number): {
164
+ versions: SkillVersion[];
165
+ recorded: RecordedVersions;
166
+ };
167
+ /** Shrink level at which the retention line speaks: a body that keeps less than this share of the
168
+ * previous one is a rewrite, not an edit. */
169
+ export declare const RETENTION_FEEDBACK_KEEP_RATIO = 0.5;
170
+ /** Floor on the REPLACED body: below this a ratio is noise (a 40-character skill cut in half is not
171
+ * news), so the line stays off for small skills. */
172
+ export declare const RETENTION_FEEDBACK_MIN_CHARS = 200;
173
+ /** What {@link contentRetentionFeedback} reads: the one write it describes. */
174
+ export interface RetentionFeedbackInput {
175
+ /** The skill's name, for the sentence. */
176
+ readonly name: string;
177
+ /** The action that produced the write; only a whole-body replacement is described. */
178
+ readonly action: string;
179
+ /** The replaced body, or null when the skill had none (create / restore). */
180
+ readonly before: string | null;
181
+ /** The body now on disk, or null when it was removed (archive / remove_file). */
182
+ readonly after: string | null;
183
+ }
184
+ /**
185
+ * The anti-overwrite feedback line (design §4, item 3): says how much of the previous body a
186
+ * replacement kept, so a destructive rewrite is visible in the same message that reports the write.
187
+ *
188
+ * Computed where the replaced bytes are still in hand — the write path's own in-lock read — instead of
189
+ * re-reading the file from a caller (that second read would race the writers this library serializes).
190
+ * It is FEEDBACK, never a gate: no ratio refuses a write, and the function owns no configuration
191
+ * (a toggle nobody reads would be configuration that does nothing).
192
+ *
193
+ * @param input - the write being described.
194
+ * @returns the line, or null when the write is not a significant whole-body replacement.
195
+ */
196
+ export declare function contentRetentionFeedback(input: RetentionFeedbackInput): string | null;
197
+ /**
198
+ * Read one skill's versions, oldest first.
199
+ * @param root - the skills root.
200
+ * @param io - the io seam.
201
+ * @param name - the skill name.
202
+ * @returns the versions; an absent or unreadable index reads as empty (never a throw).
203
+ */
204
+ export declare function loadVersions(root: string, io: EvolutionIoLike, name: string): Promise<SkillVersion[]>;
205
+ /**
206
+ * One version's content.
207
+ * @param root - the skills root.
208
+ * @param io - the io seam.
209
+ * @param name - the skill name.
210
+ * @param v - the version number.
211
+ * @returns the content, or null when the version or its blob cannot be read (never '').
212
+ */
213
+ export declare function loadVersionContent(root: string, io: EvolutionIoLike, name: string, v: number): Promise<string | null>;
214
+ /**
215
+ * Record the content one mutation produced, best-effort.
216
+ *
217
+ * Blobs are written FIRST: the index must never reference content that is not on disk, and a failed
218
+ * blob write aborts the whole record (the caller warns; the mutation itself stands).
219
+ * @param root - the skills root.
220
+ * @param io - the io seam.
221
+ * @param input - the write being recorded.
222
+ * @param keep - how many versions to retain per skill.
223
+ * @returns the version numbers, or null when there was no content to record.
224
+ */
225
+ export declare function recordVersions(root: string, io: EvolutionIoLike, input: VersionRecordInput, keep: number): Promise<RecordedVersions | null>;
226
+ //# sourceMappingURL=skill-history.d.ts.map
@@ -36,6 +36,7 @@ import { transactIo, type EvolutionIoLike } from './io.ts';
36
36
  import { type Probe } from './probe.ts';
37
37
  import type { SkillLimits } from './limits.ts';
38
38
  import { type MutationRecord } from './mutations.ts';
39
+ import { type SkillVersion } from './skill-history.ts';
39
40
  import { type SkillHealthAssessment, type SkillHealthThresholds } from './skill-health.ts';
40
41
  import type { EvolutionSkillMutatedEvent } from './events.ts';
41
42
  export interface SkillSummary {
@@ -272,6 +273,9 @@ export declare class SkillLibrary {
272
273
  /** V10-03 (P2-18): see the constructor's threatExemptLabels. Empty by
273
274
  * default — the strict ANY-hit-blocks policy is unchanged. */
274
275
  private readonly threatExemptLabels;
276
+ /** skill-history.ts: the content history is best-effort, so a failure must not be SILENT —
277
+ * but it is a deployment condition, not a per-write event, so it warns once per instance. */
278
+ private historyWarned;
275
279
  constructor(root?: string, io?: EvolutionIoLike, limits?: SkillLimits, onMutation?: (event: EvolutionSkillMutatedEvent) => void, transact?: typeof transactIo, threatExemptLabels?: readonly string[]);
276
280
  /**
277
281
  * Run one single-file read-modify-write for a mutator. When `transact` was
@@ -394,10 +398,34 @@ export declare class SkillLibrary {
394
398
  patchCount?: number;
395
399
  readCount?: number;
396
400
  }): Promise<SkillHealthAssessment | null>;
397
- /** Best-effort audit trail entry; never blocks the mutation. */
401
+ /** Best-effort audit trail entry; never blocks the mutation.
402
+ *
403
+ * Two independent best-effort writes run here, because either one failing must not cost the
404
+ * other: the CONTENT history (skill-history.ts, so a version can be read back at all) and the
405
+ * mutation ledger that points at it. The history failure is not silent — a deployment whose
406
+ * history stopped growing is a degradation worth seeing once (the ledger keeps hashes either
407
+ * way). */
398
408
  private audit;
399
409
  /** Recent mutation audit records (read-only inspection surface). */
400
410
  listMutations(): Promise<MutationRecord[]>;
411
+ /**
412
+ * Content versions recorded for one skill, oldest first (skill-history.ts, batch A).
413
+ *
414
+ * Read-only: the version seam never feeds a judgment — callers use it to LIST and to read content
415
+ * back. A malformed or unreadable index reads as empty, and a bad name reads as empty too (the
416
+ * caller is a human command, so "no versions" is the honest answer for a name that cannot exist).
417
+ * @param rawName - the skill's name.
418
+ * @returns the versions, oldest first.
419
+ */
420
+ listVersions(rawName: string): Promise<SkillVersion[]>;
421
+ /**
422
+ * One version's content, read back from the content-addressed blob store.
423
+ * @param rawName - the skill's name.
424
+ * @param v - the version number (see {@link SkillLibrary.listVersions}).
425
+ * @returns the content, or null when the version does not exist, the name is unusable, or the
426
+ * blob cannot be read — never an empty string.
427
+ */
428
+ readVersion(rawName: string, v: number): Promise<string | null>;
401
429
  /**
402
430
  * Pin or unpin a skill (`.pinned` marker). Pinned skills are protected from
403
431
  * deletion, from background-review writes, and from the lifecycle — a
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-core",
3
3
  "description": "Shared stores, prompts, signals and lifecycle logic for the dsh-evolution plugin family (community build)",
4
- "version": "0.9.0",
4
+ "version": "0.11.1",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },