@lmzhen/dsh-evolution-curator 0.25.7 → 0.25.9

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
@@ -37,3 +37,12 @@ Deterministic skill lifecycle and recovery
37
37
  ## Notes and history
38
38
 
39
39
  - The one-click `/evolution consolidate <target> <source...>` remains the direct manual path (P2-8, v11: the earlier "no LLM pass proposes merge groups" wording contradicted the tested nomination chain).
40
+
41
+ ## 单元与撤销(对外契约摘要)
42
+
43
+ - 一个单元 = 一次树改动(一个 unitId)。索引里只有一条记录带 unitHead,两形态互斥:代表片 { moves, sources }(全量清单 + 它写过哪些技能)/路标 { skill }(记录在别的技能里);判别式固定为 moves !== undefined。
44
+ - undoUnit(name, unitId?):逐路径回该单元铸的基线;没有基线才回退到最早那条的 beforeHash;单元前不存在的路径删除并锚定(盘上字节被别人改过就拒绝,不覆盖)。决策在纯函数 src/unit-undo-plan.ts,IO 与主机不进决策层。
45
+ - 跨技能单元:成员只按 sources[] 的已知 key 解析(禁止目录扫描);缺片/外来片/重复片一律拒绝并点名对象 —— 不得用剩下的片段拼一个撤销。
46
+ - 路标的 undoable 是 undefined(wire 上不出现该键),绝不为 false;聚合时跳过路标,避免它的 hash 污染同单元兄弟片。
47
+ - 剪枝按单元 all-or-nothing;保留窗口今天仍按条目计(切换条件见仓库文档)。
48
+ - 完整说明(含回退与验证命令)见仓库 docs/units-and-undo.md;该目录不随 npm 发布,故在此摘要。
package/lib/index.js CHANGED
@@ -4,7 +4,79 @@ import { join } from "node:path";
4
4
  import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
5
5
  import z from "@deepseek-ai/schemastery";
6
6
  import { preferProvider } from "@lmzhen/dsh-evolution-io";
