@lmzhen/dsh-evolution-core 0.30.18 → 0.30.19

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.
@@ -48,8 +48,9 @@ import { makeSerialQueue } from "./serial.js";
48
48
  import { h2Sections } from "./document-body.js";
49
49
  import { diffFindings } from "./skill-lint.js";
50
50
  import { contentHash, loadMutations, recordMutation } from "./mutations.js";
51
+ import { revisionFingerprint, revisionSupportDigest } from "./skill-revision.js";
51
52
  import { contentRetentionFeedback, HISTORY_DIR, loadVersionContent, loadVersions, recordVersions } from "./skill-history.js";
52
- import { suppressedFile, usageFile } from "./usage.js";
53
+ import { getRecord, mutateUsage, suppressedFile, usageFile } from "./usage.js";
53
54
  import { assessStructureHealth, DEFAULT_HEALTH_THRESHOLDS, lintBody } from "./skill-health.js";
54
55
  import { CONTENT_SPLIT_HINT, DEFAULT_SKILL_VERSION_KEEP, PROTECTED_BUILTIN_SKILLS, SKILL_NAME_RE, SUPPORT_DIRS } from "./constants.js";
55
56
  /**
@@ -472,6 +473,12 @@ function serialForRoot(root) {
472
473
  ROOT_SERIAL.set(key, created);
473
474
  return created;
474
475
  }
476
+ /** Same-content test for two revision anchors (absent only equals absent). */
477
+ function sameRevisionAnchor(expected, live) {
478
+ if (expected.kind !== live.kind)
479
+ return false;
480
+ return expected.kind === 'absent' || expected.sha256 === live.sha256;
481
+ }
475
482
  export class SkillLibrary {
476
483
  root;
477
484
  limits;
@@ -861,7 +868,55 @@ export class SkillLibrary {
861
868
  }
862
869
  }
