@lmzhen/dsh-evolution-core 0.28.0 → 0.30.0

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.
@@ -77,8 +77,8 @@ const HOST_ROUTES = {
77
77
  merge: {
78
78
  view: "/api/dsh-evolution/merge/view",
79
79
  rows: "/api/dsh-evolution/merge/rows",
80
- run: "/api/dsh-evolution/merge/run",
81
- runs: "/api/dsh-evolution/merge/runs"
80
+ body: "/api/dsh-evolution/merge/body",
81
+ history: "/api/dsh-evolution/merge/history"
82
82
  },
83
83
  diagnostics: { view: "/api/dsh-evolution/diagnostics" }
84
84
  };
@@ -1,2 +1,2 @@
1
- import { a as methodNotAllowed, i as json, n as MAX_REQUEST_BODY_BYTES, o as readJsonObject, r as hostSurface, s as registerHostRoutes, t as HOST_ROUTES } from "./http-routes-w09FdvOp.js";
1
+ import { a as methodNotAllowed, i as json, n as MAX_REQUEST_BODY_BYTES, o as readJsonObject, r as hostSurface, s as registerHostRoutes, t as HOST_ROUTES } from "./http-routes-ifJKGnu6.js";
2
2
  export { HOST_ROUTES, MAX_REQUEST_BODY_BYTES, hostSurface, json, methodNotAllowed, readJsonObject, registerHostRoutes };
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { _ as scopedPackageName, c as MAINTAINER_PRESET_ID, d as RESTRICTED_TOOLS_ROW_ID, f as RESTRICTOR_MODULE, g as scopeOfNearestManifest, p as REVIEWER_PRESET_ID, s as EVOLUTION_PRESET_IDS, u as RESTRICTED_PRESET_DISPLAY } from "./ensure-MEYMj9jn.js";
2
- import { a as methodNotAllowed, c as asRecord, i as json, l as isRecord, n as MAX_REQUEST_BODY_BYTES, o as readJsonObject, r as hostSurface, s as registerHostRoutes, t as HOST_ROUTES } from "./http-routes-w09FdvOp.js";
2
+ import { a as methodNotAllowed, c as asRecord, i as json, l as isRecord, n as MAX_REQUEST_BODY_BYTES, o as readJsonObject, r as hostSurface, s as registerHostRoutes, t as HOST_ROUTES } from "./http-routes-ifJKGnu6.js";
3
3
  import { w as allowRowCollisions } from "./composition-D8Y3-ROd.js";
4
4
  import { n as errorText, t as EVOLUTION_ERRORS } from "./errors-Br2-uUSK.js";
5
5
  import { createSubagentChannel, probeChannelState, subagentChannelState } from "./subagent/index.js";
@@ -5608,7 +5608,11 @@ function parseIndex(raw) {
5608
5608
  ...typeof record.resultRef === "string" ? { resultRef: record.resultRef } : {},
5609
5609
  ...typeof record.failure === "string" ? { failure: record.failure } : {},
5610
5610
  ...typeof record.supersededAt === "number" ? { supersededAt: record.supersededAt } : {},
5611
- ...typeof record.parentRunId === "string" ? { parentRunId: record.parentRunId } : {}
5611
+ ...typeof record.parentRunId === "string" ? { parentRunId: record.parentRunId } : {},
5612
+ ...record.mergeStatus === "running" || record.mergeStatus === "succeeded" || record.mergeStatus === "failed" || record.mergeStatus === "cancelled" || record.mergeStatus === "awaiting-approval" || record.mergeStatus === "undone" || record.mergeStatus === "superseded" || record.mergeStatus === "interrupted" || record.mergeStatus === "orphan" ? { mergeStatus: record.mergeStatus } : {},
5613
+ ...typeof record.stagingRef === "string" ? { stagingRef: record.stagingRef } : {},
5614
+ ...typeof record.pairKey === "string" ? { pairKey: record.pairKey } : {},
5615
+ ...Array.isArray(record.pairs) ? { pairs: record.pairs } : {}
5612
5616
  });
5613
5617
  }