7
- import { CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_CURATOR_BOOT_GRACE_SECONDS, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_CURATOR_MODEL, DEFAULT_CURATOR_REVIEW_MAX_TOKENS, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_MIN_IDLE_HOURS, DEFAULT_REVIEW_TIMEOUT_MS, DEFAULT_SKILL_LIMITS, DEFAULT_STALE_AFTER_DAYS, EvolutionGateSet, INSTANCE_KEYS, MAX_TIMER_DELAY_MS, PROMPT_BUNDLE, SKILL_NAME_RE, buildCuratorRunReport, claimInstance, clampOnce, clampedNumber, computeDedupGroups, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, contentHash, emptyRecord, entryTarget, evolutionHome, evolutionIoAdapter, foldCuratorFields, isPresent, isUnknown, lintSkill, liveBool, loadSuppressedNames, loadUsage, markerEntryName, mutateUsage, newSkillLibrary, paramRowId, parseCuratorNominations, parseFrontmatter, pickWithPolicy, policyStageLimits, probeList, probeText, readNumberParam, relatedSkillNames, releaseInstance, renderCuratorReportMarkdown, reportTime, reportsSweepLockTarget, sessionLastEventTime, sweepReports, transactIo, unwrapLive, updateSuppressedNames, usageObserved, userSetKeys, verifyPromptBundle } from "@lmzhen/dsh-evolution-core";
7
+ import { BASELINE_ACTION, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_CURATOR_BOOT_GRACE_SECONDS, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_CURATOR_MODEL, DEFAULT_CURATOR_REVIEW_MAX_TOKENS, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_MIN_IDLE_HOURS, DEFAULT_REVIEW_TIMEOUT_MS, DEFAULT_SKILL_LIMITS, DEFAULT_STALE_AFTER_DAYS, EvolutionGateSet, INSTANCE_KEYS, MAX_TIMER_DELAY_MS, PROMPT_BUNDLE, SKILL_NAME_RE, blobPath, buildCuratorRunReport, claimInstance, clampOnce, clampedNumber, computeDedupGroups, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, contentHash, emptyRecord, entryTarget, evolutionHome, evolutionIoAdapter, foldCuratorFields, historyIndexFile, isPresent, isUnknown, lintSkill, liveBool, loadSuppressedNames, loadUsage, markerEntryName, mutateUsage, newSkillLibrary, paramRowId, parseCuratorNominations, parseFrontmatter, parseHistoryIndex, pickWithPolicy, policyStageLimits, probeList, probeText, readNumberParam, relatedSkillNames, releaseInstance, renderCuratorReportMarkdown, reportTime, reportsSweepLockTarget, resolveUnitMembers, sessionLastEventTime, staleRouteStubs, sweepReports, transactIo, unitMembersAreComplete, unwrapLive, updateSuppressedNames, usageObserved, userSetKeys, verifyPromptBundle } from "@lmzhen/dsh-evolution-core";
8
+ //#region lib/types/unit-undo-plan.js
9
+ /**
10
+ * G3a: the pure half of `undoUnit` — deciding WHAT to write back, with no io and no host.
11
+ *
12
+ * Kept apart from the curator so it can be tested in isolation (the curator module needs a cordis
13
+ * host; this file needs nothing but the version records). The rules are the storage seat’s:
14
+ *
15
+ * - one path is described by the triple (baseline, oldest after, newest after): the bytes to write
16
+ * back are the BASELINE this unit minted for that path, and only when there is no baseline do we
17
+ * fall back to the OLDEST after’s `beforeHash`. Using the NEWEST one’s `beforeHash` would roll the
18
+ * file back to a state from the middle of the unit — the half-split file this contract exists to
19
+ * avoid;
20
+ * - a path with no baseline at all did not exist before the unit ⇒ it is REMOVED, and the removal is
21
+ * anchored on the newest after-entry’s hash so a hand edit refuses instead of being deleted;
22
+ * - anything the plan cannot resolve is a refusal, and the caller writes nothing (pre-flight).
23
+ */
24
+ /** The newest unit the index carries, or undefined when no entry has a unit id yet. */
25
+ function newestUnitId(versions) {
26
+ for (let i = versions.length - 1; i >= 0; i -= 1) {
27
+ const unit = versions[i]?.unitId;
28
+ if (unit !== void 0) return unit;
29
+ }
30
+ }
31
+ /**
32
+ * Decide the whole unit rollback. Pure: the caller resolves the hashes to bytes afterwards.
33
+ * @param members - the unit’s entries (any order from the index; oldest first is what it emits).
34
+ * @param versions - the whole index, so `entryTarget` can classify an entry the way the faces do.
35
+ * @returns the operations and the refusals.
36
+ */
37
+ function planUnitUndo(members, versions) {
38
+ const slots = /* @__PURE__ */ new Map();
39
+ const refusals = [];
40
+ const body = {};
41
+ for (const entry of members) {
42
+ const path = entry.path;
43
+ if (!(path !== void 0 && entryTarget(entry, versions) === "support")) {
44
+ if (entry.action === BASELINE_ACTION) body.baseline ??= entry;
45
+ else {
46
+ body.oldest ??= entry;
47
+ body.newest = entry;
48
+ }
49
+ continue;
50
+ }
51
+ const slot = slots.get(path) ?? {};
52
+ slots.set(path, slot);
53
+ if (entry.action === BASELINE_ACTION) slot.baseline ??= entry;
54
+ else {
55
+ slot.oldest ??= entry;
56
+ slot.newest = entry;
57
+ }
58
+ }
59
+ const ops = [];
60
+ for (const [path, slot] of slots) {
61
+ if (slot.newest === void 0) {
62
+ refusals.push(`${path}(单元里没有该路径的 after 条目)`);
63
+ continue;
64
+ }
65
+ const preUnitHash = slot.baseline !== void 0 ? slot.baseline.hash : slot.oldest?.beforeHash;
66
+ ops.push({
67
+ remove: preUnitHash === void 0,
68
+ path,
69
+ preUnitHash,
70
+ expectedHash: slot.newest.hash
71
+ });
72
+ }
73
+ return {
74
+ ops,
75
+ bodyPreUnitHash: body.baseline !== void 0 ? body.baseline.hash : body.oldest?.beforeHash,
76
+ refusals
77
+ };
78
+ }
79
+ //#endregion
8
80
  //#region lib/types/index.js