863
870
  /**
864
-
871
+ * Content anchor of one live skill: name + body + every support file under SUPPORT_DIRS.
872
+ *
873
+ * This is the commit-side counterpart of the decision-time anchor recorded by the merge
874
+ * domain (see skill-revision.ts): both sides call the same fingerprint, so a changed
875
+ * source, target or support file cannot slip past the commit CAS.
876
+ * @param rawName - caller-supplied skill name (trimmed here).
877
+ * @returns present anchor, or absent when the skill has no SKILL.md.
878
+ */
879
+ async revisionFingerprint(rawName) {
880
+ const name = rawName.trim();
881
+ if (this.badName(name) !== null)
882
+ return null;
883
+ let body;
884
+ try {
885
+ body = await this.io.readText(join(this.dirOf(name), 'SKILL.md'));
886
+ }
887
+ catch (error) {
888
+ if (error?.code === 'EISDIR')
889
+ return null;
890
+ throw error;
891
+ }
892
+ if (body === null)
893
+ return null;
894
+ return revisionFingerprint(name, body, await this.liveSupportFiles(name));
895
+ }
896
+ /** Content anchor of one live skill; the hash a decision records and a commit re-checks. */
897
+ async revisionAnchor(rawName) {
898
+ const fingerprint = await this.revisionFingerprint(rawName);
899
+ return fingerprint === null ? { kind: 'absent' } : { kind: 'present', sha256: contentHash(fingerprint) };
900
+ }
901
+ /** Every support file of one live skill, in listing order; unreadable/missing files fail loud. */
902
+ async liveSupportFiles(rawName) {
903
+ const name = rawName.trim();
904
+ if (this.badName(name) !== null)
905
+ return [];
906
+ const listed = await this.listSupportFiles(name);
907
+ if (isUnknown(listed))
908
+ throw new Error('support file list is unknown: ' + name);
909
+ if (!isPresent(listed))
910
+ return [];
911
+ const dir = this.dirOf(name);
912
+ return await Promise.all(listed.value.map(async (path) => {
913
+ const content = await this.io.readText(join(dir, ...path.replace(/\\/g, '/').split('/').filter(Boolean)));
914
+ if (content === null)
915
+ throw new Error('support file disappeared: ' + name + '/' + path);
916
+ return { path, content };
917
+ }));
918
+ }
919
+ /**
865
920
  * Single path-building choke point (rc.42 audit P2-5): every directory path
866
921
 
867
922
  * is built from the TRIMMED name, so a name that passes `badName` (which
@@ -1254,6 +1309,18 @@ export class SkillLibrary {
1254
1309
  return [];
1255
1310
  return await loadVersions(this.root, this.io, name);
1256
1311
  }
1312
+ /** Versions from the latest merge baseline onward; old versions stay listable via listVersions. */
1313
+ async listBaselineVersions(rawName) {
1314
+ const versions = await this.listVersions(rawName);
1315
+ let start = -1;
1316
+ for (let index = versions.length - 1; index >= 0; index -= 1) {
1317
+ if (versions[index]?.unitHead?.baseline === true) {
1318
+ start = index;
1319
+ break;
1320
+ }
1321
+ }
1322
+ return start < 0 ? versions : versions.slice(start);
1323
+ }
1257
1324
  /**
1258
1325
  * One version's content, read back from the content-addressed blob store.
1259
1326
  * @param rawName - the skill's name.
@@ -1267,6 +1334,50 @@ export class SkillLibrary {
1267
1334
  return null;
1268
1335
  return await loadVersionContent(this.root, this.io, name, v);
1269
1336
  }
1337
+ /** Read an archived skill's human-readable reason, exact or stamped. */
1338
+ async readArchiveReason(rawName) {
1339
+ const name = rawName.trim();
1340
+ if (this.badName(name) !== null)
1341
+ return null;
1342
+ const archiveRoot = join(this.root, '.archive');
1343
+ const exact = await this.io.readText(join(archiveRoot, name, '.archive-reason'));
1344
+ if (exact !== null)
1345
+ return exact.trim();
1346
+ const entries = await this.io.list(archiveRoot).catch(() => []);
1347
+ for (const entry of [...entries].reverse()) {
1348
+ if (entry !== name && !entry.startsWith(name + '-'))
1349
+ continue;
1350
+ const marker = await this.io.readText(join(archiveRoot, entry, '.archive-name'));
1351
+ if (marker !== null && marker.trim() !== name)
1352
+ continue;
1353
+ const reason = await this.io.readText(join(archiveRoot, entry, '.archive-reason'));
1354
+ if (reason !== null)
1355
+ return reason.trim();
1356
+ }
1357
+ return null;
1358
+ }
1359
+ /** Read an archived skill body (SKILL.md) by its original name, exact or stamped. */
1360
+ async readArchived(rawName) {
1361
+ const name = rawName.trim();
1362
+ if (this.badName(name) !== null)
1363
+ return null;
1364
+ const archiveRoot = join(this.root, '.archive');
1365
+ const exact = await this.io.readText(join(archiveRoot, name, 'SKILL.md'));
1366
+ if (exact !== null)
1367
+ return exact;
1368
+ const entries = await this.io.list(archiveRoot).catch(() => []);
1369
+ for (const entry of [...entries].reverse()) {
1370
+ if (entry !== name && !entry.startsWith(name + '-'))
1371
+ continue;
1372
+ const marker = await this.io.readText(join(archiveRoot, entry, '.archive-name'));
1373
+ if (marker !== null && marker.trim() !== name)
1374
+ continue;
1375
+ const text = await this.io.readText(join(archiveRoot, entry, 'SKILL.md'));
1376
+ if (text !== null)
1377
+ return text;
1378
+ }
1379
+ return null;
1380
+ }
1270
1381
  /**
1271
1382
  * Pin or unpin a skill (`.pinned` marker). Pinned skills are protected from
1272
1383
  * deletion, from background-review writes, and from the lifecycle — a
@@ -1812,6 +1923,19 @@ export class SkillLibrary {
1812
1923
  && !name.includes('/') && !name.includes('\\')
1813
1924
  && basename(name) === name;
1814
1925
  }
1926
+ /** Mark merge-absorbed sources archived in the usage sidecar (FR-DATA-07). */
1927
+ async foldArchivedUsage(sources, at) {
1928
+ try {
1929
+ await mutateUsage(this.root, this.io, map => {
1930
+ for (const name of sources) {
1931
+ const record = getRecord(map, name);
1932
+ record.state = 'archived';
1933
+ record.archived_at = at;
1934
+ }
1935
+ });
1936
+ }
1937
+ catch { /* lifecycle telemetry is best-effort; the archive already landed */ }
1938
+ }
1815
1939
  async archive(rawName, options = {}, origin = this.authority.origin) {
1816
1940
  // One trim per entry: paths (dirOf), validation and messages all see the same name.
1817
1941
  const name = rawName.trim();
@@ -1954,6 +2078,7 @@ export class SkillLibrary {
1954
2078
  const protectedSource = sources.find(name => PROTECTED_BUILTIN_SKILLS.has(name));
1955
2079
  if (protectedSource !== undefined)
1956
2080
  return { ok: false, message: `来源技能「${protectedSource}」是受保护的内置技能。` };
2081
+ const limits = plan.limits ?? this.limits;
1957
2082
  return await this.serial(async () => {
1958
2083
  const dir = this.dirOf(target);
1959
2084
  const targetPath = join(dir, 'SKILL.md');
@@ -1992,7 +2117,38 @@ export class SkillLibrary {
1992
2117
  return { ok: false, message: `来源技能「${source}」在计划之后被改过;拒绝提交(未写入任何内容)。` };
1993
2118
  sourceBodies.set(source, body);
1994
2119
  }
1995
- const validation = validateFrontmatter(plan.body, target, this.limits);
2120
+ // Decision-time CAS (FR-EXEC-12/GAP-CAND-02): the body compare above only covers
2121
+ // SKILL.md; a changed support file (or a changed target body recorded with different
2122
+ // formatting) would still slip through. Re-hash the LIVE tree with the same canonical
2123
+ // fingerprint the decision recorded and refuse before the first write.
2124
+ if (plan.targetRevision !== undefined) {
2125
+ const liveTargetRevision = await this.revisionAnchor(target);
2126
+ if (!sameRevisionAnchor(plan.targetRevision, liveTargetRevision)) {
2127
+ return { ok: false, code: 'target-changed', message: `目标技能「${target}」的内容在决策之后被改过;拒绝提交(未写入任何内容)。` };
2128
+ }
2129
+ }
2130
+ if (plan.sourceRevisions !== undefined) {
2131
+ for (const [name, expected] of Object.entries(plan.sourceRevisions)) {
2132
+ if (!sources.includes(name))
2133
+ continue;
2134
+ const live = await this.revisionAnchor(name);
2135
+ if (!sameRevisionAnchor(expected, live)) {
2136
+ return { ok: false, code: 'source-changed', message: `来源技能「${name}」的内容在决策之后被改过;拒绝提交(未写入任何内容)。` };
2137
+ }
2138
+ }
2139
+ }
2140
+ if (plan.supportDigest !== undefined) {
2141
+ const fingerprints = [];
2142
+ for (const name of [target, ...sources]) {
2143
+ const fingerprint = await this.revisionFingerprint(name);
2144
+ if (fingerprint !== null)
2145
+ fingerprints.push(fingerprint);
2146
+ }
2147
+ if (revisionSupportDigest(fingerprints) !== plan.supportDigest) {
2148
+ return { ok: false, code: 'support-changed', message: '参与合并的技能支撑文件在决策之后被改过;拒绝提交(未写入任何内容)。' };
2149
+ }
2150
+ }
2151
+ const validation = validateFrontmatter(plan.body, target, limits);
1996
2152
  if (validation)
1997
2153
  return { ok: false, message: validation };
1998
2154
  const supportPaths = new Set();
@@ -2004,8 +2160,8 @@ export class SkillLibrary {
2004
2160
  if (supportPaths.has(file.path))
2005
2161
  return { ok: false, message: `支撑文件重复:${file.path}` };
2006
2162
  supportPaths.add(file.path);
2007
- if (Buffer.byteLength(file.content, 'utf8') > this.limits.maxSkillFileBytes)
2008
- return { ok: false, message: `支撑文件超过 ${this.limits.maxSkillFileBytes} 字节:${file.path}` };
2163
+ if (Buffer.byteLength(file.content, 'utf8') > limits.maxSkillFileBytes)
2164
+ return { ok: false, message: `支撑文件超过 ${limits.maxSkillFileBytes} 字节:${file.path}` };
2009
2165
  const threat = this.contentThreatBlock(file.content);
2010
2166
  if (threat)
2011
2167
  return { ok: false, message: threat };
@@ -2021,7 +2177,7 @@ export class SkillLibrary {
2021
2177
  if (!existing)
2022
2178
  return { ok: false, message: `正文引用了未提供的支撑文件「${ref}」;拒绝提交。` };
2023
2179
  }
2024
- const unitId = 'u-' + Date.now().toString(36) + '-' + (nextUnitSeq += 1).toString(36);
2180
+ const unitId = plan.unitId ?? ('u-' + Date.now().toString(36) + '-' + (nextUnitSeq += 1).toString(36));
2025
2181
  const supportMoves = plan.supportFiles.map((file) => {
2026
2182
  const previous = supportPrevious.get(file.path) ?? null;
2027
2183
  return {
@@ -2033,6 +2189,8 @@ export class SkillLibrary {
2033
2189
  };
2034
2190
  });
2035
2191
  const unitHead = {
2192
+ ...(plan.runId === undefined ? {} : { runId: plan.runId }),
2193
+ baseline: true,
2036
2194
  moves: supportMoves,
2037
2195
  sources: [{ skill: target }, ...sources.map(source => ({ skill: source }))],
2038
2196
  };
@@ -2154,6 +2312,7 @@ export class SkillLibrary {
2154
2312
  archived.push(source);
2155
2313
  }
2156
2314
  await plan.onPhase?.('sources-archived');
2315
+ await this.foldArchivedUsage(sources, new Date().toISOString());
2157
2316
  await plan.onPhase?.('unit-written');
2158
2317
  return { ok: true, message: `已把 ${sources.join(', ')} 合并进「${target}」。`, unitId, targetPath: dir, archived, mutations: 1 + sources.length };
2159
2318
  }
@@ -7,6 +7,8 @@ export function skillLibraryUnitBackend(skills) {
7
7
  },
8
8
  async commit(plan, onPhase, signal, assertFence) {
9
9
  const result = await skills.commitMerge({
10
+ ...(plan.runId === undefined ? {} : { runId: plan.runId }),
11
+ ...(plan.unitId === undefined ? {} : { unitId: plan.unitId }),
10
12
  target: plan.target,
11
13
  targetExists: plan.targetExists,
12
14
  sources: plan.sources,
@@ -16,11 +18,15 @@ export function skillLibraryUnitBackend(skills) {
16
18
  expectedSourceBodies: plan.expectedSourceBodies,
17
19
  origin: plan.origin,
18
20
  summary: plan.summary,
21
+ ...(plan.skillLimits === undefined ? {} : { limits: plan.skillLimits }),
22
+ ...(plan.targetRevision === undefined ? {} : { targetRevision: plan.targetRevision }),
23
+ ...(plan.sourceRevisions === undefined ? {} : { sourceRevisions: plan.sourceRevisions }),
24
+ ...(plan.supportDigest === undefined ? {} : { supportDigest: plan.supportDigest }),
19
25
  ...(onPhase === undefined ? {} : { onPhase }),
20
26
  ...(signal === undefined ? {} : { signal }),
21
27
  ...(assertFence === undefined ? {} : { assertFence }),
22
28
  });
23
- return { ok: result.ok, message: result.message, ...(result.archived === undefined ? {} : { archived: result.archived }), ...(result.mutations === undefined ? {} : { mutations: result.mutations }) };
29
+ return { ok: result.ok, message: result.message, ...(result.code === undefined ? {} : { code: result.code }), ...(result.archived === undefined ? {} : { archived: result.archived }), ...(result.mutations === undefined ? {} : { mutations: result.mutations }) };
24
30
  },
25
31
  async restoreTarget(name, body) {
26
32
  if (body === null) {
@@ -1,6 +1,6 @@
1
1
  import type { EvolutionIoLike } from './io.ts';
2
2
  export declare const UNIT_JOURNAL_SCHEMA_VERSION = 1;
3
- export declare const UNIT_JOURNAL_PHASES: readonly ["prepared", "target-pending", "target-written", "source-archiving", "sources-archived", "support-pending", "committed", "failed", "rolled-back"];
3
+ export declare const UNIT_JOURNAL_PHASES: readonly ["prepared", "target-pending", "target-written", "source-archiving", "sources-archived", "support-pending", "unit-written", "committed", "failed", "rolled-back"];
4
4
  export type UnitJournalPhase = typeof UNIT_JOURNAL_PHASES[number];
5
5
  export interface UnitJournalCheckpoint {
6
6
  readonly source: string;
@@ -9,6 +9,7 @@ export const UNIT_JOURNAL_PHASES = [
9
9
  'source-archiving',
10
10
  'sources-archived',
11
11
  'support-pending',
12
+ 'unit-written',
12
13
  'committed',
13
14
  'failed',
14
15
  'rolled-back',
@@ -23,6 +23,11 @@ export interface UnitSnapshot {
23
23
  }[];
24
24
  readonly origin: string;
25
25
  readonly summary: string;
26
+ readonly validationPlan?: {
27
+ readonly planVersion: 1;
28
+ readonly stageDigest: string;
29
+ readonly revision: string;
30
+ };
26
31
  readonly createdAt: number;
27
32
  }
28
33
  export interface UnitEvidenceRecord {
@@ -45,6 +50,11 @@ export interface UnitEvidenceRecord {
45
50
  absorbedInto: string;
46
51
  }[];
47
52
  readonly mutations: number;
53
+ readonly validationPlan?: {
54
+ readonly planVersion: 1;
55
+ readonly stageDigest: string;
56
+ readonly revision: string;
57
+ };
48
58
  readonly at: number;
49
59
  }
50
60
  export interface UnitIndexEntry {
@@ -76,6 +86,8 @@ export interface UnitReceiptRecord {
76
86
  readonly target: string;
77
87
  readonly archived: readonly string[];
78
88
  readonly at: number;
89
+ /** Hash of the evidence written after this receipt; set on the final receipt. */
90
+ readonly evidenceSha256?: string;
79
91
  }
80
92
  export declare function unitReceiptPath(home: string, unitId: string): string;
81
93
  export declare function writeUnitReceipt(io: EvolutionIoLike, home: string, receipt: UnitReceiptRecord): Promise<string>;
@@ -7,6 +7,8 @@ export interface UnitSupportFile {
7
7
  }
8
8
  export interface UnitPlan {
9
9
  readonly runId: string;
10
+ /** Filled by the transaction before the backend write; history records it. */
11
+ readonly unitId?: string;
10
12
  readonly pairKey: string;
11
13
  readonly target: string;
12
14
  readonly targetExists: boolean;
@@ -20,10 +22,32 @@ export interface UnitPlan {
20
22
  }[];
21
23
  readonly origin: "foreground" | "subagent" | "background_review";
22
24
  readonly summary: string;
25
+ readonly skillLimits?: import("./limits.ts").SkillLimits;
26
+ readonly limitsDigest?: string;
27
+ readonly validationPlan?: {
28
+ readonly planVersion: 1;
29
+ readonly stageDigest: string;
30
+ readonly revision: string;
31
+ };
32
+ readonly targetRevision?: {
33
+ readonly kind: 'absent';
34
+ } | {
35
+ readonly kind: 'present';
36
+ readonly sha256: string;
37
+ };
38
+ readonly sourceRevisions?: Readonly<Record<string, {
39
+ readonly kind: 'absent';
40
+ } | {
41
+ readonly kind: 'present';
42
+ readonly sha256: string;
43
+ }>>;
44
+ readonly supportDigest?: string;
23
45
  }
24
46
  export interface UnitCommitResult {
25
47
  readonly ok: boolean;
26
48
  readonly message: string;
49
+ /** Typed backend refusal (revision CAS); absent means a generic commit failure. */
50
+ readonly code?: UnitErrorCode;
27
51
  readonly archived?: readonly string[];
28
52
  readonly mutations?: number;
29
53
  }
@@ -55,7 +79,7 @@ export interface UnitTransactionOptions {
55
79
  /** Gate instrumentation: tree fence then target claim. */
56
80
  readonly onLock?: (lock: "tree" | "claim") => void;
57
81
  }
58
- export type UnitErrorCode = "unit-plan-missing" | "unit-conflict" | "unit-committed" | "unit-prepare-failed" | "unit-commit-failed" | "unit-claim-failed" | "unit-fence-failed" | "unit-rollback-blocked";
82
+ export type UnitErrorCode = "unit-plan-missing" | "unit-conflict" | "unit-committed" | "unit-prepare-failed" | "unit-commit-failed" | "unit-claim-failed" | "unit-fence-failed" | "unit-rollback-blocked" | "target-changed" | "source-changed" | "support-changed";
59
83
  export interface UnitFailure {
60
84
  readonly ok: false;
61
85
  readonly code: UnitErrorCode;
@@ -101,6 +125,7 @@ export interface UnitTransaction {
101
125
  phase: UnitJournalPhase | "none";
102
126
  }>;
103
127
  }
128
+ export declare function unitEvidenceDigest(evidence: UnitEvidenceRecord): string;
104
129
  export declare class UnitTransactions implements UnitTransaction {
105
130
  private readonly options;
106
131
  private readonly prepared;
@@ -189,7 +214,7 @@ export declare class UnitTransactions implements UnitTransaction {
189
214
  phase: "target-pending" | "target-written" | "source-archiving" | "sources-archived";
190
215
  } | {
191
216
  ok: boolean;
192
- phase: "prepared" | "support-pending" | "committed" | "failed" | "rolled-back";
217
+ phase: "prepared" | "support-pending" | "unit-written" | "committed" | "failed" | "rolled-back";
193
218
  }>;
194
219
  }
195
220
  //# sourceMappingURL=unit-transaction.d.ts.map
@@ -19,6 +19,20 @@ function canonical(value) {
19
19
  function digest(plan) {
20
20
  return createHash("sha256").update(canonical(plan)).digest("hex");
21
21
  }
22
+ function canonicalJson(value) {
23
+ if (value === null || typeof value !== 'object')
24
+ return JSON.stringify(value);
25
+ if (Array.isArray(value))
26
+ return '[' + value.map(canonicalJson).join(',') + ']';
27
+ const object = value;
28
+ return '{' + Object.keys(object).sort().map(key => JSON.stringify(key) + ':' + canonicalJson(object[key])).join(',') + '}';
29
+ }
30
+ export function unitEvidenceDigest(evidence) {
31
+ return createHash("sha256").update(canonicalJson({ ...evidence, at: 0, mutations: 0 })).digest("hex");
32
+ }
33
+ function evidenceDigest(evidence) {
34
+ return createHash("sha256").update(JSON.stringify({ ...evidence, at: 0, mutations: 0 }, null, 2) + "\n").digest("hex");
35
+ }
22
36
  export class UnitTransactions {
23
37
  options;
24
38
  prepared = new Map();
@@ -54,6 +68,7 @@ export class UnitTransactions {
54
68
  sourceBefore,
55
69
  origin: plan.origin,
56
70
  summary: plan.summary,
71
+ ...(plan.validationPlan === undefined ? {} : { validationPlan: plan.validationPlan }),
57
72
  createdAt: this.now(),
58
73
  };
59
74
  }
@@ -136,13 +151,13 @@ export class UnitTransactions {
136
151
  let lock;
137
152
  let claim;
138
153
  const checkpoints = [];
139
- const writePhase = async (phase, checkpointsNext) => {
154
+ const writePhase = async (phase, checkpointsNext = checkpoints) => {
140
155
  if (lock !== undefined && !(await lock.assertHeld()))
141
156
  throw new Error("unit-fence-lost");
142
157
  await writeUnitJournal(this.options.io, this.options.home, {
143
158
  ...journalBase,
144
159
  phase,
145
- ...(checkpointsNext === undefined ? {} : { checkpoints: checkpointsNext }),
160
+ checkpoints: checkpointsNext,
146
161
  });
147
162
  await this.options.onPhase?.(phase);
148
163
  };
@@ -157,7 +172,7 @@ export class UnitTransactions {
157
172
  claim = await this.options.claim.acquire(stored.plan.target, stored.plan.runId);
158
173
  this.options.onLock?.("claim");
159
174
  await writeUnitJournal(this.options.io, this.options.home, { ...journalBase, phase: "target-pending" });
160
- const result = await this.options.backend.commit(stored.plan, async (phase, detail) => {
175
+ const result = await this.options.backend.commit({ ...stored.plan, unitId: prepared.unitId }, async (phase, detail) => {
161
176
  if (phase === "support-pending") {
162
177
  await writePhase("support-pending");
163
178
  await this.options.onPhase?.("support-pending");
@@ -179,7 +194,7 @@ export class UnitTransactions {
179
194
  archived: stored.plan.sources.filter((source) => source !== stored.plan.target),
180
195
  at: this.now(),
181
196
  });
182
- await this.options.onPhase?.("unit-written");
197
+ await writePhase("unit-written");
183
198
  return;
184
199
  }
185
200
  if (phase === "source-archiving" && detail?.source !== undefined) {
@@ -196,7 +211,7 @@ export class UnitTransactions {
196
211
  }, options?.signal, async () => lock !== undefined && (await lock.assertHeld()));
197
212
  if (!result.ok) {
198
213
  await writePhase("failed");
199
- return { ok: false, code: "unit-commit-failed", message: result.message };
214
+ return { ok: false, code: result.code ?? "unit-commit-failed", message: result.message };
200
215
  }
201
216
  const targetAfter = (await this.options.backend.readSkill(stored.plan.target)) ?? "";
202
217
  const evidence = {
@@ -221,11 +236,17 @@ export class UnitTransactions {
221
236
  absorbedInto: stored.plan.target,
222
237
  })),
223
238
  mutations: result.mutations ?? 0,
239
+ ...(stored.plan.validationPlan === undefined ? {} : { validationPlan: stored.plan.validationPlan }),
224
240
  at: this.now(),
225
241
  };
226
242
  if (!(await lock.assertHeld()))
227
243
  throw new Error("unit-fence-lost");
244
+ const evidenceSha256 = evidenceDigest(evidence);
228
245
  await writeUnitEvidence(this.options.io, this.options.home, evidence);
246
+ await writeUnitReceipt(this.options.io, this.options.home, {
247
+ schemaVersion: 1, unitId: prepared.unitId, runId: stored.plan.runId, planDigest: stored.digest,
248
+ target: stored.plan.target, archived: stored.plan.sources.filter((source) => source !== stored.plan.target), at: this.now(), evidenceSha256,
249
+ });
229
250
  await writePhase("committed", checkpoints);
230
251
  this.prepared.delete(prepared.unitId);
231
252
  return { ok: true, evidence };
@@ -278,6 +299,16 @@ export class UnitTransactions {
278
299
  code: "unit-rollback-blocked",
279
300
  message: "source checkpoint missing: " + missing.map((item) => item.name).join(","),
280
301
  };
302
+ const liveTarget = await this.options.backend.readSkill(snapshot.target);
303
+ if (liveTarget === null || liveTarget !== (snapshot.targetAfter ?? null))
304
+ return { ok: false, code: "unit-rollback-blocked", message: "target live hash does not match the prepared target; manual recovery required" };
305
+ const liveSources = await Promise.all(snapshot.sourceBefore.map(async (source) => ({ name: source.name, body: await this.options.backend.readSkill(source.name) })));
306
+ const liveSource = liveSources.find(source => source.body !== null);
307
+ if (liveSource !== undefined)
308
+ return { ok: false, code: "unit-rollback-blocked", message: "source is still live: " + liveSource.name };
309
+ const liveSupport = await Promise.all(snapshot.supportAfter.map(async (file) => ({ path: file.path, content: await this.options.backend.readSupportFile(snapshot.target, file.path) })));
310
+ if (liveSupport.some(file => file.content !== (snapshot.supportAfter.find(item => item.path === file.path)?.content ?? null)))
311
+ return { ok: false, code: "unit-rollback-blocked", message: "support file live hash does not match the prepared support; manual recovery required" };
281
312
  const evidence = {
282
313
  schemaVersion: 1,
283
314
  unitId,
@@ -298,8 +329,11 @@ export class UnitTransactions {
298
329
  absorbedInto: snapshot.target,
299
330
  })),
300
331
  mutations: 0,
332
+ ...(snapshot.validationPlan === undefined ? {} : { validationPlan: snapshot.validationPlan }),
301
333
  at: this.now(),
302
334
  };
335
+ if (receipt?.evidenceSha256 !== undefined && evidenceDigest(evidence) !== receipt.evidenceSha256)
336
+ return { ok: false, code: "unit-rollback-blocked", message: "receipt evidence hash does not match rebuilt evidence" };
303
337
  await writeUnitEvidence(this.options.io, this.options.home, evidence);
304
338
  if (journal.phase !== "committed")
305
339
  await writeUnitJournal(this.options.io, this.options.home, { ...journal, phase: "committed", checkpoints, updatedAt: this.now() });
@@ -311,7 +345,7 @@ export class UnitTransactions {
311
345
  return { ok: true, evidence: existing };
312
346
  const snapshot = await readUnitSnapshot(this.options.io, this.options.home, unitId);
313
347
  const journal = await readUnitJournal(this.options.io, this.options.home, unitId);
314
- if (snapshot === null || journal === null || (journal.phase !== "target-written" && journal.phase !== "sources-archived"))
348
+ if (snapshot === null || journal === null || !["target-written", "support-pending", "source-archiving", "sources-archived", "unit-written"].includes(journal.phase))
315
349
  return {
316
350
  ok: false,
317
351
  code: "unit-rollback-blocked",
@@ -326,8 +360,15 @@ export class UnitTransactions {
326
360
  message: "source checkpoint missing: " + missing.map((item) => item.name).join(","),
327
361
  };
328
362
  const targetAfter = await this.options.backend.readSkill(snapshot.target);
329
- if (targetAfter === null)
330
- return { ok: false, code: "unit-rollback-blocked", message: "target missing; manual recovery required" };
363
+ if (targetAfter === null || targetAfter !== (snapshot.targetAfter ?? null))
364
+ return { ok: false, code: "unit-rollback-blocked", message: "target missing or drifted; manual recovery required" };
365
+ const liveSources = await Promise.all(snapshot.sourceBefore.map(async (source) => ({ name: source.name, body: await this.options.backend.readSkill(source.name) })));
366
+ const liveSource = liveSources.find(source => source.body !== null);
367
+ if (liveSource !== undefined)
368
+ return { ok: false, code: "unit-rollback-blocked", message: "source is still live: " + liveSource.name };
369
+ const liveSupport = await Promise.all(snapshot.supportAfter.map(async (file) => ({ path: file.path, content: await this.options.backend.readSupportFile(snapshot.target, file.path) })));
370
+ if (liveSupport.some(file => file.content !== (snapshot.supportAfter.find(item => item.path === file.path)?.content ?? null)))
371
+ return { ok: false, code: "unit-rollback-blocked", message: "support file live hash does not match the prepared support; manual recovery required" };
331
372
  const evidence = {
332
373
  schemaVersion: 1,
333
374
  unitId,
@@ -348,6 +389,7 @@ export class UnitTransactions {
348
389
  absorbedInto: snapshot.target,
349
390
  })),
350
391
  mutations: 0,
392
+ ...(snapshot.validationPlan === undefined ? {} : { validationPlan: snapshot.validationPlan }),
351
393
  at: this.now(),
352
394
  };
353
395
  await writeUnitEvidence(this.options.io, this.options.home, evidence);
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.30.18",
4
+ "version": "0.30.19",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },