@lmzhen/dsh-evolution-merge 0.30.0 → 0.30.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -345,22 +345,37 @@ async function mutateMergeState(io, home, now, fn) {
345
345
  });
346
346
  return result;
347
347
  }
348
- async function upsertRow(io, home, now, row) {
349
- await mutateMergeState(io, home, now, (rows) => {
348
+ async function upsertRow(io, home, now, row, expect = "any") {
349
+ return await mutateMergeState(io, home, now, (rows) => {
350
350
  const index = rows.findIndex((item) => item.pairKey === row.pairKey);
351
+ const current = index < 0 ? void 0 : rows[index];
352
+ if (current !== void 0 && expect !== "any" && (expect === "absent" || queueOf(current.state) !== expect)) return {
353
+ rows,
354
+ result: {
355
+ ok: false,
356
+ current
357
+ }
358
+ };
359
+ if (current === void 0 && expect !== "any" && expect !== "absent") return {
360
+ rows,
361
+ result: {
362
+ ok: false,
363
+ current: row
364
+ }
365
+ };
351
366
  const normalized = {
352
367
  ...row,
353
368
  queue: queueOf(row.state)
354
369
  };
355
370
  const next = [...rows];
356
- if (index < 0) next.push(normalized);
371
+ if (current === void 0) next.push(normalized);
357
372
  else next[index] = {
358
373
  ...normalized,
359
- createdAt: rows[index]?.createdAt ?? normalized.createdAt
374
+ createdAt: current.createdAt
360
375
  };
361
376
  return {
362
377
  rows: next,
363
- result: void 0
378
+ result: { ok: true }
364
379
  };
365
380
  });
366
381
  }
@@ -563,6 +578,114 @@ async function cleanupExpiredStaging(io, home, now, keep = /* @__PURE__ */ new S
563
578
  };
564
579
  }
565
580
  //#endregion
581
+ //#region lib/types/domain/commit-journal.js
582
+ /** What a restart may do with the last durable phase. */
583
+ function recoveryFor(journal) {
584
+ switch (journal.phase) {
585
+ case "prepared": return "retry-commit";
586
+ case "target-written": return "rollback-target";
587
+ case "sources-archived": return "rollback-sources";
588
+ case "committed":
589
+ case "rolled-back":
590
+ case "failed": return "none";
591
+ }
592
+ }
593
+ //#endregion
594
+ //#region lib/types/domain/commit-journal-store.js
595
+ /** Durable commit journal: <home>/merge/journals/<run>.json. */
596
+ function journalPath(home, runId) {
597
+ return join(home, "merge", "journals", runId.replace(/[^A-Za-z0-9_-]+/g, "_") + ".json");
598
+ }
599
+ async function writeJournal(io, home, journal) {
600
+ const path = journalPath(home, journal.runId);
601
+ await io.writeText(path, JSON.stringify(journal, null, 2));
602
+ return path;
603
+ }
604
+ //#endregion
605
+ //#region lib/types/commit-snapshot.js
606
+ /** Pre-commit snapshot: enough to restore the target and archived sources after a crash. */
607
+ function snapshotPath(home, runId) {
608
+ return join(home, "merge", "snapshots", runId.replace(/[^A-Za-z0-9_-]+/g, "_") + ".json");
609
+ }
610
+ async function writeCommitSnapshot(io, home, runId, plan, now) {
611
+ const snapshot = {
612
+ runId,
613
+ target: plan.target,
614
+ create: !plan.targetExists,
615
+ sources: plan.sources,
616
+ targetBody: plan.expectedTargetBody,
617
+ sourceBodies: plan.expectedSourceBodies.map((item) => ({
618
+ name: item.name,
619
+ body: item.body
620
+ })),
621
+ at: new Date(now).toISOString()
622
+ };
623
+ await io.writeText(snapshotPath(home, runId), JSON.stringify(snapshot, null, 2));
624
+ return snapshot;
625
+ }
626
+ async function readCommitSnapshot(io, home, runId) {
627
+ const raw = await io.readText(snapshotPath(home, runId));
628
+ if (raw === null) return null;
629
+ try {
630
+ return JSON.parse(raw);
631
+ } catch {
632
+ return null;
633
+ }
634
+ }
635
+ function skillBodyPath(home, name) {
636
+ return join(home, "skills", name, "SKILL.md");
637
+ }
638
+ //#endregion
639
+ //#region lib/types/commit-recovery.js
640
+ /** Startup replay for non-terminal commit journals. */
641
+ async function recoverCommitJournals(io, home, now) {
642
+ const dir = join(home, "merge", "journals");
643
+ if (!await io.exists(dir)) return [];
644
+ const files = (await io.list(dir)).filter((name) => name.endsWith(".json"));
645
+ const reports = [];
646
+ for (const file of files) {
647
+ let journal;
648
+ try {
649
+ journal = JSON.parse(await io.readText(join(dir, file)) ?? "");
650
+ } catch {
651
+ continue;
652
+ }
653
+ const recovery = recoveryFor(journal);
654
+ if (recovery === "none") continue;
655
+ const snapshot = await readCommitSnapshot(io, home, journal.runId);
656
+ let action = "report-only";
657
+ let ok = true;
658
+ if (recovery === "retry-commit") action = "no-host-write";
659
+ else if (snapshot === null) {
660
+ action = "snapshot-missing";
661
+ ok = false;
662
+ } else try {
663
+ if (recovery === "rollback-sources") for (const source of snapshot.sourceBodies) await io.writeText(skillBodyPath(home, source.name), source.body);
664
+ if (snapshot.create && snapshot.targetBody === null) try {
665
+ await io.remove(skillBodyPath(home, snapshot.target));
666
+ } catch {}
667
+ else if (snapshot.targetBody !== null) await io.writeText(skillBodyPath(home, snapshot.target), snapshot.targetBody);
668
+ action = recovery === "rollback-sources" ? "restored-sources-and-target" : "restored-target";
669
+ } catch (error) {
670
+ action = "restore-failed: " + (error instanceof Error ? error.message : String(error));
671
+ ok = false;
672
+ }
673
+ await writeJournal(io, home, {
674
+ ...journal,
675
+ phase: ok ? "rolled-back" : "failed",
676
+ updatedAt: new Date(now).toISOString()
677
+ });
678
+ reports.push({
679
+ runId: journal.runId,
680
+ phase: journal.phase,
681
+ action,
682
+ ok
683
+ });
684
+ }
685
+ await io.writeText(join(home, "merge", "recovery.json"), JSON.stringify(reports, null, 2));
686
+ return reports;
687
+ }
688
+ //#endregion
566
689
  //#region lib/types/commit-receipt.js
567
690
  /**
568
691
  * Commit receipt: <run>/commit.json, written immediately after a successful commit.
@@ -764,10 +887,23 @@ function transition(current, event) {
764
887
  ok: true,
765
888
  row: null
766
889
  } : invalid(event, current.state);
890
+ case "target-taken":
891
+ case "source-changed": return current.state === "pending" || current.state === "approved" || current.state === "fuse" ? next(current, event.at, current.state, { expired: event.type }) : invalid(event, current.state);
767
892
  case "restore": return current.state === "skipped" ? next(current, event.at, "verify") : invalid(event, current.state);
768
893
  }
769
894
  }
770
895
  //#endregion
896
+ //#region lib/types/presentation-budget.js
897
+ /** Tool-result budget: keep the head and tail, mark what was omitted. */
898
+ function fitToolText(text, max = 8e3) {
899
+ if (text.length <= max) return text;
900
+ const marker = "\n... [truncated " + String(text.length - max) + " chars] ...\n";
901
+ const keep = Math.max(0, max - marker.length);
902
+ const head = Math.ceil(keep * .7);
903
+ const tail = keep - head;
904
+ return text.slice(0, head) + marker + (tail === 0 ? "" : text.slice(-tail));
905
+ }
906
+ //#endregion
771
907
  //#region lib/types/status.js
772
908
  function countRows(rows) {
773
909
  const counts = {
@@ -788,12 +924,12 @@ function formatStatus(rows) {
788
924
  const counts = countRows(rows);
789
925
  const lines = ["merge: verify " + String(counts.verify) + ", pending " + String(counts.pending) + ", fuse " + String(counts.fuse) + ", skipped " + String(counts.skipped)];
790
926
  for (const row of rows) lines.push("- " + describe(row));
791
- return lines.join("\n");
927
+ return fitToolText(lines.join("\n"));
792
928
  }
793
929
  function formatQueue(rows, queue) {
794
930
  const selected = rows.filter((row) => queueOf(row.state) === queue);
795
931
  if (selected.length === 0) return "merge " + queue + ": empty";
796
- return ["merge " + queue + ": " + String(selected.length)].concat(selected.map((row) => "- " + describe(row))).join("\n");
932
+ return fitToolText(["merge " + queue + ": " + String(selected.length)].concat(selected.map((row) => "- " + describe(row))).join("\n"));
797
933
  }
798
934
  //#endregion
799
935
  //#region lib/types/routes.js
@@ -811,6 +947,9 @@ function toRowView(row) {
811
947
  createdAt: row.createdAt,
812
948
  ...row.decision?.target === void 0 ? {} : { target: row.decision.target },
813
949
  ...row.decision?.confidence === void 0 ? {} : { confidence: row.decision.confidence },
950
+ ...row.decision?.aSummary === void 0 ? {} : { aSummary: row.decision.aSummary },
951
+ ...row.decision?.bSummary === void 0 ? {} : { bSummary: row.decision.bSummary },
952
+ ...row.decision?.rationale === void 0 ? {} : { rationale: row.decision.rationale },
814
953
  ...row.failure === void 0 ? {} : { failure: {
815
954
  stage: row.failure.stage,
816
955
  code: row.failure.code,
@@ -823,9 +962,9 @@ function toRowView(row) {
823
962
  ...row.expired === void 0 ? {} : { expired: row.expired },
824
963
  ...row.reason === void 0 ? {} : { reason: row.reason },
825
964
  summary: [
826
- row.a + " + " + row.b,
827
- row.decision?.rationale ?? row.reason ?? "",
828
- row.failure?.message ?? ""
965
+ row.decision?.aSummary ?? "未提供技能1摘要",
966
+ row.decision?.bSummary ?? "未提供技能2摘要",
967
+ row.decision?.rationale ?? row.reason ?? "未提供融合原因"
829
968
  ],
830
969
  actions: row.state === "pending" ? ["/evolution merge approve " + row.id, "/evolution merge reject " + row.id] : row.state === "skipped" ? ["/evolution merge skipped --restore " + row.id] : ["/evolution merge resume " + row.id]
831
970
  };
@@ -1711,7 +1850,7 @@ async function runScan(options) {
1711
1850
  },
1712
1851
  pairs,
1713
1852
  totals,
1714
- notes: [],
1853
+ notes: [...options.notes ?? []],
1715
1854
  ...remaining.length === 0 ? {} : { remaining }
1716
1855
  };
1717
1856
  }
@@ -1905,7 +2044,8 @@ async function runScanCommand(options) {
1905
2044
  ports,
1906
2045
  stage: ctx.stage,
1907
2046
  startedAt: ctx.startedAt,
1908
- ...options.timeoutMinutes === void 0 ? {} : { timeoutMinutes: options.timeoutMinutes }
2047
+ ...options.timeoutMinutes === void 0 ? {} : { timeoutMinutes: options.timeoutMinutes },
2048
+ ...options.notes === void 0 ? {} : { notes: options.notes }
1909
2049
  });
1910
2050
  }
1911
2051
  });
@@ -2372,6 +2512,12 @@ function parseDecision(text, pair) {
2372
2512
  const target = typeof record.target === "string" ? record.target.trim() : "";
2373
2513
  const sources = Array.isArray(record.sources) ? record.sources.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean) : [];
2374
2514
  const rationale = typeof record.rationale === "string" ? record.rationale.trim() : "";
2515
+ const aSummary = typeof record.aSummary === "string" ? record.aSummary.trim() : typeof record.a_summary === "string" ? record.a_summary.trim() : "";
2516
+ const bSummary = typeof record.bSummary === "string" ? record.bSummary.trim() : typeof record.b_summary === "string" ? record.b_summary.trim() : "";
2517
+ const summaries = {
2518
+ ...aSummary === "" ? {} : { aSummary },
2519
+ ...bSummary === "" ? {} : { bSummary }
2520
+ };
2375
2521
  if (!shouldMerge || mode === "keep") return {
2376
2522
  ok: true,
2377
2523
  decision: {
@@ -2379,7 +2525,8 @@ function parseDecision(text, pair) {
2379
2525
  mode: "keep",
2380
2526
  target,
2381
2527
  sources,
2382
- rationale
2528
+ rationale,
2529
+ ...summaries
2383
2530
  }
2384
2531
  };
2385
2532
  if (target === "") return {
@@ -2405,6 +2552,7 @@ function parseDecision(text, pair) {
2405
2552
  target,
2406
2553
  sources,
2407
2554
  rationale,
2555
+ ...summaries,
2408
2556
  ...typeof record.confidence === "number" ? { confidence: record.confidence } : {}
2409
2557
  }
2410
2558
  };
@@ -2890,17 +3038,6 @@ async function assembleStagedCommit(io, home, runId, port, options) {
2890
3038
  });
2891
3039
  }
