@lmzhen/dsh-evolution-merge 0.30.1 → 0.30.3

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.
@@ -2,11 +2,18 @@ import { assembleStagedCommit } from "./commit-assembly.js";
2
2
  import { writeCommitReceipt } from "./commit-receipt.js";
3
3
  import { pairKey } from "./contracts.js";
4
4
  import { writeJournal } from "./domain/commit-journal-store.js";
5
+ import { writeCommitSnapshot } from "./commit-snapshot.js";
5
6
  export async function commitStagedRun(io, home, runId, port, options) {
6
7
  const assembled = await assembleStagedCommit(io, home, runId, port, { origin: options.origin, summary: options.summary });
7
8
  if (!assembled.ok)
8
9
  return { ok: false, message: assembled.message };
9
- const journal = { runId, target: assembled.plan.target, create: !assembled.plan.targetExists, sources: assembled.plan.sources, phase: 'prepared', updatedAt: new Date(options.now()).toISOString() };
10
+ try {
11
+ await writeCommitSnapshot(io, home, runId, assembled.plan, options.now(), port);
12
+ }
13
+ catch (error) {
14
+ return { ok: false, message: 'commit snapshot failed; refusing to write: ' + (error instanceof Error ? error.message : String(error)), plan: assembled.plan };
15
+ }
16
+ const journal = { runId, target: assembled.plan.target, create: !assembled.plan.targetExists, sources: assembled.plan.sources, phase: 'sources-archived', updatedAt: new Date(options.now()).toISOString() };
10
17
  try {
11
18
  await writeJournal(io, home, journal);
12
19
  }
@@ -19,6 +26,11 @@ export async function commitStagedRun(io, home, runId, port, options) {
19
26
  catch { }
20
27
  return { ok: false, message: result.message, plan: assembled.plan };
21
28
  }
29
+ const committed = { ...journal, phase: 'committed', updatedAt: new Date(options.now()).toISOString() };
30
+ try {
31
+ await writeJournal(io, home, committed);
32
+ }
33
+ catch { }
22
34
  if (result.unitId === undefined)
23
35
  return { ok: true, plan: assembled.plan, result, warning: 'commit landed without a unitId; receipt not written' };
24
36
  const receipt = {
@@ -34,10 +46,6 @@ export async function commitStagedRun(io, home, runId, port, options) {
34
46
  };
35
47
  try {
36
48
  await writeCommitReceipt(io, home, receipt);
37
- try {
38
- await writeJournal(io, home, { ...journal, unitId: result.unitId, phase: 'committed', updatedAt: new Date(options.now()).toISOString() });
39
- }
40
- catch { }
41
49
  return { ok: true, plan: assembled.plan, result, receipt };
42
50
  }
43
51
  catch (error) {
@@ -0,0 +1,10 @@
1
+ import type { EvolutionIoLike } from '@lmzhen/dsh-evolution-core';
2
+ import type { CommitJournal } from './domain/commit-journal.ts';
3
+ export interface CommitRecoveryReport {
4
+ readonly runId: string;
5
+ readonly phase: CommitJournal['phase'];
6
+ readonly action: string;
7
+ readonly ok: boolean;
8
+ }
9
+ export declare function recoverCommitJournals(io: EvolutionIoLike, home: string, now: number, skillRoot: string, restoreSource?: (name: string) => Promise<boolean>): Promise<readonly CommitRecoveryReport[]>;
10
+ //# sourceMappingURL=commit-recovery.d.ts.map
@@ -0,0 +1,84 @@
1
+ /** Startup replay for non-terminal commit journals. */
2
+ import { join } from 'node:path';
3
+ import { recoveryFor } from "./domain/commit-journal.js";
4
+ import { writeJournal } from "./domain/commit-journal-store.js";
5
+ import { readCommitSnapshot, skillBodyPath, skillFilePath } from "./commit-snapshot.js";
6
+ export async function recoverCommitJournals(io, home, now, skillRoot, restoreSource) {
7
+ const dir = join(home, 'merge', 'journals');
8
+ if (!(await io.exists(dir)))
9
+ return [];
10
+ const files = (await io.list(dir)).filter(name => name.endsWith('.json'));
11
+ const reports = [];
12
+ for (const file of files) {
13
+ let journal;
14
+ try {
15
+ journal = JSON.parse(await io.readText(join(dir, file)) ?? '');
16
+ }
17
+ catch {
18
+ continue;
19
+ }
20
+ const recovery = recoveryFor(journal);
21
+ if (recovery === 'none')
22
+ continue;
23
+ const snapshot = await readCommitSnapshot(io, home, journal.runId);
24
+ let action = 'report-only';
25
+ let ok = true;
26
+ if (recovery === 'retry-commit') {
27
+ action = 'no-host-write';
28
+ }
29
+ else if (snapshot === null) {
30
+ action = 'snapshot-missing';
31
+ ok = false;
32
+ }
33
+ else {
34
+ try {
35
+ if (recovery === 'rollback-sources') {
36
+ let restoredArchived = restoreSource !== undefined;
37
+ if (restoreSource !== undefined) {
38
+ for (const source of snapshot.sourceBodies) {
39
+ if (!(await restoreSource(source.name)))
40
+ restoredArchived = false;
41
+ }
42
+ }
43
+ if (!restoredArchived) {
44
+ for (const source of snapshot.sourceBodies)
45
+ await io.writeText(skillBodyPath(skillRoot, source.name), source.body);
46
+ }
47
+ }
48
+ if (snapshot.create && snapshot.targetBody === null) {
49
+ try {
50
+ await io.remove(skillBodyPath(skillRoot, snapshot.target));
51
+ }
52
+ catch { /* already gone */ }
53
+ }
54
+ else if (snapshot.targetBody !== null) {
55
+ await io.writeText(skillBodyPath(skillRoot, snapshot.target), snapshot.targetBody);
56
+ }
57
+ const oldPaths = new Set((snapshot.targetSupport ?? []).map(file => file.path));
58
+ for (const file of snapshot.targetSupport ?? []) {
59
+ await io.writeText(skillFilePath(skillRoot, snapshot.target, file.path), file.content);
60
+ }
61
+ for (const file of snapshot.targetSupportAfter ?? []) {
62
+ if (!oldPaths.has(file.path)) {
63
+ try {
64
+ await io.remove(skillFilePath(skillRoot, snapshot.target, file.path));
65
+ }
66
+ catch { /* already gone */ }
67
+ }
68
+ }
69
+ for (const file of snapshot.sourceSupport ?? [])
70
+ await io.writeText(skillFilePath(skillRoot, file.name, file.path), file.content);
71
+ action = recovery === 'rollback-sources' ? 'restored-sources-and-target' : 'restored-target';
72
+ }
73
+ catch (error) {
74
+ action = 'restore-failed: ' + (error instanceof Error ? error.message : String(error));
75
+ ok = false;
76
+ }
77
+ }
78
+ await writeJournal(io, home, { ...journal, phase: ok ? 'rolled-back' : 'failed', updatedAt: new Date(now).toISOString() });
79
+ reports.push({ runId: journal.runId, phase: journal.phase, action, ok });
80
+ }
81
+ await io.writeText(join(home, 'merge', 'recovery.json'), JSON.stringify(reports, null, 2));
82
+ return reports;
83
+ }
84
+ //# sourceMappingURL=commit-recovery.js.map
@@ -0,0 +1,38 @@
1
+ import type { CommitMergePlan, EvolutionIoLike } from '@lmzhen/dsh-evolution-core';
2
+ export interface CommitSnapshot {
3
+ readonly runId: string;
4
+ readonly target: string;
5
+ readonly create: boolean;
6
+ readonly sources: readonly string[];
7
+ readonly targetBody: string | null;
8
+ readonly sourceBodies: readonly {
9
+ readonly name: string;
10
+ readonly body: string;
11
+ }[];
12
+ readonly targetSupport?: readonly {
13
+ readonly path: string;
14
+ readonly content: string;
15
+ }[];
16
+ readonly targetSupportAfter?: readonly {
17
+ readonly path: string;
18
+ readonly content: string;
19
+ }[];
20
+ readonly sourceSupport?: readonly {
21
+ readonly name: string;
22
+ readonly path: string;
23
+ readonly content: string;
24
+ }[];
25
+ readonly at: string;
26
+ }
27
+ export declare function snapshotPath(home: string, runId: string): string;
28
+ export declare function writeCommitSnapshot(io: EvolutionIoLike, home: string, runId: string, plan: CommitMergePlan, now: number, port?: {
29
+ readSupportFiles?(name: string): Promise<readonly {
30
+ readonly path: string;
31
+ readonly content: string;
32
+ }[]>;
33
+ }): Promise<CommitSnapshot>;
34
+ export declare function readCommitSnapshot(io: EvolutionIoLike, home: string, runId: string): Promise<CommitSnapshot | null>;
35
+ export declare function isSafeSkillName(name: string): boolean;
36
+ export declare function skillFilePath(root: string, name: string, path: string): string;
37
+ export declare function skillBodyPath(root: string, name: string): string;
38
+ //# sourceMappingURL=commit-snapshot.d.ts.map
@@ -0,0 +1,65 @@
1
+ /** Pre-commit snapshot: enough to restore the target and archived sources after a crash. */
2
+ import { join, resolve, sep } from 'node:path';
3
+ export function snapshotPath(home, runId) {
4
+ return join(home, 'merge', 'snapshots', runId.replace(/[^A-Za-z0-9_-]+/g, '_') + '.json');
5
+ }
6
+ export async function writeCommitSnapshot(io, home, runId, plan, now, port) {
7
+ if (!isSafeSkillName(plan.target) || plan.sources.some(name => !isSafeSkillName(name))) {
8
+ throw new Error('unsafe skill name in commit plan: ' + plan.target);
9
+ }
10
+ const readSupport = port?.readSupportFiles === undefined
11
+ ? undefined
12
+ : (name) => port.readSupportFiles?.(name) ?? Promise.resolve([]);
13
+ const targetSupport = readSupport === undefined || !plan.targetExists ? [] : await readSupport(plan.target);
14
+ const sourceSupport = [];
15
+ if (readSupport !== undefined) {
16
+ for (const name of plan.sources) {
17
+ for (const file of await readSupport(name))
18
+ sourceSupport.push({ name, path: file.path, content: file.content });
19
+ }
20
+ }
21
+ const snapshot = {
22
+ runId, target: plan.target, create: !plan.targetExists, sources: plan.sources,
23
+ targetBody: plan.expectedTargetBody,
24
+ sourceBodies: plan.expectedSourceBodies.map(item => ({ name: item.name, body: item.body })),
25
+ ...(targetSupport.length === 0 ? {} : { targetSupport }),
26
+ ...(plan.supportFiles.length === 0 ? {} : { targetSupportAfter: plan.supportFiles }),
27
+ ...(sourceSupport.length === 0 ? {} : { sourceSupport }),
28
+ at: new Date(now).toISOString(),
29
+ };
30
+ await io.writeText(snapshotPath(home, runId), JSON.stringify(snapshot, null, 2));
31
+ return snapshot;
32
+ }
33
+ export async function readCommitSnapshot(io, home, runId) {
34
+ const raw = await io.readText(snapshotPath(home, runId));
35
+ if (raw === null)
36
+ return null;
37
+ try {
38
+ return JSON.parse(raw);
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ export function isSafeSkillName(name) {
45
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) && name !== '.' && name !== '..';
46
+ }
47
+ export function skillFilePath(root, name, path) {
48
+ if (!isSafeSkillName(name) || path.startsWith('/') || path.includes('..'))
49
+ throw new Error('unsafe skill file: ' + name + '/' + path);
50
+ const base = resolve(root, name);
51
+ const file = resolve(base, path);
52
+ if (!file.startsWith(base + sep))
53
+ throw new Error('skill file escapes skill: ' + name + '/' + path);
54
+ return file;
55
+ }
56
+ export function skillBodyPath(root, name) {
57
+ if (!isSafeSkillName(name))
58
+ throw new Error('unsafe skill name: ' + name);
59
+ const base = resolve(root);
60
+ const path = resolve(base, name, 'SKILL.md');
61
+ if (!path.startsWith(base + sep))
62
+ throw new Error('skill path escapes root: ' + name);
63
+ return path;
64
+ }
65
+ //# sourceMappingURL=commit-snapshot.js.map
@@ -20,6 +20,8 @@ export type MergeEvent = {
20
20
  readonly decision?: MergeDecision;
21
21
  readonly stagingRef?: string;
22
22
  readonly stagingExpiresAt?: number;
23
+ readonly sourceRevision?: string;
24
+ readonly targetRevision?: string;
23
25
  readonly at: string;
24
26
  } | {
25
27
  readonly type: 'round1-fail';
@@ -49,6 +51,12 @@ export type MergeEvent = {
49
51
  } | {
50
52
  readonly type: 'source-gone';
51
53
  readonly at: string;
54
+ } | {
55
+ readonly type: 'target-taken';
56
+ readonly at: string;
57
+ } | {
58
+ readonly type: 'source-changed';
59
+ readonly at: string;
52
60
  } | {
53
61
  readonly type: 'gate-keep';
54
62
  readonly a: string;
@@ -98,8 +98,8 @@ export function transition(current, event) {
98
98
  if (current.state !== 'verify')
99
99
  return invalid(event, current.state);
100
100
  return event.approval === 'on'
101
- ? next(current, event.at, 'pending', { ...(event.decision === undefined ? {} : { decision: event.decision }), ...(event.stagingRef === undefined ? {} : { stagingRef: event.stagingRef }), ...(event.stagingExpiresAt === undefined ? {} : { stagingExpiresAt: event.stagingExpiresAt }) })
102
- : next(current, event.at, 'approved', { ...(event.decision === undefined ? {} : { decision: event.decision }), ...(event.stagingRef === undefined ? {} : { stagingRef: event.stagingRef }), ...(event.stagingExpiresAt === undefined ? {} : { stagingExpiresAt: event.stagingExpiresAt }) });
101
+ ? next(current, event.at, 'pending', { ...(event.decision === undefined ? {} : { decision: event.decision }), ...(event.stagingRef === undefined ? {} : { stagingRef: event.stagingRef }), ...(event.stagingExpiresAt === undefined ? {} : { stagingExpiresAt: event.stagingExpiresAt }), ...(event.sourceRevision === undefined ? {} : { sourceRevision: event.sourceRevision }), ...(event.targetRevision === undefined ? {} : { targetRevision: event.targetRevision }) })
102
+ : next(current, event.at, 'approved', { ...(event.decision === undefined ? {} : { decision: event.decision }), ...(event.stagingRef === undefined ? {} : { stagingRef: event.stagingRef }), ...(event.stagingExpiresAt === undefined ? {} : { stagingExpiresAt: event.stagingExpiresAt }), ...(event.sourceRevision === undefined ? {} : { sourceRevision: event.sourceRevision }), ...(event.targetRevision === undefined ? {} : { targetRevision: event.targetRevision }) });
103
103
  case 'round1-fail':
104
104
  return current.state === 'verify'
105
105
  ? next(current, event.at, 'verify', { failure: event.failure, attempts: (current.attempts ?? 0) + 1 })
@@ -122,6 +122,11 @@ export function transition(current, event) {
122
122
  return current.state === 'fuse' || current.state === 'approved'
123
123
  ? { ok: true, row: null }
124
124
  : invalid(event, current.state);
125
+ case 'target-taken':
126
+ case 'source-changed':
127
+ return current.state === 'pending' || current.state === 'approved' || current.state === 'fuse'
128
+ ? next(current, event.at, current.state, { expired: event.type })
129
+ : invalid(event, current.state);
125
130
  case 'restore':
126
131
  return current.state === 'skipped'
127
132
  ? next(current, event.at, 'verify')
@@ -37,8 +37,11 @@ export function parseDecision(text, pair) {
37
37
  const target = typeof record.target === 'string' ? record.target.trim() : '';
38
38
  const sources = Array.isArray(record.sources) ? record.sources.filter((item) => typeof item === 'string').map(item => item.trim()).filter(Boolean) : [];
39
39
  const rationale = typeof record.rationale === 'string' ? record.rationale.trim() : '';
40
+ const aSummary = typeof record.aSummary === 'string' ? record.aSummary.trim() : typeof record.a_summary === 'string' ? record.a_summary.trim() : '';
41
+ const bSummary = typeof record.bSummary === 'string' ? record.bSummary.trim() : typeof record.b_summary === 'string' ? record.b_summary.trim() : '';
42
+ const summaries = { ...(aSummary === '' ? {} : { aSummary }), ...(bSummary === '' ? {} : { bSummary }) };
40
43
  if (!shouldMerge || mode === 'keep') {
41
- return { ok: true, decision: { shouldMerge: false, mode: 'keep', target, sources, rationale } };
44
+ return { ok: true, decision: { shouldMerge: false, mode: 'keep', target, sources, rationale, ...summaries } };
42
45
  }
43
46
  if (target === '')
44
47
  return { ok: false, code: 'decision-invalid', message: 'merge decision needs target' };
@@ -54,6 +57,7 @@ export function parseDecision(text, pair) {
54
57
  target,
55
58
  sources,
56
59
  rationale,
60
+ ...summaries,
57
61
  ...(typeof record.confidence === 'number' ? { confidence: record.confidence } : {}),
58
62
  },
59
63
  };
@@ -4,10 +4,11 @@ import { screen as engineScreen } from '@lmzhen/dsh-evolution-merge-engine';
4
4
  import { selectWithErrors } from "./adapters/select.js";
5
5
  import { createEngineScreening } from "./adapters/engine-screen.js";
6
6
  import { createEngineRunner } from "./adapters/model-runner.js";
7
- import { authorityFrom, createSubagentChannel, DEFAULT_SKILL_LIMITS, evolutionHome, evolutionIoAdapter, newRunRegistry, newSkillLibrary, policyStageLimits } from '@lmzhen/dsh-evolution-core';
7
+ import { authorityFrom, createSubagentChannel, DEFAULT_SKILL_LIMITS, evolutionHome, evolutionIoAdapter, newRunRegistry, newSkillLibrary, policyStageLimits, skillsRoot } from '@lmzhen/dsh-evolution-core';
8
8
  import { registerHostRoutes } from '@lmzhen/dsh-evolution-core/http-routes';
9
9
  import { loadMergeState, upsertRow } from "./state.js";
10
10
  import { cleanupExpiredStaging } from "./staging.js";
11
+ import { recoverCommitJournals } from "./commit-recovery.js";
11
12
  import { readCommitReceipts } from "./commit-receipt.js";
12
13
  import { transition } from "./coordinator.js";
13
14
  import { makeMergeRoutes } from "./merge-routes.js";
@@ -137,6 +138,11 @@ export function apply(ctx, rawConfig = {}) {
137
138
  io, ctx, authority: authorityFrom({ origin: 'subagent' }),
138
139
  limits: { ...DEFAULT_SKILL_LIMITS, ...policyStageLimits(policySnapshot?.get?.()) },
139
140
  });
141
+ void recoverCommitJournals(io, home, Date.now(), skillsRoot(), async (name) => {
142
+ const result = await skills.restoreFromArchive(name);
143
+ return result.ok;
144
+ }).then((reports) => { if (reports.length > 0)
145
+ ctx.logger.info('merge commit recovery: ' + reports.map(report => report.runId + '=' + report.action).join(', ')); }).catch((error) => { ctx.logger.warn('merge commit recovery failed: ' + (error instanceof Error ? error.message : String(error))); });
140
146
  const channel = subagentChannelAdapter(createSubagentChannel(ctx));
141
147
  const stage = new StageToolRegistry();
142
148
  const engineConfig = engineConfigFromEnv(params.mergeModelsRoot);
@@ -216,7 +222,7 @@ export function apply(ctx, rawConfig = {}) {
216
222
  const probe = await skills.listSupportFiles(item.name);
217
223
  const paths = probe.kind === 'present' ? probe.value : [];
218
224
  const files = await Promise.all(paths.map(async (path) => ({ path, content: await skills.readSupportFile(item.name, path) ?? '' })));
219
- return { name: item.name, description: item.description, body: item.content ?? '', files, protectedBy: item.protectedBy, protectionUnknown: item.protectionUnknown };
225
+ return { name: item.name, description: item.description, body: item.content ?? '', files, protectedBy: item.protectedBy, protectionUnknown: item.protectionUnknown, managed: item.managed };
220
226
  }));
221
227
  },
222
228
  screeningForRun: engineScreening,
@@ -0,0 +1,3 @@
1
+ /** Tool-result budget: keep the head and tail, mark what was omitted. */
2
+ export declare function fitToolText(text: string, max?: number): string;
3
+ //# sourceMappingURL=presentation-budget.d.ts.map
@@ -0,0 +1,11 @@
1
+ /** Tool-result budget: keep the head and tail, mark what was omitted. */
2
+ export function fitToolText(text, max = 8000) {
3
+ if (text.length <= max)
4
+ return text;
5
+ const marker = '\n... [truncated ' + String(text.length - max) + ' chars] ...\n';
6
+ const keep = Math.max(0, max - marker.length);
7
+ const head = Math.ceil(keep * 0.7);
8
+ const tail = keep - head;
9
+ return text.slice(0, head) + marker + (tail === 0 ? '' : text.slice(-tail));
10
+ }
11
+ //# sourceMappingURL=presentation-budget.js.map
@@ -0,0 +1,4 @@
1
+ import type { SkillCard } from './skill-card.ts';
2
+ /** Stable source revision for a pair: names sorted, body hashed in that order. */
3
+ export declare function sourceRevisionOf(cards: readonly SkillCard[], a: string, b: string): string | undefined;
4
+ //# sourceMappingURL=revision.d.ts.map
@@ -0,0 +1,12 @@
1
+ import { contentHash } from '@lmzhen/dsh-evolution-core';
2
+ /** Stable source revision for a pair: names sorted, body hashed in that order. */
3
+ export function sourceRevisionOf(cards, a, b) {
4
+ const byName = new Map(cards.map(card => [card.name, card]));
5
+ const names = [a, b].sort();
6
+ const first = byName.get(names[0] ?? '');
7
+ const second = byName.get(names[1] ?? '');
8
+ if (first === undefined || second === undefined)
9
+ return undefined;
10
+ return contentHash(first.name + '\u0000' + first.body + '\u0000' + second.name + '\u0000' + second.body);
11
+ }
12
+ //# sourceMappingURL=revision.js.map
@@ -18,6 +18,9 @@ export interface MergeRowView {
18
18
  readonly createdAt: string;
19
19
  readonly target?: string;
20
20
  readonly confidence?: number;
21
+ readonly aSummary?: string;
22
+ readonly bSummary?: string;
23
+ readonly rationale?: string;
21
24
  readonly failure?: {
22
25
  readonly stage: string;
23
26
  readonly code: string;
@@ -14,6 +14,9 @@ export function toRowView(row) {
14
14
  createdAt: row.createdAt,
15
15
  ...(row.decision?.target === undefined ? {} : { target: row.decision.target }),
16
16
  ...(row.decision?.confidence === undefined ? {} : { confidence: row.decision.confidence }),
17
+ ...(row.decision?.aSummary === undefined ? {} : { aSummary: row.decision.aSummary }),
18
+ ...(row.decision?.bSummary === undefined ? {} : { bSummary: row.decision.bSummary }),
19
+ ...(row.decision?.rationale === undefined ? {} : { rationale: row.decision.rationale }),
17
20
  ...(row.failure === undefined ? {} : {
18
21
  failure: { stage: row.failure.stage, code: row.failure.code, message: row.failure.message, retryable: row.failure.retryable },
19
22
  }),
@@ -23,9 +26,9 @@ export function toRowView(row) {
23
26
  ...(row.expired === undefined ? {} : { expired: row.expired }),
24
27
  ...(row.reason === undefined ? {} : { reason: row.reason }),
25
28
  summary: [
26
- row.a + ' + ' + row.b,
27
- row.decision?.rationale ?? row.reason ?? '',
28
- row.failure?.message ?? '',
29
+ row.decision?.aSummary ?? '未提供技能1摘要',
30
+ row.decision?.bSummary ?? '未提供技能2摘要',
31
+ row.decision?.rationale ?? row.reason ?? '未提供融合原因',
29
32
  ],
30
33
  actions: row.state === 'pending'
31
34
  ? ['/evolution merge approve ' + row.id, '/evolution merge reject ' + row.id]
@@ -21,6 +21,13 @@ import { mergeDoctor } from "./doctor.js";
21
21
  import { createChannelScanPorts } from "./scan-ports.js";
22
22
  import { createNewChildRound2 } from "./new-child-authoring.js";
23
23
  import { skillLibraryPort } from "./adapters/library.js";
24
+ import { fitToolText } from "./presentation-budget.js";
25
+ import { sourceRevisionOf } from "./revision.js";
26
+ function forcedDecisionFor(a, b) {
27
+ 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 ') };
30
+ }
24
31
  export function createMergeRuntime(deps) {
25
32
  const readOnly = createReadOnlyMergeService(deps.io, deps.home);
26
33
  const timeout = deps.timeoutMinutes > 0 ? deps.timeoutMinutes : undefined;
@@ -41,16 +48,21 @@ export function createMergeRuntime(deps) {
41
48
  if (command.verb === 'pair') {
42
49
  const signal = new AbortController().signal;
43
50
  const cards = await deps.cards(signal);
51
+ const cardA = cards.find(card => card.name === command.a);
52
+ const cardB = cards.find(card => card.name === command.b);
53
+ if (cardA === undefined || cardB === undefined || cardA.managed !== true || cardB.managed !== true) {
54
+ return { ok: false, text: 'merge pair 仅支持 family-managed 技能: ' + command.a + ' / ' + command.b };
55
+ }
44
56
  const pair = [{ a: command.a, b: command.b }];
45
- let forced;
46
- if (command.force === true) {
47
- const key = pairKey(command.a, command.b);
48
- forced = (await loadMergeState(deps.io, deps.home)).state.rows.find(row => row.pairKey === key);
49
- if (forced === undefined || forced.decision === undefined) {
50
- return { ok: false, text: 'pair --force 需要已有决策,找不到该 pair 的 decision: ' + key };
51
- }
57
+ const existing = (await loadMergeState(deps.io, deps.home)).state.rows.find(row => row.pairKey === pairKey(command.a, command.b));
58
+ if (existing?.state === 'pending') {
59
+ return { ok: false, text: '该 pair 已在待批准队列,先 approve/reject/discard: ' + existing.id };
60
+ }
61
+ if (existing?.state === 'approved' || existing?.state === 'fuse') {
62
+ return { ok: false, text: '该 pair 已在待合并队列,用 merge resume --retry 复用: ' + (existing.runId ?? existing.id) };
52
63
  }
53
- const approval = command.force === true ? false : deps.params.mergeApproval;
64
+ const forced = command.force === true ? forcedDecisionFor(command.a, command.b) : undefined;
65
+ const approval = deps.params.mergeApproval;
54
66
  const result = await runScanCommand({
55
67
  ...common,
56
68
  approval,
@@ -66,7 +78,7 @@ export function createMergeRuntime(deps) {
66
78
  commitPort: skillLibraryPort(deps.skills), now: deps.now,
67
79
  ...stageHooks,
68
80
  });
69
- return forced === undefined ? ports : { ...ports, round1: () => Promise.resolve({ kind: 'merge', decision: forced.decision }) };
81
+ return forced === undefined ? ports : { ...ports, round1: () => Promise.resolve({ kind: 'merge', decision: forced }) };
70
82
  },
71
83
  });
72
84
  if (!result.ok)
@@ -80,12 +92,17 @@ export function createMergeRuntime(deps) {
80
92
  if (command.verb === 'scan') {
81
93
  const signal = new AbortController().signal;
82
94
  const cards = await deps.cards(signal);
95
+ const managed = new Set(cards.filter(card => card.managed === true).map(card => card.name));
83
96
  const loadedState = await loadMergeState(deps.io, deps.home);
84
97
  const skipped = skippedPairKeys(loadedState.state.rows, deps.params.mergeSkippedTtlDays, deps.now());
85
- const held = new Set(loadedState.state.rows.filter(row => row.queue === 'pending' || !canRetry(row)).map(row => row.pairKey));
98
+ const held = new Set(loadedState.state.rows.filter(row => row.queue === 'pending' || row.queue === 'fuse' || !canRetry(row)).map(row => row.pairKey));
99
+ const conflictNotes = loadedState.state.rows
100
+ .filter(row => row.state === 'pending' || row.state === 'approved' || row.state === 'fuse' || row.state === 'skipped')
101
+ .map(row => 'conflict:' + row.state + ':' + row.pairKey + ':' + row.id);
86
102
  const result = await runScanCommand({
87
103
  ...common,
88
104
  approval: deps.params.mergeApproval,
105
+ notes: conflictNotes,
89
106
  ...(detached ? { detached: true } : {}),
90
107
  makePorts: runId => createChannelScanPorts({
91
108
  io: deps.io, home: deps.home, runId, cards,
@@ -93,7 +110,7 @@ export function createMergeRuntime(deps) {
93
110
  const raw = await deps.screeningForRun(cards, runId, turnSignal);
94
111
  const filtered = raw.filter((candidate) => {
95
112
  const key = pairKey(candidate.a, candidate.b);
96
- return !skipped.has(key) && !held.has(key);
113
+ return managed.has(candidate.a) && managed.has(candidate.b) && !skipped.has(key) && !held.has(key);
97
114
  });
98
115
  const candidatePairs = raw.candidatePairs;
99
116
  return candidatePairs === undefined ? filtered : Object.assign(filtered, { candidatePairs });
@@ -153,7 +170,7 @@ export function createMergeRuntime(deps) {
153
170
  return { ok: false, text: 'report is unreadable: ' + run.resultRef + ':' + report.reason };
154
171
  return report.kind === 'absent'
155
172
  ? { ok: false, text: 'report is missing: ' + run.resultRef }
156
- : { ok: true, text: report.value.slice(0, 12000) };
173
+ : { ok: true, text: 'report: ' + run.resultRef + '\n' + fitToolText(report.value, 7600) };
157
174
  }
158
175
  if (command.verb === 'discard') {
159
176
  const loaded = await loadMergeState(deps.io, deps.home);
@@ -239,6 +256,27 @@ export function createMergeRuntime(deps) {
239
256
  if (command.verb === 'approve') {
240
257
  const signal = new AbortController().signal;
241
258
  const cards = await deps.cards(signal);
259
+ const candidate = (await loadMergeState(deps.io, deps.home)).state.rows.find(row => row.id === command.id);
260
+ if (candidate !== undefined && candidate.state === 'pending' && candidate.decision !== undefined) {
261
+ const missingSource = [candidate.a, candidate.b].find(name => !cards.some(card => card.name === name && card.managed === true));
262
+ if (missingSource !== undefined) {
263
+ await dropRow(deps.io, deps.home, deps.now, candidate.id);
264
+ return { ok: false, text: 'source-gone:' + missingSource + ' 已不在 family-managed 技能库,已从 active 队列清除;可 merge skipped --discard 清理' };
265
+ }
266
+ const currentRevision = sourceRevisionOf(cards, candidate.a, candidate.b);
267
+ if (candidate.sourceRevision !== undefined && currentRevision !== undefined && currentRevision !== candidate.sourceRevision) {
268
+ const changed = transition(candidate, { type: 'source-changed', at: new Date().toISOString() });
269
+ if (changed.ok && changed.row !== null)
270
+ await upsertRow(deps.io, deps.home, deps.now, changed.row);
271
+ return { ok: false, text: 'source-changed:源技能内容在决策后变化,拒绝照跑;出口:重新 merge pair,或 merge skipped --discard' };
272
+ }
273
+ if (candidate.decision.mode === 'merge-into-existing' && !cards.some(card => card.name === candidate.decision?.target)) {
274
+ const expired = transition(candidate, { type: 'target-taken', at: new Date().toISOString() });
275
+ if (expired.ok && expired.row !== null)
276
+ await upsertRow(deps.io, deps.home, deps.now, expired.row);
277
+ return { ok: false, text: 'target-taken:目标 ' + candidate.decision.target + ' 已不存在,行保留 pending --expired;出口:重新 merge pair,或 merge skipped --discard' };
278
+ }
279
+ }
242
280
  const result = await runApproveCommand({
243
281
  ...common,
244
282
  rowId: command.id,
@@ -17,6 +17,7 @@ export interface RunScanCommandOptions {
17
17
  readonly timeoutMinutes?: number;
18
18
  readonly now: () => number;
19
19
  readonly detached?: boolean;
20
+ readonly notes?: readonly string[];
20
21
  }
21
22
  export declare function runScanCommand(options: RunScanCommandOptions): Promise<WithMergeRunResult>;
22
23
  //# sourceMappingURL=scan-command.d.ts.map
@@ -23,6 +23,7 @@ export async function runScanCommand(options) {
23
23
  stage: ctx.stage,
24
24
  startedAt: ctx.startedAt,
25
25
  ...(options.timeoutMinutes === undefined ? {} : { timeoutMinutes: options.timeoutMinutes }),
26
+ ...(options.notes === undefined ? {} : { notes: options.notes }),
26
27
  });
27
28
  },
28
29
  });
@@ -2,6 +2,7 @@ import { pairKey } from "./contracts.js";
2
2
  import { runRound1OverChannel } from "./channel-port.js";
3
3
  import { runAuthoring } from "./authoring-flow.js";
4
4
  import { dropStaging, ensureStaging, stageKeyOf, stagingRoot } from "./staging.js";
5
+ import { sourceRevisionOf } from "./revision.js";
5
6
  export function createChannelScanPorts(options) {
6
7
  const sessions = new Map();
7
8
  return {
@@ -42,8 +43,12 @@ export function createChannelScanPorts(options) {
42
43
  }
43
44
  }
44
45
  }
46
+ const sourceRevision = result.outcome.kind === 'merge' ? sourceRevisionOf(options.cards, candidate.a, candidate.b) : undefined;
45
47
  return result.outcome.kind === 'merge'
46
- ? { ...result.outcome, stagingRef: stageKey, stagingExpiresAt: options.now() + 7 * 86_400_000 }
48
+ ? {
49
+ ...result.outcome, stagingRef: stageKey, stagingExpiresAt: options.now() + 7 * 86_400_000,
50
+ ...(sourceRevision === undefined ? {} : { sourceRevision }),
51
+ }
47
52
  : result.outcome;
48
53
  },
49
54
  async round2(row, decision, signal) {
@@ -43,6 +43,7 @@ export interface RunScanOptions {
43
43
  done: number;
44
44
  total: number;
45
45
  }) => Promise<void>;
46
+ readonly notes?: readonly string[];
46
47
  }
47
48
  export declare function runScan(options: RunScanOptions): Promise<MergeRunReportDraft>;
48
49
  //# sourceMappingURL=scan-run.d.ts.map
@@ -65,7 +65,9 @@ export async function runScan(options) {
65
65
  if (!created.ok || created.row === null)
66
66
  throw new Error(created.ok ? 'candidate row missing' : created.message);
67
67
  row = created.row;
68
- await upsertRow(options.io, options.home, options.now, row);
68
+ const placed = await upsertRow(options.io, options.home, options.now, row, 'verify');
69
+ if (!placed.ok)
70
+ throw new Error('pair already in ' + placed.current.state);
69
71
  }
70
72
  catch (error) {
71
73
  totals.failed += 1;
@@ -179,7 +181,7 @@ export async function runScan(options) {
179
181
  },
180
182
  pairs,
181
183
  totals,
182
- notes: [],
184
+ notes: [...(options.notes ?? [])],
183
185
  ...(remaining.length === 0 ? {} : { remaining }),
184
186
  };
185
187
  }
@@ -8,5 +8,6 @@ export interface SkillCard {
8
8
  }[];
9
9
  readonly protectedBy?: string | null;
10
10
  readonly protectionUnknown?: boolean;
11
+ readonly managed?: boolean;
11
12
  }
12
13
  //# sourceMappingURL=skill-card.d.ts.map
@@ -22,7 +22,14 @@ export interface MoveRowPatch {
22
22
  readonly origin?: MergeRow['origin'];
23
23
  readonly attempts?: number;
24
24
  }
25
- export declare function upsertRow(io: EvolutionIoLike, home: string, now: () => number, row: MergeRow): Promise<void>;
25
+ export type UpsertExpect = 'any' | 'absent' | 'verify' | 'pending' | 'fuse' | 'skipped';
26
+ export type UpsertResult = {
27
+ readonly ok: true;
28
+ } | {
29
+ readonly ok: false;
30
+ readonly current: MergeRow;
31
+ };
32
+ export declare function upsertRow(io: EvolutionIoLike, home: string, now: () => number, row: MergeRow, expect?: UpsertExpect): Promise<UpsertResult>;
26
33
  export declare function moveRow(io: EvolutionIoLike, home: string, now: () => number, id: string, patch: MoveRowPatch): Promise<MergeRow | undefined>;
27
34
  export declare function dropRow(io: EvolutionIoLike, home: string, now: () => number, id: string): Promise<boolean>;
28
35
  export declare function rowsOf(io: EvolutionIoLike, home: string, queue?: MergeQueue): Promise<readonly MergeRow[]>;