5614
5618
  return {
@@ -5642,6 +5646,7 @@ function newRunRegistry(options) {
5642
5646
  const orphanOf = (row) => ({
5643
5647
  ...row,
5644
5648
  state: "failed",
5649
+ mergeStatus: "orphan",
5645
5650
  endedAt: now(),
5646
5651
  failure: ORPHAN_FAILURE
5647
5652
  });
@@ -5780,6 +5785,10 @@ function newRunRegistry(options) {
5780
5785
  pid: process.pid,
5781
5786
  ...seed?.command !== void 0 ? { command: seed.command } : {},
5782
5787
  ...seed?.parentRunId !== void 0 ? { parentRunId: seed.parentRunId } : {},
5788
+ ...seed?.mergeStatus !== void 0 ? { mergeStatus: seed.mergeStatus } : {},
5789
+ ...seed?.stagingRef !== void 0 ? { stagingRef: seed.stagingRef } : {},
5790
+ ...seed?.pairKey !== void 0 ? { pairKey: seed.pairKey } : {},
5791
+ ...seed?.pairs !== void 0 ? { pairs: seed.pairs } : {},
5783
5792
  ...seed?.total !== void 0 ? { progress: {
5784
5793
  done: 0,
5785
5794
  total: seed.total
@@ -5811,6 +5820,7 @@ function newRunRegistry(options) {
5811
5820
  const row = owned.get(id);
5812
5821
  if (row === void 0 || row.supersededAt !== void 0) continue;
5813
5822
  row.supersededAt = now();
5823
+ if (row.kind === "merge") row.mergeStatus = "superseded";
5814
5824
  changed = true;
5815
5825
  }
5816
5826
  if (changed) await persist();
@@ -5825,6 +5835,7 @@ function newRunRegistry(options) {
5825
5835
  for (const row of merged.values()) {
5826
5836
  if (row.kind !== kind || row.supersededAt !== void 0) continue;
5827
5837
  row.supersededAt = now();
5838
+ if (row.kind === "merge") row.mergeStatus = "superseded";
5828
5839
  marked += 1;
5829
5840
  }
5830
5841
  const kept = capped([...merged.values()]);
@@ -5848,6 +5859,7 @@ function newRunRegistry(options) {
5848
5859
  row.endedAt = now();
5849
5860
  if (outcome.resultRef !== void 0) row.resultRef = outcome.resultRef;
5850
5861
  if (outcome.failure !== void 0) row.failure = outcome.failure;
5862
+ if (outcome.mergeStatus !== void 0) row.mergeStatus = outcome.mergeStatus;
5851
5863
  controllers.delete(id);
5852
5864
  await persist();
5853
5865
  },
@@ -7057,7 +7069,7 @@ function renderLongRunTable() {
7057
7069
  * `client` probes run in the browser half, where the host has no view of the seats.
7058
7070
  * @module @lmzhen/dsh-evolution-core
7059
7071
  */
7060
- /** The 25 probes, in service-name order (the guard checks this list against the tree). */
7072
+ /** The 26 probes, in service-name order (the guard checks this list against the tree). */
7061
7073
  const PLATFORM_SERVICE_PROBES = Object.freeze([
7062
7074
  {
7063
7075
  service: "agents",
@@ -7104,6 +7116,11 @@ const PLATFORM_SERVICE_PROBES = Object.freeze([
7104
7116
  feature: "the memory-budget divergence section of this report",
7105
7117
  side: "host"
7106
7118
  },
7119
+ {
7120
+ service: "evolutionMerge",
7121
+ feature: "the merge command and its read-only panel routes",
7122
+ side: "host"
7123
+ },
7107
7124
  {
7108
7125
  service: "evolutionPolicy",
7109
7126
  feature: "the policy snapshot that shadows row knobs",
@@ -10960,24 +10977,41 @@ var SkillLibrary = class {
10960
10977
  ok: false,
10961
10978
  message: stuck.length === 0 ? `合并失败,已回滚:${reason}` : `合并失败(${reason});已回滚,但以下未回滚:${stuck.join(", ")}:它们仍在 .archive 里,请用 /evolution skill restore 恢复。`
10962
10979
  });
10963
- if (plan.targetExists) {
10964
- for (const source of sources) {
10965
- if (await this.io.readText(join(this.dirOf(source), "SKILL.md")) !== sourceBodies.get(source)) {
10966
- const stuck = await rollbackSources();
10967
- return rollbackMessage(`来源技能「${source}」在归档前被改过`, stuck);
10968
- }
10969
- const result = await this.archive(source, {
10970
- absorbedInto: target,
10980
+ const rollbackTarget = async () => {
10981
+ const stuck = [];
10982
+ const support = plan.supportFiles.map((file) => ({
10983
+ target: join(dir, ...file.path.replace(/\\/g, "/").split("/").filter(Boolean)),
10984
+ content: supportPrevious.get(file.path) ?? null,
10985
+ previous: file.content
10986
+ }));
10987
+ const writes = [{
10988
+ target: targetPath,
10989
+ content: plan.expectedTargetBody ?? "",
10990
+ expected: plan.body.trimEnd() + "\n"
10991
+ }, ...support.map((file) => ({
10992
+ target: file.target,
10993
+ content: file.content ?? "",
10994
+ expected: file.previous
10995
+ }))];
10996
+ try {
10997
+ if (!(await this.applyTreeChange({
10998
+ name: target,
10999
+ origin: plan.origin,
11000
+ protection: "write",
11001
+ writes,
11002
+ auditAction: "merge-rollback",
11003
+ auditSummary: "merge rollback: restore target snapshot",
11004
+ eventAction: "merge-rollback",
10971
11005
  unitId,
10972
- unitHead: { skill: target },
10973
- reason: `Merged into ${target}`
10974
- }, plan.origin);
10975
- if (!result.ok) {
10976
- const stuck = await rollbackSources();
10977
- return rollbackMessage(result.message, stuck);
10978
- }
10979
- archived.push(source);
11006
+ unitHead
11007
+ })).ok) stuck.push(target);
11008
+ } catch {
11009
+ stuck.push(target);
10980
11010
  }
11011
+ return stuck;
11012
+ };
11013
+ if (plan.targetExists) {
11014
+ for (const source of sources) if (await this.io.readText(join(this.dirOf(source), "SKILL.md")) !== sourceBodies.get(source)) return rollbackMessage(`来源技能「${source}」在归档前被改过`, []);
10981
11015
  const writes = [{
10982
11016
  target: targetPath,
10983
11017
  content: plan.body.trimEnd() + "\n",
@@ -10998,9 +11032,25 @@ var SkillLibrary = class {
10998
11032
  unitId,
10999
11033
  unitHead
11000
11034
  });
11001
- if (!result.ok) {
11002
- const stuck = await rollbackSources();
11003
- return rollbackMessage(result.message, stuck);
11035
+ if (!result.ok) return rollbackMessage(result.message, []);
11036
+ for (const source of sources) {
11037
+ if (await this.io.readText(join(this.dirOf(source), "SKILL.md")) !== sourceBodies.get(source)) {
11038
+ const stuck = await rollbackSources();
11039
+ stuck.push(...await rollbackTarget());
11040
+ return rollbackMessage(`来源技能「${source}」在归档前被改过`, stuck);
11041
+ }
11042
+ const archivedResult = await this.archive(source, {
11043
+ absorbedInto: target,
11044
+ unitId,
11045
+ unitHead: { skill: target },
11046
+ reason: `Merged into ${target}`
11047
+ }, plan.origin);
11048
+ if (!archivedResult.ok) {
11049
+ const stuck = await rollbackSources();
11050
+ stuck.push(...await rollbackTarget());
11051
+ return rollbackMessage(archivedResult.message, stuck);
11052
+ }
11053
+ archived.push(source);
11004
11054
  }
11005
11055
  return {
11006
11056
  ok: true,
@@ -11881,6 +11931,12 @@ var SkillLibrary = class {
11881
11931
  * @returns the expired entry names ([] when the backend has no mtime probe,
11882
11932
  * because an unknown age must never read as expired).
11883
11933
  */
11934
+ /** Names currently in .archive, for target occupancy; unknown listing is null. */
11935
+ async archivedNames() {
11936
+ const listed = await probeList(this.io, join(this.root, ".archive"));
11937
+ if (isUnknown(listed)) return null;
11938
+ return isPresent(listed) ? listed.value.slice() : [];
11939
+ }
11884
11940
  async expiredArchives() {
11885
11941
  const archiveRoot = join(this.root, ".archive");
11886
11942
  const listed = await probeList(this.io, archiveRoot);
@@ -14031,6 +14087,17 @@ const PARAM_EXPOSURE = Object.freeze([
14031
14087
  docAnchor: "PARAMETERS.md#merge",
14032
14088
  summary: "Wall-clock budget of one pair round (a stuck child guard).",
14033
14089
  why: "internal"
14090
+ },
14091
+ {
14092
+ id: "mergeModelsRoot",
14093
+ group: "merge",
14094
+ tier: "E2",
14095
+ authority: "cordis",
14096
+ owner: "evolution-merge",
14097
+ applies: "none",
14098
+ docAnchor: "PARAMETERS.md#merge",
14099
+ summary: "Local model root containing the int8 and pca64 model directories; empty falls back to SKILL_MERGE_MODELS_ROOT. No auto provisioning.",
14100
+ why: "path"
14034
14101
  }
14035
14102
  ]);
14036
14103
  /** The settings namespace each OWNER PACKAGE registers (design §7.2).
@@ -56,8 +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
+ readonly body: "/api/dsh-evolution/merge/body";
60
+ readonly history: "/api/dsh-evolution/merge/history";
61
61
  };
62
62
  readonly diagnostics: {
63
63
  readonly view: "/api/dsh-evolution/diagnostics";
@@ -53,8 +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
+ body: '/api/dsh-evolution/merge/body',
57
+ history: '/api/dsh-evolution/merge/history',
58
58
  },
59
59
  diagnostics: {
60
60
  view: '/api/dsh-evolution/diagnostics',
@@ -208,6 +208,7 @@ 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' },
211
212
  ]);
212
213
  /** The settings namespace each OWNER PACKAGE registers (design §7.2).
213
214
  *
@@ -20,7 +20,7 @@ export interface PlatformServiceProbe {
20
20
  /** Where the probe runs. */
21
21
  readonly side: 'host' | 'client';
22
22
  }
23
- /** The 25 probes, in service-name order (the guard checks this list against the tree). */
23
+ /** The 26 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 25 probes, in service-name order (the guard checks this list against the tree). */
14
+ /** The 26 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' },
@@ -22,6 +22,7 @@ export const PLATFORM_SERVICE_PROBES = Object.freeze([
22
22
  { service: 'evolutionIo', feature: 'every durable read and write (the IO seam)', side: 'host' },
23
23
  { service: 'hmr', feature: 'the preset install read-merge-write commit (the platform HMR lock serializes it)', side: 'host' },
24
24
  { 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' },
25
26
  { service: 'evolutionPolicy', feature: 'the policy snapshot that shadows row knobs', side: 'host' },
26
27
  { service: 'evolutionReplay', feature: '/evolution replay', side: 'host' },
27
28
  { service: 'evolutionState', feature: 'the state facade behind activity and replay', side: 'host' },
@@ -75,6 +75,11 @@ export interface RunRecord {
75
75
  supersededAt?: number | undefined;
76
76
  /** The run this run was spawned from (approve/resume after an earlier command). */
77
77
  parentRunId?: string | undefined;
78
+ /** Merge-side projection; core lifecycle stays four-state. */
79
+ mergeStatus?: 'running' | 'succeeded' | 'failed' | 'cancelled' | 'awaiting-approval' | 'undone' | 'superseded' | 'interrupted' | 'orphan' | undefined;
80
+ stagingRef?: string | undefined;
81
+ pairKey?: string | undefined;
82
+ pairs?: readonly unknown[] | undefined;
78
83
  }
79
84
  /** What a started run gets: its id and the signal its work must honour. */
80
85
  export interface RunHandle {
@@ -85,12 +90,18 @@ export interface RunHandle {
85
90
  readonly startedAt: number;
86
91
  readonly command?: RunCommand | undefined;
87
92
  readonly parentRunId?: string | undefined;
93
+ /** Merge-side projection; core lifecycle stays four-state. */
94
+ readonly mergeStatus?: 'running' | 'succeeded' | 'failed' | 'cancelled' | 'awaiting-approval' | 'undone' | 'superseded' | 'interrupted' | 'orphan' | undefined;
95
+ readonly stagingRef?: string | undefined;
96
+ readonly pairKey?: string | undefined;
97
+ readonly pairs?: readonly unknown[] | undefined;
88
98
  }
89
99
  /** How a run ended, as reported by its owner. */
90
100
  export interface RunOutcome {
91
101
  state: 'succeeded' | 'failed' | 'cancelled';
92
102
  resultRef?: string | undefined;
93
103
  failure?: string | undefined;
104
+ mergeStatus?: 'running' | 'succeeded' | 'failed' | 'cancelled' | 'awaiting-approval' | 'undone' | 'superseded' | 'interrupted' | 'orphan' | undefined;
94
105
  }
95
106
  /** The registry surface the commands row drives. */
96
107
  export interface RunRegistry {
@@ -114,6 +125,10 @@ export interface RunRegistry {
114
125
  command?: RunCommand;
115
126
  total?: number;
116
127
  parentRunId?: string;
128
+ mergeStatus?: 'running' | 'succeeded' | 'failed' | 'cancelled' | 'awaiting-approval' | 'undone' | 'superseded' | 'interrupted' | 'orphan';
129
+ stagingRef?: string;
130
+ pairKey?: string;
131
+ pairs?: readonly unknown[];
117
132
  }): Promise<RunHandle>;
118
133
  /** Record a run's terminal state (first writer wins). */
119
134
  settle(id: string, outcome: RunOutcome): Promise<void>;
@@ -110,6 +110,10 @@ function parseIndex(raw) {
110
110
  ...(typeof record.failure === 'string' ? { failure: record.failure } : {}),
111
111
  ...(typeof record.supersededAt === 'number' ? { supersededAt: record.supersededAt } : {}),
112
112
  ...(typeof record.parentRunId === 'string' ? { parentRunId: record.parentRunId } : {}),
113
+ ...(record.mergeStatus === 'running' || record.mergeStatus === 'succeeded' || record.mergeStatus === 'failed' || record.mergeStatus === 'cancelled' || record.mergeStatus === 'awaiting-approval' || record.mergeStatus === 'undone' || record.mergeStatus === 'superseded' || record.mergeStatus === 'interrupted' || record.mergeStatus === 'orphan' ? { mergeStatus: record.mergeStatus } : {}),
114
+ ...(typeof record.stagingRef === 'string' ? { stagingRef: record.stagingRef } : {}),
115
+ ...(typeof record.pairKey === 'string' ? { pairKey: record.pairKey } : {}),
116
+ ...(Array.isArray(record.pairs) ? { pairs: record.pairs } : {}),
113
117
  });
114
118
  }
115
119
  return { ok: true, records, foreign, ...(notes.length > 0 ? { note: notes.join('; ') } : {}) };
@@ -135,7 +139,7 @@ export function newRunRegistry(options) {
135
139
  /** A row whose owner cannot be reached: `pid` absent (an older build) or not alive. */
136
140
  const ownerIsGone = (row) => row.pid === undefined || !isProcessAlive(row.pid);
137
141
  /** The verdict is materialized ONCE, so its `endedAt` (and the duration a reader sees) is stable. */
138
- const orphanOf = (row) => ({ ...row, state: 'failed', endedAt: now(), failure: ORPHAN_FAILURE });
142
+ const orphanOf = (row) => ({ ...row, state: 'failed', mergeStatus: 'orphan', endedAt: now(), failure: ORPHAN_FAILURE });
139
143
  /** Every row this process knows, newest first. ALL reads go through here. */
140
144
  const view = () => [...owned.values(), ...foreign.values()].sort((a, b) => b.startedAt - a.startedAt);
141
145
  /** Retained history: every running row plus the newest terminal rows per kind. */
@@ -301,6 +305,10 @@ export function newRunRegistry(options) {
301
305
  pid: process.pid,
302
306
  ...(seed?.command !== undefined ? { command: seed.command } : {}),
303
307
  ...(seed?.parentRunId !== undefined ? { parentRunId: seed.parentRunId } : {}),
308
+ ...(seed?.mergeStatus !== undefined ? { mergeStatus: seed.mergeStatus } : {}),
309
+ ...(seed?.stagingRef !== undefined ? { stagingRef: seed.stagingRef } : {}),
310
+ ...(seed?.pairKey !== undefined ? { pairKey: seed.pairKey } : {}),
311
+ ...(seed?.pairs !== undefined ? { pairs: seed.pairs } : {}),
304
312
  ...(seed?.total !== undefined ? { progress: { done: 0, total: seed.total } } : {}),
305
313
  });
306
314
  // Write-through (0.19.1, review P2): answering "started" promises a run another
@@ -332,6 +340,8 @@ export function newRunRegistry(options) {
332
340
  if (row === undefined || row.supersededAt !== undefined)
333
341
  continue;
334
342
  row.supersededAt = now();
343
+ if (row.kind === 'merge')
344
+ row.mergeStatus = 'superseded';
335
345
  changed = true;
336
346
  }
337
347
  if (changed)
@@ -350,6 +360,8 @@ export function newRunRegistry(options) {
350
360
  if (row.kind !== kind || row.supersededAt !== undefined)
351
361
  continue;
352
362
  row.supersededAt = now();
363
+ if (row.kind === 'merge')
364
+ row.mergeStatus = 'superseded';
353
365
  marked += 1;
354
366
  }
355
367
  const kept = capped([...merged.values()]);
@@ -371,6 +383,8 @@ export function newRunRegistry(options) {
371
383
  row.resultRef = outcome.resultRef;
372
384
  if (outcome.failure !== undefined)
373
385
  row.failure = outcome.failure;
386
+ if (outcome.mergeStatus !== undefined)
387
+ row.mergeStatus = outcome.mergeStatus;
374
388
  controllers.delete(id);
375
389
  await persist();
376
390
  },
@@ -667,6 +667,8 @@ export declare class SkillLibrary {
667
667
  * @returns the expired entry names ([] when the backend has no mtime probe,
668
668
  * because an unknown age must never read as expired).
669
669
  */
670
+ /** Names currently in .archive, for target occupancy; unknown listing is null. */
671
+ archivedNames(): Promise<string[] | null>;
670
672
  expiredArchives(): Promise<string[] | null>;
671
673
  /** V4 (design §16.6): support-file char policy, resolved at the call site. */
672
674
  supportFileCharPolicy(): 'report' | 'enforce';
@@ -2036,24 +2036,42 @@ export class SkillLibrary {
2036
2036
  ? `合并失败,已回滚:${reason}`
2037
2037
  : `合并失败(${reason});已回滚,但以下未回滚:${stuck.join(', ')}:它们仍在 .archive 里,请用 /evolution skill restore 恢复。`,
2038
2038
  });
2039
+ const rollbackTarget = async () => {
2040
+ const stuck = [];
2041
+ const support = plan.supportFiles.map(file => ({
2042
+ target: join(dir, ...file.path.replace(/\\/g, '/').split('/').filter(Boolean)),
2043
+ content: supportPrevious.get(file.path) ?? null,
2044
+ previous: file.content,
2045
+ }));
2046
+ const writes = [
2047
+ { target: targetPath, content: plan.expectedTargetBody ?? '', expected: plan.body.trimEnd() + '\n' },
2048
+ ...support.map(file => ({ target: file.target, content: file.content ?? '', expected: file.previous })),
2049
+ ];
2050
+ try {
2051
+ const restored = await this.applyTreeChange({
2052
+ name: target,
2053
+ origin: plan.origin,
2054
+ protection: 'write',
2055
+ writes,
2056
+ auditAction: 'merge-rollback',
2057
+ auditSummary: 'merge rollback: restore target snapshot',
2058
+ eventAction: 'merge-rollback',
2059
+ unitId,
2060
+ unitHead,
2061
+ });
2062
+ if (!restored.ok)
2063
+ stuck.push(target);
2064
+ }
2065
+ catch {
2066
+ stuck.push(target);
2067
+ }
2068
+ return stuck;
2069
+ };
2039
2070
  if (plan.targetExists) {
2040
2071
  for (const source of sources) {
2041
2072
  const fresh = await this.io.readText(join(this.dirOf(source), 'SKILL.md'));
2042
- if (fresh !== sourceBodies.get(source)) {
2043
- const stuck = await rollbackSources();
2044
- return rollbackMessage(`来源技能「${source}」在归档前被改过`, stuck);
2045
- }
2046
- const result = await this.archive(source, {
2047
- absorbedInto: target,
2048
- unitId,
2049
- unitHead: { skill: target },
2050
- reason: `Merged into ${target}`,
2051
- }, plan.origin);
2052
- if (!result.ok) {
2053
- const stuck = await rollbackSources();
2054
- return rollbackMessage(result.message, stuck);
2055
- }
2056
- archived.push(source);
2073
+ if (fresh !== sourceBodies.get(source))
2074
+ return rollbackMessage(`来源技能「${source}」在归档前被改过`, []);
2057
2075
  }
2058
2076
  const writes = [
2059
2077
  { target: targetPath, content: plan.body.trimEnd() + '\n', expected: plan.expectedTargetBody },
@@ -2074,9 +2092,27 @@ export class SkillLibrary {
2074
2092
  unitId,
2075
2093
  unitHead,
2076
2094
  });
2077
- if (!result.ok) {
2078
- const stuck = await rollbackSources();
2079
- return rollbackMessage(result.message, stuck);
2095
+ if (!result.ok)
2096
+ return rollbackMessage(result.message, []);
2097
+ for (const source of sources) {
2098
+ const fresh = await this.io.readText(join(this.dirOf(source), 'SKILL.md'));
2099
+ if (fresh !== sourceBodies.get(source)) {
2100
+ const stuck = await rollbackSources();
2101
+ stuck.push(...await rollbackTarget());
2102
+ return rollbackMessage(`来源技能「${source}」在归档前被改过`, stuck);
2103
+ }
2104
+ const archivedResult = await this.archive(source, {
2105
+ absorbedInto: target,
2106
+ unitId,
2107
+ unitHead: { skill: target },
2108
+ reason: `Merged into ${target}`,
2109
+ }, plan.origin);
2110
+ if (!archivedResult.ok) {
2111
+ const stuck = await rollbackSources();
2112
+ stuck.push(...await rollbackTarget());
2113
+ return rollbackMessage(archivedResult.message, stuck);
2114
+ }
2115
+ archived.push(source);
2080
2116
  }
2081
2117
  return { ok: true, message: `已把 ${sources.join(', ')} 合并进「${target}」。`, unitId, targetPath: dir, archived, mutations: 1 + sources.length };
2082
2118
  }
@@ -3094,6 +3130,13 @@ export class SkillLibrary {
3094
3130
  * @returns the expired entry names ([] when the backend has no mtime probe,
3095
3131
  * because an unknown age must never read as expired).
3096
3132
  */
3133
+ /** Names currently in .archive, for target occupancy; unknown listing is null. */
3134
+ async archivedNames() {
3135
+ const listed = await probeList(this.io, join(this.root, '.archive'));
3136
+ if (isUnknown(listed))
3137
+ return null;
3138
+ return isPresent(listed) ? listed.value.slice() : [];
3139
+ }
3097
3140
  async expiredArchives() {
3098
3141
  const archiveRoot = join(this.root, '.archive');
3099
3142
  // Three-state read (N14), the same helper every other listing uses: a
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-core",
3
3
  "description": "Shared stores, prompts, signals and lifecycle logic for the dsh-evolution plugin family (community build)",
4
- "version": "0.28.0",
4
+ "version": "0.30.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -207,6 +207,15 @@
207
207
  "marker": "transactIo(options.io, path",
208
208
  "state": [],
209
209
  "note": "The release half of the same lock: delete only when the recorded token still matches."
210
+ },
211
+ {
212
+ "id": "merge-pair-index",
213
+ "path": "<evolutionHome>/merge/pair-index.json",
214
+ "writer": "evolution-merge/src/domain/pair-index-store.ts",
215
+ "serializedBy": "transact",
216
+ "marker": "transactIo(io, pairIndexPath(home)",
217
+ "state": [],
218
+ "note": "pairKey → result/runId/target index; RMW under the same IO lock so two merge runs cannot lose each other's entries."
210
219
  }
211
220
  ]
212
221
  }