@lmzhen/dsh-evolution-merge 0.30.18 → 0.30.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +21 -4
  2. package/data/minhash_params.json +1063 -0
  3. package/lib/index.js +7313 -3272
  4. package/lib/types/adapters/channel.d.ts +1 -0
  5. package/lib/types/adapters/channel.js +15 -1
  6. package/lib/types/adapters/engine-screen.d.ts +1 -1
  7. package/lib/types/adapters/engine-screen.js +5 -2
  8. package/lib/types/adapters/model-runner.d.ts +0 -5
  9. package/lib/types/adapters/model-runner.js +8 -2
  10. package/lib/types/adapters/model.js +2 -1
  11. package/lib/types/adapters/select.js +12 -8
  12. package/lib/types/approve-command.d.ts +12 -1
  13. package/lib/types/approve-command.js +39 -6
  14. package/lib/types/authoring-flow.d.ts +16 -0
  15. package/lib/types/authoring-flow.js +37 -13
  16. package/lib/types/channel-port.d.ts +3 -1
  17. package/lib/types/channel-port.js +8 -3
  18. package/lib/types/command-defs.d.ts +5 -0
  19. package/lib/types/command-defs.js +33 -0
  20. package/lib/types/command-surface.d.ts +1 -1
  21. package/lib/types/command-surface.js +10 -43
  22. package/lib/types/commands.d.ts +3 -4
  23. package/lib/types/commands.js +33 -72
  24. package/lib/types/commit-assembly.d.ts +14 -1
  25. package/lib/types/commit-assembly.js +65 -9
  26. package/lib/types/commit-execute.d.ts +7 -1
  27. package/lib/types/commit-execute.js +54 -22
  28. package/lib/types/commit-plan.d.ts +7 -1
  29. package/lib/types/commit-plan.js +4 -0
  30. package/lib/types/commit-receipt.d.ts +3 -0
  31. package/lib/types/contracts.d.ts +43 -0
  32. package/lib/types/doctor.d.ts +11 -1
  33. package/lib/types/doctor.js +81 -27
  34. package/lib/types/domain/authoring-manifest.d.ts +26 -0
  35. package/lib/types/domain/authoring-manifest.js +43 -0
  36. package/lib/types/domain/engine-criteria.d.ts +10 -0
  37. package/lib/types/domain/engine-criteria.js +11 -0
  38. package/lib/types/domain/engine-input.d.ts +10 -0
  39. package/lib/types/domain/engine-input.js +26 -0
  40. package/lib/types/domain/errors.d.ts +15 -1
  41. package/lib/types/domain/errors.js +23 -4
  42. package/lib/types/domain/evidence-hash.d.ts +3 -0
  43. package/lib/types/domain/evidence-hash.js +14 -0
  44. package/lib/types/domain/failure-route.d.ts +14 -0
  45. package/lib/types/domain/failure-route.js +12 -0
  46. package/lib/types/domain/live-facts.d.ts +31 -0
  47. package/lib/types/domain/live-facts.js +54 -0
  48. package/lib/types/domain/merge-limits.d.ts +18 -0
  49. package/lib/types/domain/merge-limits.js +22 -0
  50. package/lib/types/domain/merge-report.d.ts +41 -0
  51. package/lib/types/domain/merge-report.js +2 -0
  52. package/lib/types/domain/merge-snapshot.d.ts +99 -0
  53. package/lib/types/domain/merge-snapshot.js +82 -0
  54. package/lib/types/domain/pair-ledger.d.ts +34 -0
  55. package/lib/types/domain/pair-ledger.js +96 -0
  56. package/lib/types/domain/pre-1b-backup.d.ts +43 -0
  57. package/lib/types/domain/pre-1b-backup.js +140 -0
  58. package/lib/types/domain/provider-key.d.ts +2 -0
  59. package/lib/types/domain/provider-key.js +11 -0
  60. package/lib/types/domain/read-probe.d.ts +5 -0
  61. package/lib/types/domain/read-probe.js +11 -0
  62. package/lib/types/domain/reconcile.d.ts +47 -0
  63. package/lib/types/domain/reconcile.js +178 -0
  64. package/lib/types/domain/resolve-pair.d.ts +2 -9
  65. package/lib/types/domain/resolve-pair.js +2 -15
  66. package/lib/types/domain/row-v4.d.ts +148 -0
  67. package/lib/types/domain/row-v4.js +159 -0
  68. package/lib/types/domain/run-coordinator.d.ts +51 -0
  69. package/lib/types/domain/run-coordinator.js +45 -0
  70. package/lib/types/domain/schema4.d.ts +53 -0
  71. package/lib/types/domain/schema4.js +129 -0
  72. package/lib/types/domain/selection-policy.d.ts +14 -0
  73. package/lib/types/domain/selection-policy.js +6 -0
  74. package/lib/types/domain/skipped-expiry.d.ts +5 -0
  75. package/lib/types/domain/skipped-expiry.js +16 -0
  76. package/lib/types/domain/stage-aggregate.d.ts +96 -0
  77. package/lib/types/domain/stage-aggregate.js +293 -0
  78. package/lib/types/domain/staging-index.d.ts +1 -0
  79. package/lib/types/domain/state-bootstrap.d.ts +43 -0
  80. package/lib/types/domain/state-bootstrap.js +267 -0
  81. package/lib/types/domain/state-v3.d.ts +3 -14
  82. package/lib/types/domain/state-v3.js +3 -79
  83. package/lib/types/domain/undo-journal.d.ts +19 -0
  84. package/lib/types/domain/undo-journal.js +20 -0
  85. package/lib/types/domain/undo-receipt.d.ts +18 -0
  86. package/lib/types/domain/undo-receipt.js +22 -0
  87. package/lib/types/domain/validation-plan.d.ts +65 -0
  88. package/lib/types/domain/validation-plan.js +49 -0
  89. package/lib/types/facts.d.ts +3 -1
  90. package/lib/types/facts.js +13 -16
  91. package/lib/types/index.d.ts +2 -0
  92. package/lib/types/index.js +181 -68
  93. package/lib/types/merge-command.d.ts +3 -1
  94. package/lib/types/merge-command.js +10 -6
  95. package/lib/types/merge-routes.d.ts +10 -5
  96. package/lib/types/merge-routes.js +72 -7
  97. package/lib/types/merge-tool.d.ts +1 -1
  98. package/lib/types/merge-tool.js +2 -2
  99. package/lib/types/metrics.js +1 -1
  100. package/lib/types/new-child-authoring.d.ts +2 -0
  101. package/lib/types/new-child-authoring.js +57 -10
  102. package/lib/types/params.d.ts +2 -4
  103. package/lib/types/params.js +13 -0
  104. package/lib/types/pending-actions.d.ts +2 -2
  105. package/lib/types/pending-actions.js +4 -4
  106. package/lib/types/prompts.d.ts +2 -4
  107. package/lib/types/prompts.js +21 -8
  108. package/lib/types/read-commands.d.ts +3 -3
  109. package/lib/types/read-commands.js +19 -28
  110. package/lib/types/recovery-coordinator.d.ts +34 -0
  111. package/lib/types/recovery-coordinator.js +84 -0
  112. package/lib/types/recovery-projection.d.ts +31 -0
  113. package/lib/types/recovery-projection.js +96 -0
  114. package/lib/types/resume-command.d.ts +5 -1
  115. package/lib/types/resume-command.js +46 -13
  116. package/lib/types/revision.d.ts +17 -0
  117. package/lib/types/revision.js +39 -3
  118. package/lib/types/round2-outcome.d.ts +2 -1
  119. package/lib/types/round2-outcome.js +59 -11
  120. package/lib/types/run-shell.d.ts +16 -2
  121. package/lib/types/run-shell.js +49 -5
  122. package/lib/types/runtime.d.ts +0 -5
  123. package/lib/types/runtime.js +274 -89
  124. package/lib/types/scan-command.d.ts +1 -0
  125. package/lib/types/scan-command.js +2 -0
  126. package/lib/types/scan-ports.d.ts +18 -0
  127. package/lib/types/scan-ports.js +49 -23
  128. package/lib/types/scan-run.d.ts +17 -0
  129. package/lib/types/scan-run.js +143 -48
  130. package/lib/types/stage-tool-registry.d.ts +7 -0
  131. package/lib/types/stage-tool-registry.js +12 -1
  132. package/lib/types/state.d.ts +5 -5
  133. package/lib/types/state.js +22 -69
  134. package/lib/types/status.d.ts +6 -1
  135. package/lib/types/status.js +47 -0
  136. package/lib/types/surface/body.d.ts +4 -0
  137. package/lib/types/surface/body.js +16 -0
  138. package/lib/types/surface/merge-view.d.ts +14 -0
  139. package/lib/types/surface/merge-view.js +126 -0
  140. package/lib/types/undo-operation.d.ts +24 -0
  141. package/lib/types/undo-operation.js +135 -0
  142. package/lib/types/wire.d.ts +22 -3
  143. package/package.json +6 -4