9
81
  /**
10
82
  * Deterministic skill lifecycle curator with interval gate and archive.
@@ -821,6 +893,41 @@ var EvolutionCurator = class extends Service {
821
893
  llmReviewEnabled: settings.llmReview,
822
894
  ...gatedNominations.warnings.length > 0 ? { nominationsWarnings: gatedNominations.warnings } : {}
823
895
  });
896
+ report.routeStubs = await (async () => {
897
+ const history = join(this.skills.root, ".history", "skills");
898
+ const listed = await probeList(this.io, history);
899
+ if (!isPresent(listed)) return {
900
+ skills: 0,
901
+ stubs: 0,
902
+ reason: "index-root-absent",
903
+ detail: []
904
+ };
905
+ const indexes = /* @__PURE__ */ new Map();
906
+ for (const name of listed.value) {
907
+ const probe = await probeText(this.io, join(history, name, "index.json"));
908
+ if (!isPresent(probe)) {
909
+ indexes.set(name, null);
910
+ continue;
911
+ }
912
+ try {
913
+ indexes.set(name, JSON.parse(probe.value).versions ?? null);
914
+ } catch {
915
+ indexes.set(name, null);
916
+ }
917
+ }
918
+ const detail = staleRouteStubs(indexes).map((stub) => ({
919
+ skill: stub.skill,
920
+ v: stub.v,
921
+ owner: stub.owner,
922
+ reason: stub.reason
923
+ }));
924
+ return {
925
+ skills: indexes.size,
926
+ stubs: detail.length,
927
+ reason: "ok",
928
+ detail
929
+ };
930
+ })();
824
931
  const reportsRoot = join(evolutionHome(), "reports");
825
932
  try {
826
933
  await this.io.writeText(join(reportsRoot, `curator-${runId}.json`), JSON.stringify(report, null, 2));
@@ -1458,6 +1565,117 @@ var EvolutionCurator = class extends Service {
1458
1565
  message: `${result.message} (已撤销到 v${target} —— 仅内容:标记、使用计数与策展状态不变,且这次撤销本身就是一个新版本。)`
1459
1566
  };
1460
1567
  }
