@lmzhen/dsh-evolution-merge 0.30.17 → 0.30.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/README.md +25 -7
  2. package/data/minhash_params.json +1063 -0
  3. package/lib/index.js +7308 -1800
  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/library.js +4 -1
  9. package/lib/types/adapters/model-runner.d.ts +0 -5
  10. package/lib/types/adapters/model-runner.js +8 -2
  11. package/lib/types/adapters/model.d.ts +31 -0
  12. package/lib/types/adapters/model.js +47 -0
  13. package/lib/types/adapters/select.js +12 -8
  14. package/lib/types/approve-command.d.ts +14 -1
  15. package/lib/types/approve-command.js +72 -44
  16. package/lib/types/authoring-flow.d.ts +19 -1
  17. package/lib/types/authoring-flow.js +49 -15
  18. package/lib/types/channel-port.d.ts +3 -1
  19. package/lib/types/channel-port.js +8 -3
  20. package/lib/types/command-defs.d.ts +5 -0
  21. package/lib/types/command-defs.js +33 -0
  22. package/lib/types/command-surface.d.ts +5 -0
  23. package/lib/types/command-surface.js +11 -0
  24. package/lib/types/commands.d.ts +3 -4
  25. package/lib/types/commands.js +33 -72
  26. package/lib/types/commit-assembly.d.ts +16 -1
  27. package/lib/types/commit-assembly.js +72 -11
  28. package/lib/types/commit-execute.d.ts +14 -2
  29. package/lib/types/commit-execute.js +76 -14
  30. package/lib/types/commit-plan.d.ts +7 -1
  31. package/lib/types/commit-plan.js +4 -0
  32. package/lib/types/commit-receipt.d.ts +3 -0
  33. package/lib/types/commit-recovery.d.ts +7 -2
  34. package/lib/types/commit-recovery.js +73 -96
  35. package/lib/types/contracts.d.ts +44 -1
  36. package/lib/types/contracts.js +1 -1
  37. package/lib/types/coordinator.d.ts +3 -2
  38. package/lib/types/coordinator.js +4 -3
  39. package/lib/types/decision.js +1 -1
  40. package/lib/types/doctor.d.ts +18 -1
  41. package/lib/types/doctor.js +164 -10
  42. package/lib/types/domain/authoring-manifest.d.ts +26 -0
  43. package/lib/types/domain/authoring-manifest.js +43 -0
  44. package/lib/types/domain/claim.d.ts +4 -1
  45. package/lib/types/domain/claim.js +2 -2
  46. package/lib/types/domain/commit-journal-store.js +5 -1
  47. package/lib/types/domain/commit-journal.js +1 -1
  48. package/lib/types/domain/consistency.d.ts +22 -0
  49. package/lib/types/domain/consistency.js +50 -0
  50. package/lib/types/domain/engine-criteria.d.ts +10 -0
  51. package/lib/types/domain/engine-criteria.js +11 -0
  52. package/lib/types/domain/engine-input.d.ts +10 -0
  53. package/lib/types/domain/engine-input.js +26 -0
  54. package/lib/types/domain/errors.d.ts +15 -1
  55. package/lib/types/domain/errors.js +23 -4
  56. package/lib/types/domain/evidence-hash.d.ts +3 -0
  57. package/lib/types/domain/evidence-hash.js +14 -0
  58. package/lib/types/domain/failure-route.d.ts +14 -0
  59. package/lib/types/domain/failure-route.js +12 -0
  60. package/lib/types/domain/ledger.js +10 -1
  61. package/lib/types/domain/live-facts.d.ts +31 -0
  62. package/lib/types/domain/live-facts.js +54 -0
  63. package/lib/types/domain/merge-limits.d.ts +18 -0
  64. package/lib/types/domain/merge-limits.js +22 -0
  65. package/lib/types/domain/merge-report.d.ts +41 -0
  66. package/lib/types/domain/merge-report.js +2 -0
  67. package/lib/types/domain/merge-snapshot.d.ts +99 -0
  68. package/lib/types/domain/merge-snapshot.js +82 -0
  69. package/lib/types/domain/migrate-v3.d.ts +20 -0
  70. package/lib/types/domain/migrate-v3.js +68 -0
  71. package/lib/types/domain/pair-ledger.d.ts +34 -0
  72. package/lib/types/domain/pair-ledger.js +96 -0
  73. package/lib/types/domain/pre-1b-backup.d.ts +43 -0
  74. package/lib/types/domain/pre-1b-backup.js +140 -0
  75. package/lib/types/domain/provider-key.d.ts +2 -0
  76. package/lib/types/domain/provider-key.js +11 -0
  77. package/lib/types/domain/read-probe.d.ts +5 -0
  78. package/lib/types/domain/read-probe.js +11 -0
  79. package/lib/types/domain/reconcile.d.ts +47 -0
  80. package/lib/types/domain/reconcile.js +178 -0
  81. package/lib/types/domain/resolve-pair.d.ts +2 -9
  82. package/lib/types/domain/resolve-pair.js +2 -15
  83. package/lib/types/domain/row-tx.d.ts +27 -0
  84. package/lib/types/domain/row-tx.js +31 -0
  85. package/lib/types/domain/row-v3-bridge.d.ts +6 -0
  86. package/lib/types/domain/row-v3-bridge.js +47 -0
  87. package/lib/types/domain/row-v3.d.ts +113 -0
  88. package/lib/types/domain/row-v3.js +107 -0
  89. package/lib/types/domain/row-v4.d.ts +148 -0
  90. package/lib/types/domain/row-v4.js +159 -0
  91. package/lib/types/domain/row.d.ts +1 -0
  92. package/lib/types/domain/run-coordinator.d.ts +51 -0
  93. package/lib/types/domain/run-coordinator.js +45 -0
  94. package/lib/types/domain/schema4.d.ts +53 -0
  95. package/lib/types/domain/schema4.js +129 -0
  96. package/lib/types/domain/selection-policy.d.ts +14 -0
  97. package/lib/types/domain/selection-policy.js +6 -0
  98. package/lib/types/domain/skipped-expiry.d.ts +5 -0
  99. package/lib/types/domain/skipped-expiry.js +16 -0
  100. package/lib/types/domain/stage-aggregate.d.ts +96 -0
  101. package/lib/types/domain/stage-aggregate.js +293 -0
  102. package/lib/types/domain/staging-index.d.ts +15 -0
  103. package/lib/types/domain/staging-index.js +41 -0
  104. package/lib/types/domain/state-bootstrap.d.ts +43 -0
  105. package/lib/types/domain/state-bootstrap.js +267 -0
  106. package/lib/types/domain/state-v3.d.ts +4 -0
  107. package/lib/types/domain/state-v3.js +4 -0
  108. package/lib/types/domain/undo-journal.d.ts +19 -0
  109. package/lib/types/domain/undo-journal.js +20 -0
  110. package/lib/types/domain/undo-receipt.d.ts +18 -0
  111. package/lib/types/domain/undo-receipt.js +22 -0
  112. package/lib/types/domain/validation-plan.d.ts +65 -0
  113. package/lib/types/domain/validation-plan.js +49 -0
  114. package/lib/types/facts.d.ts +26 -0
  115. package/lib/types/facts.js +65 -0
  116. package/lib/types/gate-filter.d.ts +31 -0
  117. package/lib/types/gate-filter.js +64 -0
  118. package/lib/types/index.d.ts +7 -2
  119. package/lib/types/index.js +185 -82
  120. package/lib/types/ladder.js +1 -1
  121. package/lib/types/merge-command.d.ts +3 -1
  122. package/lib/types/merge-command.js +10 -6
  123. package/lib/types/merge-routes.d.ts +12 -7
  124. package/lib/types/merge-routes.js +87 -23
  125. package/lib/types/merge-tool.d.ts +1 -1
  126. package/lib/types/merge-tool.js +2 -2
  127. package/lib/types/metrics.d.ts +16 -0
  128. package/lib/types/metrics.js +27 -0
  129. package/lib/types/new-child-authoring.d.ts +5 -1
  130. package/lib/types/new-child-authoring.js +71 -13
  131. package/lib/types/params.d.ts +2 -4
  132. package/lib/types/params.js +13 -0
  133. package/lib/types/pending-actions.d.ts +2 -2
  134. package/lib/types/pending-actions.js +17 -17
  135. package/lib/types/prompts.d.ts +2 -4
  136. package/lib/types/prompts.js +21 -8
  137. package/lib/types/propose.d.ts +26 -0
  138. package/lib/types/propose.js +69 -0
  139. package/lib/types/read-commands.d.ts +4 -4
  140. package/lib/types/read-commands.js +19 -26
  141. package/lib/types/recovery-coordinator.d.ts +34 -0
  142. package/lib/types/recovery-coordinator.js +84 -0
  143. package/lib/types/recovery-projection.d.ts +31 -0
  144. package/lib/types/recovery-projection.js +96 -0
  145. package/lib/types/resume-command.d.ts +7 -1
  146. package/lib/types/resume-command.js +50 -14
  147. package/lib/types/revision.d.ts +17 -0
  148. package/lib/types/revision.js +39 -3
  149. package/lib/types/round2-outcome.d.ts +2 -1
  150. package/lib/types/round2-outcome.js +68 -12
  151. package/lib/types/run-shell.d.ts +16 -2
  152. package/lib/types/run-shell.js +49 -5
  153. package/lib/types/runtime.d.ts +4 -6
  154. package/lib/types/runtime.js +341 -86
  155. package/lib/types/scan-command.d.ts +1 -0
  156. package/lib/types/scan-command.js +2 -0
  157. package/lib/types/scan-ports.d.ts +21 -1
  158. package/lib/types/scan-ports.js +51 -23
  159. package/lib/types/scan-run.d.ts +17 -0
  160. package/lib/types/scan-run.js +221 -63
  161. package/lib/types/screening-port.d.ts +47 -0
  162. package/lib/types/screening-port.js +93 -0
  163. package/lib/types/screening.d.ts +30 -0
  164. package/lib/types/screening.js +75 -0
  165. package/lib/types/stage-tool-registry.d.ts +7 -0
  166. package/lib/types/stage-tool-registry.js +12 -1
  167. package/lib/types/state.d.ts +15 -1
  168. package/lib/types/state.js +39 -17
  169. package/lib/types/status.d.ts +8 -0
  170. package/lib/types/status.js +64 -0
  171. package/lib/types/surface/body.d.ts +4 -0
  172. package/lib/types/surface/body.js +16 -0
  173. package/lib/types/surface/merge-view.d.ts +14 -0
  174. package/lib/types/surface/merge-view.js +126 -0
  175. package/lib/types/target-claim.d.ts +1 -0
  176. package/lib/types/target-claim.js +63 -18
  177. package/lib/types/undo-operation.d.ts +24 -0
  178. package/lib/types/undo-operation.js +135 -0
  179. package/lib/types/unit-claim.d.ts +5 -0
  180. package/lib/types/unit-claim.js +15 -0
  181. package/lib/types/wire-projection.d.ts +36 -0
  182. package/lib/types/wire-projection.js +69 -0
  183. package/lib/types/wire.d.ts +273 -0
  184. package/lib/types/wire.js +3 -0
  185. package/package.json +10 -4
  186. package/lib/types/routes.d.ts +0 -70
  187. package/lib/types/routes.js +0 -78