@@ -3,33 +3,64 @@
3
3
  * Plugin wiring supplies the channel, model and library; this module keeps the orchestration
4
4
  * out of apply() and makes the mutating verbs testable with fakes.
5
5
  */
6
- import { probeText, SkillLibrary, UnitTransactions, skillLibraryUnitBackend } from '@lmzhen/dsh-evolution-core';
6
+ import { createHash } from 'node:crypto';
7
+ import { errorText, probeText, SkillLibrary, UnitTransactions, skillLibraryUnitBackend } from '@lmzhen/dsh-evolution-core';
7
8
  import { targetClaimUnitPort } from "./unit-claim.js";
8
9
  import { canRetry, pairKey } from "./contracts.js";
9
10
  import { skippedPairKeys } from "./skipped.js";
10
11
  import { createReadOnlyMergeService } from "./merge-command.js";
11
- import { formatRunsView } from "./status.js";
12
- import { toRunsView } from "./wire-projection.js";
12
+ import { formatRunDetail, formatRunsView } from "./status.js";
13
+ import { readMergeSnapshot } from "./domain/merge-snapshot.js";
14
+ import { buildRunDetail, buildRunsView } from "./surface/merge-view.js";
13
15
  import { handleReadOnlyMergeCommand } from "./read-commands.js";
14
16
  import { runScanCommand } from "./scan-command.js";
15
17
  import { runApproveCommand } from "./approve-command.js";
16
18
  import { runResumeCommand } from "./resume-command.js";
17
- import { dropRowV3, loadMergeState, transactRows } from "./state.js";
19
+ import { loadMergeState } from "./state.js";
20
+ import { pairLedger } from "./domain/pair-ledger.js";
18
21
  import { readCommitReceipt } from "./commit-receipt.js";
19
- import { undoMergeReceipt } from "./undo-merge.js";
20
- import { recordPair } from "./domain/pair-index-store.js";
21
- import { readStagingIndex } from "./domain/staging-index.js";
22
+ import { runUndoOperation } from "./undo-operation.js";
23
+ import { readStageIndex } from "./domain/stage-aggregate.js";
22
24
  import { skillLibraryUndoPort } from "./adapters/undo.js";
23
25
  import { mergeDoctor } from "./doctor.js";
24
- import { createChannelScanPorts } from "./scan-ports.js";
26
+ import { formatReconcileReport, reconcileMergeState } from "./domain/reconcile.js";
27
+ import { commandError, formatMergeError } from "./domain/errors.js";
28
+ import { createChannelScanPorts, createStageForCards } from "./scan-ports.js";
25
29
  import { createNewChildRound2 } from "./new-child-authoring.js";
26
30
  import { skillLibraryPort } from "./adapters/library.js";
27
31
  import { fitToolText } from "./presentation-budget.js";
28
- import { sourceRevisionOf, targetRevisionOf } from "./revision.js";
32
+ import { sourceRevisionOf, targetRevisionOf, sourceRevisionRefOf, targetRevisionRefOf, supportDigestOf, revisionMismatch } from "./revision.js";
33
+ import { inFlightPairRun, readRunMetas } from "./domain/run-coordinator.js";
34
+ import { liveRowFactsOf } from "./domain/live-facts.js";
35
+ function fail(stage, code, hint, next, runId) {
36
+ return { ok: false, text: formatMergeError(commandError(stage, code, hint, next, runId)) };
37
+ }
38
+ function failRun(stage, hint, next, result) {
39
+ return result.error === undefined ? fail(stage, 'run-failed', hint, next, result.runId) : { ok: false, text: formatMergeError(result.error) };
40
+ }
41
+ /**
42
+ * The approved/fuse row that already holds a target. Pair/approve move a row to
43
+ * approved/fuse BEFORE their run starts, so this row IS the in-flight claim a second
44
+ * same-target run would collide with (the pair itself is guarded by run-meta).
45
+ */
46
+ function busyTarget(rows, target, excludeRowId) {
47
+ const row = rows.find(item => item.id !== excludeRowId && (item.state === 'approved' || item.state === 'fuse') && item.decision?.target === target);
48
+ return row === undefined ? undefined : { id: row.id, ...(row.runId === undefined ? {} : { runId: row.runId }) };
49
+ }
29
50
  function forcedDecisionFor(a, b) {
30
51
  const sorted = [a, b].sort();
31
- const target = ('merge-' + sorted.join('-')).replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64);
32
- return { shouldMerge: true, mode: 'create-umbrella', target, sources: [a, b], rationale: 'forced pair merge of ' + sorted.join(' and ') };
52
+ const raw = ('merge-' + sorted.join('-')).replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
53
+ const target = raw.length <= 64 ? raw : raw.slice(0, 55) + '-' + createHash('sha256').update(raw).digest('hex').slice(0, 8);
54
+ return { shouldMerge: true, mode: 'create-umbrella', target, sources: [a, b], rationale: 'forced pair merge of ' + sorted.join(' and '), confidence: 0.5, aSummary: 'A: ' + sorted[0] + ' 的现有技能面。', bSummary: 'B: ' + sorted[1] + ' 的现有技能面。' };
55
+ }
56
+ async function removeRuntimeRow(io, home, id, event, unitId) {
57
+ return await pairLedger(io, home).transact(tx => {
58
+ const current = tx.rows().find(item => item.id === id);
59
+ if (current === undefined)
60
+ return false;
61
+ const unit = event === 'committed' ? { unitId: unitId ?? 'unknown', evidenceSha256: 'legacy' } : undefined;
62
+ return tx.remove(id, event, unit, current.version).ok;
63
+ });
33
64
  }