2892
3040
  //#endregion
2893
- //#region lib/types/domain/commit-journal-store.js
2894
- /** Durable commit journal: <home>/merge/journals/<run>.json. */
2895
- function journalPath(home, runId) {
2896
- return join(home, "merge", "journals", runId.replace(/[^A-Za-z0-9_-]+/g, "_") + ".json");
2897
- }
2898
- async function writeJournal(io, home, journal) {
2899
- const path = journalPath(home, journal.runId);
2900
- await io.writeText(path, JSON.stringify(journal, null, 2));
2901
- return path;
2902
- }
2903
- //#endregion
2904
3041
  //#region lib/types/commit-execute.js
2905
3042
  async function commitStagedRun(io, home, runId, port, options) {
2906
3043
  const assembled = await assembleStagedCommit(io, home, runId, port, {
@@ -2911,12 +3048,21 @@ async function commitStagedRun(io, home, runId, port, options) {
2911
3048
  ok: false,
2912
3049
  message: assembled.message
2913
3050
  };
3051
+ try {
3052
+ await writeCommitSnapshot(io, home, runId, assembled.plan, options.now());
3053
+ } catch (error) {
3054
+ return {
3055
+ ok: false,
3056
+ message: "commit snapshot failed; refusing to write: " + (error instanceof Error ? error.message : String(error)),
3057
+ plan: assembled.plan
3058
+ };
3059
+ }
2914
3060
  const journal = {
2915
3061
  runId,
2916
3062
  target: assembled.plan.target,
2917
3063
  create: !assembled.plan.targetExists,
2918
3064
  sources: assembled.plan.sources,
2919
- phase: "prepared",
3065
+ phase: "sources-archived",
2920
3066
  updatedAt: new Date(options.now()).toISOString()
2921
3067
  };
2922
3068
  try {
@@ -2927,7 +3073,6 @@ async function commitStagedRun(io, home, runId, port, options) {
2927
3073
  try {
2928
3074
  await writeJournal(io, home, {
2929
3075
  ...journal,
2930
- phase: "failed",
2931
3076
  updatedAt: new Date(options.now()).toISOString()
2932
3077
  });
2933
3078
  } catch {}
@@ -3050,6 +3195,17 @@ async function runAuthoring(options) {
3050
3195
  };
3051
3196
  }
3052
3197
  //#endregion
3198
+ //#region lib/types/revision.js
3199
+ /** Stable source revision for a pair: names sorted, body hashed in that order. */
3200
+ function sourceRevisionOf(cards, a, b) {
3201
+ const byName = new Map(cards.map((card) => [card.name, card]));
3202
+ const names = [a, b].sort();
3203
+ const first = byName.get(names[0] ?? "");
3204
+ const second = byName.get(names[1] ?? "");
3205
+ if (first === void 0 || second === void 0) return void 0;
3206
+ return contentHash(first.name + "\0" + first.body + "\0" + second.name + "\0" + second.body);
3207
+ }
3208
+ //#endregion
3053
3209
  //#region lib/types/scan-ports.js
3054
3210
  function createChannelScanPorts(options) {
3055
3211
  const sessions = /* @__PURE__ */ new Map();
@@ -3107,10 +3263,12 @@ function createChannelScanPorts(options) {
3107
3263
  phase: "author"
3108
3264
  });
3109
3265
  }
3266
+ const sourceRevision = result.outcome.kind === "merge" ? sourceRevisionOf(options.cards, candidate.a, candidate.b) : void 0;
3110
3267
  return result.outcome.kind === "merge" ? {
3111
3268
  ...result.outcome,
3112
3269
  stagingRef: stageKey,
3113
- stagingExpiresAt: options.now() + 7 * 864e5
3270
+ stagingExpiresAt: options.now() + 7 * 864e5,
3271
+ ...sourceRevision === void 0 ? {} : { sourceRevision }
3114
3272
  } : result.outcome;
3115
3273
  },
3116
3274
  async round2(row, decision, signal) {
@@ -3356,6 +3514,16 @@ function skillLibraryPort(skills) {
3356
3514
  * Plugin wiring supplies the channel, model and library; this module keeps the orchestration
3357
3515
  * out of apply() and makes the mutating verbs testable with fakes.
3358
3516
  */
3517
+ function forcedDecisionFor(a, b) {
3518
+ const sorted = [a, b].sort();
3519
+ return {
3520
+ shouldMerge: true,
3521
+ mode: "create-umbrella",
3522
+ target: ("merge-" + sorted.join("-")).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64),
3523
+ sources: [a, b],
3524
+ rationale: "forced pair merge of " + sorted.join(" and ")
3525
+ };
3526
+ }
3359
3527
  function createMergeRuntime(deps) {
3360
3528
  const readOnly = createReadOnlyMergeService(deps.io, deps.home);
3361
3529
  const timeout = deps.timeoutMinutes > 0 ? deps.timeoutMinutes : void 0;
@@ -3382,20 +3550,27 @@ function createMergeRuntime(deps) {
3382
3550
  if (command.verb === "pair") {
3383
3551
  const signal = new AbortController().signal;
3384
3552
  const cards = await deps.cards(signal);
3553
+ const cardA = cards.find((card) => card.name === command.a);
3554
+ const cardB = cards.find((card) => card.name === command.b);
3555
+ if (cardA === void 0 || cardB === void 0 || cardA.managed !== true || cardB.managed !== true) return {
3556
+ ok: false,
3557
+ text: "merge pair 仅支持 family-managed 技能: " + command.a + " / " + command.b
3558
+ };
3385
3559
  const pair = [{
3386
3560
  a: command.a,
3387
3561
  b: command.b
3388
3562
  }];
3389
- let forced;
3390
- if (command.force === true) {
3391
- const key = pairKey(command.a, command.b);
3392
- forced = (await loadMergeState(deps.io, deps.home)).state.rows.find((row) => row.pairKey === key);
3393
- if (forced === void 0 || forced.decision === void 0) return {
3394
- ok: false,
3395
- text: "pair --force 需要已有决策,找不到该 pair 的 decision: " + key
3396
- };
3397
- }
3398
- const approval = command.force === true ? false : deps.params.mergeApproval;
3563
+ const existing = (await loadMergeState(deps.io, deps.home)).state.rows.find((row) => row.pairKey === pairKey(command.a, command.b));
3564
+ if (existing?.state === "pending") return {
3565
+ ok: false,
3566
+ text: "该 pair 已在待批准队列,先 approve/reject/discard: " + existing.id
3567
+ };
3568
+ if (existing?.state === "approved" || existing?.state === "fuse") return {
3569
+ ok: false,
3570
+ text: "该 pair 已在待合并队列,用 merge resume --retry 复用: " + (existing.runId ?? existing.id)
3571
+ };
3572
+ const forced = command.force === true ? forcedDecisionFor(command.a, command.b) : void 0;
3573
+ const approval = deps.params.mergeApproval;
3399
3574
  const result = await runScanCommand({
3400
3575
  ...common,
3401
3576
  approval,
@@ -3421,7 +3596,7 @@ function createMergeRuntime(deps) {
3421
3596
  ...ports,
3422
3597
  round1: () => Promise.resolve({
3423
3598
  kind: "merge",
3424
- decision: forced.decision
3599
+ decision: forced
3425
3600
  })
3426
3601
  };
3427
3602
  }
@@ -3446,12 +3621,15 @@ function createMergeRuntime(deps) {
3446
3621
  if (command.verb === "scan") {
3447
3622
  const signal = new AbortController().signal;
3448
3623
  const cards = await deps.cards(signal);
3624
+ const managed = new Set(cards.filter((card) => card.managed === true).map((card) => card.name));
3449
3625
  const loadedState = await loadMergeState(deps.io, deps.home);
3450
3626
  const skipped = skippedPairKeys(loadedState.state.rows, deps.params.mergeSkippedTtlDays, deps.now());
3451
3627
  const held = new Set(loadedState.state.rows.filter((row) => row.queue === "pending" || !canRetry(row)).map((row) => row.pairKey));
3628
+ const conflictNotes = loadedState.state.rows.filter((row) => row.state === "pending" || row.state === "approved" || row.state === "fuse" || row.state === "skipped").map((row) => "conflict:" + row.state + ":" + row.pairKey + ":" + row.id);
3452
3629
  const result = await runScanCommand({
3453
3630
  ...common,
3454
3631
  approval: deps.params.mergeApproval,
3632
+ notes: conflictNotes,
3455
3633
  ...detached ? { detached: true } : {},
3456
3634
  makePorts: (runId) => createChannelScanPorts({
3457
3635
  io: deps.io,
@@ -3462,7 +3640,7 @@ function createMergeRuntime(deps) {
3462
3640
  const raw = await deps.screeningForRun(cards, runId, turnSignal);
3463
3641
  const filtered = raw.filter((candidate) => {
3464
3642
  const key = pairKey(candidate.a, candidate.b);
3465
- return !skipped.has(key) && !held.has(key);
3643
+ return managed.has(candidate.a) && managed.has(candidate.b) && !skipped.has(key) && !held.has(key);
3466
3644
  });
3467
3645
  const candidatePairs = raw.candidatePairs;
3468
3646
  return candidatePairs === void 0 ? filtered : Object.assign(filtered, { candidatePairs });
@@ -3561,7 +3739,7 @@ function createMergeRuntime(deps) {
3561
3739
  text: "report is missing: " + run.resultRef
3562
3740
  } : {
3563
3741
  ok: true,
3564
- text: report.value.slice(0, 12e3)
3742
+ text: "report: " + run.resultRef + "\n" + fitToolText(report.value, 7600)
3565
3743
  };
3566
3744
  }
3567
3745
  if (command.verb === "discard") {
@@ -3707,6 +3885,40 @@ function createMergeRuntime(deps) {
3707
3885
  if (command.verb === "approve") {
3708
3886
  const signal = new AbortController().signal;
3709
3887
  const cards = await deps.cards(signal);
3888
+ const candidate = (await loadMergeState(deps.io, deps.home)).state.rows.find((row) => row.id === command.id);
3889
+ if (candidate !== void 0 && candidate.state === "pending" && candidate.decision !== void 0) {
3890
+ const missingSource = [candidate.a, candidate.b].find((name) => !cards.some((card) => card.name === name && card.managed === true));
3891
+ if (missingSource !== void 0) {
3892
+ await dropRow(deps.io, deps.home, deps.now, candidate.id);
3893
+ return {
3894
+ ok: false,
3895
+ text: "source-gone:" + missingSource + " 已不在 family-managed 技能库,已从 active 队列清除;可 merge skipped --discard 清理"
3896
+ };
3897
+ }
3898
+ const currentRevision = sourceRevisionOf(cards, candidate.a, candidate.b);
3899
+ if (candidate.sourceRevision !== void 0 && currentRevision !== void 0 && currentRevision !== candidate.sourceRevision) {
3900
+ const changed = transition(candidate, {
3901
+ type: "source-changed",
3902
+ at: (/* @__PURE__ */ new Date()).toISOString()
3903
+ });
3904
+ if (changed.ok && changed.row !== null) await upsertRow(deps.io, deps.home, deps.now, changed.row);
3905
+ return {
3906
+ ok: false,
3907
+ text: "source-changed:源技能内容在决策后变化,拒绝照跑;出口:重新 merge pair,或 merge skipped --discard"
3908
+ };
3909
+ }
3910
+ if (candidate.decision.mode === "merge-into-existing" && !cards.some((card) => card.name === candidate.decision?.target)) {
3911
+ const expired = transition(candidate, {
3912
+ type: "target-taken",
3913
+ at: (/* @__PURE__ */ new Date()).toISOString()
3914
+ });
3915
+ if (expired.ok && expired.row !== null) await upsertRow(deps.io, deps.home, deps.now, expired.row);
3916
+ return {
3917
+ ok: false,
3918
+ text: "target-taken:目标 " + candidate.decision.target + " 已不存在,行保留 pending --expired;出口:重新 merge pair,或 merge skipped --discard"
3919
+ };
3920
+ }
3921
+ }
3710
3922
  const result = await runApproveCommand({
3711
3923
  ...common,
3712
3924
  rowId: command.id,
@@ -4192,6 +4404,11 @@ function apply(ctx, rawConfig = {}) {
4192
4404
  loadMergeState(io, home).then((loaded) => cleanupExpiredStaging(io, home, Date.now(), new Set(loaded.state.rows.map((row) => row.runId).filter((id) => id !== void 0)))).catch((error) => {
4193
4405
  ctx.logger.warn("merge staging cleanup failed: " + (error instanceof Error ? error.message : String(error)));
4194
4406
  });
4407
+ recoverCommitJournals(io, home, Date.now()).then((reports) => {
4408
+ if (reports.length > 0) ctx.logger.info("merge commit recovery: " + reports.map((report) => report.runId + "=" + report.action).join(", "));
4409
+ }).catch((error) => {
4410
+ ctx.logger.warn("merge commit recovery failed: " + (error instanceof Error ? error.message : String(error)));
4411
+ });
4195
4412
  const runs = newRunRegistry({
4196
4413
  io,
4197
4414
  home,
@@ -4339,7 +4556,8 @@ function apply(ctx, rawConfig = {}) {
4339
4556
  body: item.content ?? "",
4340
4557
  files,
4341
4558
  protectedBy: item.protectedBy,
4342
- protectionUnknown: item.protectionUnknown
4559
+ protectionUnknown: item.protectionUnknown,
4560
+ managed: item.managed
4343
4561
  };
4344
4562
  }));
4345
4563
  },
@@ -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());
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
  }
@@ -14,7 +21,7 @@ export async function commitStagedRun(io, home, runId, port, options) {
14
21
  const result = await port.commitMerge(assembled.plan);
15
22
  if (!result.ok) {
16
23
  try {
17
- await writeJournal(io, home, { ...journal, phase: 'failed', updatedAt: new Date(options.now()).toISOString() });
24
+ await writeJournal(io, home, { ...journal, updatedAt: new Date(options.now()).toISOString() });
18
25
  }
19
26
  catch { }
20
27
  return { ok: false, message: result.message, plan: assembled.plan };
@@ -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): Promise<readonly CommitRecoveryReport[]>;
10
+ //# sourceMappingURL=commit-recovery.d.ts.map
@@ -0,0 +1,61 @@
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 } from "./commit-snapshot.js";
6
+ export async function recoverCommitJournals(io, home, now) {
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
+ for (const source of snapshot.sourceBodies)
37
+ await io.writeText(skillBodyPath(home, source.name), source.body);
38
+ }
39
+ if (snapshot.create && snapshot.targetBody === null) {
40
+ try {
41
+ await io.remove(skillBodyPath(home, snapshot.target));
42
+ }
43
+ catch { /* already gone */ }
44
+ }
45
+ else if (snapshot.targetBody !== null) {
46
+ await io.writeText(skillBodyPath(home, snapshot.target), snapshot.targetBody);
47
+ }
48
+ action = recovery === 'rollback-sources' ? 'restored-sources-and-target' : 'restored-target';
49
+ }
50
+ catch (error) {
51
+ action = 'restore-failed: ' + (error instanceof Error ? error.message : String(error));
52
+ ok = false;
53
+ }
54
+ }
55
+ await writeJournal(io, home, { ...journal, phase: ok ? 'rolled-back' : 'failed', updatedAt: new Date(now).toISOString() });
56
+ reports.push({ runId: journal.runId, phase: journal.phase, action, ok });
57
+ }
58
+ await io.writeText(join(home, 'merge', 'recovery.json'), JSON.stringify(reports, null, 2));
59
+ return reports;
60
+ }
61
+ //# sourceMappingURL=commit-recovery.js.map
@@ -0,0 +1,18 @@
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 at: string;
13
+ }
14
+ export declare function snapshotPath(home: string, runId: string): string;
15
+ export declare function writeCommitSnapshot(io: EvolutionIoLike, home: string, runId: string, plan: CommitMergePlan, now: number): Promise<CommitSnapshot>;
16
+ export declare function readCommitSnapshot(io: EvolutionIoLike, home: string, runId: string): Promise<CommitSnapshot | null>;
17
+ export declare function skillBodyPath(home: string, name: string): string;
18
+ //# sourceMappingURL=commit-snapshot.d.ts.map
@@ -0,0 +1,30 @@
1
+ /** Pre-commit snapshot: enough to restore the target and archived sources after a crash. */
2
+ import { join } 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) {
7
+ const snapshot = {
8
+ runId, target: plan.target, create: !plan.targetExists, sources: plan.sources,
9
+ targetBody: plan.expectedTargetBody,
10
+ sourceBodies: plan.expectedSourceBodies.map(item => ({ name: item.name, body: item.body })),
11
+ at: new Date(now).toISOString(),
12
+ };
13
+ await io.writeText(snapshotPath(home, runId), JSON.stringify(snapshot, null, 2));
14
+ return snapshot;
15
+ }
16
+ export async function readCommitSnapshot(io, home, runId) {
17
+ const raw = await io.readText(snapshotPath(home, runId));
18
+ if (raw === null)
19
+ return null;
20
+ try {
21
+ return JSON.parse(raw);
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ export function skillBodyPath(home, name) {
28
+ return join(home, 'skills', name, 'SKILL.md');
29
+ }
30
+ //# sourceMappingURL=commit-snapshot.js.map
@@ -49,6 +49,12 @@ export type MergeEvent = {
49
49
  } | {
50
50
  readonly type: 'source-gone';
51
51
  readonly at: string;
52
+ } | {
53
+ readonly type: 'target-taken';
54
+ readonly at: string;
55
+ } | {
56
+ readonly type: 'source-changed';
57
+ readonly at: string;
52
58
  } | {
53
59
  readonly type: 'gate-keep';
54
60
  readonly a: string;
@@ -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
  };
@@ -8,6 +8,7 @@ import { authorityFrom, createSubagentChannel, DEFAULT_SKILL_LIMITS, evolutionHo
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";
@@ -121,6 +122,8 @@ export function apply(ctx, rawConfig = {}) {
121
122
  const io = evolutionIoAdapter(() => registry.provider());
122
123
  const home = evolutionHome();
123
124
  void loadMergeState(io, home).then(loaded => cleanupExpiredStaging(io, home, Date.now(), new Set(loaded.state.rows.map(row => row.runId).filter((id) => id !== undefined)))).catch((error) => { ctx.logger.warn('merge staging cleanup failed: ' + (error instanceof Error ? error.message : String(error))); });
125
+ void recoverCommitJournals(io, home, Date.now()).then((reports) => { if (reports.length > 0)
126
+ 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))); });
124
127
  const runs = newRunRegistry({ io, home, warn: (message) => { ctx.logger.warn(message); } });
125
128
  void runs.load();
126
129
  ctx.effect(() => {
@@ -216,7 +219,7 @@ export function apply(ctx, rawConfig = {}) {
216
219
  const probe = await skills.listSupportFiles(item.name);
217
220
  const paths = probe.kind === 'present' ? probe.value : [];
218
221
  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 };
222
+ return { name: item.name, description: item.description, body: item.content ?? '', files, protectedBy: item.protectedBy, protectionUnknown: item.protectionUnknown, managed: item.managed };
220
223
  }));
221
224
  },
222
225
  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
98
  const held = new Set(loadedState.state.rows.filter(row => row.queue === 'pending' || !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
@@ -179,7 +179,7 @@ export async function runScan(options) {
179
179
  },
180
180
  pairs,
181
181
  totals,
182
- notes: [],
182
+ notes: [...(options.notes ?? [])],
183
183
  ...(remaining.length === 0 ? {} : { remaining }),
184
184
  };
185
185
  }
@@ -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[]>;
@@ -55,16 +55,23 @@ export async function mutateMergeState(io, home, now, fn) {
55
55
  void now;
56
56
  return result;
57
57
  }
58
- export async function upsertRow(io, home, now, row) {
59
- await mutateMergeState(io, home, now, (rows) => {
58
+ export async function upsertRow(io, home, now, row, expect = 'any') {
59
+ return await mutateMergeState(io, home, now, (rows) => {
60
60
  const index = rows.findIndex(item => item.pairKey === row.pairKey);
61
+ const current = index < 0 ? undefined : rows[index];
62
+ if (current !== undefined && expect !== 'any' && (expect === 'absent' || queueOf(current.state) !== expect)) {
63
+ return { rows, result: { ok: false, current } };
64
+ }
65
+ if (current === undefined && expect !== 'any' && expect !== 'absent') {
66
+ return { rows, result: { ok: false, current: row } };
67
+ }
61
68
  const normalized = { ...row, queue: queueOf(row.state) };
62
69
  const next = [...rows];
63
- if (index < 0)
70
+ if (current === undefined)
64
71
  next.push(normalized);
65
72
  else
66
- next[index] = { ...normalized, createdAt: rows[index]?.createdAt ?? normalized.createdAt };
67
- return { rows: next, result: undefined };
73
+ next[index] = { ...normalized, createdAt: current.createdAt };
74
+ return { rows: next, result: { ok: true } };
68
75
  });
69
76
  }
70
77
  export async function moveRow(io, home, now, id, patch) {
@@ -1,4 +1,5 @@
1
1
  import { queueOf, stageOf } from "./contracts.js";
2
+ import { fitToolText } from "./presentation-budget.js";
2
3
  export function countRows(rows) {
3
4
  const counts = { verify: 0, pending: 0, fuse: 0, skipped: 0 };
4
5
  for (const row of rows)
@@ -17,12 +18,12 @@ export function formatStatus(rows) {
17
18
  ];
18
19
  for (const row of rows)
19
20
  lines.push('- ' + describe(row));
20
- return lines.join('\n');
21
+ return fitToolText(lines.join('\n'));
21
22
  }
22
23
  export function formatQueue(rows, queue) {
23
24
  const selected = rows.filter(row => queueOf(row.state) === queue);
24
25
  if (selected.length === 0)
25
26
  return 'merge ' + queue + ': empty';
26
- return ['merge ' + queue + ': ' + String(selected.length)].concat(selected.map(row => '- ' + describe(row))).join('\n');
27
+ return fitToolText(['merge ' + queue + ': ' + String(selected.length)].concat(selected.map(row => '- ' + describe(row))).join('\n'));
27
28
  }
28
29
  //# sourceMappingURL=status.js.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-merge",
3
3
  "description": "Skill auto-merge domain: screening, two-round authoring, queues and commit orchestration (community build)",
4
- "version": "0.30.0",
4
+ "version": "0.30.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -27,8 +27,8 @@
27
27
  "license": "MIT",
28
28
  "dependencies": {
29
29
  "@deepseek-ai/schemastery": "^3.18.5",
30
- "@lmzhen/dsh-evolution-core": "^0.30.0",
31
- "@lmzhen/dsh-evolution-merge-engine": "^0.30.0"
30
+ "@lmzhen/dsh-evolution-core": "^0.30.2",
31
+ "@lmzhen/dsh-evolution-merge-engine": "^0.30.2"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "@deepseek-ai/cordis": "^4.0.5"