@lmzhen/dsh-evolution-core 0.30.17 → 0.30.18

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.
@@ -1,4 +1,4 @@
1
- import { h as restrictedPresetId, n as ensureRestrictedPreset, r as restrictedPresetState } from "../ensure-MEYMj9jn.js";
1
+ import { h as restrictedPresetId, n as ensureRestrictedPreset, r as restrictedPresetState } from "../ensure-BuWsjPGD.js";
2
2
  //#region lib/types/subagent/readiness.js
3
3
  function serviceOf$1(ctx, name) {
4
4
  return ctx.get(name);
@@ -372,7 +372,7 @@ export function presetRowBlock(patchText, rowId) {
372
372
  const span = rowBlockSpan(lines, rowId);
373
373
  if (span === null)
374
374
  return null;
375
- const block = [...lines.slice(span.start, span.end)];
375
+ const block = lines.slice(span.start, span.end);
376
376
  while (block.length > 0 && (block[block.length - 1] ?? '').trim() === '')
377
377
  block.pop();
378
378
  return block.join('\n') + '\n';
@@ -385,7 +385,7 @@ export function yamlPlainScalarNeedsQuotes(value) {
385
385
  return true;
386
386
  if (/^(?:[-+]?\.inf|\.nan)$/i.test(value))
387
387
  return true;
388
- if (/^[-?:,[\]{}#&*!|>'\"%@`\s]/.test(value))
388
+ if (/^[-?:,[\]{}#&*!|>'"%@`\s]/.test(value))
389
389
  return true;
390
390
  return false;
391
391
  }
@@ -56,6 +56,8 @@ export declare const HOST_ROUTES: {
56
56
  readonly merge: {
57
57
  readonly view: "/api/dsh-evolution/merge/view";
58
58
  readonly rows: "/api/dsh-evolution/merge/rows";
59
+ readonly run: "/api/dsh-evolution/merge/run";
60
+ readonly runs: "/api/dsh-evolution/merge/runs";
59
61
  readonly body: "/api/dsh-evolution/merge/body";
60
62
  readonly history: "/api/dsh-evolution/merge/history";
61
63
  };
@@ -53,6 +53,8 @@ export const HOST_ROUTES = {
53
53
  merge: {
54
54
  view: '/api/dsh-evolution/merge/view',
55
55
  rows: '/api/dsh-evolution/merge/rows',
56
+ run: '/api/dsh-evolution/merge/run',
57
+ runs: '/api/dsh-evolution/merge/runs',
56
58
  body: '/api/dsh-evolution/merge/body',
57
59
  history: '/api/dsh-evolution/merge/history',
58
60
  },
@@ -33,6 +33,10 @@
33
33
  */
34
34
  export * from './citations.ts';
35
35
  export * from './tree-write.ts';
36
+ export * from './unit-storage.ts';
37
+ export * from './unit-journal.ts';
38
+ export * from './unit-transaction.ts';
39
+ export * from './unit-backend.ts';
36
40
  export * from './package-identity.ts';
37
41
  export * from './write-authority.ts';
38
42
  export * from './write-gates.ts';
@@ -34,6 +34,10 @@
34
34
  export * from "./citations.js";
35
35
  // G1/1.1: the ONE authority behind a write (its type and the authorityFrom builder).
36
36
  export * from "./tree-write.js";
37
+ export * from "./unit-storage.js";
38
+ export * from "./unit-journal.js";
39
+ export * from "./unit-transaction.js";
40
+ export * from "./unit-backend.js";
37
41
  export * from "./package-identity.js";
38
42
  export * from "./write-authority.js";
39
43
  export * from "./write-gates.js";
@@ -208,7 +208,6 @@ export const PARAM_EXPOSURE = Object.freeze([
208
208
  { id: 'mergeSkippedTtlDays', group: 'merge', tier: 'E3', authority: 'cordis', owner: 'evolution-merge', applies: 'live', docAnchor: 'PARAMETERS.md#merge', summary: 'Days a skipped pair stays out of screening; 0 means it never expires.', label: '无需合并保留天数', hint: '判定无需合并的对,多少天内不再参与筛选;0 表示一直保留。', control: 'number', unit: '天', values: '', card: 'evolution-merge' },
209
209
  { id: 'mergeRunTimeoutMinutes', group: 'merge', tier: 'E3', authority: 'cordis', owner: 'evolution-merge', applies: 'live', docAnchor: 'PARAMETERS.md#merge', summary: 'Wall-clock budget of one merge run; 0 means no limit.', label: '单次运行上限', hint: '一次合并运行最多持续多少分钟;0 表示不设上限。', control: 'number', unit: '分钟', values: '', card: 'evolution-merge' },
210
210
  { id: 'mergePairTimeoutMinutes', group: 'merge', tier: 'E2', authority: 'cordis', owner: 'evolution-merge', applies: 'none', docAnchor: 'PARAMETERS.md#merge', summary: 'Wall-clock budget of one pair round (a stuck child guard).', why: 'internal' },
211
- { id: 'mergeModelsRoot', group: 'merge', tier: 'E2', authority: 'cordis', owner: 'evolution-merge', applies: 'none', docAnchor: 'PARAMETERS.md#merge', summary: 'Local model root containing the int8 and pca64 model directories; empty falls back to SKILL_MERGE_MODELS_ROOT. No auto provisioning.', why: 'path' },
212
211
  ]);
213
212
  /** The settings namespace each OWNER PACKAGE registers (design §7.2).
214
213
  *
@@ -20,7 +20,7 @@ export interface PlatformServiceProbe {
20
20
  /** Where the probe runs. */
21
21
  readonly side: 'host' | 'client';
22
22
  }
23
- /** The 26 probes, in service-name order (the guard checks this list against the tree). */
23
+ /** The 25 probes, in service-name order (the guard checks this list against the tree). */
24
24
  export declare const PLATFORM_SERVICE_PROBES: readonly PlatformServiceProbe[];
25
25
  /**
26
26
  * The HOST-side probes that do not resolve in this runtime — what `/evolution doctor` prints as
@@ -11,7 +11,7 @@
11
11
  * `client` probes run in the browser half, where the host has no view of the seats.
12
12
  * @module @lmzhen/dsh-evolution-core
13
13
  */
14
- /** The 26 probes, in service-name order (the guard checks this list against the tree). */
14
+ /** The 25 probes, in service-name order (the guard checks this list against the tree). */
15
15
  export const PLATFORM_SERVICE_PROBES = Object.freeze([
16
16
  { service: 'agents', feature: 'review and curator subagent wake-ups', side: 'host' },
17
17
  { service: 'approval', feature: 'the platform approval policy the approval row shadows', side: 'host' },
@@ -20,9 +20,9 @@ export const PLATFORM_SERVICE_PROBES = Object.freeze([
20
20
  { service: 'evolutionApproval', feature: 'pending writes: list, approve and reject from the command surface', side: 'host' },
21
21
  { service: 'evolutionCurator', feature: '/evolution curator subcommands, skill undo and history', side: 'host' },
22
22
  { service: 'evolutionIo', feature: 'every durable read and write (the IO seam)', side: 'host' },
23
+ { service: 'evolutionMerge', feature: 'merge rows and run state', side: 'host' },
23
24
  { service: 'hmr', feature: 'the preset install read-merge-write commit (the platform HMR lock serializes it)', side: 'host' },
24
25
  { service: 'evolutionMemoryBudget', feature: 'the memory-budget divergence section of this report', side: 'host' },
25
- { service: 'evolutionMerge', feature: 'the merge command and its read-only panel routes', side: 'host' },
26
26
  { service: 'evolutionPolicy', feature: 'the policy snapshot that shadows row knobs', side: 'host' },
27
27
  { service: 'evolutionReplay', feature: '/evolution replay', side: 'host' },
28
28
  { service: 'evolutionState', feature: 'the state facade behind activity and replay', side: 'host' },
@@ -30,7 +30,7 @@ const SECRET_PATTERNS = [
30
30
  ['google api key', /AIza[0-9A-Za-z_-]{30,}/g],
31
31
  // F-335 (0.3.23): case-insensitive (`bearer`), and `\s+` so a tab or run of
32
32
  // spaces between `Bearer` and the token is still masked (`Bearer\t...`).
33
- ['bearer credential', /Bearer[\s]+[a-z0-9._~+/=\-]{16,}/gi],
33
+ ['bearer credential', /Bearer[\s]+[a-z0-9._~+/=-]{16,}/gi],
34
34
  // R3 (round-2 audit): HTTP Basic — `Authorization: Basic <base64>` crossed
35
35
  // every model boundary: `Basic` was in no value-shape row and
36
36
  // `authorization` in no keyword set, so the base64 user:password blob
@@ -117,7 +117,7 @@ const PEM_PRIVATE_KEY_PATTERN = new RegExp(`${PEM_HEAD}[\\s\\S]*?${PEM_TAIL}`, '
117
117
  // secret crossed verbatim. Both anchors now tolerate an optional `\r` so the
118
118
  // line-paired pass behaves identically on LF and CRLF input (Windows-authored
119
119
  // .env / compose / kubectl YAML is the realistic carrier).
120
- const BLOCK_KEY_ONLY_LINE = /(?:^|\s)([\w-]*[_\-])?((?:token|api[_-]?key|secret|password|passwd)(?:[_\-][\w-]*)?)\s*:(?:\r)?$/i;
120
+ const BLOCK_KEY_ONLY_LINE = /(?:^|\s)([\w-]*[_-])?((?:token|api[_-]?key|secret|password|passwd)(?:[_-][\w-]*)?)\s*:(?:\r)?$/i;
121
121
  // S0.3 (v37 P0-2): the two patterns above cannot express a camelCase hump —
122
122
  // they are case-INSENSITIVE, so [A-Z] would match lowercase too. This pair
123
123
  // decides by a case-insensitive KEYWORD test that rejects a lowercase
@@ -114,6 +114,12 @@ export interface RunRegistry {
114
114
  ok: boolean;
115
115
  note?: string;
116
116
  }>;
117
+ /** Load and throw on unreadable/invalid index. */
118
+ ensureLoaded(): Promise<void>;
119
+ /** The active run after ensureLoaded; explicit read entry. */
120
+ findActive(kind?: RunKind): Promise<RunRecord | undefined>;
121
+ /** Whether this run id is currently owned by a live running process (RG0b). */
122
+ hasOwner(id: string): Promise<boolean>;
117
123
  /**
118
124
  * Register a new run, arm its cancellation, and return its handle.
119
125
  *
@@ -146,6 +152,8 @@ export interface RunRegistry {
146
152
  runs(kind?: RunKind): readonly RunRecord[];
147
153
  /** One run by id, converged view included. */
148
154
  find(id: string): RunRecord | undefined;
155
+ /** Settle a running run whose owner is gone as failed(orphan). @returns whether a run was running. */
156
+ settleOrphan(id: string): Promise<boolean>;
149
157
  /** Ask a running run to stop; it settles as `cancelled`. @returns whether it was running. */
150
158
  cancel(id: string): boolean;
151
159
  /** Ask every running run to stop (fiber dispose). @returns how many were running. */
@@ -291,6 +291,26 @@ export function newRunRegistry(options) {
291
291
  ].filter((note) => note !== undefined);
292
292
  return { ok: true, ...(notes.length > 0 ? { note: notes.join('; ') } : {}) };
293
293
  },
294
+ async ensureLoaded() {
295
+ const loaded = await this.load();
296
+ if (!loaded.ok)
297
+ throw new Error(loaded.note ?? 'runs-unreadable');
298
+ },
299
+ async findActive(kind) {
300
+ await this.ensureLoaded();
301
+ return this.inFlight(kind);
302
+ },
303
+ async hasOwner(id) {
304
+ await this.ensureLoaded();
305
+ return this.find(id)?.state === 'running';
306
+ },
307
+ async settleOrphan(id) {
308
+ const run = this.find(id);
309
+ if (run?.state !== 'running')
310
+ return false;
311
+ await this.settle(id, { state: 'failed', failure: 'orphan', mergeStatus: 'orphan' });
312
+ return true;
313
+ },
294
314
  async begin(kind, seed) {
295
315
  const id = randomUUID();
296
316
  const startedAt = now();
@@ -198,6 +198,14 @@ export interface CommitMergePlan {
198
198
  }[];
199
199
  readonly origin: WriteOrigin;
200
200
  readonly summary: string;
201
+ /** Real write-phase callback used by UnitTransaction; not persisted. */
202
+ readonly onPhase?: (phase: 'target-pending' | 'target-applied' | 'target-written' | 'source-archiving' | 'sources-archived' | 'support-pending' | 'unit-written', detail?: {
203
+ source?: string;
204
+ }) => void | Promise<void>;
205
+ /** Phase-boundary cancellation; checked before target/source writes. */
206
+ readonly signal?: AbortSignal;
207
+ /** Tree-fence guard; checked before every tree/source write. */
208
+ readonly assertFence?: () => Promise<boolean>;
201
209
  }
202
210
  export interface CommitMergeResult {
203
211
  readonly ok: boolean;
@@ -444,6 +452,8 @@ export declare class SkillLibrary {
444
452
  deleteProtection(rawName: string, options?: {
445
453
  allowBundled?: boolean | undefined;
446
454
  }): Promise<string | null>;
455
+ /** Whether the target directory exists at all (a SKILL.md-less ghost still occupies the name). */
456
+ skillDirExists(rawName: string): Promise<boolean>;
447
457
  isManaged(rawName: string): Promise<boolean>;
448
458
  /** Whether the skill carries the bundled marker (curator prune-builtins eligibility). */
449
459
  isBundled(rawName: string): Promise<boolean>;
@@ -51,7 +51,7 @@ import { contentHash, loadMutations, recordMutation } from "./mutations.js";
51
51
  import { contentRetentionFeedback, HISTORY_DIR, loadVersionContent, loadVersions, recordVersions } from "./skill-history.js";
52
52
  import { suppressedFile, usageFile } from "./usage.js";
53
53
  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";
54
+ import { CONTENT_SPLIT_HINT, DEFAULT_SKILL_VERSION_KEEP, PROTECTED_BUILTIN_SKILLS, SKILL_NAME_RE, SUPPORT_DIRS } from "./constants.js";
55
55
  /**
56
56
  * The CLASS boundary default authority (G1/1.1, Stage 2).
57
57
  *
@@ -949,6 +949,13 @@ export class SkillLibrary {
949
949
  }
950
950
  return null;
951
951
  }
952
+ /** Whether the target directory exists at all (a SKILL.md-less ghost still occupies the name). */
953
+ async skillDirExists(rawName) {
954
+ const name = rawName.trim();
955
+ if (this.badName(name) !== null)
956
+ return false;
957
+ return await this.io.exists(this.dirOf(name));
958
+ }
952
959
  async isManaged(rawName) {
953
960
  // One trim per entry: paths (dirOf), validation and messages all see the same name.
954
961
  const name = rawName.trim();
@@ -1477,7 +1484,7 @@ export class SkillLibrary {
1477
1484
  // marker never landed" inside the fsync/durability wrapper, producing a
1478
1485
  // self-contradicting operator-facing message.
1479
1486
  let markerWarning = '';
1480
- if (origin !== 'foreground') {
1487
+ if (origin !== 'foreground' || options.managed === true) {
1481
1488
  // v29 SK-05: the marker write gets the same committed-only tolerance as
1482
1489
  // every other write in this file (A1-15) — a post-rename dir-fsync
1483
1490
  // failure means the MARKER landed; rejecting the create as a plain
@@ -1942,9 +1949,17 @@ export class SkillLibrary {
1942
1949
  if (bad)
1943
1950
  return { ok: false, message: bad };
1944
1951
  }
1952
+ if (PROTECTED_BUILTIN_SKILLS.has(target))
1953
+ return { ok: false, message: `目标技能「${target}」是受保护的内置技能。` };
1954
+ const protectedSource = sources.find(name => PROTECTED_BUILTIN_SKILLS.has(name));
1955
+ if (protectedSource !== undefined)
1956
+ return { ok: false, message: `来源技能「${protectedSource}」是受保护的内置技能。` };
1945
1957
  return await this.serial(async () => {
1946
1958
  const dir = this.dirOf(target);
1947
1959
  const targetPath = join(dir, 'SKILL.md');
1960
+ if (!plan.targetExists && await this.io.exists(dir)) {
1961
+ return { ok: false, message: `目标目录「${target}」已存在但不是技能;拒绝把 SKILL.md 写进同名目录。` };
1962
+ }
1948
1963
  const currentTarget = await this.io.readText(targetPath);
1949
1964
  if (plan.targetExists !== (currentTarget !== null)) {
1950
1965
  return { ok: false, message: plan.targetExists ? `目标技能「${target}」不存在。` : `目标技能「${target}」已存在。` };
@@ -1952,6 +1967,12 @@ export class SkillLibrary {
1952
1967
  if (currentTarget !== plan.expectedTargetBody) {
1953
1968
  return { ok: false, message: `目标技能「${target}」在计划之后被改过;拒绝提交(未写入任何内容)。` };
1954
1969
  }
1970
+ if (plan.targetExists && !(await this.isManaged(target))) {
1971
+ return { ok: false, message: `目标技能「${target}」不是 family-managed;拒绝提交。` };
1972
+ }
1973
+ if (await this.io.exists(markerPath(dir, 'pinned'))) {
1974
+ return { ok: false, message: `目标技能「${target}」已被固定;拒绝提交。` };
1975
+ }
1955
1976
  const targetProtection = await this.writeProtection(target, plan.origin);
1956
1977
  if (targetProtection)
1957
1978
  return { ok: false, message: `技能「${target}」受保护(${targetProtection})。` };
@@ -2081,6 +2102,9 @@ export class SkillLibrary {
2081
2102
  expected: supportPrevious.get(file.path) ?? null,
2082
2103
  })),
2083
2104
  ];
2105
+ await plan.onPhase?.('target-pending');
2106
+ if (plan.assertFence !== undefined && !(await plan.assertFence()))
2107
+ return { ok: false, message: 'tree fence lost before target write' };
2084
2108
  const result = await this.applyTreeChange({
2085
2109
  name: target,
2086
2110
  origin: plan.origin,
@@ -2094,13 +2118,28 @@ export class SkillLibrary {
2094
2118
  });
2095
2119
  if (!result.ok)
2096
2120
  return rollbackMessage(result.message, []);
2121
+ await plan.onPhase?.('target-applied');
2122
+ await plan.onPhase?.('target-written');
2123
+ if (plan.signal?.aborted) {
2124
+ const stuck = await rollbackTarget();
2125
+ return rollbackMessage('cancelled before sources archived', stuck);
2126
+ }
2097
2127
  for (const source of sources) {
2128
+ if (plan.signal?.aborted) {
2129
+ const stuck = await rollbackSources();
2130
+ stuck.push(...await rollbackTarget());
2131
+ return rollbackMessage('cancelled before next source archive', stuck);
2132
+ }
2098
2133
  const fresh = await this.io.readText(join(this.dirOf(source), 'SKILL.md'));
2099
2134
  if (fresh !== sourceBodies.get(source)) {
2100
2135
  const stuck = await rollbackSources();
2101
2136
  stuck.push(...await rollbackTarget());
2102
2137
  return rollbackMessage(`来源技能「${source}」在归档前被改过`, stuck);
2103
2138
  }
2139
+ if (plan.assertFence !== undefined && !(await plan.assertFence())) {
2140
+ return { ok: false, message: 'tree fence lost before source archive; recovery required' };
2141
+ }
2142
+ await plan.onPhase?.('source-archiving', { source });
2104
2143
  const archivedResult = await this.archive(source, {
2105
2144
  absorbedInto: target,
2106
2145
  unitId,
@@ -2114,12 +2153,30 @@ export class SkillLibrary {
2114
2153
  }
2115
2154
  archived.push(source);
2116
2155
  }
2156
+ await plan.onPhase?.('sources-archived');
2157
+ await plan.onPhase?.('unit-written');
2117
2158
  return { ok: true, message: `已把 ${sources.join(', ')} 合并进「${target}」。`, unitId, targetPath: dir, archived, mutations: 1 + sources.length };
2118
2159
  }
2119
- const created = await this.createCore(target, plan.body, plan.origin, { unitId, unitHead });
2160
+ await plan.onPhase?.('target-pending');
2161
+ if (plan.assertFence !== undefined && !(await plan.assertFence()))
2162
+ return { ok: false, message: 'tree fence lost before target write' };
2163
+ const created = await this.createCore(target, plan.body, plan.origin, { unitId, unitHead, managed: true });
2120
2164
  if (!created.ok)
2121
2165
  return { ok: false, message: created.message };
2166
+ await plan.onPhase?.('target-applied');
2167
+ await plan.onPhase?.('target-written');
2168
+ if (plan.signal?.aborted) {
2169
+ const targetRollback = await this.archive(target, { reason: 'merge cancelled' }, plan.origin);
2170
+ return rollbackMessage('cancelled before sources archived', targetRollback.ok ? [] : [target]);
2171
+ }
2122
2172
  for (const source of sources) {
2173
+ if (plan.signal?.aborted) {
2174
+ const stuck = await rollbackSources();
2175
+ const targetRollback = await this.archive(target, { reason: 'merge cancelled' }, plan.origin);
2176
+ if (!targetRollback.ok)
2177
+ stuck.push(target);
2178
+ return rollbackMessage('cancelled before next source archive', stuck);
2179
+ }
2123
2180
  const fresh = await this.io.readText(join(this.dirOf(source), 'SKILL.md'));
2124
2181
  if (fresh !== sourceBodies.get(source)) {
2125
2182
  const stuck = await rollbackSources();
@@ -2128,6 +2185,10 @@ export class SkillLibrary {
2128
2185
  stuck.push(target);
2129
2186
  return rollbackMessage(`来源技能「${source}」在归档前被改过`, stuck);
2130
2187
  }
2188
+ if (plan.assertFence !== undefined && !(await plan.assertFence())) {
2189
+ return { ok: false, message: 'tree fence lost before source archive; recovery required' };
2190
+ }
2191
+ await plan.onPhase?.('source-archiving', { source });
2131
2192
  const result = await this.archive(source, {
2132
2193
  absorbedInto: target,
2133
2194
  unitId,
@@ -2144,6 +2205,10 @@ export class SkillLibrary {
2144
2205
  archived.push(source);
2145
2206
  }
2146
2207
  if (plan.supportFiles.length > 0) {
2208
+ await plan.onPhase?.('support-pending');
2209
+ if (plan.assertFence !== undefined && !(await plan.assertFence())) {
2210
+ return { ok: false, message: 'tree fence lost before support write; recovery required' };
2211
+ }
2147
2212
  const writes = plan.supportFiles.map(file => ({
2148
2213
  target: join(dir, ...file.path.replace(/\\/g, '/').split('/').filter(Boolean)),
2149
2214
  content: file.content,
@@ -2168,6 +2233,8 @@ export class SkillLibrary {
2168
2233
  return rollbackMessage(result.message, stuck);
2169
2234
  }
2170
2235
  }
2236
+ await plan.onPhase?.('sources-archived');
2237
+ await plan.onPhase?.('unit-written');
2171
2238
  return { ok: true, message: `已把 ${sources.join(', ')} 合并进「${target}」。`, unitId, targetPath: dir, archived, mutations: 1 + sources.length };
2172
2239
  });
2173
2240
  }
@@ -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,49 @@
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
+ target: plan.target,
11
+ targetExists: plan.targetExists,
12
+ sources: plan.sources,
13
+ body: plan.body,
14
+ supportFiles: plan.supportFiles,
15
+ expectedTargetBody: plan.expectedTargetBody,
16
+ expectedSourceBodies: plan.expectedSourceBodies,
17
+ origin: plan.origin,
18
+ summary: plan.summary,
19
+ ...(onPhase === undefined ? {} : { onPhase }),
20
+ ...(signal === undefined ? {} : { signal }),
21
+ ...(assertFence === undefined ? {} : { assertFence }),
22
+ });
23
+ return { ok: result.ok, message: result.message, ...(result.archived === undefined ? {} : { archived: result.archived }), ...(result.mutations === undefined ? {} : { mutations: result.mutations }) };
24
+ },
25
+ async restoreTarget(name, body) {
26
+ if (body === null) {
27
+ await skills.archive(name, { reason: 'unit-rollback' });
28
+ return;
29
+ }
30
+ const result = await skills.update(name, body);
31
+ if (!result.ok)
32
+ throw new Error(result.message);
33
+ },
34
+ async restoreSupportFile(name, path, content) {
35
+ const result = content === null ? await skills.removeSupportFile(name, path) : await skills.writeSupportFile(name, path, content);
36
+ if (!result.ok)
37
+ throw new Error(result.message);
38
+ },
39
+ async restoreSource(name, body) {
40
+ const restored = await skills.restoreFromArchive(name);
41
+ if (restored.ok)
42
+ return;
43
+ const result = await skills.update(name, body);
44
+ if (!result.ok)
45
+ throw new Error(result.message);
46
+ },
47
+ };
48
+ }
49
+ //# 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", "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,50 @@
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
+ 'committed',
13
+ 'failed',
14
+ 'rolled-back',
15
+ ];
16
+ export function unitJournalPath(home, unitId) {
17
+ return join(home, 'merge', 'unit-journals', createHash('sha256').update(unitId).digest('hex') + '.json');
18
+ }
19
+ export function unitJournalPhaseRank(phase) {
20
+ return UNIT_JOURNAL_PHASES.indexOf(phase);
21
+ }
22
+ export function assertUnitJournalTransition(from, to) {
23
+ if (to === 'failed' || to === 'rolled-back')
24
+ return;
25
+ if (from === 'failed' || from === 'rolled-back' || from === 'committed')
26
+ throw new Error('unit-journal-terminal:' + from);
27
+ if (unitJournalPhaseRank(to) <= unitJournalPhaseRank(from))
28
+ throw new Error('unit-journal-nonmonotonic:' + from + '->' + to);
29
+ }
30
+ export async function writeUnitJournal(io, home, journal) {
31
+ const path = unitJournalPath(home, journal.unitId);
32
+ const content = JSON.stringify(journal, null, 2) + '\n';
33
+ if (io.transact !== undefined)
34
+ await io.transact(path, () => content);
35
+ else
36
+ await io.writeText(path, content);
37
+ return path;
38
+ }
39
+ export async function readUnitJournal(io, home, unitId) {
40
+ const raw = await io.readText(unitJournalPath(home, unitId));
41
+ if (raw === null)
42
+ return null;
43
+ try {
44
+ return JSON.parse(raw);
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ //# 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