34
65
  export function unitFor(deps) {
35
66
  if (deps.unit !== undefined)
@@ -55,42 +86,59 @@ export function createMergeRuntime(deps) {
55
86
  return {
56
87
  async execute(command, context) {
57
88
  const parent = context?.parent ?? deps.parent;
89
+ const agentless = context?.agentless === true;
58
90
  const detached = deps.detached !== false;
59
91
  if (command.verb === 'pair') {
60
92
  const signal = new AbortController().signal;
61
93
  if (command.a === command.b)
62
- return { ok: false, text: 'merge pair 需要两个不同技能源: ' + command.a };
94
+ return fail('pair', 'pair-same-source', 'merge pair 需要两个不同技能源:' + command.a, '重新指定两个不同的 family-managed 技能');
95
+ if ([command.a, command.b].some(name => name.includes('/') || name.includes(':')))
96
+ return fail('pair', 'target-invalid', '技能名不能包含 provider/name 或 provider:name 语法:' + command.a + ' / ' + command.b, '使用裸技能名重新 pair');
97
+ // FR-RUN-03: a session-less invocation cannot host a merge child. Answer E-305 in
98
+ // the three-line shape instead of opening a run that is guaranteed to fail.
99
+ if (agentless)
100
+ return fail('pair', 'e-305', errorText('e-305-this-invocation-carries-no', { a1: 'pair' }), '在 GUI 或 CLI 的会话里运行 /evolution merge pair ' + command.a + ' ' + command.b);
101
+ // FR-EXEC-17/NFR-04: two pair runs for one pair must not race for the same row.
102
+ const pairKeyValue = pairKey(command.a, command.b);
103
+ const inFlight = inFlightPairRun(await readRunMetas(deps.io, deps.home), pairKeyValue);
104
+ if (inFlight !== undefined && (deps.isOwnerActive === undefined || (await deps.isOwnerActive(inFlight.runId)) === true)) {
105
+ return fail('pair', 'merge-pair-in-flight', '同一 pair 已在运行 ' + inFlight.runId, '等待该 run 结束,或运行 /evolution merge status ' + inFlight.runId, inFlight.runId);
106
+ }
63
107
  const cards = await deps.cards(signal);
64
108
  const cardA = cards.find(card => card.name === command.a);
65
109
  const cardB = cards.find(card => card.name === command.b);
66
110
  if (cardA === undefined || cardB === undefined || cardA.managed !== true || cardB.managed !== true) {
67
- return { ok: false, text: 'merge pair 仅支持 family-managed 技能: ' + command.a + ' / ' + command.b };
111
+ return fail('pair', 'pair-not-managed', 'merge pair 仅支持 family-managed 技能:' + command.a + ' / ' + command.b, '运行 /evolution doctor 查看 family-managed 技能范围');
68
112
  }
69
113
  if (cardA.protectedBy != null || cardB.protectedBy != null
70
114
  || cardA.protectionUnknown === true || cardB.protectionUnknown === true) {
71
- return { ok: false, text: 'merge pair 不接受受保护/保护状态未知技能: ' + command.a + ' / ' + command.b };
115
+ return fail('pair', 'pair-protected', 'merge pair 不接受受保护/保护状态未知技能:' + command.a + ' / ' + command.b, '解除保护或换一对技能后重试');
72
116
  }
73
117
  if ((await deps.isBlocked?.(command.a)) === true || (await deps.isBlocked?.(command.b)) === true) {
74
- return { ok: false, text: 'merge pair 不接受被范围门拦下的技能: ' + command.a + ' / ' + command.b };
118
+ return fail('pair', 'pair-blocked', 'merge pair 不接受被范围门拦下的技能:' + command.a + ' / ' + command.b, '运行 /evolution scope 查看范围门,或换一对技能');
75
119
  }
76
120
  const pair = [{ a: command.a, b: command.b }];
77
121
  const existing = (await loadMergeState(deps.io, deps.home)).state.rows.find(row => row.pairKey === pairKey(command.a, command.b));
78
122
  if (existing?.state === 'skipped') {
79
- const dropped = await dropRowV3(deps.io, deps.home, existing.id, 'superseded');
123
+ const dropped = await removeRuntimeRow(deps.io, deps.home, existing.id, 'superseded');
80
124
  if (!dropped)
81
- return { ok: false, text: 'row changed before cleanup: ' + existing.id };
125
+ return fail('pair', 'row-changed', '清理 skipped 行时发现并发修改:' + existing.id, '运行 /evolution merge status 后重试');
126
+ }
127
+ if (existing?.state === 'verify' && existing.runId !== undefined && (await deps.isOwnerActive?.(existing.runId)) === true) {
128
+ return fail('pair', 'merge-pair-in-flight', '同一 pair 已在运行 ' + existing.runId, '等待该 run 结束,或运行 /evolution merge status ' + existing.runId, existing.runId);
82
129
  }
83
130
  if (existing?.state === 'pending') {
84
- return { ok: false, text: '该 pair 已在待批准队列,先 approve/reject/discard: ' + existing.id };
131
+ return fail('pair', 'pair-pending', '该 pair 已在待批准队列:' + existing.id, '先运行 /evolution merge approve/reject/discard ' + existing.id, existing.runId);
85
132
  }
86
133
  if (existing?.state === 'approved' || existing?.state === 'fuse') {
87
- return { ok: false, text: '该 pair 已在待合并队列,用 merge resume --retry 复用: ' + (existing.runId ?? existing.id) };
134
+ return fail('pair', 'pair-fuse', '该 pair 已在待合并队列,可复用决策重跑', '运行 /evolution merge resume --retry ' + (existing.runId ?? existing.id), existing.runId);
88
135
  }
89
136
  const forced = command.force === true ? forcedDecisionFor(command.a, command.b) : undefined;
90
137
  const approval = deps.params.mergeApproval;
91
138
  const result = await runScanCommand({
92
139
  ...common,
93
140
  approval,
141
+ cards,
94
142
  ...(detached ? { detached: true } : {}),
95
143
  makePorts: (runId) => {
96
144
  const ports = createChannelScanPorts({
@@ -101,6 +149,7 @@ export function createMergeRuntime(deps) {
101
149
  timeoutMs: deps.params.mergePairTimeoutMinutes * 60_000,
102
150
  approval,
103
151
  commitPort: skillLibraryPort(deps.skills), now: deps.now, ...(unitFor(deps) === undefined ? {} : { unit: unitFor(deps) }),
152
+ ...(deps.params.limitsSnapshot === undefined ? {} : { limits: deps.params.limitsSnapshot.skillLimits }),
104
153
  ...(deps.isBlocked === undefined ? {} : { isBlocked: deps.isBlocked }),
105
154
  ...stageHooks,
106
155
  });
@@ -108,14 +157,16 @@ export function createMergeRuntime(deps) {
108
157
  },
109
158
  });
110
159
  if (!result.ok)
111
- return { ok: false, text: result.message };
160
+ return failRun('pair', result.message, '修复后重新发起 pair;run 报告见 /evolution merge report', result);
112
161
  if ('accepted' in result)
113
162
  return { ok: true, text: 'merge pair 已启动:' + result.runId };
114
163
  if (!('report' in result))
115
- return { ok: false, text: 'merge pair started without a report' };
164
+ return fail('pair', 'report-missing', 'merge pair 已结束但没有写报告', '重新运行 pair,或运行 /evolution merge runs 查看运行');
116
165
  return { ok: true, text: 'merge pair 完成:merged ' + String(result.report.totals.merged) + ' / pending ' + String(result.report.totals.pending) + ' / failed ' + String(result.report.totals.failed) };
117
166
  }
118
167
  if (command.verb === 'scan') {
168
+ if (agentless)
169
+ return fail('scan', 'e-305', errorText('e-305-this-invocation-carries-no', { a1: 'scan' }), '在 GUI 或 CLI 的会话里运行 /evolution merge scan');
119
170
  const signal = new AbortController().signal;
120
171
  const cards = await deps.cards(signal);
121
172
  const blocked = new Set();
@@ -130,57 +181,128 @@ export function createMergeRuntime(deps) {
130
181
  const held = new Set(loadedState.state.rows.filter(row => row.queue === 'pending' || row.queue === 'fuse'
131
182
  || (row.queue === 'verify' && (row.runId !== undefined && deps.now() - Date.parse(row.updatedAt) < 10 * 60_000 || !canRetry(row))))
132
183
  .map(row => row.pairKey));
133
- const conflictNotes = loadedState.state.rows
184
+ const notes = loadedState.state.rows
134
185
  .filter(row => row.state === 'pending' || row.state === 'approved' || row.state === 'fuse' || row.state === 'skipped')
135
186
  .map(row => 'conflict:' + row.state + ':' + row.pairKey + ':' + row.id);
136
187
  const result = await runScanCommand({
137
188
  ...common,
138
189
  approval: deps.params.mergeApproval,
139
- notes: conflictNotes,
190
+ notes,
191
+ cards,
140
192
  ...(detached ? { detached: true } : {}),
141
193
  makePorts: runId => createChannelScanPorts({
142
194
  io: deps.io, home: deps.home, runId, cards,
195
+ resolve: async (candidate) => {
196
+ const live = await deps.cards(signal);
197
+ return live.some(card => card.name === candidate.a) && live.some(card => card.name === candidate.b);
198
+ },
143
199
  screening: async (turnSignal) => {
144
200
  const raw = await deps.screeningForRun(cards, runId, turnSignal);
201
+ const degraded = raw.degraded;
202
+ if (degraded !== undefined)
203
+ notes.push(...degraded);
145
204
  const filtered = raw.filter((candidate) => {
146
205
  const key = pairKey(candidate.a, candidate.b);
147
206
  return managed.has(candidate.a) && managed.has(candidate.b) && !skipped.has(key) && !held.has(key);
148
207
  });
149
208
  const candidatePairs = raw.candidatePairs;
150
- return candidatePairs === undefined ? filtered : Object.assign(filtered, { candidatePairs });
209
+ const reportOnly = raw.reportOnly;
210
+ return Object.assign(filtered, {
211
+ ...(candidatePairs === undefined ? {} : { candidatePairs }),
212
+ ...(reportOnly === undefined ? {} : { reportOnly }),
213
+ });
151
214
  },
152
215
  channel: deps.channel, parent, allow: deps.allow,
153
216
  ...(deps.cwd === undefined ? {} : { cwd: deps.cwd }),
154
217
  timeoutMs: deps.params.mergePairTimeoutMinutes * 60_000,
155
218
  approval: deps.params.mergeApproval,
156
219
  commitPort: skillLibraryPort(deps.skills), now: deps.now, ...(unitFor(deps) === undefined ? {} : { unit: unitFor(deps) }),
220
+ ...(deps.params.limitsSnapshot === undefined ? {} : { limits: deps.params.limitsSnapshot.skillLimits }),
157
221
  ...(deps.isBlocked === undefined ? {} : { isBlocked: deps.isBlocked }),
158
222
  ...stageHooks,
159
223
  }),
160
224
  });
161
225
  if (!result.ok)
162
- return { ok: false, text: result.message };
226
+ return failRun('scan', result.message, '修复后重新 scan;run 报告见 /evolution merge report', result);
163
227
  if ('accepted' in result)
164
228
  return { ok: true, text: 'merge scan 已启动:' + result.runId };
165
229
  if (!('report' in result))
166
- return { ok: false, text: 'merge scan started without a report' };
230
+ return fail('scan', 'report-missing', 'merge scan 已结束但没有写报告', '重新运行 scan,或运行 /evolution merge runs 查看运行');
167
231
  return { ok: true, text: 'merge scan 完成:merged ' + String(result.report.totals.merged) + ' / pending ' + String(result.report.totals.pending) + ' / failed ' + String(result.report.totals.failed) };
168
232
  }
233
+ if (command.verb === 'candidates' && command.retry === true) {
234
+ if (command.id === undefined || command.id === '')
235
+ return fail('candidates', 'candidates-id-required', 'candidates --retry 需要行 id', '运行 /evolution merge candidates 查看可重试行');
236
+ const loaded = await loadMergeState(deps.io, deps.home);
237
+ const row = loaded.state.rows.find(item => item.id === command.id);
238
+ if (row === undefined)
239
+ return fail('candidates', 'row-not-found', '找不到候选行:' + command.id, '运行 /evolution merge candidates 查看候选');
240
+ if (row.state !== 'verify')
241
+ return fail('candidates', 'candidates-retry-unavailable', '只有待判断行可重跑第 1 轮:' + row.state, '改用 resume --retry 或重新 pair');
242
+ const signal = new AbortController().signal;
243
+ const cards = await deps.cards(signal);
244
+ const pairCandidate = { a: row.a, b: row.b };
245
+ const result = await runScanCommand({
246
+ ...common,
247
+ approval: deps.params.mergeApproval,
248
+ notes: ['candidates --retry ' + row.id],
249
+ cards,
250
+ makePorts: runId => createChannelScanPorts({
251
+ io: deps.io, home: deps.home, runId, cards,
252
+ screening: async () => [pairCandidate],
253
+ resolve: async (candidate) => {
254
+ const live = await deps.cards(signal);
255
+ return live.some(card => card.name === candidate.a) && live.some(card => card.name === candidate.b);
256
+ },
257
+ channel: deps.channel, parent, allow: deps.allow,
258
+ ...(deps.cwd === undefined ? {} : { cwd: deps.cwd }),
259
+ timeoutMs: deps.params.mergePairTimeoutMinutes * 60_000,
260
+ approval: deps.params.mergeApproval,
261
+ commitPort: skillLibraryPort(deps.skills), now: deps.now, ...(unitFor(deps) === undefined ? {} : { unit: unitFor(deps) }),
262
+ ...(deps.params.limitsSnapshot === undefined ? {} : { limits: deps.params.limitsSnapshot.skillLimits }),
263
+ ...(deps.isBlocked === undefined ? {} : { isBlocked: deps.isBlocked }),
264
+ ...stageHooks,
265
+ }),
266
+ });
267
+ if (!result.ok)
268
+ return failRun('candidates', result.message, '修复后重试 candidates --retry', result);
269
+ if ('accepted' in result)
270
+ return { ok: true, text: 'candidates --retry 已启动:' + result.runId };
271
+ if (!('report' in result))
272
+ return fail('candidates', 'report-missing', 'candidates --retry 已结束但没有写报告', '运行 /evolution merge runs 查看运行');
273
+ return { ok: true, text: 'candidates --retry 完成:merged ' + String(result.report.totals.merged) + ' / pending ' + String(result.report.totals.pending) + ' / failed ' + String(result.report.totals.failed) };
274
+ }
169
275
  if (command.verb === 'reject') {
170
276
  const loaded = await loadMergeState(deps.io, deps.home);
171
277
  const row = loaded.state.rows.find(item => item.id === command.id);
172
278
  if (row === undefined)
173
- return { ok: false, text: 'merge row not found: ' + command.id };
174
- const changed = await transactRows(deps.io, deps.home, tx => {
279
+ return fail('reject', 'row-not-found', '找不到合并行 ' + command.id, '运行 /evolution merge pending 查看待处理行', command.id);
280
+ const changed = await pairLedger(deps.io, deps.home).transact(tx => {
175
281
  const current = tx.rows().find(item => item.id === row.id);
176
282
  if (current === undefined || current.state !== 'pending')
177
283
  return false;
178
284
  return tx.transition(row.id, { type: 'skipped', reason: 'rejected', origin: 'human' }, current.version).ok;
179
285
  });
180
286
  if (!changed)
181
- return { ok: false, text: 'merge row is not pending or changed: ' + command.id };
287
+ return fail('reject', 'row-not-pending', '该行不是 pending,或已被并发修改:' + command.id, '运行 /evolution merge status ' + command.id + ' 后重试', row.runId);
182
288
  return { ok: true, text: '已拒绝合并:' + command.id };
183
289
  }
290
+ if (command.verb === 'reconcile') {
291
+ const cards = await deps.cards(new AbortController().signal).catch(() => undefined);
292
+ // An empty/unreadable library is NOT "every source is gone": only a non-empty live
293
+ // list may authorize source-gone convergence.
294
+ const managed = cards === undefined || cards.length === 0 ? undefined : new Set(cards.map(card => card.name));
295
+ const live = cards === undefined || cards.length === 0 ? undefined : liveRowFactsOf(cards);
296
+ try {
297
+ const report = await reconcileMergeState({ io: deps.io, home: deps.home, now: deps.now, ttlDays: deps.params.mergeSkippedTtlDays, ...(managed === undefined ? {} : { managed }), ...(live === undefined ? {} : { live }) });
298
+ if (report.state === 'blocked')
299
+ return fail('reconcile', report.code ?? 'merge-state-blocked', report.message ?? '合并状态不可读', '先修复合并状态,再运行 /evolution merge doctor');
300
+ return { ok: true, text: formatReconcileReport(report) };
301
+ }
302
+ catch (error) {
303
+ return fail('reconcile', 'reconcile-failed', error instanceof Error ? error.message : String(error), '运行 /evolution merge doctor 查看状态后重试');
304
+ }
305
+ }
184
306
  if (command.verb === 'doctor') {
185
307
  const rows = await mergeDoctor(deps.io, deps.home, deps.params.mergeModelsRoot);
186
308
  return { ok: true, text: rows.map(row => row.key + ' [' + row.status + '] ' + row.label + ':' + row.detail).join('\n') };
@@ -189,96 +311,104 @@ export function createMergeRuntime(deps) {
189
311
  const registry = deps.registry;
190
312
  if (command.verb === 'runs') {
191
313
  const mergeRuns = registry.runs('merge');
192
- return { ok: true, text: formatRunsView(toRunsView(mergeRuns, deps.now())) };
314
+ const snapshot = await readMergeSnapshot({ io: deps.io, home: deps.home, runs: mergeRuns, approval: deps.params.mergeApproval, now: deps.now });
315
+ return { ok: true, text: formatRunsView(buildRunsView(snapshot)) };
193
316
  }
194
317
  if (command.verb === 'cancel') {
195
318
  if (command.id !== undefined) {
196
319
  const cancelled = registry.cancel(command.id);
197
- return { ok: cancelled, text: cancelled ? '已请求取消 ' + command.id : '没有可取消的 run: ' + command.id };
320
+ return cancelled ? { ok: true, text: '已请求取消 ' + command.id } : fail('cancel', 'run-not-found', '没有可取消的 run:' + command.id, '运行 /evolution merge runs 查看可取消的运行');
198
321
  }
199
- const count = registry.cancelAll();
200
- return { ok: true, text: '已请求取消 ' + String(count) + ' 个运行' };
322
+ const runs = registry.runs('merge');
323
+ let count = 0;
324
+ for (const run of runs)
325
+ if (registry.cancel(run.id))
326
+ count += 1;
327
+ return { ok: true, text: '已请求取消 ' + String(count) + ' 个 merge 运行' };
201
328
  }
202
329
  const run = command.id === undefined ? registry.runs('merge')[0] : registry.find(command.id);
203
330
  if (run === undefined)
204
- return { ok: false, text: 'merge run not found: ' + (command.id ?? '(latest)') };
331
+ return fail('report', 'run-not-found', '找不到 merge run:' + (command.id ?? '(latest)'), '运行 /evolution merge runs 查看运行列表');
205
332
  if (run.resultRef === undefined)
206
- return { ok: false, text: 'merge run has no report yet: ' + run.id };
333
+ return fail('report', 'report-missing', '该 run 还没有报告:' + run.id, '等待 run 结束,或运行 /evolution merge status ' + run.id, run.id);
207
334
  const report = await probeText(deps.io, run.resultRef);
208
335
  if (report.kind === 'unknown')
209
- return { ok: false, text: 'report is unreadable: ' + run.resultRef + ':' + report.reason };
336
+ return fail('report', 'report-unreadable', '报告不可读:' + run.resultRef + '(' + report.reason + ')', '检查结果文件权限,或重新运行该命令', run.id);
210
337
  return report.kind === 'absent'
211
- ? { ok: false, text: 'report is missing: ' + run.resultRef }
338
+ ? fail('report', 'report-missing', '报告不存在:' + run.resultRef, '重新运行该命令生成报告', run.id)
212
339
  : { ok: true, text: 'report: ' + run.resultRef + '\n' + fitToolText(report.value, 7600) };
213
340
  }
214
341
  if (command.verb === 'discard') {
215
342
  const loaded = await loadMergeState(deps.io, deps.home);
216
343
  const row = loaded.state.rows.find(item => item.id === command.id);
217
344
  if (row === undefined)
218
- return { ok: false, text: 'merge row not found: ' + command.id };
345
+ return fail('discard', 'row-not-found', '找不到合并行 ' + command.id, '运行 /evolution merge status 查看当前队列', command.id);
219
346
  if (row.state !== 'pending' && row.state !== 'approved' && row.state !== 'fuse')
220
- return { ok: false, text: 'merge row is not discardable: ' + command.id };
221
- const dropped = await dropRowV3(deps.io, deps.home, row.id, 'superseded');
347
+ return fail('discard', 'row-not-discardable', '该行状态是 ' + row.state + ',不可丢弃:' + command.id, '只有 pending/approved/fuse 行可丢弃', row.runId);
348
+ const dropped = await removeRuntimeRow(deps.io, deps.home, row.id, 'superseded');
222
349
  if (!dropped)
223
- return { ok: false, text: 'row changed before discard: ' + command.id };
350
+ return fail('discard', 'row-changed', '丢弃时发现并发修改:' + command.id, '运行 /evolution merge status ' + command.id + ' 后重试');
224
351
  return { ok: true, text: '已丢弃合并行:' + command.id };
225
352
  }
226
353
  if (command.verb === 'undo') {
227
354
  const receipt = await readCommitReceipt(deps.io, deps.home, command.run, command.target);
228
355
  if (receipt === null)
229
- return { ok: false, text: 'merge run has no commit receipt: ' + command.run };
356
+ return fail('undo', 'receipt-not-found', '该 run 没有提交回执:' + command.run, '只有已提交的 run 可 undo;运行 /evolution merge runs 查看');
230
357
  if (receipt.target !== command.target)
231
- return { ok: false, text: 'receipt target is ' + receipt.target + ', not ' + command.target };
232
- const result = await undoMergeReceipt(receipt, skillLibraryUndoPort(deps.skills));
233
- if (result.ok && receipt.pairKey !== undefined) {
234
- await recordPair(deps.io, deps.home, { pairKey: receipt.pairKey, runId: receipt.parentRunId ?? receipt.runId, result: 'undone', target: receipt.target });
235
- }
236
- return { ok: result.ok, text: result.message };
358
+ return fail('undo', 'receipt-target-mismatch', '回执目标是 ' + receipt.target + ',不是 ' + command.target, '按回执目标重新运行 undo');
359
+ const undoRunId = receipt.parentRunId ?? receipt.runId;
360
+ const undoRegistry = deps.registry;
361
+ if (undoRegistry.runs('merge').some(run => run.id === undoRunId && run.supersededAt !== undefined))
362
+ return fail('undo', 'run-superseded', '该 run 已因技能树 restore 失效,不能再 undo:' + undoRunId, '运行 /evolution merge runs 查看失效标记');
363
+ const unit = unitFor(deps);
364
+ const result = await runUndoOperation({ io: deps.io, home: deps.home, runId: undoRunId, port: skillLibraryUndoPort(deps.skills), now: deps.now, ...(receipt.pairKey === undefined ? {} : { pairKey: receipt.pairKey }), ...(unit === undefined ? {} : { finalizeUnit: id => unit.finalize.call(unit, id) }) });
365
+ return result.ok ? { ok: true, text: result.message } : fail('undo', 'undo-failed', result.message, '检查回执与暂存后重试');
237
366
  }
238
367
  if (command.verb === 'skipped' && command.restore !== undefined) {
239
368
  const loaded = await loadMergeState(deps.io, deps.home);
240
369
  const row = loaded.state.rows.find(item => item.id === command.restore);
241
370
  if (row === undefined || row.state !== 'skipped')
242
- return { ok: false, text: 'skipped row not found: ' + command.restore };
243
- const dropped = await dropRowV3(deps.io, deps.home, row.id, 'restored');
371
+ return fail('skipped', 'row-not-found', '找不到可恢复的 skipped 行:' + command.restore, '运行 /evolution merge skipped 查看跳过队列', command.restore);
372
+ const dropped = await removeRuntimeRow(deps.io, deps.home, row.id, 'restored');
244
373
  if (!dropped)
245
- return { ok: false, text: 'row changed before restore: ' + row.id };
374
+ return fail('skipped', 'row-changed', '恢复时发现并发修改:' + row.id, '运行 /evolution merge status 后重试');
246
375
  return { ok: true, text: '已恢复参与筛选:' + row.id };
247
376
  }
248
377
  if (command.verb === 'resume') {
249
378
  if (command.clear !== undefined) {
250
379
  const cleared = (await loadMergeState(deps.io, deps.home)).state.rows.find(item => item.id === command.clear);
251
380
  if (cleared === undefined)
252
- return { ok: false, text: 'merge row not found: ' + command.clear };
253
- const dropped = await dropRowV3(deps.io, deps.home, cleared.id, 'superseded');
381
+ return fail('resume', 'row-not-found', '找不到要清除的行:' + command.clear, '运行 /evolution merge status 查看当前队列');
382
+ const dropped = await removeRuntimeRow(deps.io, deps.home, cleared.id, 'superseded');
254
383
  if (!dropped)
255
- return { ok: false, text: 'row changed before clear: ' + cleared.id };
384
+ return fail('resume', 'row-changed', '清除时发现并发修改:' + cleared.id, '运行 /evolution merge status 后重试');
256
385
  return { ok: true, text: '已清除合并行:' + cleared.id };
257
386
  }
258
387
  const loaded = await loadMergeState(deps.io, deps.home);
259
- let row = command.id === undefined ? undefined : loaded.state.rows.find(item => item.id === command.id);
260
388
  if (command.id === undefined) {
261
- for (const item of loaded.state.rows) {
262
- if (item.state !== 'fuse' && item.state !== 'verify' && !(command.retry === true && item.state === 'approved'))
263
- continue;
264
- if (item.runId !== undefined && (await deps.isOwnerActive?.(item.runId)) === true)
265
- continue;
266
- row = item;
267
- break;
268
- }
389
+ const resumable = loaded.state.rows.filter(item => item.state === 'fuse' || item.state === 'verify' || (command.retry === true && item.state === 'approved'));
390
+ return fail('resume', 'resume-id-required', 'merge resume 需要 <id>;可恢复行:' + (resumable.length === 0 ? '(none)' : resumable.map(item => item.id).join(', ')), '运行 /evolution merge resume --retry <id> 复用某一行的决策');
269
391
  }
392
+ const row = loaded.state.rows.find(item => item.id === command.id);
270
393
  if (row === undefined)
271
- return { ok: false, text: 'merge row not found: ' + (command.id ?? '(auto)') };
394
+ return fail('resume', 'row-not-found', '找不到可恢复行 ' + command.id, '运行 /evolution merge candidates 查看可恢复行');
272
395
  const signal = new AbortController().signal;
273
396
  const cards = await deps.cards(signal);
274
397
  if ((row.state === 'fuse' || row.state === 'approved') && row.decision !== undefined && row.decision.mode !== 'create-umbrella') {
275
398
  const targetCard = cards.find(card => card.name === row.decision?.target);
276
399
  if (targetCard !== undefined && targetCard.managed !== true)
277
- return { ok: false, text: 'target 不是 family-managed:' + row.decision.target };
400
+ return fail('resume', 'target-not-managed', 'target 不是 family-managed:' + row.decision.target, '重新 pair 并选一个 managed 目标', row.runId);
278
401
  if (targetCard !== undefined && (targetCard.protectionUnknown === true || targetCard.protectedBy != null))
279
- return { ok: false, text: 'target 受保护:' + row.decision.target };
402
+ return fail('resume', 'target-protected', 'target 受保护:' + row.decision.target, '解除保护或改选其它目标', row.runId);
280
403
  if (await deps.isBlocked?.(row.decision.target) === true)
281
- return { ok: false, text: 'target 被范围门拦下:' + row.decision.target };
404
+ return fail('resume', 'target-blocked', 'target 被范围门拦下:' + row.decision.target, '运行 /evolution scope 查看范围门', row.runId);
405
+ }
406
+ // NFR-04: a resume commits into the same target a running approve/pair may hold;
407
+ // the target is serialised here too, not only at the commit claim.
408
+ if ((row.state === 'fuse' || row.state === 'approved') && row.decision !== undefined) {
409
+ const busy = busyTarget(loaded.state.rows, row.decision.target, row.id);
410
+ if (busy !== undefined)
411
+ return fail('resume', 'merge-target-busy', '目标 ' + row.decision.target + ' 正被 ' + busy.id + ' 占用,串行等待', '等待占用行结算,或 /evolution merge discard ' + busy.id, busy.runId);
282
412
  }
283
413
  const pair = () => Promise.resolve([{ a: row.a, b: row.b }]);
284
414
  const failed = (stage, code, message) => Promise.resolve({
@@ -291,7 +421,8 @@ export function createMergeRuntime(deps) {
291
421
  round2: () => failed('author', 'resume-needs-child', 'fuse resume needs a new child'),
292
422
  };
293
423
  const result = await runResumeCommand({
294
- ...common, rowId: row.id, ports: basePorts,
424
+ ...common, rowId: row.id, ports: basePorts, approval: deps.params.mergeApproval,
425
+ ...(agentless ? { agentless: true } : {}),
295
426
  ...(detached ? { detached: true } : {}),
296
427
  pairTimeoutMinutes: deps.params.mergePairTimeoutMinutes,
297
428
  ...(deps.isOwnerActive === undefined ? {} : { isOwnerActive: deps.isOwnerActive }),
@@ -301,6 +432,7 @@ export function createMergeRuntime(deps) {
301
432
  ...(deps.cwd === undefined ? {} : { cwd: deps.cwd }),
302
433
  timeoutMs: deps.params.mergePairTimeoutMinutes * 60_000,
303
434
  approval: deps.params.mergeApproval, commitPort: skillLibraryPort(deps.skills), now: deps.now, ...(unitFor(deps) === undefined ? {} : { unit: unitFor(deps) }),
435
+ ...(deps.params.limitsSnapshot === undefined ? {} : { limits: deps.params.limitsSnapshot.skillLimits }),
304
436
  ...(deps.isBlocked === undefined ? {} : { isBlocked: deps.isBlocked }),
305
437
  }),
306
438
  round2WithRun: createNewChildRound2({
@@ -308,16 +440,17 @@ export function createMergeRuntime(deps) {
308
440
  allow: deps.allow, ...(deps.cwd === undefined ? {} : { cwd: deps.cwd }),
309
441
  timeoutMs: deps.params.mergePairTimeoutMinutes * 60_000,
310
442
  commitPort: skillLibraryPort(deps.skills), now: deps.now, ...(unitFor(deps) === undefined ? {} : { unit: unitFor(deps) }),
443
+ ...(deps.params.limitsSnapshot === undefined ? {} : { limits: deps.params.limitsSnapshot.skillLimits }),
311
444
  ...(deps.isBlocked === undefined ? {} : { isBlocked: deps.isBlocked }),
312
445
  ...stageHooks,
313
446
  }),
314
447
  });
315
448
  if (!result.ok)
316
- return { ok: false, text: result.message };
449
+ return failRun('resume', result.message, '修复后重新 resume;run 报告见 /evolution merge report', result);
317
450
  if ('accepted' in result)
318
451
  return { ok: true, text: 'merge resume 已启动:' + result.runId };
319
452
  if (!('report' in result))
320
- return { ok: false, text: 'merge resume started without a report' };
453
+ return fail('resume', 'report-missing', 'merge resume 已结束但没有写报告', '重新运行 resume,或运行 /evolution merge runs 查看运行');
321
454
  return { ok: true, text: 'merge resume 完成:merged ' + String(result.report.totals.merged) };
322
455
  }
323
456
  if (command.verb === 'approve') {
@@ -325,88 +458,140 @@ export function createMergeRuntime(deps) {
325
458
  const cards = await deps.cards(signal);
326
459
  let candidate = (await loadMergeState(deps.io, deps.home)).state.rows.find(row => row.id === command.id);
327
460
  if (candidate !== undefined && candidate.stagingRef === undefined) {
328
- const entry = await readStagingIndex(deps.io, deps.home, candidate.id, deps.now());
461
+ const entry = await readStageIndex(deps.io, deps.home, candidate.id, deps.now());
329
462
  if (entry !== null)
330
463
  candidate = { ...candidate, stagingRef: entry.ref, stagingExpiresAt: entry.expiresAt };
331
464
  }
332
465
  if (candidate !== undefined && candidate.state === 'pending' && candidate.decision !== undefined) {
466
+ const target = candidate.decision.target;
467
+ const rowsNow = (await loadMergeState(deps.io, deps.home)).state.rows;
468
+ // NFR-04: a second approve for the same target waits for the holder.
469
+ const busy = busyTarget(rowsNow, target, candidate.id);
470
+ if (busy !== undefined) {
471
+ return fail('approve', 'merge-target-busy', '目标 ' + target + ' 正被 ' + busy.id + ' 占用,串行等待', '等待占用行结算,或 /evolution merge discard ' + busy.id, busy.runId);
472
+ }
473
+ const targetCardNow = cards.find(card => card.name === target);
474
+ if (targetCardNow !== undefined && (targetCardNow.protectionUnknown === true || targetCardNow.protectedBy != null)) {
475
+ return fail('approve', 'target-protected', '目标技能「' + target + '」在决策后变为受保护,拒绝提交', '解除保护或改选其它目标', candidate.runId);
476
+ }
333
477
  const missingSource = [candidate.a, candidate.b].find(name => !cards.some(card => card.name === name && card.managed === true));
334
478
  if (missingSource !== undefined) {
335
- const dropped = await dropRowV3(deps.io, deps.home, candidate.id, 'superseded');
479
+ const dropped = await removeRuntimeRow(deps.io, deps.home, candidate.id, 'superseded');
336
480
  if (!dropped)
337
- return { ok: false, text: 'source-gone 但 row 已变化,未清理:' + candidate.id };
338
- return { ok: false, text: 'source-gone:' + missingSource + ' 已不在 family-managed 技能库,已从 active 队列清除;可 merge skipped --discard 清理' };
481
+ return fail('approve', 'row-changed', 'source-gone,但行已被并发修改,未清理:' + candidate.id, '运行 /evolution merge status ' + candidate.id + ' 后重试');
482
+ return fail('approve', 'source-gone', missingSource + ' 已不在 family-managed 技能库,已从 active 队列清除', '重新 pair 该技能对;剩余行用 /evolution merge discard ' + candidate.id, candidate.runId);
483
+ }
484
+ const sourceExpected = candidate.decision?.sourceRevisionRef ?? candidate.sourceRevisionRef;
485
+ const targetExpected = candidate.decision?.targetRevisionRef ?? candidate.targetRevisionRef;
486
+ const targetMismatch = revisionMismatch(targetExpected, targetRevisionRefOf(cards, candidate.decision.target));
487
+ if (targetMismatch !== null) {
488
+ await pairLedger(deps.io, deps.home).transact(tx => { const current = tx.rows().find(item => item.id === candidate.id); return current === undefined ? false : tx.transition(candidate.id, { type: 'expired', kind: 'target-taken' }, current.version).ok; });
489
+ return fail('approve', 'target-taken', '目标 ' + candidate.decision.target + ' 在决策后已被占用', '重新 pair,或 /evolution merge discard ' + candidate.id, candidate.runId);
490
+ }
491
+ if (candidate.decision.supportDigest !== undefined && candidate.decision.supportDigest !== supportDigestOf(cards, [candidate.decision.target, candidate.a, candidate.b])) {
492
+ await pairLedger(deps.io, deps.home).transact(tx => { const current = tx.rows().find(item => item.id === candidate.id); return current === undefined ? false : tx.transition(candidate.id, { type: 'expired', kind: 'source-changed' }, current.version).ok; });
493
+ return fail('approve', 'source-changed', '源技能内容在决策后变化,拒绝照跑', '重新 pair,或 /evolution merge discard ' + candidate.id, candidate.runId);
494
+ }
495
+ if (revisionMismatch(sourceExpected, sourceRevisionRefOf(cards, candidate.a, candidate.b)) !== null) {
496
+ await pairLedger(deps.io, deps.home).transact(tx => { const current = tx.rows().find(item => item.id === candidate.id); return current === undefined ? false : tx.transition(candidate.id, { type: 'expired', kind: 'source-changed' }, current.version).ok; });
497
+ return fail('approve', 'source-changed', '源技能参考在决策后变化,拒绝照跑', '重新 pair,或 /evolution merge discard ' + candidate.id, candidate.runId);
339
498
  }
340
499
  const currentRevision = sourceRevisionOf(cards, candidate.a, candidate.b);
341
500
  if (candidate.sourceRevision !== undefined && currentRevision !== undefined && currentRevision !== candidate.sourceRevision) {
342
- const recorded = await transactRows(deps.io, deps.home, tx => {
501
+ const recorded = await pairLedger(deps.io, deps.home).transact(tx => {
343
502
  const current = tx.rows().find(item => item.id === candidate.id);
344
503
  return current === undefined ? false : tx.transition(candidate.id, { type: 'expired', kind: 'source-changed' }, current.version).ok;
345
504
  });
346
505
  if (!recorded)
347
- return { ok: false, text: 'row changed before source-changed record: ' + candidate.id };
348
- return { ok: false, text: 'source-changed:源技能内容在决策后变化,拒绝照跑;出口:重新 merge pair,或 merge skipped --discard' };
506
+ return fail('approve', 'row-changed', '记录 source-changed 时发现并发修改:' + candidate.id, '运行 /evolution merge status ' + candidate.id + ' 后重试');
507
+ return fail('approve', 'source-changed', '源技能内容在决策后变化,拒绝照跑', '重新 pair,或 /evolution merge discard ' + candidate.id, candidate.runId);
349
508
  }
350
509
  const currentTargetRevision = targetRevisionOf(cards, candidate.decision.target);
351
510
  if (candidate.targetRevision !== undefined && currentTargetRevision !== undefined
352
511
  && currentTargetRevision !== candidate.targetRevision) {
353
- const recorded = await transactRows(deps.io, deps.home, tx => {
512
+ const recorded = await pairLedger(deps.io, deps.home).transact(tx => {
354
513
  const current = tx.rows().find(item => item.id === candidate.id);
355
514
  return current === undefined ? false : tx.transition(candidate.id, { type: 'expired', kind: 'target-taken' }, current.version).ok;
356
515
  });
357
516
  if (!recorded)
358
- return { ok: false, text: 'row changed before target-taken record: ' + candidate.id };
359
- return { ok: false, text: 'target-taken:目标 ' + candidate.decision.target + ' 在决策后变化,拒绝照跑;可重新 merge pair 或 discard' };
517
+ return fail('approve', 'row-changed', '记录 target-taken 时发现并发修改:' + candidate.id, '运行 /evolution merge status ' + candidate.id + ' 后重试');
518
+ return fail('approve', 'target-taken', '目标 ' + candidate.decision.target + ' 在决策后变化,拒绝照跑', '重新 pair,或 /evolution merge discard ' + candidate.id, candidate.runId);
360
519
  }
361
520
  if (candidate.decision.mode !== 'create-umbrella') {
362
521
  const targetCard = cards.find(card => card.name === candidate.decision?.target);
363
522
  if (targetCard !== undefined && targetCard.managed !== true) {
364
- return { ok: false, text: 'target 不是 family-managed:' + candidate.decision.target };
523
+ return fail('approve', 'target-not-managed', 'target 不是 family-managed:' + candidate.decision.target, '重新 pair 并选一个 managed 目标', candidate.runId);
365
524
  }
366
525
  if (targetCard !== undefined && (targetCard.protectionUnknown === true || targetCard.protectedBy != null)) {
367
- return { ok: false, text: 'target 受保护:' + candidate.decision.target };
526
+ return fail('approve', 'target-protected', 'target 受保护:' + candidate.decision.target, '解除保护或改选其它目标', candidate.runId);
368
527
  }
369
528
  if (await deps.isBlocked?.(candidate.decision.target) === true) {
370
- return { ok: false, text: 'target 被范围门拦下:' + candidate.decision.target };
529
+ return fail('approve', 'target-blocked', 'target 被范围门拦下:' + candidate.decision.target, '运行 /evolution scope 查看范围门', candidate.runId);
371
530
  }
372
531
  }
373
532
  if (candidate.decision.mode === 'merge-into-existing' && !cards.some(card => card.name === candidate.decision?.target)) {
374
- const recorded = await transactRows(deps.io, deps.home, tx => {
533
+ const recorded = await pairLedger(deps.io, deps.home).transact(tx => {
375
534
  const current = tx.rows().find(item => item.id === candidate.id);
376
535
  return current === undefined ? false : tx.transition(candidate.id, { type: 'expired', kind: 'target-taken' }, current.version).ok;
377
536
  });
378
537
  if (!recorded)
379
- return { ok: false, text: 'row changed before target-taken record: ' + candidate.id };
380
- return { ok: false, text: 'target-taken:目标 ' + candidate.decision.target + ' 已不存在,行保留 pending --expired;出口:重新 merge pair,或 merge skipped --discard' };
538
+ return fail('approve', 'row-changed', '记录 target-taken 时发现并发修改:' + candidate.id, '运行 /evolution merge status ' + candidate.id + ' 后重试');
539
+ return fail('approve', 'target-taken', '目标 ' + candidate.decision.target + ' 已不存在,行保留 pending --expired', '重新 pair,或 /evolution merge discard ' + candidate.id, candidate.runId);
381
540
  }
382
541
  }
383
542
  const result = await runApproveCommand({
384
543
  ...common,
385
544
  rowId: command.id,
545
+ ...(agentless ? { agentless: true } : {}),
386
546
  ...(detached ? { detached: true } : {}),
387
547
  pairTimeoutMinutes: deps.params.mergePairTimeoutMinutes,
388
548
  ports: {
549
+ stage: createStageForCards({ io: deps.io, home: deps.home, runId: command.id, cards, now: deps.now }),
389
550
  round2: createNewChildRound2({
390
551
  io: deps.io, home: deps.home, cards, channel: deps.channel, parent,
391
552
  allow: deps.allow, ...(deps.cwd === undefined ? {} : { cwd: deps.cwd }),
392
553
  timeoutMs: deps.params.mergePairTimeoutMinutes * 60_000,
393
554
  commitPort: skillLibraryPort(deps.skills), now: deps.now, ...(unitFor(deps) === undefined ? {} : { unit: unitFor(deps) }),
555
+ ...(deps.params.limitsSnapshot === undefined ? {} : { limits: deps.params.limitsSnapshot.skillLimits }),
394
556
  ...(deps.isBlocked === undefined ? {} : { isBlocked: deps.isBlocked }),
557
+ ...stageHooks,
395
558
  }),
396
559
  },
397
560
  });
398
561
  if (!result.ok)
399
- return { ok: false, text: result.message };
562
+ return failRun('approve', result.message, '修复后重新 approve;run 报告见 /evolution merge report', result);
400
563
  if ('accepted' in result)
401
564
  return { ok: true, text: 'merge approve 已启动:' + result.runId };
402
565
  if (!('report' in result))
403
- return { ok: false, text: 'merge approve started without a report' };
566
+ return fail('approve', 'report-missing', 'merge approve 已结束但没有写报告', '重新运行 approve,或运行 /evolution merge runs 查看运行');
404
567
  return { ok: true, text: 'merge approve 完成:merged ' + String(result.report.totals.merged) };
405
568
  }
406
569
  const cards = command.verb === 'pending' ? await deps.cards(new AbortController().signal) : undefined;
407
570
  const registry = deps.registry;
408
571
  const runs = command.verb === 'status' ? registry.runs('merge') : undefined;
409
- const text = await handleReadOnlyMergeCommand(command, deps.io, deps.home, cards, runs, deps.params.mergeApproval);
572
+ if (command.verb === 'status' && command.id !== undefined) {
573
+ const found = runs?.find(item => item.id === command.id) ?? registry.find(command.id);
574
+ if (found !== undefined && found.kind === 'merge') {
575
+ // CLI consumer of the RunDetail envelope: current pair, per-pair outcome, stop reason.
576
+ const snapshot = await readMergeSnapshot({ io: deps.io, home: deps.home, runs: runs !== undefined && runs.some(item => item.id === found.id) ? runs : [...(runs ?? []), found], approval: deps.params.mergeApproval, now: deps.now });
577
+ if (snapshot.blocked !== undefined)
578
+ return fail('status', snapshot.blocked.code, snapshot.blocked.message, '先修复合并状态(/evolution merge doctor),再查看运行');
579
+ const detail = buildRunDetail(snapshot, found.id);
580
+ if (detail === null)
581
+ return fail('status', 'run-not-found', '找不到 merge run ' + found.id, '运行 /evolution merge runs 查看运行列表');
582
+ return { ok: true, text: formatRunDetail(detail) };
583
+ }
584
+ const row = (await loadMergeState(deps.io, deps.home)).state.rows.find(item => item.id === command.id);
585
+ if (row === undefined)
586
+ return fail('status', 'row-not-found', '找不到合并行或 run:' + command.id, '运行 /evolution merge status 查看队列,或 /evolution merge runs 查看运行');
587
+ }
588
+ // FR-LIFE-14: CLI reads derive the same pending expiry the snapshot route does;
589
+ // the library read stays lazy (only invoked when a pending row needs derivation).
590
+ const liveForRead = async () => {
591
+ const list = cards ?? await deps.cards(new AbortController().signal).catch(() => []);
592
+ return list.length === 0 ? undefined : liveRowFactsOf(list);
593
+ };
594
+ const text = await handleReadOnlyMergeCommand(command, deps.io, deps.home, cards, runs, deps.params.mergeApproval, liveForRead);
410
595
  if (text !== null)
411
596
  return { ok: true, text };
412
597
  return readOnly.execute(command);