@@ -3,30 +3,71 @@
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 } from '@lmzhen/dsh-evolution-core';
6
+ import { createHash } from 'node:crypto';
7
+ import { errorText, probeText, SkillLibrary, UnitTransactions, skillLibraryUnitBackend } from '@lmzhen/dsh-evolution-core';
8
+ import { targetClaimUnitPort } from "./unit-claim.js";
7
9
  import { canRetry, pairKey } from "./contracts.js";
8
10
  import { skippedPairKeys } from "./skipped.js";
9
11
  import { createReadOnlyMergeService } from "./merge-command.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";
10
15
  import { handleReadOnlyMergeCommand } from "./read-commands.js";
11
16
  import { runScanCommand } from "./scan-command.js";
12
17
  import { runApproveCommand } from "./approve-command.js";
13
18
  import { runResumeCommand } from "./resume-command.js";
14
- import { dropRow, loadMergeState, upsertRow } from "./state.js";
15
- import { transition } from "./coordinator.js";
19
+ import { loadMergeState } from "./state.js";
20
+ import { pairLedger } from "./domain/pair-ledger.js";
16
21
  import { readCommitReceipt } from "./commit-receipt.js";
17
- import { undoMergeReceipt } from "./undo-merge.js";
18
- import { recordPair } from "./domain/pair-index-store.js";
22
+ import { runUndoOperation } from "./undo-operation.js";
23
+ import { readStageIndex } from "./domain/stage-aggregate.js";
19
24
  import { skillLibraryUndoPort } from "./adapters/undo.js";
