@lmzhen/dsh-evolution-merge 0.30.16 → 0.30.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +4 -3
  2. package/lib/index.js +2325 -855
  3. package/lib/types/adapters/library.js +4 -1
  4. package/lib/types/adapters/model.d.ts +31 -0
  5. package/lib/types/adapters/model.js +46 -0
  6. package/lib/types/approve-command.d.ts +2 -0
  7. package/lib/types/approve-command.js +41 -46
  8. package/lib/types/authoring-flow.d.ts +3 -1
  9. package/lib/types/authoring-flow.js +15 -5
  10. package/lib/types/command-surface.d.ts +5 -0
  11. package/lib/types/command-surface.js +44 -0
  12. package/lib/types/commit-assembly.d.ts +2 -0
  13. package/lib/types/commit-assembly.js +7 -2
  14. package/lib/types/commit-execute.d.ts +7 -1
  15. package/lib/types/commit-execute.js +40 -10
  16. package/lib/types/commit-recovery.d.ts +7 -2
  17. package/lib/types/commit-recovery.js +73 -96
  18. package/lib/types/contracts.d.ts +1 -1
  19. package/lib/types/contracts.js +1 -1
  20. package/lib/types/coordinator.d.ts +3 -2
  21. package/lib/types/coordinator.js +4 -3
  22. package/lib/types/decision.js +1 -1
  23. package/lib/types/doctor.d.ts +7 -0
  24. package/lib/types/doctor.js +104 -4
  25. package/lib/types/domain/claim.d.ts +4 -1
  26. package/lib/types/domain/claim.js +2 -2
  27. package/lib/types/domain/commit-journal-store.js +5 -1
  28. package/lib/types/domain/commit-journal.js +1 -1
  29. package/lib/types/domain/consistency.d.ts +22 -0
  30. package/lib/types/domain/consistency.js +50 -0
  31. package/lib/types/domain/ledger.js +10 -1
  32. package/lib/types/domain/migrate-v3.d.ts +20 -0
  33. package/lib/types/domain/migrate-v3.js +68 -0
  34. package/lib/types/domain/row-tx.d.ts +27 -0
  35. package/lib/types/domain/row-tx.js +31 -0
  36. package/lib/types/domain/row-v3-bridge.d.ts +6 -0
  37. package/lib/types/domain/row-v3-bridge.js +47 -0
  38. package/lib/types/domain/row-v3.d.ts +113 -0
  39. package/lib/types/domain/row-v3.js +107 -0
  40. package/lib/types/domain/row.d.ts +1 -0
  41. package/lib/types/domain/staging-index.d.ts +14 -0
  42. package/lib/types/domain/staging-index.js +41 -0
  43. package/lib/types/domain/state-v3.d.ts +15 -0
  44. package/lib/types/domain/state-v3.js +80 -0
  45. package/lib/types/facts.d.ts +24 -0
  46. package/lib/types/facts.js +68 -0
  47. package/lib/types/gate-filter.d.ts +31 -0
  48. package/lib/types/gate-filter.js +64 -0
  49. package/lib/types/index.d.ts +5 -2
  50. package/lib/types/index.js +16 -26
  51. package/lib/types/ladder.js +1 -1
  52. package/lib/types/merge-routes.d.ts +2 -2
  53. package/lib/types/merge-routes.js +21 -22
  54. package/lib/types/metrics.d.ts +16 -0
  55. package/lib/types/metrics.js +27 -0
  56. package/lib/types/new-child-authoring.d.ts +3 -1
  57. package/lib/types/new-child-authoring.js +14 -3
  58. package/lib/types/pending-actions.d.ts +2 -2
  59. package/lib/types/pending-actions.js +17 -17
  60. package/lib/types/propose.d.ts +26 -0
  61. package/lib/types/propose.js +69 -0
  62. package/lib/types/read-commands.d.ts +2 -2
  63. package/lib/types/read-commands.js +5 -3
  64. package/lib/types/resume-command.d.ts +2 -0
  65. package/lib/types/resume-command.js +4 -1
  66. package/lib/types/round2-outcome.js +17 -9
  67. package/lib/types/runtime.d.ts +4 -1
  68. package/lib/types/runtime.js +99 -29
  69. package/lib/types/scan-ports.d.ts +3 -1
  70. package/lib/types/scan-ports.js +2 -0
  71. package/lib/types/scan-run.js +116 -51
  72. package/lib/types/screening-port.d.ts +47 -0
  73. package/lib/types/screening-port.js +93 -0
  74. package/lib/types/screening.d.ts +30 -0
  75. package/lib/types/screening.js +75 -0
  76. package/lib/types/state.d.ts +14 -0
  77. package/lib/types/state.js +72 -1
  78. package/lib/types/status.d.ts +3 -0
  79. package/lib/types/status.js +17 -0
  80. package/lib/types/target-claim.d.ts +1 -0
  81. package/lib/types/target-claim.js +63 -18
  82. package/lib/types/unit-claim.d.ts +5 -0
  83. package/lib/types/unit-claim.js +15 -0
  84. package/lib/types/wire-projection.d.ts +36 -0
  85. package/lib/types/wire-projection.js +69 -0
  86. package/lib/types/wire.d.ts +254 -0
  87. package/lib/types/wire.js +3 -0
  88. package/package.json +8 -4
  89. package/lib/types/routes.d.ts +0 -70
  90. package/lib/types/routes.js +0 -78
@@ -0,0 +1,69 @@
1
+ const SYSTEM = 'You propose skill pairs that may be merged. Return only a JSON object: {"pairs":[{"a":"skill-a","b":"skill-b","reason":"one sentence"}]}. Use only supplied skill names. Do not decide whether a merge is good.';
2
+ function parsePairs(raw, known, cap) {
3
+ let value = raw;
4
+ if (typeof raw === 'string') {
5
+ try {
6
+ value = JSON.parse(raw);
7
+ }
8
+ catch {
9
+ return [];
10
+ }
11
+ }
12
+ if (typeof value !== 'object' || value === null)
13
+ return [];
14
+ const pairs = value.pairs;
15
+ if (!Array.isArray(pairs))
16
+ return [];
17
+ const out = [];
18
+ const seen = new Set();
19
+ for (const item of pairs) {
20
+ if (out.length >= cap)
21
+ break;
22
+ const pair = item;
23
+ if (typeof pair.a !== 'string' || typeof pair.b !== 'string')
24
+ continue;
25
+ const a = pair.a.trim();
26
+ const b = pair.b.trim();
27
+ if (a === b || !known.has(a) || !known.has(b))
28
+ continue;
29
+ const [first, second] = a < b ? [a, b] : [b, a];
30
+ const key = first + '\u0000' + second;
31
+ if (seen.has(key))
32
+ continue;
33
+ seen.add(key);
34
+ out.push({ a: first, b: second, source: 'proposed', ...(typeof pair.reason === 'string' && pair.reason.trim() !== '' ? { reason: pair.reason.trim() } : {}) });
35
+ }
36
+ return out;
37
+ }
38
+ export async function proposePairs(cards, port, options) {
39
+ const shardSize = Math.max(2, options.shardSize ?? 60);
40
+ const cap = Math.max(0, options.maxPairs ?? 200);
41
+ const known = new Set(cards.map(card => card.name));
42
+ const candidates = [];
43
+ const degraded = [];
44
+ let shards = 0;
45
+ for (let start = 0; start < cards.length && candidates.length < cap; start += shardSize) {
46
+ const shard = cards.slice(start, start + shardSize);
47
+ if (shard.length < 2)
48
+ continue;
49
+ shards += 1;
50
+ try {
51
+ const raw = await port.callJson({
52
+ system: SYSTEM,
53
+ user: JSON.stringify(shard.map(card => ({
54
+ name: card.name,
55
+ description: card.description.slice(0, 400),
56
+ body: card.body.slice(0, 600),
57
+ }))),
58
+ maxTokens: 1200,
59
+ signal: options.signal,
60
+ });
61
+ candidates.push(...parsePairs(raw, known, cap - candidates.length));
62
+ }
63
+ catch (error) {
64
+ degraded.push('proposal shard failed: ' + (error instanceof Error ? error.message : String(error)));
65
+ }
66
+ }
67
+ return { candidates, shards, degraded };
68
+ }
69
+ //# sourceMappingURL=propose.js.map
@@ -2,10 +2,10 @@
2
2
  * Read-only command handlers: status/pending/skipped are served from merge-state.
3
3
  * Mutating verbs return null so the host shell can route them to the run engine.
4
4
  */
5
- import type { EvolutionIoLike } from '@lmzhen/dsh-evolution-core';
5
+ import type { EvolutionIoLike, RunRecord } from '@lmzhen/dsh-evolution-core';
6
6
  import type { MergeCliCommand } from './commands.ts';
7
7
  export declare function handleReadOnlyMergeCommand(command: MergeCliCommand, io: EvolutionIoLike, home: string, cards?: readonly {
8
8
  readonly name: string;
9
- }[]): Promise<string | null>;
9
+ }[], runs?: readonly RunRecord[], approval?: boolean): Promise<string | null>;
10
10
  export declare function mergeCommandHelp(): string;
11
11
  //# sourceMappingURL=read-commands.d.ts.map
@@ -1,7 +1,8 @@
1
+ import { buildFacts } from "./facts.js";
1
2
  import { loadMergeState } from "./state.js";
2
- import { formatQueue, formatStatus } from "./status.js";
3
+ import { formatFacts, formatQueue, formatStatus } from "./status.js";
3
4
  import { mergeUsage } from "./commands.js";
4
- export async function handleReadOnlyMergeCommand(command, io, home, cards) {
5
+ export async function handleReadOnlyMergeCommand(command, io, home, cards, runs, approval = false) {
5
6
  const withNote = (text, note) => note === undefined ? text : 'note: ' + note + '\n' + text;
6
7
  const load = async () => {
7
8
  const result = await loadMergeState(io, home);
@@ -15,7 +16,8 @@ export async function handleReadOnlyMergeCommand(command, io, home, cards) {
15
16
  const row = loaded.state.rows.find(item => item.id === command.id);
16
17
  return withNote(row === undefined ? 'merge row not found: ' + command.id : formatStatus([row]), loaded.note);
17
18
  }
18
- return withNote(formatStatus(loaded.state.rows), loaded.note);
19
+ const facts = buildFacts({ rows: loaded.state.rows, runs: runs ?? [], approval, readAt: Date.now(), ...(loaded.note === undefined ? {} : { notes: [loaded.note] }) });
20
+ return withNote(formatFacts(facts), loaded.note);
19
21
  }
20
22
  if (command.verb === 'pending') {
21
23
  const loaded = await load();
@@ -17,6 +17,8 @@ export interface RunResumeCommandOptions {
17
17
  readonly round2WithRun?: (row: MergeRow, decision: MergeDecision, signal: AbortSignal, runId: string) => Promise<ScanRound2Outcome>;
18
18
  readonly rowId: string;
19
19
  readonly platformWrite: boolean;
20
+ readonly detached?: boolean;
21
+ readonly pairTimeoutMinutes?: number;
20
22
  readonly timeoutMinutes?: number;
21
23
  readonly now: () => number;
22
24
  readonly isOwnerActive?: (runId: string) => boolean | Promise<boolean>;
@@ -14,6 +14,7 @@ export async function runResumeCommand(options) {
14
14
  return await runScanCommand({
15
15
  io: options.io, home: options.home, registry: options.registry,
16
16
  approval: true, platformWrite: options.platformWrite, now: options.now,
17
+ ...(options.detached === true ? { detached: true } : {}),
17
18
  ...(options.makePorts === undefined
18
19
  ? { ports: { ...options.ports, screening: () => Promise.resolve([{ a: row.a, b: row.b }]) } }
19
20
  : {
@@ -36,9 +37,10 @@ export async function runResumeCommand(options) {
36
37
  const decision = row.decision;
37
38
  if (decision === undefined)
38
39
  return { ok: false, message: 'fuse row has no decision' };
39
- return await withMergeRun({
40
+ const result = await withMergeRun({
40
41
  io: options.io, home: options.home, registry: options.registry,
41
42
  command: 'resume', approval: { platformWrite: options.platformWrite, mergeApproval: true }, now: options.now,
43
+ ...(options.detached === true ? { detached: true } : {}),
42
44
  async body(ctx) {
43
45
  await ctx.stage('author', { done: 0, total: 1 });
44
46
  let outcome;
@@ -60,5 +62,6 @@ export async function runResumeCommand(options) {
60
62
  };
61
63
  },
62
64
  });
65
+ return result;
63
66
  }
64
67
  //# sourceMappingURL=resume-command.js.map
@@ -1,12 +1,13 @@
1
- import { transition } from "./coordinator.js";
2
- import { dropRow, upsertRow } from "./state.js";
1
+ import { v3ToLegacy } from "./domain/row-v3-bridge.js";
2
+ import { dropRowV3, transactRows } from "./state.js";
3
3
  import { recordPair } from "./domain/pair-index-store.js";
4
4
  export async function recordRound2Outcome(io, home, now, row, outcome) {
5
- const at = new Date(now()).toISOString();
5
+ void now;
6
6
  if (outcome.kind === 'merged') {
7
- const success = transition(row, { type: 'round2-success', at });
8
- if (success.ok) {
9
- await dropRow(io, home, now, row.id);
7
+ {
8
+ const dropped = await dropRowV3(io, home, row.id, 'committed', outcome.unitId ?? 'unknown');
9
+ if (!dropped)
10
+ return { merged: false, pair: { id: row.id, pairKey: row.pairKey, a: row.a, b: row.b, queue: row.queue, outcome: 'failed', archived: [], failure: { stage: 'commit', code: 'row-changed', message: 'row changed before commit record', retryable: true, retryFrom: 'agent2' } } };
10
11
  const target = outcome.target?.actual ?? outcome.target?.requested ?? row.decision?.target;
11
12
  await recordPair(io, home, {
12
13
  pairKey: row.pairKey,
@@ -28,9 +29,16 @@ export async function recordRound2Outcome(io, home, now, row, outcome) {
28
29
  };
29
30
  }
30
31
  const failure = outcome.failure ?? { stage: 'author', code: 'merge-failed', message: 'round2 failed', retryable: true, retryFrom: 'agent2' };
31
- const next = transition(row, { type: 'round2-fail', failure, at });
32
- if (next.ok && next.row !== null) {
33
- await upsertRow(io, home, now, next.row);
32
+ const projected = await transactRows(io, home, tx => {
33
+ const current = tx.rows().find(item => item.id === row.id);
34
+ if (current === undefined)
35
+ return null;
36
+ const moved = tx.transition(row.id, { type: 'failed', failure }, current.version);
37
+ return moved.ok && moved.row !== null ? v3ToLegacy(moved.row) : null;
38
+ });
39
+ if (projected === null)
40
+ return { merged: false, pair: { id: row.id, pairKey: row.pairKey, a: row.a, b: row.b, queue: row.queue, outcome: 'failed', archived: [], failure: { ...failure, code: 'row-changed', message: 'row changed before failure record' } } };
41
+ {
34
42
  await recordPair(io, home, {
35
43
  pairKey: row.pairKey,
36
44
  runId: row.runId ?? 'merge',
@@ -3,7 +3,7 @@
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 { type EvolutionIoLike, type SkillLibrary } from '@lmzhen/dsh-evolution-core';
6
+ import { SkillLibrary, type EvolutionIoLike, type UnitTransaction } from '@lmzhen/dsh-evolution-core';
7
7
  import { type MergeCandidate } from './contracts.ts';
8
8
  import type { MergeParams } from './params.ts';
9
9
  import type { SkillCard } from './skill-card.ts';
@@ -37,6 +37,9 @@ export interface MergeRuntimeDeps {
37
37
  /** Run-scoped engine screening. */
38
38
  readonly screeningForRun: (cards: readonly SkillCard[], runId: string, signal: AbortSignal) => Promise<readonly MergeCandidate[]>;
39
39
  readonly stage?: MergeStageBindingPort;
40
+ /** G2 transitional: when set, authoring commits through core UnitTransaction. */
41
+ readonly unit?: UnitTransaction;
40
42
  }
43
+ export declare function unitFor(deps: MergeRuntimeDeps): UnitTransaction | undefined;
41
44
  export declare function createMergeRuntime(deps: MergeRuntimeDeps): MergeCommandService;
42
45
  //# sourceMappingURL=runtime.d.ts.map
@@ -3,19 +3,22 @@
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 { probeText, SkillLibrary, UnitTransactions, skillLibraryUnitBackend } from '@lmzhen/dsh-evolution-core';
7
+ import { targetClaimUnitPort } from "./unit-claim.js";
7
8
  import { canRetry, pairKey } from "./contracts.js";
8
9
  import { skippedPairKeys } from "./skipped.js";
9
10
  import { createReadOnlyMergeService } from "./merge-command.js";
11
+ import { formatRunsView } from "./status.js";
12
+ import { toRunsView } from "./wire-projection.js";
10
13
  import { handleReadOnlyMergeCommand } from "./read-commands.js";
11
14
  import { runScanCommand } from "./scan-command.js";
12
15
  import { runApproveCommand } from "./approve-command.js";
13
16
  import { runResumeCommand } from "./resume-command.js";
14
- import { dropRow, loadMergeState, upsertRow } from "./state.js";
15
- import { transition } from "./coordinator.js";
17
+ import { dropRowV3, loadMergeState, transactRows } from "./state.js";
16
18
  import { readCommitReceipt } from "./commit-receipt.js";
17
19
  import { undoMergeReceipt } from "./undo-merge.js";
18
20
  import { recordPair } from "./domain/pair-index-store.js";
21
+ import { readStagingIndex } from "./domain/staging-index.js";
19
22
  import { skillLibraryUndoPort } from "./adapters/undo.js";
20
23
  import { mergeDoctor } from "./doctor.js";
21
24
  import { createChannelScanPorts } from "./scan-ports.js";
@@ -28,6 +31,13 @@ function forcedDecisionFor(a, b) {
28
31
  const target = ('merge-' + sorted.join('-')).replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64);
29
32
  return { shouldMerge: true, mode: 'create-umbrella', target, sources: [a, b], rationale: 'forced pair merge of ' + sorted.join(' and ') };
30
33
  }
34
+ export function unitFor(deps) {
35
+ if (deps.unit !== undefined)
36
+ return deps.unit;
37
+ if (!(deps.skills instanceof SkillLibrary))
38
+ return undefined;
39
+ return new UnitTransactions({ io: deps.io, home: deps.home, backend: skillLibraryUnitBackend(deps.skills), claim: targetClaimUnitPort(deps.io, deps.home, deps.now), now: deps.now });
40
+ }
31
41
  export function createMergeRuntime(deps) {
32
42
  const readOnly = createReadOnlyMergeService(deps.io, deps.home);
33
43
  const timeout = deps.timeoutMinutes > 0 ? deps.timeoutMinutes : undefined;
@@ -66,7 +76,9 @@ export function createMergeRuntime(deps) {
66
76
  const pair = [{ a: command.a, b: command.b }];
67
77
  const existing = (await loadMergeState(deps.io, deps.home)).state.rows.find(row => row.pairKey === pairKey(command.a, command.b));
68
78
  if (existing?.state === 'skipped') {
69
- await dropRow(deps.io, deps.home, deps.now, existing.id);
79
+ const dropped = await dropRowV3(deps.io, deps.home, existing.id, 'superseded');
80
+ if (!dropped)
81
+ return { ok: false, text: 'row changed before cleanup: ' + existing.id };
70
82
  }
71
83
  if (existing?.state === 'pending') {
72
84
  return { ok: false, text: '该 pair 已在待批准队列,先 approve/reject/discard: ' + existing.id };
@@ -88,7 +100,8 @@ export function createMergeRuntime(deps) {
88
100
  ...(deps.cwd === undefined ? {} : { cwd: deps.cwd }),
89
101
  timeoutMs: deps.params.mergePairTimeoutMinutes * 60_000,
90
102
  approval,
91
- commitPort: skillLibraryPort(deps.skills), now: deps.now,
103
+ commitPort: skillLibraryPort(deps.skills), now: deps.now, ...(unitFor(deps) === undefined ? {} : { unit: unitFor(deps) }),
104
+ ...(deps.isBlocked === undefined ? {} : { isBlocked: deps.isBlocked }),
92
105
  ...stageHooks,
93
106
  });
94
107
  return forced === undefined ? ports : { ...ports, round1: () => Promise.resolve({ kind: 'merge', decision: forced }) };
@@ -140,7 +153,8 @@ export function createMergeRuntime(deps) {
140
153
  ...(deps.cwd === undefined ? {} : { cwd: deps.cwd }),
141
154
  timeoutMs: deps.params.mergePairTimeoutMinutes * 60_000,
142
155
  approval: deps.params.mergeApproval,
143
- commitPort: skillLibraryPort(deps.skills), now: deps.now,
156
+ commitPort: skillLibraryPort(deps.skills), now: deps.now, ...(unitFor(deps) === undefined ? {} : { unit: unitFor(deps) }),
157
+ ...(deps.isBlocked === undefined ? {} : { isBlocked: deps.isBlocked }),
144
158
  ...stageHooks,
145
159
  }),
146
160
  });
@@ -157,10 +171,14 @@ export function createMergeRuntime(deps) {
157
171
  const row = loaded.state.rows.find(item => item.id === command.id);
158
172
  if (row === undefined)
159
173
  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);
174
+ const changed = await transactRows(deps.io, deps.home, tx => {
175
+ const current = tx.rows().find(item => item.id === row.id);
176
+ if (current === undefined || current.state !== 'pending')
177
+ return false;
178
+ return tx.transition(row.id, { type: 'skipped', reason: 'rejected', origin: 'human' }, current.version).ok;
179
+ });
180
+ if (!changed)
181
+ return { ok: false, text: 'merge row is not pending or changed: ' + command.id };
164
182
  return { ok: true, text: '已拒绝合并:' + command.id };
165
183
  }
166
184
  if (command.verb === 'doctor') {
@@ -171,7 +189,7 @@ export function createMergeRuntime(deps) {
171
189
  const registry = deps.registry;
172
190
  if (command.verb === 'runs') {
173
191
  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') };
192
+ return { ok: true, text: formatRunsView(toRunsView(mergeRuns, deps.now())) };
175
193
  }
176
194
  if (command.verb === 'cancel') {
177
195
  if (command.id !== undefined) {
@@ -200,7 +218,9 @@ export function createMergeRuntime(deps) {
200
218
  return { ok: false, text: 'merge row not found: ' + command.id };
201
219
  if (row.state !== 'pending' && row.state !== 'approved' && row.state !== 'fuse')
202
220
  return { ok: false, text: 'merge row is not discardable: ' + command.id };
203
- await dropRow(deps.io, deps.home, deps.now, row.id);
221
+ const dropped = await dropRowV3(deps.io, deps.home, row.id, 'superseded');
222
+ if (!dropped)
223
+ return { ok: false, text: 'row changed before discard: ' + command.id };
204
224
  return { ok: true, text: '已丢弃合并行:' + command.id };
205
225
  }
206
226
  if (command.verb === 'undo') {
@@ -220,7 +240,9 @@ export function createMergeRuntime(deps) {
220
240
  const row = loaded.state.rows.find(item => item.id === command.restore);
221
241
  if (row === undefined || row.state !== 'skipped')
222
242
  return { ok: false, text: 'skipped row not found: ' + command.restore };
223
- await dropRow(deps.io, deps.home, deps.now, row.id);
243
+ const dropped = await dropRowV3(deps.io, deps.home, row.id, 'restored');
244
+ if (!dropped)
245
+ return { ok: false, text: 'row changed before restore: ' + row.id };
224
246
  return { ok: true, text: '已恢复参与筛选:' + row.id };
225
247
  }
226
248
  if (command.verb === 'resume') {
@@ -228,7 +250,9 @@ export function createMergeRuntime(deps) {
228
250
  const cleared = (await loadMergeState(deps.io, deps.home)).state.rows.find(item => item.id === command.clear);
229
251
  if (cleared === undefined)
230
252
  return { ok: false, text: 'merge row not found: ' + command.clear };
231
- await dropRow(deps.io, deps.home, deps.now, cleared.id);
253
+ const dropped = await dropRowV3(deps.io, deps.home, cleared.id, 'superseded');
254
+ if (!dropped)
255
+ return { ok: false, text: 'row changed before clear: ' + cleared.id };
232
256
  return { ok: true, text: '已清除合并行:' + cleared.id };
233
257
  }
234
258
  const loaded = await loadMergeState(deps.io, deps.home);
@@ -247,6 +271,15 @@ export function createMergeRuntime(deps) {
247
271
  return { ok: false, text: 'merge row not found: ' + (command.id ?? '(auto)') };
248
272
  const signal = new AbortController().signal;
249
273
  const cards = await deps.cards(signal);
274
+ if ((row.state === 'fuse' || row.state === 'approved') && row.decision !== undefined && row.decision.mode !== 'create-umbrella') {
275
+ const targetCard = cards.find(card => card.name === row.decision?.target);
276
+ if (targetCard !== undefined && targetCard.managed !== true)
277
+ return { ok: false, text: 'target 不是 family-managed:' + row.decision.target };
278
+ if (targetCard !== undefined && (targetCard.protectionUnknown === true || targetCard.protectedBy != null))
279
+ return { ok: false, text: 'target 受保护:' + row.decision.target };
280
+ if (await deps.isBlocked?.(row.decision.target) === true)
281
+ return { ok: false, text: 'target 被范围门拦下:' + row.decision.target };
282
+ }
250
283
  const pair = () => Promise.resolve([{ a: row.a, b: row.b }]);
251
284
  const failed = (stage, code, message) => Promise.resolve({
252
285
  kind: 'failed',
@@ -259,19 +292,23 @@ export function createMergeRuntime(deps) {
259
292
  };
260
293
  const result = await runResumeCommand({
261
294
  ...common, rowId: row.id, ports: basePorts,
295
+ ...(detached ? { detached: true } : {}),
296
+ pairTimeoutMinutes: deps.params.mergePairTimeoutMinutes,
262
297
  ...(deps.isOwnerActive === undefined ? {} : { isOwnerActive: deps.isOwnerActive }),
263
298
  makePorts: runId => createChannelScanPorts({
264
299
  io: deps.io, home: deps.home, runId, cards, screening: pair,
265
300
  channel: deps.channel, parent, allow: deps.allow,
266
301
  ...(deps.cwd === undefined ? {} : { cwd: deps.cwd }),
267
302
  timeoutMs: deps.params.mergePairTimeoutMinutes * 60_000,
268
- approval: deps.params.mergeApproval, commitPort: skillLibraryPort(deps.skills), now: deps.now,
303
+ approval: deps.params.mergeApproval, commitPort: skillLibraryPort(deps.skills), now: deps.now, ...(unitFor(deps) === undefined ? {} : { unit: unitFor(deps) }),
304
+ ...(deps.isBlocked === undefined ? {} : { isBlocked: deps.isBlocked }),
269
305
  }),
270
306
  round2WithRun: createNewChildRound2({
271
307
  io: deps.io, home: deps.home, cards, channel: deps.channel, parent,
272
308
  allow: deps.allow, ...(deps.cwd === undefined ? {} : { cwd: deps.cwd }),
273
309
  timeoutMs: deps.params.mergePairTimeoutMinutes * 60_000,
274
- commitPort: skillLibraryPort(deps.skills), now: deps.now,
310
+ commitPort: skillLibraryPort(deps.skills), now: deps.now, ...(unitFor(deps) === undefined ? {} : { unit: unitFor(deps) }),
311
+ ...(deps.isBlocked === undefined ? {} : { isBlocked: deps.isBlocked }),
275
312
  ...stageHooks,
276
313
  }),
277
314
  });
@@ -286,44 +323,75 @@ export function createMergeRuntime(deps) {
286
323
  if (command.verb === 'approve') {
287
324
  const signal = new AbortController().signal;
288
325
  const cards = await deps.cards(signal);
289
- const candidate = (await loadMergeState(deps.io, deps.home)).state.rows.find(row => row.id === command.id);
326
+ let candidate = (await loadMergeState(deps.io, deps.home)).state.rows.find(row => row.id === command.id);
327
+ if (candidate !== undefined && candidate.stagingRef === undefined) {
328
+ const entry = await readStagingIndex(deps.io, deps.home, candidate.id, deps.now());
329
+ if (entry !== null)
330
+ candidate = { ...candidate, stagingRef: entry.ref, stagingExpiresAt: entry.expiresAt };
331
+ }
290
332
  if (candidate !== undefined && candidate.state === 'pending' && candidate.decision !== undefined) {
291
333
  const missingSource = [candidate.a, candidate.b].find(name => !cards.some(card => card.name === name && card.managed === true));
292
334
  if (missingSource !== undefined) {
293
- await dropRow(deps.io, deps.home, deps.now, candidate.id);
335
+ const dropped = await dropRowV3(deps.io, deps.home, candidate.id, 'superseded');
336
+ if (!dropped)
337
+ return { ok: false, text: 'source-gone 但 row 已变化,未清理:' + candidate.id };
294
338
  return { ok: false, text: 'source-gone:' + missingSource + ' 已不在 family-managed 技能库,已从 active 队列清除;可 merge skipped --discard 清理' };
295
339
  }
296
340
  const currentRevision = sourceRevisionOf(cards, candidate.a, candidate.b);
297
341
  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);
342
+ const recorded = await transactRows(deps.io, deps.home, tx => {
343
+ const current = tx.rows().find(item => item.id === candidate.id);
344
+ return current === undefined ? false : tx.transition(candidate.id, { type: 'expired', kind: 'source-changed' }, current.version).ok;
345
+ });
346
+ if (!recorded)
347
+ return { ok: false, text: 'row changed before source-changed record: ' + candidate.id };
301
348
  return { ok: false, text: 'source-changed:源技能内容在决策后变化,拒绝照跑;出口:重新 merge pair,或 merge skipped --discard' };
302
349
  }
303
350
  const currentTargetRevision = targetRevisionOf(cards, candidate.decision.target);
304
351
  if (candidate.targetRevision !== undefined && currentTargetRevision !== undefined
305
352
  && 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);
353
+ const recorded = await transactRows(deps.io, deps.home, tx => {
354
+ const current = tx.rows().find(item => item.id === candidate.id);
355
+ return current === undefined ? false : tx.transition(candidate.id, { type: 'expired', kind: 'target-taken' }, current.version).ok;
356
+ });
357
+ if (!recorded)
358
+ return { ok: false, text: 'row changed before target-taken record: ' + candidate.id };
309
359
  return { ok: false, text: 'target-taken:目标 ' + candidate.decision.target + ' 在决策后变化,拒绝照跑;可重新 merge pair 或 discard' };
310
360
  }
361
+ if (candidate.decision.mode !== 'create-umbrella') {
362
+ const targetCard = cards.find(card => card.name === candidate.decision?.target);
363
+ if (targetCard !== undefined && targetCard.managed !== true) {
364
+ return { ok: false, text: 'target 不是 family-managed:' + candidate.decision.target };
365
+ }
366
+ if (targetCard !== undefined && (targetCard.protectionUnknown === true || targetCard.protectedBy != null)) {
367
+ return { ok: false, text: 'target 受保护:' + candidate.decision.target };
368
+ }
369
+ if (await deps.isBlocked?.(candidate.decision.target) === true) {
370
+ return { ok: false, text: 'target 被范围门拦下:' + candidate.decision.target };
371
+ }
372
+ }
311
373
  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);
374
+ const recorded = await transactRows(deps.io, deps.home, tx => {
375
+ const current = tx.rows().find(item => item.id === candidate.id);
376
+ return current === undefined ? false : tx.transition(candidate.id, { type: 'expired', kind: 'target-taken' }, current.version).ok;
377
+ });
378
+ if (!recorded)
379
+ return { ok: false, text: 'row changed before target-taken record: ' + candidate.id };
315
380
  return { ok: false, text: 'target-taken:目标 ' + candidate.decision.target + ' 已不存在,行保留 pending --expired;出口:重新 merge pair,或 merge skipped --discard' };
316
381
  }
317
382
  }
318
383
  const result = await runApproveCommand({
319
384
  ...common,
320
385
  rowId: command.id,
386
+ ...(detached ? { detached: true } : {}),
387
+ pairTimeoutMinutes: deps.params.mergePairTimeoutMinutes,
321
388
  ports: {
322
389
  round2: createNewChildRound2({
323
390
  io: deps.io, home: deps.home, cards, channel: deps.channel, parent,
324
391
  allow: deps.allow, ...(deps.cwd === undefined ? {} : { cwd: deps.cwd }),
325
392
  timeoutMs: deps.params.mergePairTimeoutMinutes * 60_000,
326
- commitPort: skillLibraryPort(deps.skills), now: deps.now,
393
+ commitPort: skillLibraryPort(deps.skills), now: deps.now, ...(unitFor(deps) === undefined ? {} : { unit: unitFor(deps) }),
394
+ ...(deps.isBlocked === undefined ? {} : { isBlocked: deps.isBlocked }),
327
395
  }),
328
396
  },
329
397
  });
@@ -336,7 +404,9 @@ export function createMergeRuntime(deps) {
336
404
  return { ok: true, text: 'merge approve 完成:merged ' + String(result.report.totals.merged) };
337
405
  }
338
406
  const cards = command.verb === 'pending' ? await deps.cards(new AbortController().signal) : undefined;
339
- const text = await handleReadOnlyMergeCommand(command, deps.io, deps.home, cards);
407
+ const registry = deps.registry;
408
+ const runs = command.verb === 'status' ? registry.runs('merge') : undefined;
409
+ const text = await handleReadOnlyMergeCommand(command, deps.io, deps.home, cards, runs, deps.params.mergeApproval);
340
410
  if (text !== null)
341
411
  return { ok: true, text };
342
412
  return readOnly.execute(command);
@@ -5,7 +5,7 @@
5
5
  * Approval on: round1 disposes the child immediately; the row waits in pending and the later
6
6
  * approve run must open a new child (not handled by this factory).
7
7
  */
8
- import type { EvolutionIoLike } from '@lmzhen/dsh-evolution-core';
8
+ import type { EvolutionIoLike, UnitTransaction } from '@lmzhen/dsh-evolution-core';
9
9
  import type { SkillCard } from './skill-card.ts';
10
10
  import { type ConversationChannelLike } from './channel-port.ts';
11
11
  import type { CommitPort } from './commit-execute.ts';
@@ -24,7 +24,9 @@ export interface CreateScanPortsOptions {
24
24
  readonly timeoutMs: number;
25
25
  readonly approval: boolean;
26
26
  readonly commitPort: CommitPort;
27
+ readonly unit?: UnitTransaction | undefined;
27
28
  readonly now: () => number;
29
+ readonly isBlocked?: (name: string) => boolean | Promise<boolean>;
28
30
  readonly onChild?: (context: StageToolContext) => void;
29
31
  readonly onChildClosed?: (childId: string) => void;
30
32
  }
@@ -72,7 +72,9 @@ export function createChannelScanPorts(options) {
72
72
  mode: decision.mode,
73
73
  cards: options.cards,
74
74
  commitPort: options.commitPort,
75
+ ...(options.unit === undefined ? {} : { unit: options.unit }),
75
76
  now: options.now,
77
+ ...(options.isBlocked === undefined ? {} : { isBlocked: options.isBlocked }),
76
78
  summary: 'merge ' + row.a + ' + ' + row.b,
77
79
  author: async () => {
78
80
  await entry.result.author?.(row, decision, signal);