@lmzhen/dsh-evolution-core 0.30.17 → 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.
Files changed (37) hide show
  1. package/lib/composition/index.js +2 -2
  2. package/lib/{composition-D8Y3-ROd.js → composition-DSesmTvf.js} +3 -3
  3. package/lib/{http-routes-ifJKGnu6.js → http-routes-D1Ty6nnh.js} +2 -0
  4. package/lib/http-routes.js +1 -1
  5. package/lib/index.js +3205 -2080
  6. package/lib/subagent/index.js +1 -1
  7. package/lib/types/composition/composer.js +1 -1
  8. package/lib/types/frontmatter.js +1 -1
  9. package/lib/types/http-routes.d.ts +2 -0
  10. package/lib/types/http-routes.js +2 -0
  11. package/lib/types/index.d.ts +5 -0
  12. package/lib/types/index.js +5 -0
  13. package/lib/types/params.js +1 -2
  14. package/lib/types/platform-services.js +1 -1
  15. package/lib/types/redact.js +2 -2
  16. package/lib/types/run-registry.d.ts +27 -6
  17. package/lib/types/run-registry.js +58 -15
  18. package/lib/types/skill-history.d.ts +4 -0
  19. package/lib/types/skill-revision.d.ts +37 -0
  20. package/lib/types/skill-revision.js +45 -0
  21. package/lib/types/skill-store.d.ts +43 -1
  22. package/lib/types/skill-store.js +235 -9
  23. package/lib/types/tree-lock.d.ts +3 -0
  24. package/lib/types/tree-lock.js +13 -5
  25. package/lib/types/unit-backend.d.ts +5 -0
  26. package/lib/types/unit-backend.js +55 -0
  27. package/lib/types/unit-journal.d.ts +29 -0
  28. package/lib/types/unit-journal.js +51 -0
  29. package/lib/types/unit-recovery.d.ts +5 -0
  30. package/lib/types/unit-recovery.js +12 -0
  31. package/lib/types/unit-storage.d.ts +95 -0
  32. package/lib/types/unit-storage.js +124 -0
  33. package/lib/types/unit-transaction.d.ts +220 -0
  34. package/lib/types/unit-transaction.js +487 -0
  35. package/package.json +1 -1
  36. package/persisted-write-inventory.json +91 -1
  37. package/lib/{ensure-MEYMj9jn.js → ensure-BuWsjPGD.js} +1 -1