20
25
  import { mergeDoctor } from "./doctor.js";
21
- 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";
22
29
  import { createNewChildRound2 } from "./new-child-authoring.js";
23
30
  import { skillLibraryPort } from "./adapters/library.js";
24
31
  import { fitToolText } from "./presentation-budget.js";
25
- 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
+ }
26
50
  function forcedDecisionFor(a, b) {
27
51
  const sorted = [a, b].sort();
28
- const target = ('merge-' + sorted.join('-')).replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64);
29
- 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
+ });
64
+ }
65
+ export function unitFor(deps) {
66
+ if (deps.unit !== undefined)
67
+ return deps.unit;
68
+ if (!(deps.skills instanceof SkillLibrary))
69
+ return undefined;
70
+ return new UnitTransactions({ io: deps.io, home: deps.home, backend: skillLibraryUnitBackend(deps.skills), claim: targetClaimUnitPort(deps.io, deps.home, deps.now), now: deps.now });
30
71
  }
31
72
  export function createMergeRuntime(deps) {
32
73
  const readOnly = createReadOnlyMergeService(deps.io, deps.home);
@@ -45,40 +86,59 @@ export function createMergeRuntime(deps) {
45
86
  return {
46
87
  async execute(command, context) {
47
88
  const parent = context?.parent ?? deps.parent;
89
+ const agentless = context?.agentless === true;
48
90
  const detached = deps.detached !== false;
49
91
  if (command.verb === 'pair') {
50
92
  const signal = new AbortController().signal;
51
93
  if (command.a === command.b)
52
- 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
+ }
53
107
  const cards = await deps.cards(signal);
54
108
  const cardA = cards.find(card => card.name === command.a);
55
109
  const cardB = cards.find(card => card.name === command.b);
56
110
  if (cardA === undefined || cardB === undefined || cardA.managed !== true || cardB.managed !== true) {
57
- 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 技能范围');
58
112
  }
59
113
  if (cardA.protectedBy != null || cardB.protectedBy != null
60
114
  || cardA.protectionUnknown === true || cardB.protectionUnknown === true) {
61
- return { ok: false, text: 'merge pair 不接受受保护/保护状态未知技能: ' + command.a + ' / ' + command.b };
115
+ return fail('pair', 'pair-protected', 'merge pair 不接受受保护/保护状态未知技能:' + command.a + ' / ' + command.b, '解除保护或换一对技能后重试');
62
116
  }
63
117
  if ((await deps.isBlocked?.(command.a)) === true || (await deps.isBlocked?.(command.b)) === true) {
64
- return { ok: false, text: 'merge pair 不接受被范围门拦下的技能: ' + command.a + ' / ' + command.b };
118
+ return fail('pair', 'pair-blocked', 'merge pair 不接受被范围门拦下的技能:' + command.a + ' / ' + command.b, '运行 /evolution scope 查看范围门,或换一对技能');
65
119
  }
66
120
  const pair = [{ a: command.a, b: command.b }];
67
121
  const existing = (await loadMergeState(deps.io, deps.home)).state.rows.find(row => row.pairKey === pairKey(command.a, command.b));
68
122
  if (existing?.state === 'skipped') {
69
- await dropRow(deps.io, deps.home, deps.now, existing.id);
123
+ const dropped = await removeRuntimeRow(deps.io, deps.home, existing.id, 'superseded');
124
+ if (!dropped)
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);
70
129
  }
71
130
  if (existing?.state === 'pending') {
72
- 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);
73
132
  }
74
133
  if (existing?.state === 'approved' || existing?.state === 'fuse') {
75
- 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);
76
135
  }
77
136
  const forced = command.force === true ? forcedDecisionFor(command.a, command.b) : undefined;
78
137
  const approval = deps.params.mergeApproval;
79
138
  const result = await runScanCommand({
80
139
  ...common,
81
140
  approval,
141
+ cards,
82
142
  ...(detached ? { detached: true } : {}),
83
143
  makePorts: (runId) => {
84
144
  const ports = createChannelScanPorts({
@@ -88,21 +148,25 @@ export function createMergeRuntime(deps) {
88
148
  ...(deps.cwd === undefined ? {} : { cwd: deps.cwd }),
89
149
  timeoutMs: deps.params.mergePairTimeoutMinutes * 60_000,
90
150
  approval,
91
- commitPort: skillLibraryPort(deps.skills), now: deps.now,
151
+ commitPort: skillLibraryPort(deps.skills), now: deps.now, ...(unitFor(deps) === undefined ? {} : { unit: unitFor(deps) }),
152
+ ...(deps.params.limitsSnapshot === undefined ? {} : { limits: deps.params.limitsSnapshot.skillLimits }),
153
+ ...(deps.isBlocked === undefined ? {} : { isBlocked: deps.isBlocked }),
92
154
  ...stageHooks,
93
155
  });
94
156
  return forced === undefined ? ports : { ...ports, round1: () => Promise.resolve({ kind: 'merge', decision: forced }) };
95
157
  },
96
158
  });
97
159
  if (!result.ok)
98
- return { ok: false, text: result.message };
160
+ return failRun('pair', result.message, '修复后重新发起 pair;run 报告见 /evolution merge report', result);
99
161
  if ('accepted' in result)
100
162
  return { ok: true, text: 'merge pair 已启动:' + result.runId };
101
163
  if (!('report' in result))
102
- return { ok: false, text: 'merge pair started without a report' };
164
+ return fail('pair', 'report-missing', 'merge pair 已结束但没有写报告', '重新运行 pair,或运行 /evolution merge runs 查看运行');
103
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) };
104
166
  }