1568
+ /**
1569
+ * G3a: undo ONE unit — every path that unit wrote goes back together, or nothing does.
1570
+ *
1571
+ * Phase A is a ZERO-WRITE preflight (the body and each support file still hold the unit’s
1572
+ * after-bytes, and every before-blob is readable); Phase B applies support files first and the
1573
+ * body last, passing the anchors Phase A computed so a concurrent change is refused, not
1574
+ * overwritten. Blobs are read BY HASH — entries are pruned by `keep`, blobs are never reclaimed.
1575
+ * @param name - the skill.
1576
+ * @param unitId - the unit to undo; omitted takes the newest unit the index carries.
1577
+ * @returns the outcome; the message names what was reverted, or why nothing was written.
1578
+ */
1579
+ async undoUnit(name, unitId) {
1580
+ const versions = await this.skills.listVersions(name);
1581
+ if (versions.length === 0) return {
1582
+ ok: false,
1583
+ message: `技能「${name}」还没有记录内容版本 —— 没有可撤销的。`
1584
+ };
1585
+ const live = await this.skills.read(name);
1586
+ if (live === null) return {
1587
+ ok: false,
1588
+ message: `「${name}」不在活动目录树里 —— 撤销恢复的是存活技能;若它已归档,请先 restore。`
1589
+ };
1590
+ const unit = unitId ?? newestUnitId(versions);
1591
+ if (unit === void 0) return {
1592
+ ok: false,
1593
+ message: `技能「${name}」还没有记录任何单元 —— 没有可整体撤销的。`
1594
+ };
1595
+ const members = versions.filter((entry) => entry.unitId === unit);
1596
+ if (members.length === 0) return {
1597
+ ok: false,
1598
+ message: `单元 ${unit} 不在「${name}」已记录的历史里 —— 拒绝瞎猜、不做写入。`
1599
+ };
1600
+ const head = members[0];
1601
+ const headLike = head !== void 0 && head.unitHead !== void 0 ? head.unitHead : void 0;
1602
+ const sources = headLike?.sources;
1603
+ if (sources !== void 0 && sources.length > 0) {
1604
+ const resolved = await resolveUnitMembers(this.skills.root, this.io, {
1605
+ skill: name,
1606
+ unitId: unit,
1607
+ sources,
1608
+ ...headLike === void 0 ? {} : { unitHead: headLike }
1609
+ }, historyIndexFile, parseHistoryIndex);
1610
+ if (!unitMembersAreComplete(resolved)) {
1611
+ const missing = resolved.missing.map((m) => m.skill + (m.reason === "index-absent" ? "(索引文件不在)" : "(索引里没有这个单元)")).join("、");
1612
+ const conflicts = resolved.conflicts.map((c) => c.path + "(" + c.kind + ")").join("、");
1613
+ return {
1614
+ ok: false,
1615
+ message: "单元 " + unit + " 的成员没解析齐:" + missing + (conflicts === "" ? "" : ";冲突:" + conflicts) + " —— 拒绝用剩余片段拼一个撤销。"
1616
+ };
1617
+ }
1618
+ if (resolved.members.length > 1) return {
1619
+ ok: false,
1620
+ message: "单元 " + unit + " 跨 " + String(resolved.members.length) + " 个技能(" + resolved.members.map((m) => m.skill).join("、") + ")—— 跨技能整体撤销尚未接线,拒绝只撤销其中一片。"
1621
+ };
1622
+ }
1623
+ const blobOf = async (hash) => {
1624
+ if (hash === void 0) return null;
1625
+ const probe = await probeText(this.io, blobPath(this.skills.root, hash));
1626
+ return isPresent(probe) ? probe.value : null;
1627
+ };
1628
+ const plan = planUnitUndo(members, versions);
1629
+ const refused = [...plan.refusals];
1630
+ const ops = [];
1631
+ for (const op of plan.ops) {
1632
+ const back = await blobOf(op.preUnitHash);
1633
+ if (op.preUnitHash !== void 0 && back === null) {
1634
+ refused.push(`${op.path}(before 字节读不到:blob ${op.preUnitHash})`);
1635
+ continue;
1636
+ }
1637
+ const liveProbe = await probeText(this.io, join(this.skills.root, name, ...op.path.split("/")));
1638
+ if (isPresent(liveProbe) && contentHash(liveProbe.value) !== op.expectedHash) {
1639
+ refused.push(`${op.path}(盘上字节已变,回退会覆盖别人的改动)`);
1640
+ continue;
1641
+ }
1642
+ ops.push({
1643
+ remove: op.remove,
1644
+ path: op.path,
1645
+ bytes: back ?? "",
1646
+ anchor: { sha256: op.expectedHash }
1647
+ });
1648
+ }
1649
+ const bodyBytes = await blobOf(plan.bodyPreUnitHash);
1650
+ if (plan.bodyPreUnitHash !== void 0 && bodyBytes === null) refused.push("SKILL.md(正文 before 字节读不到)");
1651
+ if (refused.length > 0) return {
1652
+ ok: false,
1653
+ message: `整单元撤销未开始(零写)—— 预检未通过:${refused.join(";")}。修好后可重试。`
1654
+ };
1655
+ const undoUnitId = `u-undo-${Date.now().toString(36)}`;
1656
+ const reverted = [];
1657
+ const done = () => reverted.length === 0 ? "无" : reverted.join(", ");
1658
+ for (const op of ops) {
1659
+ const result = op.remove ? await this.skills.removeSupportFile(name, op.path, "foreground", op.anchor, undoUnitId) : await this.skills.writeSupportFile(name, op.path, op.bytes, "foreground", op.anchor);
1660
+ if (!result.ok) return {
1661
+ ok: false,
1662
+ message: `「${op.path}」:${result.message} —— 撤销未完成(已回退:${done()})。`
1663
+ };
1664
+ reverted.push(op.path);
1665
+ }
1666
+ if (plan.bodyPreUnitHash !== void 0 && bodyBytes !== null) {
1667
+ const result = await this.skills.update(name, bodyBytes, "foreground", { sha256: contentHash(live) });
1668
+ if (!result.ok) return {
1669
+ ok: false,
1670
+ message: `正文:${result.message} —— 撤销未完成(已回退:${done()})。`
1671
+ };
1672
+ reverted.push("SKILL.md");
1673
+ }
1674
+ return {
1675
+ ok: true,
1676
+ message: `已按单元撤销「${name}」:${reverted.join(", ")}(撤销单元 ${undoUnitId},被撤单元 ${unit})。`
1677
+ };
1678
+ }
1461
1679
  /** The content the live bytes replaced: the CHAIN first, the recorded order as the fallback.
1462
1680
  *
1463
1681
  * Every entry written from 0.10.0 on carries `beforeHash`, read under the content lock, so the link
@@ -562,6 +562,18 @@ export declare class EvolutionCurator extends Service {
562
562
  */
563
563
  undo(name: string, v?: number): Promise<SkillActionResult>;
564
564
  private undoMutate;
565
+ /**
566
+ * G3a: undo ONE unit — every path that unit wrote goes back together, or nothing does.
567
+ *
568
+ * Phase A is a ZERO-WRITE preflight (the body and each support file still hold the unit’s
569
+ * after-bytes, and every before-blob is readable); Phase B applies support files first and the
570
+ * body last, passing the anchors Phase A computed so a concurrent change is refused, not
571
+ * overwritten. Blobs are read BY HASH — entries are pruned by `keep`, blobs are never reclaimed.
572
+ * @param name - the skill.
573
+ * @param unitId - the unit to undo; omitted takes the newest unit the index carries.
574
+ * @returns the outcome; the message names what was reverted, or why nothing was written.
575
+ */
576
+ undoUnit(name: string, unitId?: string): Promise<SkillActionResult>;
565
577
  /** The content the live bytes replaced: the CHAIN first, the recorded order as the fallback.
566
578
  *
567
579
  * Every entry written from 0.10.0 on carries `beforeHash`, read under the content lock, so the link
@@ -0,0 +1,45 @@
1
+ /**
2
+ * G3a: the pure half of `undoUnit` — deciding WHAT to write back, with no io and no host.
3
+ *
4
+ * Kept apart from the curator so it can be tested in isolation (the curator module needs a cordis
5
+ * host; this file needs nothing but the version records). The rules are the storage seat’s:
6
+ *
7
+ * - one path is described by the triple (baseline, oldest after, newest after): the bytes to write
8
+ * back are the BASELINE this unit minted for that path, and only when there is no baseline do we
9
+ * fall back to the OLDEST after’s `beforeHash`. Using the NEWEST one’s `beforeHash` would roll the
10
+ * file back to a state from the middle of the unit — the half-split file this contract exists to
11
+ * avoid;
12
+ * - a path with no baseline at all did not exist before the unit ⇒ it is REMOVED, and the removal is
13
+ * anchored on the newest after-entry’s hash so a hand edit refuses instead of being deleted;
14
+ * - anything the plan cannot resolve is a refusal, and the caller writes nothing (pre-flight).
15
+ */
16
+ import { type SkillVersion } from '@lmzhen/dsh-evolution-core';
17
+ /** One path’s action inside a unit undo. */
18
+ export interface UnitUndoOp {
19
+ /** True when the unit created the file (no pre-unit bytes) ⇒ remove it, anchored. */
20
+ remove: boolean;
21
+ /** The support file, relative to the skill directory. */
22
+ path: string;
23
+ /** The blob to write back (hash), or undefined when the unit created the path. */
24
+ preUnitHash: string | undefined;
25
+ /** The bytes the unit left on disk — the CAS anchor for either action. */
26
+ expectedHash: string;
27
+ }
28
+ /** What the undo would do, plus why it refuses to do anything. */
29
+ export interface UnitUndoPlan {
30
+ ops: UnitUndoOp[];
31
+ /** The body’s pre-unit bytes (hash), or undefined when the unit did not rewrite the body. */
32
+ bodyPreUnitHash: string | undefined;
33
+ /** Non-empty ⇒ the caller must NOT write anything (pre-flight). */
34
+ refusals: string[];
35
+ }
36
+ /** The newest unit the index carries, or undefined when no entry has a unit id yet. */
37
+ export declare function newestUnitId(versions: SkillVersion[]): string | undefined;
38
+ /**
39
+ * Decide the whole unit rollback. Pure: the caller resolves the hashes to bytes afterwards.
40
+ * @param members - the unit’s entries (any order from the index; oldest first is what it emits).
41
+ * @param versions - the whole index, so `entryTarget` can classify an entry the way the faces do.
42
+ * @returns the operations and the refusals.
43
+ */
44
+ export declare function planUnitUndo(members: SkillVersion[], versions: SkillVersion[]): UnitUndoPlan;
45
+ //# sourceMappingURL=unit-undo-plan.d.ts.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-curator",
3
3
  "description": "Deterministic skill lifecycle and recovery (community build)",
4
- "version": "0.25.7",
4
+ "version": "0.25.9",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -27,20 +27,20 @@
27
27
  "license": "MIT",
28
28
  "dependencies": {
29
29
  "@deepseek-ai/schemastery": "^3.18.4",
30
- "@lmzhen/dsh-evolution-core": "^0.25.7"
30
+ "@lmzhen/dsh-evolution-core": "^0.25.9"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "@deepseek-ai/cordis": "^4.0.1",
34
34
  "@deepseek-ai/dsh-llm": "^0.2.0-rc.1",
35
35
  "@deepseek-ai/dsh-session": "^0.2.0-rc.1",
36
- "@lmzhen/dsh-evolution-io": "^0.25.7",
37
- "@lmzhen/dsh-evolution-state": "^0.25.7"
36
+ "@lmzhen/dsh-evolution-io": "^0.25.9",
37
+ "@lmzhen/dsh-evolution-state": "^0.25.9"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@deepseek-ai/dsh-llm": "^0.2.0-rc.1",
41
41
  "@deepseek-ai/dsh-session": "^0.2.0-rc.1",
42
- "@lmzhen/dsh-evolution-core": "^0.25.7",
43
- "@lmzhen/dsh-evolution-io": "^0.25.7",
44
- "@lmzhen/dsh-evolution-state": "^0.25.7"
42
+ "@lmzhen/dsh-evolution-core": "^0.25.9",
43
+ "@lmzhen/dsh-evolution-io": "^0.25.9",
44
+ "@lmzhen/dsh-evolution-state": "^0.25.9"
45
45
  }
46
46
  }