@@ -48,10 +48,11 @@ 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
- import { CONTENT_SPLIT_HINT, DEFAULT_SKILL_VERSION_KEEP, SKILL_NAME_RE, SUPPORT_DIRS } from "./constants.js";
55
+ import { CONTENT_SPLIT_HINT, DEFAULT_SKILL_VERSION_KEEP, PROTECTED_BUILTIN_SKILLS, SKILL_NAME_RE, SUPPORT_DIRS } from "./constants.js";
55
56
  /**
56
57
  * The CLASS boundary default authority (G1/1.1, Stage 2).
57
58
  *
@@ -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
@@ -949,6 +1004,13 @@ export class SkillLibrary {
949
1004
  }
950
1005
  return null;
951
1006
  }
1007
+ /** Whether the target directory exists at all (a SKILL.md-less ghost still occupies the name). */
1008
+ async skillDirExists(rawName) {
1009
+ const name = rawName.trim();
1010
+ if (this.badName(name) !== null)
1011
+ return false;
1012
+ return await this.io.exists(this.dirOf(name));
1013
+ }
952
1014
  async isManaged(rawName) {
953
1015
  // One trim per entry: paths (dirOf), validation and messages all see the same name.
954
1016
  const name = rawName.trim();
@@ -1247,6 +1309,18 @@ export class SkillLibrary {
1247
1309
  return [];
1248
1310
  return await loadVersions(this.root, this.io, name);
1249
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
+ }
1250
1324
  /**
1251
1325
  * One version's content, read back from the content-addressed blob store.
1252
1326
  * @param rawName - the skill's name.
@@ -1260,6 +1334,50 @@ export class SkillLibrary {
1260
1334
  return null;
1261
1335
  return await loadVersionContent(this.root, this.io, name, v);
1262
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
+ }
1263
1381
  /**
1264
1382
  * Pin or unpin a skill (`.pinned` marker). Pinned skills are protected from
1265
1383
  * deletion, from background-review writes, and from the lifecycle — a
@@ -1477,7 +1595,7 @@ export class SkillLibrary {
1477
1595
  // marker never landed" inside the fsync/durability wrapper, producing a
1478
1596
  // self-contradicting operator-facing message.
1479
1597
  let markerWarning = '';
1480
- if (origin !== 'foreground') {
1598
+ if (origin !== 'foreground' || options.managed === true) {
1481
1599
  // v29 SK-05: the marker write gets the same committed-only tolerance as
1482
1600
  // every other write in this file (A1-15) — a post-rename dir-fsync
1483
1601
  // failure means the MARKER landed; rejecting the create as a plain
@@ -1805,6 +1923,19 @@ export class SkillLibrary {
1805
1923
  && !name.includes('/') && !name.includes('\\')
1806
1924
  && basename(name) === name;
1807
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
+ }
1808
1939
  async archive(rawName, options = {}, origin = this.authority.origin) {
1809
1940
  // One trim per entry: paths (dirOf), validation and messages all see the same name.
1810
1941
  const name = rawName.trim();
@@ -1942,9 +2073,18 @@ export class SkillLibrary {
1942
2073
  if (bad)
1943
2074
  return { ok: false, message: bad };
1944
2075
  }
2076
+ if (PROTECTED_BUILTIN_SKILLS.has(target))
2077
+ return { ok: false, message: `目标技能「${target}」是受保护的内置技能。` };
2078
+ const protectedSource = sources.find(name => PROTECTED_BUILTIN_SKILLS.has(name));
2079
+ if (protectedSource !== undefined)
2080
+ return { ok: false, message: `来源技能「${protectedSource}」是受保护的内置技能。` };
2081
+ const limits = plan.limits ?? this.limits;
1945
2082
  return await this.serial(async () => {
1946
2083
  const dir = this.dirOf(target);
1947
2084
  const targetPath = join(dir, 'SKILL.md');
2085
+ if (!plan.targetExists && await this.io.exists(dir)) {
2086
+ return { ok: false, message: `目标目录「${target}」已存在但不是技能;拒绝把 SKILL.md 写进同名目录。` };
2087
+ }
1948
2088
  const currentTarget = await this.io.readText(targetPath);
1949
2089
  if (plan.targetExists !== (currentTarget !== null)) {
1950
2090
  return { ok: false, message: plan.targetExists ? `目标技能「${target}」不存在。` : `目标技能「${target}」已存在。` };
@@ -1952,6 +2092,12 @@ export class SkillLibrary {
1952
2092
  if (currentTarget !== plan.expectedTargetBody) {
1953
2093
  return { ok: false, message: `目标技能「${target}」在计划之后被改过;拒绝提交(未写入任何内容)。` };
1954
2094
  }
2095
+ if (plan.targetExists && !(await this.isManaged(target))) {
2096
+ return { ok: false, message: `目标技能「${target}」不是 family-managed;拒绝提交。` };
2097
+ }
2098
+ if (await this.io.exists(markerPath(dir, 'pinned'))) {
2099
+ return { ok: false, message: `目标技能「${target}」已被固定;拒绝提交。` };
2100
+ }
1955
2101
  const targetProtection = await this.writeProtection(target, plan.origin);
1956
2102
  if (targetProtection)
1957
2103
  return { ok: false, message: `技能「${target}」受保护(${targetProtection})。` };
@@ -1971,7 +2117,38 @@ export class SkillLibrary {
1971
2117
  return { ok: false, message: `来源技能「${source}」在计划之后被改过;拒绝提交(未写入任何内容)。` };
1972
2118
  sourceBodies.set(source, body);
1973
2119
  }
1974
- 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);
1975
2152
  if (validation)
1976
2153
  return { ok: false, message: validation };
1977
2154
  const supportPaths = new Set();
@@ -1983,8 +2160,8 @@ export class SkillLibrary {
1983
2160
  if (supportPaths.has(file.path))
1984
2161
  return { ok: false, message: `支撑文件重复:${file.path}` };
1985
2162
  supportPaths.add(file.path);
1986
- if (Buffer.byteLength(file.content, 'utf8') > this.limits.maxSkillFileBytes)
1987
- 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}` };
1988
2165
  const threat = this.contentThreatBlock(file.content);
1989
2166
  if (threat)
1990
2167
  return { ok: false, message: threat };
@@ -2000,7 +2177,7 @@ export class SkillLibrary {
2000
2177
  if (!existing)
2001
2178
  return { ok: false, message: `正文引用了未提供的支撑文件「${ref}」;拒绝提交。` };
2002
2179
  }
2003
- 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));
2004
2181
  const supportMoves = plan.supportFiles.map((file) => {
2005
2182
  const previous = supportPrevious.get(file.path) ?? null;
2006
2183
  return {
@@ -2012,6 +2189,8 @@ export class SkillLibrary {
2012
2189
  };
2013
2190
  });
2014
2191
  const unitHead = {
2192
+ ...(plan.runId === undefined ? {} : { runId: plan.runId }),
2193
+ baseline: true,
2015
2194
  moves: supportMoves,
2016
2195
  sources: [{ skill: target }, ...sources.map(source => ({ skill: source }))],
2017
2196
  };
@@ -2081,6 +2260,9 @@ export class SkillLibrary {
2081
2260
  expected: supportPrevious.get(file.path) ?? null,
2082
2261
  })),
2083
2262
  ];
2263
+ await plan.onPhase?.('target-pending');
2264
+ if (plan.assertFence !== undefined && !(await plan.assertFence()))
2265
+ return { ok: false, message: 'tree fence lost before target write' };
2084
2266
  const result = await this.applyTreeChange({
2085
2267
  name: target,
2086
2268
  origin: plan.origin,
@@ -2094,13 +2276,28 @@ export class SkillLibrary {
2094
2276
  });
2095
2277
  if (!result.ok)
2096
2278
  return rollbackMessage(result.message, []);
2279
+ await plan.onPhase?.('target-applied');
2280
+ await plan.onPhase?.('target-written');
2281
+ if (plan.signal?.aborted) {
2282
+ const stuck = await rollbackTarget();
2283
+ return rollbackMessage('cancelled before sources archived', stuck);
2284
+ }
2097
2285
  for (const source of sources) {
2286
+ if (plan.signal?.aborted) {
2287
+ const stuck = await rollbackSources();
2288
+ stuck.push(...await rollbackTarget());
2289
+ return rollbackMessage('cancelled before next source archive', stuck);
2290
+ }
2098
2291
  const fresh = await this.io.readText(join(this.dirOf(source), 'SKILL.md'));
2099
2292
  if (fresh !== sourceBodies.get(source)) {
2100
2293
  const stuck = await rollbackSources();
2101
2294
  stuck.push(...await rollbackTarget());
2102
2295
  return rollbackMessage(`来源技能「${source}」在归档前被改过`, stuck);
2103
2296
  }
2297
+ if (plan.assertFence !== undefined && !(await plan.assertFence())) {
2298
+ return { ok: false, message: 'tree fence lost before source archive; recovery required' };
2299
+ }
2300
+ await plan.onPhase?.('source-archiving', { source });
2104
2301
  const archivedResult = await this.archive(source, {
2105
2302
  absorbedInto: target,
2106
2303
  unitId,
@@ -2114,12 +2311,31 @@ export class SkillLibrary {
2114
2311
  }
2115
2312
  archived.push(source);
2116
2313
  }
2314
+ await plan.onPhase?.('sources-archived');
2315
+ await this.foldArchivedUsage(sources, new Date().toISOString());
2316
+ await plan.onPhase?.('unit-written');
2117
2317
  return { ok: true, message: `已把 ${sources.join(', ')} 合并进「${target}」。`, unitId, targetPath: dir, archived, mutations: 1 + sources.length };
2118
2318
  }
2119
- const created = await this.createCore(target, plan.body, plan.origin, { unitId, unitHead });
2319
+ await plan.onPhase?.('target-pending');
2320
+ if (plan.assertFence !== undefined && !(await plan.assertFence()))
2321
+ return { ok: false, message: 'tree fence lost before target write' };
2322
+ const created = await this.createCore(target, plan.body, plan.origin, { unitId, unitHead, managed: true });
2120
2323
  if (!created.ok)
2121
2324
  return { ok: false, message: created.message };
2325
+ await plan.onPhase?.('target-applied');
2326
+ await plan.onPhase?.('target-written');
2327
+ if (plan.signal?.aborted) {
2328
+ const targetRollback = await this.archive(target, { reason: 'merge cancelled' }, plan.origin);
2329
+ return rollbackMessage('cancelled before sources archived', targetRollback.ok ? [] : [target]);
2330
+ }
2122
2331
  for (const source of sources) {
2332
+ if (plan.signal?.aborted) {
2333
+ const stuck = await rollbackSources();
2334
+ const targetRollback = await this.archive(target, { reason: 'merge cancelled' }, plan.origin);
2335
+ if (!targetRollback.ok)
2336
+ stuck.push(target);
2337
+ return rollbackMessage('cancelled before next source archive', stuck);
2338
+ }
2123
2339
  const fresh = await this.io.readText(join(this.dirOf(source), 'SKILL.md'));
2124
2340
  if (fresh !== sourceBodies.get(source)) {
2125
2341
  const stuck = await rollbackSources();
@@ -2128,6 +2344,10 @@ export class SkillLibrary {
2128
2344
  stuck.push(target);
2129
2345
  return rollbackMessage(`来源技能「${source}」在归档前被改过`, stuck);
2130
2346
  }
2347
+ if (plan.assertFence !== undefined && !(await plan.assertFence())) {
2348
+ return { ok: false, message: 'tree fence lost before source archive; recovery required' };
2349
+ }
2350
+ await plan.onPhase?.('source-archiving', { source });
2131
2351
  const result = await this.archive(source, {
2132
2352
  absorbedInto: target,
2133
2353
  unitId,
@@ -2144,6 +2364,10 @@ export class SkillLibrary {
2144
2364
  archived.push(source);
2145
2365
  }
2146
2366
  if (plan.supportFiles.length > 0) {
2367
+ await plan.onPhase?.('support-pending');
2368
+ if (plan.assertFence !== undefined && !(await plan.assertFence())) {
2369
+ return { ok: false, message: 'tree fence lost before support write; recovery required' };
2370
+ }
2147
2371
  const writes = plan.supportFiles.map(file => ({
2148
2372
  target: join(dir, ...file.path.replace(/\\/g, '/').split('/').filter(Boolean)),
2149
2373
  content: file.content,
@@ -2168,6 +2392,8 @@ export class SkillLibrary {
2168
2392
  return rollbackMessage(result.message, stuck);
2169
2393
  }
2170
2394
  }
2395
+ await plan.onPhase?.('sources-archived');
2396
+ await plan.onPhase?.('unit-written');
2171
2397
  return { ok: true, message: `已把 ${sources.join(', ')} 合并进「${target}」。`, unitId, targetPath: dir, archived, mutations: 1 + sources.length };
2172
2398
  });
2173
2399
  }
@@ -1,14 +1,17 @@
1
1
  import type { EvolutionIoLike } from './io.ts';
2
2
  export interface TreeLockRecord {
3
3
  readonly token: string;
4
+ readonly epoch: number;
4
5
  readonly pid: number;
5
6
  readonly startedAt: number;
6
7
  }
7
8
  export interface TreeLock {
8
9
  readonly token: string;
10
+ readonly epoch: number;
9
11
  readonly path: string;
10
12
  readonly acquiredAt: number;
11
13
  release(): Promise<void>;
14
+ assertHeld(): Promise<boolean>;
12
15
  }
13
16
  export type TreeLockAcquireResult = {
14
17
  readonly ok: true;
@@ -25,7 +25,7 @@ function parseLock(raw) {
25
25
  const value = JSON.parse(raw);
26
26
  if (typeof value.token !== 'string' || typeof value.pid !== 'number' || typeof value.startedAt !== 'number')
27
27
  return undefined;
28
- return { token: value.token, pid: value.pid, startedAt: value.startedAt };
28
+ return { token: value.token, epoch: typeof value.epoch === 'number' ? value.epoch : 0, pid: value.pid, startedAt: value.startedAt };
29
29
  }
30
30
  catch {
31
31
  return undefined;
@@ -40,6 +40,7 @@ export async function readTreeLock(io, home) {
40
40
  async function tryAcquire(io, home, now) {
41
41
  const path = treeLockFile(home);
42
42
  let token;
43
+ let epoch;
43
44
  let holder;
44
45
  await transactIo(io, path, (current) => {
45
46
  const existing = parseLock(current);
@@ -47,11 +48,12 @@ async function tryAcquire(io, home, now) {
47
48
  holder = existing;
48
49
  return current;
49
50
  }
50
- // Absent, corrupt, our own stale row, or a dead owner: take it.
51
+ // Absent, corrupt, our own stale row, or a dead owner: take it and bump the epoch.
51
52
  token = randomUUID();
52
- return JSON.stringify({ token, pid: process.pid, startedAt: now() });
53
+ epoch = (existing?.epoch ?? 0) + 1;
54
+ return JSON.stringify({ token, epoch, pid: process.pid, startedAt: now() });
53
55
  });
54
- return token === undefined ? (holder === undefined ? {} : { holder }) : { token };
56
+ return token === undefined ? (holder === undefined ? {} : { holder }) : { token, epoch: epoch ?? 0 };
55
57
  }
56
58
  /**
57
59
  * Acquire the tree write lock, waiting up to `waitMs`.
@@ -71,19 +73,25 @@ export async function acquireTreeWriteLock(options) {
71
73
  const attempt = await tryAcquire(options.io, options.home, now);
72
74
  if (attempt.token !== undefined) {
73
75
  const token = attempt.token;
76
+ const epoch = attempt.epoch ?? 0;
74
77
  const path = treeLockFile(options.home);
75
78
  let released = false;
76
79
  const lock = {
77
80
  token,
81
+ epoch,
78
82
  path,
79
83
  acquiredAt: now(),
84
+ async assertHeld() {
85
+ const current = parseLock(await options.io.readText(path));
86
+ return current?.token === token && current.epoch === epoch;
87
+ },
80
88
  async release() {
81
89
  if (released)
82
90
  return;
83
91
  released = true;
84
92
  await transactIo(options.io, path, (current) => {
85
93
  const existing = parseLock(current);
86
- return existing?.token === token ? null : current;
94
+ return existing?.token === token && existing.epoch === epoch ? null : current;
87
95
  });
88
96
  },
89
97
  };
@@ -0,0 +1,5 @@
1
+ /** G2 adapter: core UnitBackend over the existing SkillLibrary. */
2
+ import type { SkillLibrary } from './skill-store.ts';
3
+ import type { UnitBackend } from './unit-transaction.ts';
4
+ export declare function skillLibraryUnitBackend(skills: SkillLibrary): UnitBackend;
5
+ //# sourceMappingURL=unit-backend.d.ts.map
@@ -0,0 +1,55 @@
1
+ export function skillLibraryUnitBackend(skills) {
2
+ return {
3
+ async readSkill(name) { return await skills.read(name); },
4
+ async readSupportFile(name, path) {
5
+ const probe = skills.readSupportFile;
6
+ return typeof probe === 'function' ? await probe.call(skills, name, path) : null;
7
+ },
8
+ async commit(plan, onPhase, signal, assertFence) {
9
+ const result = await skills.commitMerge({
10
+ ...(plan.runId === undefined ? {} : { runId: plan.runId }),
11
+ ...(plan.unitId === undefined ? {} : { unitId: plan.unitId }),
12
+ target: plan.target,
13
+ targetExists: plan.targetExists,
14
+ sources: plan.sources,
15
+ body: plan.body,
16
+ supportFiles: plan.supportFiles,
17
+ expectedTargetBody: plan.expectedTargetBody,
18
+ expectedSourceBodies: plan.expectedSourceBodies,
19
+ origin: plan.origin,
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 }),
25
+ ...(onPhase === undefined ? {} : { onPhase }),
26
+ ...(signal === undefined ? {} : { signal }),
27
+ ...(assertFence === undefined ? {} : { assertFence }),
28
+ });
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 }) };
30
+ },
31
+ async restoreTarget(name, body) {
32
+ if (body === null) {
33
+ await skills.archive(name, { reason: 'unit-rollback' });
34
+ return;
35
+ }
36
+ const result = await skills.update(name, body);
37
+ if (!result.ok)
38
+ throw new Error(result.message);
39
+ },
40
+ async restoreSupportFile(name, path, content) {
41
+ const result = content === null ? await skills.removeSupportFile(name, path) : await skills.writeSupportFile(name, path, content);
42
+ if (!result.ok)
43
+ throw new Error(result.message);
44
+ },
45
+ async restoreSource(name, body) {
46
+ const restored = await skills.restoreFromArchive(name);
47
+ if (restored.ok)
48
+ return;
49
+ const result = await skills.update(name, body);
50
+ if (!result.ok)
51
+ throw new Error(result.message);
52
+ },
53
+ };
54
+ }
55
+ //# sourceMappingURL=unit-backend.js.map
@@ -0,0 +1,29 @@
1
+ import type { EvolutionIoLike } from './io.ts';
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", "unit-written", "committed", "failed", "rolled-back"];
4
+ export type UnitJournalPhase = typeof UNIT_JOURNAL_PHASES[number];
5
+ export interface UnitJournalCheckpoint {
6
+ readonly source: string;
7
+ readonly phase: 'archived';
8
+ readonly hash: string;
9
+ readonly at: number;
10
+ }
11
+ export interface UnitJournal {
12
+ readonly schemaVersion: typeof UNIT_JOURNAL_SCHEMA_VERSION;
13
+ readonly unitId: string;
14
+ readonly runId: string;
15
+ readonly planDigest: string;
16
+ readonly target: string;
17
+ readonly sources: readonly string[];
18
+ readonly phase: UnitJournalPhase;
19
+ readonly storageKey: string;
20
+ readonly snapshotSha256: string;
21
+ readonly checkpoints?: readonly UnitJournalCheckpoint[];
22
+ readonly updatedAt: number;
23
+ }
24
+ export declare function unitJournalPath(home: string, unitId: string): string;
25
+ export declare function unitJournalPhaseRank(phase: UnitJournalPhase): number;
26
+ export declare function assertUnitJournalTransition(from: UnitJournalPhase, to: UnitJournalPhase): void;
27
+ export declare function writeUnitJournal(io: EvolutionIoLike, home: string, journal: UnitJournal): Promise<string>;
28
+ export declare function readUnitJournal(io: EvolutionIoLike, home: string, unitId: string): Promise<UnitJournal | null>;
29
+ //# sourceMappingURL=unit-journal.d.ts.map
@@ -0,0 +1,51 @@
1
+ /** G2 unit journal: intent/phase/checkpoint only, never evidence bodies. */
2
+ import { createHash } from 'node:crypto';
3
+ import { join } from 'node:path';
4
+ export const UNIT_JOURNAL_SCHEMA_VERSION = 1;
5
+ export const UNIT_JOURNAL_PHASES = [
6
+ 'prepared',
7
+ 'target-pending',
8
+ 'target-written',
9
+ 'source-archiving',
10
+ 'sources-archived',
11
+ 'support-pending',
12
+ 'unit-written',
13
+ 'committed',
14
+ 'failed',
15
+ 'rolled-back',
16
+ ];
17
+ export function unitJournalPath(home, unitId) {
18
+ return join(home, 'merge', 'unit-journals', createHash('sha256').update(unitId).digest('hex') + '.json');
19
+ }
20
+ export function unitJournalPhaseRank(phase) {
21
+ return UNIT_JOURNAL_PHASES.indexOf(phase);
22
+ }
23
+ export function assertUnitJournalTransition(from, to) {
24
+ if (to === 'failed' || to === 'rolled-back')
25
+ return;
26
+ if (from === 'failed' || from === 'rolled-back' || from === 'committed')
27
+ throw new Error('unit-journal-terminal:' + from);
28
+ if (unitJournalPhaseRank(to) <= unitJournalPhaseRank(from))
29
+ throw new Error('unit-journal-nonmonotonic:' + from + '->' + to);
30
+ }
31
+ export async function writeUnitJournal(io, home, journal) {
32
+ const path = unitJournalPath(home, journal.unitId);
33
+ const content = JSON.stringify(journal, null, 2) + '\n';
34
+ if (io.transact !== undefined)
35
+ await io.transact(path, () => content);
36
+ else
37
+ await io.writeText(path, content);
38
+ return path;
39
+ }
40
+ export async function readUnitJournal(io, home, unitId) {
41
+ const raw = await io.readText(unitJournalPath(home, unitId));
42
+ if (raw === null)
43
+ return null;
44
+ try {
45
+ return JSON.parse(raw);
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ //# sourceMappingURL=unit-journal.js.map
@@ -0,0 +1,5 @@
1
+ /** G2 recovery arbitration: unit journal/evidence wins over a legacy commit journal. */
2
+ import type { EvolutionIoLike } from './io.ts';
3
+ export type UnitAuthority = 'unit' | 'legacy' | 'none';
4
+ export declare function resolveUnitAuthority(io: EvolutionIoLike, home: string, unitId: string, legacyExists: boolean): Promise<UnitAuthority>;
5
+ //# sourceMappingURL=unit-recovery.d.ts.map
@@ -0,0 +1,12 @@
1
+ import { readUnitEvidence, readUnitSnapshot } from "./unit-storage.js";
2
+ import { readUnitJournal } from "./unit-journal.js";
3
+ export async function resolveUnitAuthority(io, home, unitId, legacyExists) {
4
+ if (await readUnitEvidence(io, home, unitId) !== null)
5
+ return 'unit';
6
+ if (await readUnitJournal(io, home, unitId) !== null)
7
+ return 'unit';
8
+ if (await readUnitSnapshot(io, home, unitId) !== null)
9
+ return 'unit';
10
+ return legacyExists ? 'legacy' : 'none';
11
+ }
12
+ //# sourceMappingURL=unit-recovery.js.map