105
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');
106
170
  const signal = new AbortController().signal;
107
171
  const cards = await deps.cards(signal);
108
172
  const blocked = new Set();
@@ -117,52 +181,128 @@ export function createMergeRuntime(deps) {
117
181
  const held = new Set(loadedState.state.rows.filter(row => row.queue === 'pending' || row.queue === 'fuse'
118
182
  || (row.queue === 'verify' && (row.runId !== undefined && deps.now() - Date.parse(row.updatedAt) < 10 * 60_000 || !canRetry(row))))
119
183
  .map(row => row.pairKey));
120
- const conflictNotes = loadedState.state.rows
184
+ const notes = loadedState.state.rows
121
185
  .filter(row => row.state === 'pending' || row.state === 'approved' || row.state === 'fuse' || row.state === 'skipped')
122
186
  .map(row => 'conflict:' + row.state + ':' + row.pairKey + ':' + row.id);
123
187
  const result = await runScanCommand({
124
188
  ...common,
125
189
  approval: deps.params.mergeApproval,
126
- notes: conflictNotes,
190
+ notes,
191
+ cards,
127
192
  ...(detached ? { detached: true } : {}),
128
193
  makePorts: runId => createChannelScanPorts({
129
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
+ },
130
199
  screening: async (turnSignal) => {
131
200
  const raw = await deps.screeningForRun(cards, runId, turnSignal);
201
+ const degraded = raw.degraded;
202
+ if (degraded !== undefined)
203
+ notes.push(...degraded);
132
204
  const filtered = raw.filter((candidate) => {
133
205
  const key = pairKey(candidate.a, candidate.b);
134
206
  return managed.has(candidate.a) && managed.has(candidate.b) && !skipped.has(key) && !held.has(key);
135
207
  });
136
208
  const candidatePairs = raw.candidatePairs;
137
- 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
+ });
138
214
  },
139
215
  channel: deps.channel, parent, allow: deps.allow,
140
216
  ...(deps.cwd === undefined ? {} : { cwd: deps.cwd }),
141
217
  timeoutMs: deps.params.mergePairTimeoutMinutes * 60_000,
142
218
  approval: deps.params.mergeApproval,
143
- commitPort: skillLibraryPort(deps.skills), now: deps.now,
219
+ commitPort: skillLibraryPort(deps.skills), now: deps.now, ...(unitFor(deps) === undefined ? {} : { unit: unitFor(deps) }),
220
+ ...(deps.params.limitsSnapshot === undefined ? {} : { limits: deps.params.limitsSnapshot.skillLimits }),
221
+ ...(deps.isBlocked === undefined ? {} : { isBlocked: deps.isBlocked }),
144
222
  ...stageHooks,
145
223
  }),
