@lmzhen/dsh-evolution-merge 0.30.4 → 0.30.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1284,7 +1284,7 @@ function makeMergeRoutes(deps, _audit) {
1284
1284
  }
1285
1285
  //#endregion
1286
1286
  //#region lib/types/commands.js
1287
- const USAGE = "usage: /evolution merge scan | pair [--force] <a> <b> | status [id] | report [id] | cancel [id] | runs | doctor | pending [--expired] | candidates [--retry] | approve <id> | reject <id> | resume [id] [--retry] [--clear <id>] | skipped [--restore <id>] | discard <id> | undo <target> --run <id>";
1287
+ const USAGE = "usage: /evolution merge scan | pair [--force] <a> <b> | status [id] | report [id] | cancel [id] | runs | doctor | pending [--expired] | candidates [--retry] | approve <id> | reject <id> | resume [id] [--retry] [--clear <id>] | skipped [--restore <id>] | discard <id> | undo <target> --run <id>\ndefaults: mergeApproval=false, mergeSkippedTtlDays=0, mergeRunTimeoutMinutes=0, mergePairTimeoutMinutes=15, mergeModelsRoot=\"\"";
1288
1288
  function flags(args, allowed) {
1289
1289
  const values = /* @__PURE__ */ new Map();
1290
1290
  for (let index = 0; index < args.length; index += 1) {
@@ -1517,7 +1517,7 @@ function parseMergeCommand(argv) {
1517
1517
  default: return {
1518
1518
  ok: false,
1519
1519
  code: "usage",
1520
- message: "unknown merge command: " + verb + "\nusage: /evolution merge scan | pair [--force] <a> <b> | status [id] | report [id] | cancel [id] | runs | doctor | pending [--expired] | candidates [--retry] | approve <id> | reject <id> | resume [id] [--retry] [--clear <id>] | skipped [--restore <id>] | discard <id> | undo <target> --run <id>"
1520
+ message: "unknown merge command: " + verb + "\nusage: /evolution merge scan | pair [--force] <a> <b> | status [id] | report [id] | cancel [id] | runs | doctor | pending [--expired] | candidates [--retry] | approve <id> | reject <id> | resume [id] [--retry] [--clear <id>] | skipped [--restore <id>] | discard <id> | undo <target> --run <id>\ndefaults: mergeApproval=false, mergeSkippedTtlDays=0, mergeRunTimeoutMinutes=0, mergePairTimeoutMinutes=15, mergeModelsRoot=\"\""
1521
1521
  };
1522
1522
  }
1523
1523
  }
@@ -1527,24 +1527,28 @@ function mergeUsage() {
1527
1527
  //#endregion
1528
1528
  //#region lib/types/read-commands.js
1529
1529
  async function handleReadOnlyMergeCommand(command, io, home) {
1530
+ const withNote = (text, note) => note === void 0 ? text : "note: " + note + "\n" + text;
1530
1531
  if (command.verb === "status") {
1531
1532
  const loaded = await loadMergeState(io, home);
1532
1533
  if (command.id !== void 0) {
1533
1534
  const row = loaded.state.rows.find((item) => item.id === command.id);
1534
- return row === void 0 ? "merge row not found: " + command.id : formatStatus([row]);
1535
+ return withNote(row === void 0 ? "merge row not found: " + command.id : formatStatus([row]), loaded.note);
1535
1536
  }
1536
- return formatStatus(loaded.state.rows);
1537
+ return withNote(formatStatus(loaded.state.rows), loaded.note);
1537
1538
  }
1538
1539
  if (command.verb === "pending") {
1539
1540
  const loaded = await loadMergeState(io, home);
1540
1541
  const rows = command.expired === true ? loaded.state.rows.filter((row) => row.expired !== void 0) : loaded.state.rows;
1541
- return command.expired === true ? formatStatus(rows) : formatQueue(rows, "pending");
1542
+ return withNote(command.expired === true ? formatStatus(rows) : formatQueue(rows, "pending"), loaded.note);
1542
1543
  }
1543
1544
  if (command.verb === "candidates") {
1544
1545
  const loaded = await loadMergeState(io, home);
1545
- return formatQueue(command.retry === true ? loaded.state.rows.filter((row) => row.state === "approved") : loaded.state.rows, command.retry === true ? "fuse" : "verify");
1546
+ return withNote(formatQueue(command.retry === true ? loaded.state.rows.filter((row) => row.state === "approved") : loaded.state.rows, command.retry === true ? "fuse" : "verify"), loaded.note);
1547
+ }
1548
+ if (command.verb === "skipped") {
1549
+ const loaded = await loadMergeState(io, home);
1550
+ return withNote(formatQueue(loaded.state.rows, "skipped"), loaded.note);
1546
1551
  }
1547
- if (command.verb === "skipped") return formatQueue((await loadMergeState(io, home)).state.rows, "skipped");
1548
1552
  return null;
1549
1553
  }
1550
1554
  function mergeCommandHelp() {
@@ -1554,8 +1558,8 @@ function mergeCommandHelp() {
1554
1558
  //#region lib/types/domain/errors.js
1555
1559
  function formatMergeError(error) {
1556
1560
  if (error.kind === "parameter") return "merge: " + error.message + " (" + error.code + ")";
1557
- const detail = "\n" + (error.detail ?? "no additional detail");
1558
- return "merge failed: " + error.message + " (" + error.code + ")" + detail + "\nnext: fix the target or rerun after the cause is gone";
1561
+ const where = error.stage === void 0 && error.runId === void 0 ? error.detail ?? "no additional detail" : [error.stage === void 0 ? void 0 : "stage=" + error.stage, error.runId === void 0 ? void 0 : "run=" + error.runId].filter(Boolean).join(" ");
1562
+ return "merge failed: " + error.message + " (" + error.code + ")\n" + where + "\nnext: fix the target or rerun after the cause is gone";
1559
1563
  }
1560
1564
  //#endregion
1561
1565
  //#region lib/types/merge-command.js
@@ -1767,7 +1771,16 @@ async function runScan(options) {
1767
1771
  if (!created.ok || created.row === null) throw new Error(created.ok ? "candidate row missing" : created.message);
1768
1772
  row = created.row;
1769
1773
  const placed = await upsertRow(options.io, options.home, options.now, row, "verify");
1770
- if (!placed.ok) throw new Error("pair already in " + placed.current.state);
1774
+ if (!placed.ok && placed.current.state === "skipped") {
1775
+ const restored = transition(placed.current, {
1776
+ type: "restore",
1777
+ at: at(options.now)
1778
+ });
1779
+ if (!restored.ok || restored.row === null) throw new Error("cannot restore skipped pair");
1780
+ row = restored.row;
1781
+ const retried = await upsertRow(options.io, options.home, options.now, row, "verify");
1782
+ if (!retried.ok) throw new Error("pair already in " + retried.current.state);
1783
+ } else if (!placed.ok) throw new Error("pair already in " + placed.current.state);
1771
1784
  } catch (error) {
1772
1785
  totals.failed += 1;
1773
1786
  pairs.push({
@@ -3286,14 +3299,23 @@ async function runAuthoring(options) {
3286
3299
  }
3287
3300
  //#endregion
3288
3301
  //#region lib/types/revision.js
3289
- /** Stable source revision for a pair: names sorted, body hashed in that order. */
3302
+ function fingerprint(card) {
3303
+ const files = [...card.files ?? []].sort((left, right) => left.path.localeCompare(right.path));
3304
+ return card.name + "\0" + card.body + "\0" + files.map((file) => file.path + "\0" + file.content).join("\0");
3305
+ }
3306
+ /** Stable source revision for a pair: names sorted, body+support hashed in that order. */
3290
3307
  function sourceRevisionOf(cards, a, b) {
3291
3308
  const byName = new Map(cards.map((card) => [card.name, card]));
3292
3309
  const names = [a, b].sort();
3293
3310
  const first = byName.get(names[0] ?? "");
3294
3311
  const second = byName.get(names[1] ?? "");
3295
3312
  if (first === void 0 || second === void 0) return void 0;
3296
- return contentHash(first.name + "\0" + first.body + "\0" + second.name + "\0" + second.body);
3313
+ return contentHash(fingerprint(first) + "\0" + fingerprint(second));
3314
+ }
3315
+ /** Revision of the merge target; undefined when the target is not a live card. */
3316
+ function targetRevisionOf(cards, target) {
3317
+ const card = cards.find((item) => item.name === target);
3318
+ return card === void 0 ? void 0 : contentHash(fingerprint(card));
3297
3319
  }
3298
3320
  //#endregion
3299
3321
  //#region lib/types/scan-ports.js
@@ -3354,11 +3376,14 @@ function createChannelScanPorts(options) {
3354
3376
  });
3355
3377
  }
3356
3378
  const sourceRevision = result.outcome.kind === "merge" ? sourceRevisionOf(options.cards, candidate.a, candidate.b) : void 0;
3379
+ const target = result.outcome.kind === "merge" ? result.outcome.decision?.target : void 0;
3380
+ const targetRevision = target === void 0 ? void 0 : targetRevisionOf(options.cards, target);
3357
3381
  return result.outcome.kind === "merge" ? {
3358
3382
  ...result.outcome,
3359
3383
  stagingRef: stageKey,
3360
3384
  stagingExpiresAt: options.now() + 7 * 864e5,
3361
- ...sourceRevision === void 0 ? {} : { sourceRevision }
3385
+ ...sourceRevision === void 0 ? {} : { sourceRevision },
3386
+ ...targetRevision === void 0 ? {} : { targetRevision }
3362
3387
  } : result.outcome;
3363
3388
  },
3364
3389
  async round2(row, decision, signal) {
@@ -3653,6 +3678,10 @@ function createMergeRuntime(deps) {
3653
3678
  const detached = deps.detached !== false;
3654
3679
  if (command.verb === "pair") {
3655
3680
  const signal = new AbortController().signal;
3681
+ if (command.a === command.b) return {
3682
+ ok: false,
3683
+ text: "merge pair 需要两个不同技能源: " + command.a
3684
+ };
3656
3685
  const cards = await deps.cards(signal);
3657
3686
  const cardA = cards.find((card) => card.name === command.a);
3658
3687
  const cardB = cards.find((card) => card.name === command.b);
@@ -3660,6 +3689,10 @@ function createMergeRuntime(deps) {
3660
3689
  ok: false,
3661
3690
  text: "merge pair 仅支持 family-managed 技能: " + command.a + " / " + command.b
3662
3691
  };
3692
+ if (cardA.protectedBy != null || cardB.protectedBy != null || cardA.protectionUnknown === true || cardB.protectionUnknown === true) return {
3693
+ ok: false,
3694
+ text: "merge pair 不接受受保护/保护状态未知技能: " + command.a + " / " + command.b
3695
+ };
3663
3696
  const pair = [{
3664
3697
  a: command.a,
3665
3698
  b: command.b
@@ -3726,10 +3759,10 @@ function createMergeRuntime(deps) {
3726
3759
  if (command.verb === "scan") {
3727
3760
  const signal = new AbortController().signal;
3728
3761
  const cards = await deps.cards(signal);
3729
- const managed = new Set(cards.filter((card) => card.managed === true).map((card) => card.name));
3762
+ const managed = new Set(cards.filter((card) => card.managed === true && card.protectedBy == null).map((card) => card.name));
3730
3763
  const loadedState = await loadMergeState(deps.io, deps.home);
3731
3764
  const skipped = skippedPairKeys(loadedState.state.rows, deps.params.mergeSkippedTtlDays, deps.now());
3732
- const held = new Set(loadedState.state.rows.filter((row) => row.queue === "pending" || row.queue === "fuse" || !canRetry(row)).map((row) => row.pairKey));
3765
+ const held = new Set(loadedState.state.rows.filter((row) => row.queue === "pending" || row.queue === "fuse" || row.queue === "verify" && !canRetry(row)).map((row) => row.pairKey));
3733
3766
  const conflictNotes = loadedState.state.rows.filter((row) => row.state === "pending" || row.state === "approved" || row.state === "fuse" || row.state === "skipped").map((row) => "conflict:" + row.state + ":" + row.pairKey + ":" + row.id);
3734
3767
  const result = await runScanCommand({
3735
3768
  ...common,
@@ -4012,6 +4045,18 @@ function createMergeRuntime(deps) {
4012
4045
  text: "source-changed:源技能内容在决策后变化,拒绝照跑;出口:重新 merge pair,或 merge skipped --discard"
4013
4046
  };
4014
4047
  }
4048
+ const currentTargetRevision = targetRevisionOf(cards, candidate.decision.target);
4049
+ if (candidate.targetRevision !== void 0 && currentTargetRevision !== void 0 && currentTargetRevision !== candidate.targetRevision) {
4050
+ const taken = transition(candidate, {
4051
+ type: "target-taken",
4052
+ at: (/* @__PURE__ */ new Date()).toISOString()
4053
+ });
4054
+ if (taken.ok && taken.row !== null) await upsertRow(deps.io, deps.home, deps.now, taken.row);
4055
+ return {
4056
+ ok: false,
4057
+ text: "target-taken:目标 " + candidate.decision.target + " 在决策后变化,拒绝照跑;可重新 merge pair 或 discard"
4058
+ };
4059
+ }
4015
4060
  if (candidate.decision.mode === "merge-into-existing" && !cards.some((card) => card.name === candidate.decision?.target)) {
4016
4061
  const expired = transition(candidate, {
4017
4062
  type: "target-taken",
@@ -1,4 +1,4 @@
1
- const USAGE = 'usage: /evolution merge scan | pair [--force] <a> <b> | status [id] | report [id] | cancel [id] | runs | doctor | pending [--expired] | candidates [--retry] | approve <id> | reject <id> | resume [id] [--retry] [--clear <id>] | skipped [--restore <id>] | discard <id> | undo <target> --run <id>';
1
+ const USAGE = 'usage: /evolution merge scan | pair [--force] <a> <b> | status [id] | report [id] | cancel [id] | runs | doctor | pending [--expired] | candidates [--retry] | approve <id> | reject <id> | resume [id] [--retry] [--clear <id>] | skipped [--restore <id>] | discard <id> | undo <target> --run <id>\ndefaults: mergeApproval=false, mergeSkippedTtlDays=0, mergeRunTimeoutMinutes=0, mergePairTimeoutMinutes=15, mergeModelsRoot=""';
2
2
  function flags(args, allowed) {
3
3
  const values = new Map();
4
4
  for (let index = 0; index < args.length; index += 1) {
@@ -5,6 +5,8 @@ export interface MergeError {
5
5
  readonly code: string;
6
6
  readonly message: string;
7
7
  readonly detail?: string;
8
+ readonly stage?: string;
9
+ readonly runId?: string;
8
10
  }
9
11
  export declare function formatMergeError(error: MergeError): string;
10
12
  //# sourceMappingURL=errors.d.ts.map
@@ -1,7 +1,9 @@
1
1
  export function formatMergeError(error) {
2
2
  if (error.kind === 'parameter')
3
3
  return 'merge: ' + error.message + ' (' + error.code + ')';
4
- const detail = '\n' + (error.detail ?? 'no additional detail');
5
- return 'merge failed: ' + error.message + ' (' + error.code + ')' + detail + '\nnext: fix the target or rerun after the cause is gone';
4
+ const where = error.stage === undefined && error.runId === undefined
5
+ ? (error.detail ?? 'no additional detail')
6
+ : [error.stage === undefined ? undefined : 'stage=' + error.stage, error.runId === undefined ? undefined : 'run=' + error.runId].filter(Boolean).join(' ');
7
+ return 'merge failed: ' + error.message + ' (' + error.code + ')\n' + where + '\nnext: fix the target or rerun after the cause is gone';
6
8
  }
7
9
  //# sourceMappingURL=errors.js.map
@@ -2,27 +2,28 @@ import { loadMergeState } from "./state.js";
2
2
  import { formatQueue, formatStatus } from "./status.js";
3
3
  import { mergeUsage } from "./commands.js";
4
4
  export async function handleReadOnlyMergeCommand(command, io, home) {
5
+ const withNote = (text, note) => note === undefined ? text : 'note: ' + note + '\n' + text;
5
6
  if (command.verb === 'status') {
6
7
  const loaded = await loadMergeState(io, home);
7
8
  if (command.id !== undefined) {
8
9
  const row = loaded.state.rows.find(item => item.id === command.id);
9
- return row === undefined ? 'merge row not found: ' + command.id : formatStatus([row]);
10
+ return withNote(row === undefined ? 'merge row not found: ' + command.id : formatStatus([row]), loaded.note);
10
11
  }
11
- return formatStatus(loaded.state.rows);
12
+ return withNote(formatStatus(loaded.state.rows), loaded.note);
12
13
  }
13
14
  if (command.verb === 'pending') {
14
15
  const loaded = await loadMergeState(io, home);
15
16
  const rows = command.expired === true ? loaded.state.rows.filter(row => row.expired !== undefined) : loaded.state.rows;
16
- return command.expired === true ? formatStatus(rows) : formatQueue(rows, 'pending');
17
+ return withNote(command.expired === true ? formatStatus(rows) : formatQueue(rows, 'pending'), loaded.note);
17
18
  }
18
19
  if (command.verb === 'candidates') {
19
20
  const loaded = await loadMergeState(io, home);
20
21
  const rows = command.retry === true ? loaded.state.rows.filter(row => row.state === 'approved') : loaded.state.rows;
21
- return formatQueue(rows, command.retry === true ? 'fuse' : 'verify');
22
+ return withNote(formatQueue(rows, command.retry === true ? 'fuse' : 'verify'), loaded.note);
22
23
  }
23
24
  if (command.verb === 'skipped') {
24
25
  const loaded = await loadMergeState(io, home);
25
- return formatQueue(loaded.state.rows, 'skipped');
26
+ return withNote(formatQueue(loaded.state.rows, 'skipped'), loaded.note);
26
27
  }
27
28
  return null;
28
29
  }
@@ -1,4 +1,6 @@
1
1
  import type { SkillCard } from './skill-card.ts';
2
- /** Stable source revision for a pair: names sorted, body hashed in that order. */
2
+ /** Stable source revision for a pair: names sorted, body+support hashed in that order. */
3
3
  export declare function sourceRevisionOf(cards: readonly SkillCard[], a: string, b: string): string | undefined;
4
+ /** Revision of the merge target; undefined when the target is not a live card. */
5
+ export declare function targetRevisionOf(cards: readonly SkillCard[], target: string): string | undefined;
4
6
  //# sourceMappingURL=revision.d.ts.map
@@ -1,5 +1,9 @@
1
1
  import { contentHash } from '@lmzhen/dsh-evolution-core';
2
- /** Stable source revision for a pair: names sorted, body hashed in that order. */
2
+ function fingerprint(card) {
3
+ const files = [...(card.files ?? [])].sort((left, right) => left.path.localeCompare(right.path));
4
+ return card.name + '\u0000' + card.body + '\u0000' + files.map(file => file.path + '\u0000' + file.content).join('\u0000');
5
+ }
6
+ /** Stable source revision for a pair: names sorted, body+support hashed in that order. */
3
7
  export function sourceRevisionOf(cards, a, b) {
4
8
  const byName = new Map(cards.map(card => [card.name, card]));
5
9
  const names = [a, b].sort();
@@ -7,6 +11,11 @@ export function sourceRevisionOf(cards, a, b) {
7
11
  const second = byName.get(names[1] ?? '');
8
12
  if (first === undefined || second === undefined)
9
13
  return undefined;
10
- return contentHash(first.name + '\u0000' + first.body + '\u0000' + second.name + '\u0000' + second.body);
14
+ return contentHash(fingerprint(first) + '\u0000' + fingerprint(second));
15
+ }
16
+ /** Revision of the merge target; undefined when the target is not a live card. */
17
+ export function targetRevisionOf(cards, target) {
18
+ const card = cards.find(item => item.name === target);
19
+ return card === undefined ? undefined : contentHash(fingerprint(card));
11
20
  }
12
21
  //# sourceMappingURL=revision.js.map
@@ -22,7 +22,7 @@ import { createChannelScanPorts } from "./scan-ports.js";
22
22
  import { createNewChildRound2 } from "./new-child-authoring.js";
23
23
  import { skillLibraryPort } from "./adapters/library.js";
24
24
  import { fitToolText } from "./presentation-budget.js";
25
- import { sourceRevisionOf } from "./revision.js";
25
+ import { sourceRevisionOf, targetRevisionOf } from "./revision.js";
26
26
  function forcedDecisionFor(a, b) {
27
27
  const sorted = [a, b].sort();
28
28
  const target = ('merge-' + sorted.join('-')).replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64);
@@ -47,12 +47,18 @@ export function createMergeRuntime(deps) {
47
47
  const detached = deps.detached !== false;
48
48
  if (command.verb === 'pair') {
49
49
  const signal = new AbortController().signal;
50
+ if (command.a === command.b)
51
+ return { ok: false, text: 'merge pair 需要两个不同技能源: ' + command.a };
50
52
  const cards = await deps.cards(signal);
51
53
  const cardA = cards.find(card => card.name === command.a);
52
54
  const cardB = cards.find(card => card.name === command.b);
53
55
  if (cardA === undefined || cardB === undefined || cardA.managed !== true || cardB.managed !== true) {
54
56
  return { ok: false, text: 'merge pair 仅支持 family-managed 技能: ' + command.a + ' / ' + command.b };
55
57
  }
58
+ if (cardA.protectedBy != null || cardB.protectedBy != null
59
+ || cardA.protectionUnknown === true || cardB.protectionUnknown === true) {
60
+ return { ok: false, text: 'merge pair 不接受受保护/保护状态未知技能: ' + command.a + ' / ' + command.b };
61
+ }
56
62
  const pair = [{ a: command.a, b: command.b }];
57
63
  const existing = (await loadMergeState(deps.io, deps.home)).state.rows.find(row => row.pairKey === pairKey(command.a, command.b));
58
64
  if (existing?.state === 'skipped') {
@@ -95,10 +101,10 @@ export function createMergeRuntime(deps) {
95
101
  if (command.verb === 'scan') {
96
102
  const signal = new AbortController().signal;
97
103
  const cards = await deps.cards(signal);
98
- const managed = new Set(cards.filter(card => card.managed === true).map(card => card.name));
104
+ const managed = new Set(cards.filter(card => card.managed === true && card.protectedBy == null).map(card => card.name));
99
105
  const loadedState = await loadMergeState(deps.io, deps.home);
100
106
  const skipped = skippedPairKeys(loadedState.state.rows, deps.params.mergeSkippedTtlDays, deps.now());
101
- const held = new Set(loadedState.state.rows.filter(row => row.queue === 'pending' || row.queue === 'fuse' || !canRetry(row)).map(row => row.pairKey));
107
+ const held = new Set(loadedState.state.rows.filter(row => row.queue === 'pending' || row.queue === 'fuse' || (row.queue === 'verify' && !canRetry(row))).map(row => row.pairKey));
102
108
  const conflictNotes = loadedState.state.rows
103
109
  .filter(row => row.state === 'pending' || row.state === 'approved' || row.state === 'fuse' || row.state === 'skipped')
104
110
  .map(row => 'conflict:' + row.state + ':' + row.pairKey + ':' + row.id);
@@ -273,6 +279,14 @@ export function createMergeRuntime(deps) {
273
279
  await upsertRow(deps.io, deps.home, deps.now, changed.row);
274
280
  return { ok: false, text: 'source-changed:源技能内容在决策后变化,拒绝照跑;出口:重新 merge pair,或 merge skipped --discard' };
275
281
  }
282
+ const currentTargetRevision = targetRevisionOf(cards, candidate.decision.target);
283
+ if (candidate.targetRevision !== undefined && currentTargetRevision !== undefined
284
+ && currentTargetRevision !== candidate.targetRevision) {
285
+ const taken = transition(candidate, { type: 'target-taken', at: new Date().toISOString() });
286
+ if (taken.ok && taken.row !== null)
287
+ await upsertRow(deps.io, deps.home, deps.now, taken.row);
288
+ return { ok: false, text: 'target-taken:目标 ' + candidate.decision.target + ' 在决策后变化,拒绝照跑;可重新 merge pair 或 discard' };
289
+ }
276
290
  if (candidate.decision.mode === 'merge-into-existing' && !cards.some(card => card.name === candidate.decision?.target)) {
277
291
  const expired = transition(candidate, { type: 'target-taken', at: new Date().toISOString() });
278
292
  if (expired.ok && expired.row !== null)
@@ -2,7 +2,7 @@ import { pairKey } from "./contracts.js";
2
2
  import { runRound1OverChannel } from "./channel-port.js";
3
3
  import { runAuthoring } from "./authoring-flow.js";
4
4
  import { dropStaging, ensureStaging, stageKeyOf, stagingRoot } from "./staging.js";
5
- import { sourceRevisionOf } from "./revision.js";
5
+ import { sourceRevisionOf, targetRevisionOf } from "./revision.js";
6
6
  export function createChannelScanPorts(options) {
7
7
  const sessions = new Map();
8
8
  return {
@@ -44,10 +44,13 @@ export function createChannelScanPorts(options) {
44
44
  }
45
45
  }
46
46
  const sourceRevision = result.outcome.kind === 'merge' ? sourceRevisionOf(options.cards, candidate.a, candidate.b) : undefined;
47
+ const target = result.outcome.kind === 'merge' ? result.outcome.decision?.target : undefined;
48
+ const targetRevision = target === undefined ? undefined : targetRevisionOf(options.cards, target);
47
49
  return result.outcome.kind === 'merge'
48
50
  ? {
49
51
  ...result.outcome, stagingRef: stageKey, stagingExpiresAt: options.now() + 7 * 86_400_000,
50
52
  ...(sourceRevision === undefined ? {} : { sourceRevision }),
53
+ ...(targetRevision === undefined ? {} : { targetRevision }),
51
54
  }
52
55
  : result.outcome;
53
56
  },
@@ -66,8 +66,18 @@ export async function runScan(options) {
66
66
  throw new Error(created.ok ? 'candidate row missing' : created.message);
67
67
  row = created.row;
68
68
  const placed = await upsertRow(options.io, options.home, options.now, row, 'verify');
69
- if (!placed.ok)
69
+ if (!placed.ok && placed.current.state === 'skipped') {
70
+ const restored = transition(placed.current, { type: 'restore', at: at(options.now) });
71
+ if (!restored.ok || restored.row === null)
72
+ throw new Error('cannot restore skipped pair');
73
+ row = restored.row;
74
+ const retried = await upsertRow(options.io, options.home, options.now, row, 'verify');
75
+ if (!retried.ok)
76
+ throw new Error('pair already in ' + retried.current.state);
77
+ }
78
+ else if (!placed.ok) {
70
79
  throw new Error('pair already in ' + placed.current.state);
80
+ }
71
81
  }
72
82
  catch (error) {
73
83
  totals.failed += 1;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-merge",
3
3
  "description": "Skill auto-merge domain: screening, two-round authoring, queues and commit orchestration (community build)",
4
- "version": "0.30.4",
4
+ "version": "0.30.6",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -27,8 +27,8 @@
27
27
  "license": "MIT",
28
28
  "dependencies": {
29
29
  "@deepseek-ai/schemastery": "^3.18.5",
30
- "@lmzhen/dsh-evolution-core": "^0.30.4",
31
- "@lmzhen/dsh-evolution-merge-engine": "^0.30.4"
30
+ "@lmzhen/dsh-evolution-core": "^0.30.6",
31
+ "@lmzhen/dsh-evolution-merge-engine": "^0.30.6"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "@deepseek-ai/cordis": "^4.0.5"