146
224
  });
147
225
  if (!result.ok)
148
- return { ok: false, text: result.message };
226
+ return failRun('scan', result.message, '修复后重新 scan;run 报告见 /evolution merge report', result);
149
227
  if ('accepted' in result)
150
228
  return { ok: true, text: 'merge scan 已启动:' + result.runId };
151
229
  if (!('report' in result))
152
- return { ok: false, text: 'merge scan started without a report' };
230
+ return fail('scan', 'report-missing', 'merge scan 已结束但没有写报告', '重新运行 scan,或运行 /evolution merge runs 查看运行');
153
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) };
154
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
+ }
155
275
  if (command.verb === 'reject') {
156
276
  const loaded = await loadMergeState(deps.io, deps.home);
157
277
  const row = loaded.state.rows.find(item => item.id === command.id);
158
278
  if (row === undefined)
159
- return { ok: false, text: 'merge row not found: ' + command.id };
160
- const next = transition(row, { type: 'reject', at: new Date(deps.now()).toISOString() });
161
- if (!next.ok || next.row === null)
162
- return { ok: false, text: 'merge row is not pending: ' + command.id };
163
- await upsertRow(deps.io, deps.home, deps.now, next.row);
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 => {
281
+ const current = tx.rows().find(item => item.id === row.id);
282
+ if (current === undefined || current.state !== 'pending')
283
+ return false;
284
+ return tx.transition(row.id, { type: 'skipped', reason: 'rejected', origin: 'human' }, current.version).ok;
285
+ });
286
+ if (!changed)
287
+ return fail('reject', 'row-not-pending', '该行不是 pending,或已被并发修改:' + command.id, '运行 /evolution merge status ' + command.id + ' 后重试', row.runId);
164
288
  return { ok: true, text: '已拒绝合并:' + command.id };
165
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
+ }
166
306
  if (command.verb === 'doctor') {
167
307
  const rows = await mergeDoctor(deps.io, deps.home, deps.params.mergeModelsRoot);
168
308
  return { ok: true, text: rows.map(row => row.key + ' [' + row.status + '] ' + row.label + ':' + row.detail).join('\n') };
@@ -171,82 +311,105 @@ export function createMergeRuntime(deps) {
171
311
  const registry = deps.registry;
172
312
  if (command.verb === 'runs') {
173
313
  const mergeRuns = registry.runs('merge');
174
- return { ok: true, text: mergeRuns.length === 0 ? 'merge runs: empty' : ['merge runs: ' + String(mergeRuns.length)].concat(mergeRuns.map(run => '- ' + run.id + ' [' + run.state + ']' + (run.resultRef === undefined ? '' : ' ' + run.resultRef))).join('\n') };
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)) };
175
316
  }
176
317
  if (command.verb === 'cancel') {
177
318
  if (command.id !== undefined) {
178
319
  const cancelled = registry.cancel(command.id);
179
- 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 查看可取消的运行');
180
321
  }
181
- const count = registry.cancelAll();
182
- 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 运行' };
183
328
  }
184
329
  const run = command.id === undefined ? registry.runs('merge')[0] : registry.find(command.id);
185
330
  if (run === undefined)
186
- 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 查看运行列表');
187
332
  if (run.resultRef === undefined)
188
- 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);
189
334
  const report = await probeText(deps.io, run.resultRef);
190
335
  if (report.kind === 'unknown')
191
- return { ok: false, text: 'report is unreadable: ' + run.resultRef + ':' + report.reason };
336
+ return fail('report', 'report-unreadable', '报告不可读:' + run.resultRef + '(' + report.reason + ')', '检查结果文件权限,或重新运行该命令', run.id);
192
337
  return report.kind === 'absent'
193
- ? { ok: false, text: 'report is missing: ' + run.resultRef }
338
+ ? fail('report', 'report-missing', '报告不存在:' + run.resultRef, '重新运行该命令生成报告', run.id)
194
339
  : { ok: true, text: 'report: ' + run.resultRef + '\n' + fitToolText(report.value, 7600) };
195
340
  }
196
341
  if (command.verb === 'discard') {
197
342
  const loaded = await loadMergeState(deps.io, deps.home);
198
343
  const row = loaded.state.rows.find(item => item.id === command.id);
199
344
  if (row === undefined)
200
- return { ok: false, text: 'merge row not found: ' + command.id };
345
+ return fail('discard', 'row-not-found', '找不到合并行 ' + command.id, '运行 /evolution merge status 查看当前队列', command.id);
201
346
  if (row.state !== 'pending' && row.state !== 'approved' && row.state !== 'fuse')
202
- return { ok: false, text: 'merge row is not discardable: ' + command.id };
203
- await dropRow(deps.io, deps.home, deps.now, row.id);
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');
349
+ if (!dropped)
350
+ return fail('discard', 'row-changed', '丢弃时发现并发修改:' + command.id, '运行 /evolution merge status ' + command.id + ' 后重试');
204
351
  return { ok: true, text: '已丢弃合并行:' + command.id };
205
352
  }
206
353
  if (command.verb === 'undo') {
207
354
  const receipt = await readCommitReceipt(deps.io, deps.home, command.run, command.target);
208
355
  if (receipt === null)
209
- 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 查看');
210
357
  if (receipt.target !== command.target)
211
- return { ok: false, text: 'receipt target is ' + receipt.target + ', not ' + command.target };
212
- const result = await undoMergeReceipt(receipt, skillLibraryUndoPort(deps.skills));
213
- if (result.ok && receipt.pairKey !== undefined) {
214
- await recordPair(deps.io, deps.home, { pairKey: receipt.pairKey, runId: receipt.parentRunId ?? receipt.runId, result: 'undone', target: receipt.target });
215
- }
216
- 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, '检查回执与暂存后重试');
217
366
  }
218
367
  if (command.verb === 'skipped' && command.restore !== undefined) {
219
368
  const loaded = await loadMergeState(deps.io, deps.home);
220
369
  const row = loaded.state.rows.find(item => item.id === command.restore);
221
370
  if (row === undefined || row.state !== 'skipped')
222
- return { ok: false, text: 'skipped row not found: ' + command.restore };
223
- await dropRow(deps.io, deps.home, deps.now, row.id);
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');
373
+ if (!dropped)
374
+ return fail('skipped', 'row-changed', '恢复时发现并发修改:' + row.id, '运行 /evolution merge status 后重试');
224
375
  return { ok: true, text: '已恢复参与筛选:' + row.id };
225
376
  }
226
377
  if (command.verb === 'resume') {
227
378
  if (command.clear !== undefined) {
228
379
  const cleared = (await loadMergeState(deps.io, deps.home)).state.rows.find(item => item.id === command.clear);
229
380
  if (cleared === undefined)
230
- return { ok: false, text: 'merge row not found: ' + command.clear };
231
- await dropRow(deps.io, deps.home, deps.now, cleared.id);
381
+ return fail('resume', 'row-not-found', '找不到要清除的行:' + command.clear, '运行 /evolution merge status 查看当前队列');
382
+ const dropped = await removeRuntimeRow(deps.io, deps.home, cleared.id, 'superseded');
383
+ if (!dropped)
384
+ return fail('resume', 'row-changed', '清除时发现并发修改:' + cleared.id, '运行 /evolution merge status 后重试');
232
385
  return { ok: true, text: '已清除合并行:' + cleared.id };
233
386
  }
234
387
  const loaded = await loadMergeState(deps.io, deps.home);
235
- let row = command.id === undefined ? undefined : loaded.state.rows.find(item => item.id === command.id);
236
388
  if (command.id === undefined) {
237
- for (const item of loaded.state.rows) {
238
- if (item.state !== 'fuse' && item.state !== 'verify' && !(command.retry === true && item.state === 'approved'))
239
- continue;
240
- if (item.runId !== undefined && (await deps.isOwnerActive?.(item.runId)) === true)
241
- continue;
242
- row = item;
243
- break;
244
- }
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> 复用某一行的决策');
245
391
  }
392
+ const row = loaded.state.rows.find(item => item.id === command.id);
246
393
  if (row === undefined)
247
- return { ok: false, text: 'merge row not found: ' + (command.id ?? '(auto)') };
394
+ return fail('resume', 'row-not-found', '找不到可恢复行 ' + command.id, '运行 /evolution merge candidates 查看可恢复行');
248
395
  const signal = new AbortController().signal;
249
396
  const cards = await deps.cards(signal);
397
+ if ((row.state === 'fuse' || row.state === 'approved') && row.decision !== undefined && row.decision.mode !== 'create-umbrella') {
398
+ const targetCard = cards.find(card => card.name === row.decision?.target);
399
+ if (targetCard !== undefined && targetCard.managed !== true)
400
+ return fail('resume', 'target-not-managed', 'target 不是 family-managed:' + row.decision.target, '重新 pair 并选一个 managed 目标', row.runId);
401
+ if (targetCard !== undefined && (targetCard.protectionUnknown === true || targetCard.protectedBy != null))
402
+ return fail('resume', 'target-protected', 'target 受保护:' + row.decision.target, '解除保护或改选其它目标', row.runId);
403
+ if (await deps.isBlocked?.(row.decision.target) === true)
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);
412
+ }
250
413
  const pair = () => Promise.resolve([{ a: row.a, b: row.b }]);
251
414
  const failed = (stage, code, message) => Promise.resolve({
252
415
  kind: 'failed',
@@ -258,85 +421,177 @@ export function createMergeRuntime(deps) {
258
421
  round2: () => failed('author', 'resume-needs-child', 'fuse resume needs a new child'),
259
422
  };
260
423
  const result = await runResumeCommand({
261
- ...common, rowId: row.id, ports: basePorts,
424
+ ...common, rowId: row.id, ports: basePorts, approval: deps.params.mergeApproval,
425
+ ...(agentless ? { agentless: true } : {}),
426
+ ...(detached ? { detached: true } : {}),
427
+ pairTimeoutMinutes: deps.params.mergePairTimeoutMinutes,
262
428
  ...(deps.isOwnerActive === undefined ? {} : { isOwnerActive: deps.isOwnerActive }),
263
429
  makePorts: runId => createChannelScanPorts({
264
430
  io: deps.io, home: deps.home, runId, cards, screening: pair,
265
431
  channel: deps.channel, parent, allow: deps.allow,
266
432
  ...(deps.cwd === undefined ? {} : { cwd: deps.cwd }),
267
433
  timeoutMs: deps.params.mergePairTimeoutMinutes * 60_000,
268
- approval: deps.params.mergeApproval, commitPort: skillLibraryPort(deps.skills), now: deps.now,
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 }),
436
+ ...(deps.isBlocked === undefined ? {} : { isBlocked: deps.isBlocked }),
269
437
  }),
270
438
  round2WithRun: createNewChildRound2({
271
439
  io: deps.io, home: deps.home, cards, channel: deps.channel, parent,
272
440
  allow: deps.allow, ...(deps.cwd === undefined ? {} : { cwd: deps.cwd }),
273
441
  timeoutMs: deps.params.mergePairTimeoutMinutes * 60_000,
274
- commitPort: skillLibraryPort(deps.skills), now: deps.now,
442
+ commitPort: skillLibraryPort(deps.skills), now: deps.now, ...(unitFor(deps) === undefined ? {} : { unit: unitFor(deps) }),
443
+ ...(deps.params.limitsSnapshot === undefined ? {} : { limits: deps.params.limitsSnapshot.skillLimits }),
444
+ ...(deps.isBlocked === undefined ? {} : { isBlocked: deps.isBlocked }),
275
445
  ...stageHooks,
276
446
  }),
277
447
  });
278
448
  if (!result.ok)
279
- return { ok: false, text: result.message };
449
+ return failRun('resume', result.message, '修复后重新 resume;run 报告见 /evolution merge report', result);
280
450
  if ('accepted' in result)
281
451
  return { ok: true, text: 'merge resume 已启动:' + result.runId };
282
452
  if (!('report' in result))
283
- return { ok: false, text: 'merge resume started without a report' };
453
+ return fail('resume', 'report-missing', 'merge resume 已结束但没有写报告', '重新运行 resume,或运行 /evolution merge runs 查看运行');
284
454
  return { ok: true, text: 'merge resume 完成:merged ' + String(result.report.totals.merged) };
285
455
  }
286
456
  if (command.verb === 'approve') {
287
457
  const signal = new AbortController().signal;
288
458
  const cards = await deps.cards(signal);
289
- const candidate = (await loadMergeState(deps.io, deps.home)).state.rows.find(row => row.id === command.id);
459
+ let candidate = (await loadMergeState(deps.io, deps.home)).state.rows.find(row => row.id === command.id);
460
+ if (candidate !== undefined && candidate.stagingRef === undefined) {
461
+ const entry = await readStageIndex(deps.io, deps.home, candidate.id, deps.now());
462
+ if (entry !== null)
463
+ candidate = { ...candidate, stagingRef: entry.ref, stagingExpiresAt: entry.expiresAt };
464
+ }
290
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
+ }
291
477
  const missingSource = [candidate.a, candidate.b].find(name => !cards.some(card => card.name === name && card.managed === true));
292
478
  if (missingSource !== undefined) {
293
- await dropRow(deps.io, deps.home, deps.now, candidate.id);
294
- return { ok: false, text: 'source-gone:' + missingSource + ' 已不在 family-managed 技能库,已从 active 队列清除;可 merge skipped --discard 清理' };
479
+ const dropped = await removeRuntimeRow(deps.io, deps.home, candidate.id, 'superseded');
480
+ if (!dropped)
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);
295
498
  }
296
499
  const currentRevision = sourceRevisionOf(cards, candidate.a, candidate.b);
297
500
  if (candidate.sourceRevision !== undefined && currentRevision !== undefined && currentRevision !== candidate.sourceRevision) {
298
- const changed = transition(candidate, { type: 'source-changed', at: new Date().toISOString() });
299
- if (changed.ok && changed.row !== null)
300
- await upsertRow(deps.io, deps.home, deps.now, changed.row);
301
- return { ok: false, text: 'source-changed:源技能内容在决策后变化,拒绝照跑;出口:重新 merge pair,或 merge skipped --discard' };
501
+ const recorded = await pairLedger(deps.io, deps.home).transact(tx => {
502
+ const current = tx.rows().find(item => item.id === candidate.id);
503
+ return current === undefined ? false : tx.transition(candidate.id, { type: 'expired', kind: 'source-changed' }, current.version).ok;
504
+ });
505
+ if (!recorded)
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);
302
508
  }
303
509
  const currentTargetRevision = targetRevisionOf(cards, candidate.decision.target);
304
510
  if (candidate.targetRevision !== undefined && currentTargetRevision !== undefined
305
511
  && currentTargetRevision !== candidate.targetRevision) {
306
- const taken = transition(candidate, { type: 'target-taken', at: new Date().toISOString() });
307
- if (taken.ok && taken.row !== null)
308
- await upsertRow(deps.io, deps.home, deps.now, taken.row);
309
- return { ok: false, text: 'target-taken:目标 ' + candidate.decision.target + ' 在决策后变化,拒绝照跑;可重新 merge pair 或 discard' };
512
+ const recorded = await pairLedger(deps.io, deps.home).transact(tx => {
513
+ const current = tx.rows().find(item => item.id === candidate.id);
514
+ return current === undefined ? false : tx.transition(candidate.id, { type: 'expired', kind: 'target-taken' }, current.version).ok;
515
+ });
516
+ if (!recorded)
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);
519
+ }
520
+ if (candidate.decision.mode !== 'create-umbrella') {
521
+ const targetCard = cards.find(card => card.name === candidate.decision?.target);
522
+ if (targetCard !== undefined && targetCard.managed !== true) {
523
+ return fail('approve', 'target-not-managed', 'target 不是 family-managed:' + candidate.decision.target, '重新 pair 并选一个 managed 目标', candidate.runId);
524
+ }
525
+ if (targetCard !== undefined && (targetCard.protectionUnknown === true || targetCard.protectedBy != null)) {
526
+ return fail('approve', 'target-protected', 'target 受保护:' + candidate.decision.target, '解除保护或改选其它目标', candidate.runId);
527
+ }
528
+ if (await deps.isBlocked?.(candidate.decision.target) === true) {
529
+ return fail('approve', 'target-blocked', 'target 被范围门拦下:' + candidate.decision.target, '运行 /evolution scope 查看范围门', candidate.runId);
530
+ }
310
531
  }
311
532
  if (candidate.decision.mode === 'merge-into-existing' && !cards.some(card => card.name === candidate.decision?.target)) {
312
- const expired = transition(candidate, { type: 'target-taken', at: new Date().toISOString() });
313
- if (expired.ok && expired.row !== null)
314
- await upsertRow(deps.io, deps.home, deps.now, expired.row);
315
- return { ok: false, text: 'target-taken:目标 ' + candidate.decision.target + ' 已不存在,行保留 pending --expired;出口:重新 merge pair,或 merge skipped --discard' };
533
+ const recorded = await pairLedger(deps.io, deps.home).transact(tx => {
534
+ const current = tx.rows().find(item => item.id === candidate.id);
535
+ return current === undefined ? false : tx.transition(candidate.id, { type: 'expired', kind: 'target-taken' }, current.version).ok;
536
+ });
537
+ if (!recorded)
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);
316
540
  }
317
541
  }
318
542
  const result = await runApproveCommand({
319
543
  ...common,
320
544
  rowId: command.id,
545
+ ...(agentless ? { agentless: true } : {}),
546
+ ...(detached ? { detached: true } : {}),
547
+ pairTimeoutMinutes: deps.params.mergePairTimeoutMinutes,
321
548
  ports: {
549
+ stage: createStageForCards({ io: deps.io, home: deps.home, runId: command.id, cards, now: deps.now }),
322
550
  round2: createNewChildRound2({
323
551
  io: deps.io, home: deps.home, cards, channel: deps.channel, parent,
324
552
  allow: deps.allow, ...(deps.cwd === undefined ? {} : { cwd: deps.cwd }),
325
553
  timeoutMs: deps.params.mergePairTimeoutMinutes * 60_000,
326
- commitPort: skillLibraryPort(deps.skills), now: deps.now,
554
+ commitPort: skillLibraryPort(deps.skills), now: deps.now, ...(unitFor(deps) === undefined ? {} : { unit: unitFor(deps) }),
555
+ ...(deps.params.limitsSnapshot === undefined ? {} : { limits: deps.params.limitsSnapshot.skillLimits }),
556
+ ...(deps.isBlocked === undefined ? {} : { isBlocked: deps.isBlocked }),
557
+ ...stageHooks,
327
558
  }),
328
559
  },
329
560
  });
330
561
  if (!result.ok)
331
- return { ok: false, text: result.message };
562
+ return failRun('approve', result.message, '修复后重新 approve;run 报告见 /evolution merge report', result);
332
563
  if ('accepted' in result)
333
564
  return { ok: true, text: 'merge approve 已启动:' + result.runId };
334
565
  if (!('report' in result))
335
- return { ok: false, text: 'merge approve started without a report' };
566
+ return fail('approve', 'report-missing', 'merge approve 已结束但没有写报告', '重新运行 approve,或运行 /evolution merge runs 查看运行');
336
567
  return { ok: true, text: 'merge approve 完成:merged ' + String(result.report.totals.merged) };
337
568
  }
338
569
  const cards = command.verb === 'pending' ? await deps.cards(new AbortController().signal) : undefined;
339
- const text = await handleReadOnlyMergeCommand(command, deps.io, deps.home, cards);
570
+ const registry = deps.registry;
571
+ const runs = command.verb === 'status' ? registry.runs('merge') : undefined;
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);
340
595
  if (text !== null)
341
596
  return { ok: true, text };
342
597
  return readOnly.execute(command);