@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.
package/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
- import { join } from "node:path";
1
+ import { join, resolve, sep } from "node:path";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { pairKeyOf, screen, selectCandidates, toEngineCandidate } from "@lmzhen/dsh-evolution-merge-engine";
4
- import { DEFAULT_SKILL_LIMITS, authorityFrom, contentHash, createSubagentChannel, evolutionHome, evolutionIoAdapter, executeUnitUndo, isPresent, newRunRegistry, newSkillLibrary, parseFrontmatter, planUnitUndoAcross, policyStageLimits, probeAbsent, probeList, probePresent, probeText, probeUnknown, scanContentThreats, supportRefs, transactIo, validateFrontmatter, validateSupportPath } from "@lmzhen/dsh-evolution-core";
4
+ import { DEFAULT_SKILL_LIMITS, authorityFrom, contentHash, createSubagentChannel, evolutionHome, evolutionIoAdapter, executeUnitUndo, isPresent, newRunRegistry, newSkillLibrary, parseFrontmatter, planUnitUndoAcross, policyStageLimits, probeAbsent, probeList, probePresent, probeText, probeUnknown, scanContentThreats, skillsRoot, supportRefs, transactIo, validateFrontmatter, validateSupportPath } from "@lmzhen/dsh-evolution-core";
5
5
  import { HOST_ROUTES, json, methodNotAllowed, registerHostRoutes } from "@lmzhen/dsh-evolution-core/http-routes";
6
6
  import { createHash } from "node:crypto";
7
7
  //#region lib/types/adapters/select.js
@@ -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" && expect !== "verify") 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,152 @@ 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, port) {
611
+ if (!isSafeSkillName(plan.target) || plan.sources.some((name) => !isSafeSkillName(name))) throw new Error("unsafe skill name in commit plan: " + plan.target);
612
+ const readSupport = port?.readSupportFiles === void 0 ? void 0 : (name) => port.readSupportFiles?.(name) ?? Promise.resolve([]);
613
+ const targetSupport = readSupport === void 0 || !plan.targetExists ? [] : await readSupport(plan.target);
614
+ const sourceSupport = [];
615
+ if (readSupport !== void 0) for (const name of plan.sources) for (const file of await readSupport(name)) sourceSupport.push({
616
+ name,
617
+ path: file.path,
618
+ content: file.content
619
+ });
620
+ const snapshot = {
621
+ runId,
622
+ target: plan.target,
623
+ create: !plan.targetExists,
624
+ sources: plan.sources,
625
+ targetBody: plan.expectedTargetBody,
626
+ sourceBodies: plan.expectedSourceBodies.map((item) => ({
627
+ name: item.name,
628
+ body: item.body
629
+ })),
630
+ ...targetSupport.length === 0 ? {} : { targetSupport },
631
+ ...plan.supportFiles.length === 0 ? {} : { targetSupportAfter: plan.supportFiles },
632
+ ...sourceSupport.length === 0 ? {} : { sourceSupport },
633
+ at: new Date(now).toISOString()
634
+ };
635
+ await io.writeText(snapshotPath(home, runId), JSON.stringify(snapshot, null, 2));
636
+ return snapshot;
637
+ }
638
+ async function readCommitSnapshot(io, home, runId) {
639
+ const raw = await io.readText(snapshotPath(home, runId));
640
+ if (raw === null) return null;
641
+ try {
642
+ return JSON.parse(raw);
643
+ } catch {
644
+ return null;
645
+ }
646
+ }
647
+ function isSafeSkillName(name) {
648
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) && name !== "." && name !== "..";
649
+ }
650
+ function skillFilePath(root, name, path) {
651
+ if (!isSafeSkillName(name) || path.startsWith("/") || path.includes("..")) throw new Error("unsafe skill file: " + name + "/" + path);
652
+ const base = resolve(root, name);
653
+ const file = resolve(base, path);
654
+ if (!file.startsWith(base + sep)) throw new Error("skill file escapes skill: " + name + "/" + path);
655
+ return file;
656
+ }
657
+ function skillBodyPath(root, name) {
658
+ if (!isSafeSkillName(name)) throw new Error("unsafe skill name: " + name);
659
+ const base = resolve(root);
660
+ const path = resolve(base, name, "SKILL.md");
661
+ if (!path.startsWith(base + sep)) throw new Error("skill path escapes root: " + name);
662
+ return path;
663
+ }
664
+ //#endregion
665
+ //#region lib/types/commit-recovery.js
666
+ /** Startup replay for non-terminal commit journals. */
667
+ async function recoverCommitJournals(io, home, now, skillRoot, restoreSource) {
668
+ const dir = join(home, "merge", "journals");
669
+ if (!await io.exists(dir)) return [];
670
+ const files = (await io.list(dir)).filter((name) => name.endsWith(".json"));
671
+ const reports = [];
672
+ for (const file of files) {
673
+ let journal;
674
+ try {
675
+ journal = JSON.parse(await io.readText(join(dir, file)) ?? "");
676
+ } catch {
677
+ continue;
678
+ }
679
+ const recovery = recoveryFor(journal);
680
+ if (recovery === "none") continue;
681
+ const snapshot = await readCommitSnapshot(io, home, journal.runId);
682
+ let action = "report-only";
683
+ let ok = true;
684
+ if (recovery === "retry-commit") action = "no-host-write";
685
+ else if (snapshot === null) {
686
+ action = "snapshot-missing";
687
+ ok = false;
688
+ } else try {
689
+ if (recovery === "rollback-sources") {
690
+ let restoredArchived = restoreSource !== void 0;
691
+ if (restoreSource !== void 0) {
692
+ for (const source of snapshot.sourceBodies) if (!await restoreSource(source.name)) restoredArchived = false;
693
+ }
694
+ if (!restoredArchived) for (const source of snapshot.sourceBodies) await io.writeText(skillBodyPath(skillRoot, source.name), source.body);
695
+ }
696
+ if (snapshot.create && snapshot.targetBody === null) try {
697
+ await io.remove(skillBodyPath(skillRoot, snapshot.target));
698
+ } catch {}
699
+ else if (snapshot.targetBody !== null) await io.writeText(skillBodyPath(skillRoot, snapshot.target), snapshot.targetBody);
700
+ const oldPaths = new Set((snapshot.targetSupport ?? []).map((file) => file.path));
701
+ for (const file of snapshot.targetSupport ?? []) await io.writeText(skillFilePath(skillRoot, snapshot.target, file.path), file.content);
702
+ for (const file of snapshot.targetSupportAfter ?? []) if (!oldPaths.has(file.path)) try {
703
+ await io.remove(skillFilePath(skillRoot, snapshot.target, file.path));
704
+ } catch {}
705
+ for (const file of snapshot.sourceSupport ?? []) await io.writeText(skillFilePath(skillRoot, file.name, file.path), file.content);
706
+ action = recovery === "rollback-sources" ? "restored-sources-and-target" : "restored-target";
707
+ } catch (error) {
708
+ action = "restore-failed: " + (error instanceof Error ? error.message : String(error));
709
+ ok = false;
710
+ }
711
+ await writeJournal(io, home, {
712
+ ...journal,
713
+ phase: ok ? "rolled-back" : "failed",
714
+ updatedAt: new Date(now).toISOString()
715
+ });
716
+ reports.push({
717
+ runId: journal.runId,
718
+ phase: journal.phase,
719
+ action,
720
+ ok
721
+ });
722
+ }
723
+ await io.writeText(join(home, "merge", "recovery.json"), JSON.stringify(reports, null, 2));
724
+ return reports;
725
+ }
726
+ //#endregion
566
727
  //#region lib/types/commit-receipt.js
567
728
  /**
568
729
  * Commit receipt: <run>/commit.json, written immediately after a successful commit.
@@ -743,11 +904,15 @@ function transition(current, event) {
743
904
  return event.approval === "on" ? next(current, event.at, "pending", {
744
905
  ...event.decision === void 0 ? {} : { decision: event.decision },
745
906
  ...event.stagingRef === void 0 ? {} : { stagingRef: event.stagingRef },
746
- ...event.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: event.stagingExpiresAt }
907
+ ...event.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: event.stagingExpiresAt },
908
+ ...event.sourceRevision === void 0 ? {} : { sourceRevision: event.sourceRevision },
909
+ ...event.targetRevision === void 0 ? {} : { targetRevision: event.targetRevision }
747
910
  }) : next(current, event.at, "approved", {
748
911
  ...event.decision === void 0 ? {} : { decision: event.decision },
749
912
  ...event.stagingRef === void 0 ? {} : { stagingRef: event.stagingRef },
750
- ...event.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: event.stagingExpiresAt }
913
+ ...event.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: event.stagingExpiresAt },
914
+ ...event.sourceRevision === void 0 ? {} : { sourceRevision: event.sourceRevision },
915
+ ...event.targetRevision === void 0 ? {} : { targetRevision: event.targetRevision }
751
916
  });
752
917
  case "round1-fail": return current.state === "verify" ? next(current, event.at, "verify", {
753
918
  failure: event.failure,
@@ -764,10 +929,23 @@ function transition(current, event) {
764
929
  ok: true,
765
930
  row: null
766
931
  } : invalid(event, current.state);
932
+ case "target-taken":
933
+ 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
934
  case "restore": return current.state === "skipped" ? next(current, event.at, "verify") : invalid(event, current.state);
768
935
  }
769
936
  }
770
937
  //#endregion
938
+ //#region lib/types/presentation-budget.js
939
+ /** Tool-result budget: keep the head and tail, mark what was omitted. */
940
+ function fitToolText(text, max = 8e3) {
941
+ if (text.length <= max) return text;
942
+ const marker = "\n... [truncated " + String(text.length - max) + " chars] ...\n";
943
+ const keep = Math.max(0, max - marker.length);
944
+ const head = Math.ceil(keep * .7);
945
+ const tail = keep - head;
946
+ return text.slice(0, head) + marker + (tail === 0 ? "" : text.slice(-tail));
947
+ }
948
+ //#endregion
771
949
  //#region lib/types/status.js
772
950
  function countRows(rows) {
773
951
  const counts = {
@@ -788,12 +966,12 @@ function formatStatus(rows) {
788
966
  const counts = countRows(rows);
789
967
  const lines = ["merge: verify " + String(counts.verify) + ", pending " + String(counts.pending) + ", fuse " + String(counts.fuse) + ", skipped " + String(counts.skipped)];
790
968
  for (const row of rows) lines.push("- " + describe(row));
791
- return lines.join("\n");
969
+ return fitToolText(lines.join("\n"));
792
970
  }
793
971
  function formatQueue(rows, queue) {
794
972
  const selected = rows.filter((row) => queueOf(row.state) === queue);
795
973
  if (selected.length === 0) return "merge " + queue + ": empty";
796
- return ["merge " + queue + ": " + String(selected.length)].concat(selected.map((row) => "- " + describe(row))).join("\n");
974
+ return fitToolText(["merge " + queue + ": " + String(selected.length)].concat(selected.map((row) => "- " + describe(row))).join("\n"));
797
975
  }
798
976
  //#endregion
799
977
  //#region lib/types/routes.js
@@ -811,6 +989,9 @@ function toRowView(row) {
811
989
  createdAt: row.createdAt,
812
990
  ...row.decision?.target === void 0 ? {} : { target: row.decision.target },
813
991
  ...row.decision?.confidence === void 0 ? {} : { confidence: row.decision.confidence },
992
+ ...row.decision?.aSummary === void 0 ? {} : { aSummary: row.decision.aSummary },
993
+ ...row.decision?.bSummary === void 0 ? {} : { bSummary: row.decision.bSummary },
994
+ ...row.decision?.rationale === void 0 ? {} : { rationale: row.decision.rationale },
814
995
  ...row.failure === void 0 ? {} : { failure: {
815
996
  stage: row.failure.stage,
816
997
  code: row.failure.code,
@@ -823,9 +1004,9 @@ function toRowView(row) {
823
1004
  ...row.expired === void 0 ? {} : { expired: row.expired },
824
1005
  ...row.reason === void 0 ? {} : { reason: row.reason },
825
1006
  summary: [
826
- row.a + " + " + row.b,
827
- row.decision?.rationale ?? row.reason ?? "",
828
- row.failure?.message ?? ""
1007
+ row.decision?.aSummary ?? "未提供技能1摘要",
1008
+ row.decision?.bSummary ?? "未提供技能2摘要",
1009
+ row.decision?.rationale ?? row.reason ?? "未提供融合原因"
829
1010
  ],
830
1011
  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
1012
  };
@@ -1539,7 +1720,8 @@ async function runScan(options) {
1539
1720
  });
1540
1721
  if (!created.ok || created.row === null) throw new Error(created.ok ? "candidate row missing" : created.message);
1541
1722
  row = created.row;
1542
- await upsertRow(options.io, options.home, options.now, row);
1723
+ const placed = await upsertRow(options.io, options.home, options.now, row, "verify");
1724
+ if (!placed.ok) throw new Error("pair already in " + placed.current.state);
1543
1725
  } catch (error) {
1544
1726
  totals.failed += 1;
1545
1727
  pairs.push({
@@ -1711,7 +1893,7 @@ async function runScan(options) {
1711
1893
  },
1712
1894
  pairs,
1713
1895
  totals,
1714
- notes: [],
1896
+ notes: [...options.notes ?? []],
1715
1897
  ...remaining.length === 0 ? {} : { remaining }
1716
1898
  };
1717
1899
  }
@@ -1905,7 +2087,8 @@ async function runScanCommand(options) {
1905
2087
  ports,
1906
2088
  stage: ctx.stage,
1907
2089
  startedAt: ctx.startedAt,
1908
- ...options.timeoutMinutes === void 0 ? {} : { timeoutMinutes: options.timeoutMinutes }
2090
+ ...options.timeoutMinutes === void 0 ? {} : { timeoutMinutes: options.timeoutMinutes },
2091
+ ...options.notes === void 0 ? {} : { notes: options.notes }
1909
2092
  });
1910
2093
  }
1911
2094
  });
@@ -2372,6 +2555,12 @@ function parseDecision(text, pair) {
2372
2555
  const target = typeof record.target === "string" ? record.target.trim() : "";
2373
2556
  const sources = Array.isArray(record.sources) ? record.sources.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean) : [];
2374
2557
  const rationale = typeof record.rationale === "string" ? record.rationale.trim() : "";
2558
+ const aSummary = typeof record.aSummary === "string" ? record.aSummary.trim() : typeof record.a_summary === "string" ? record.a_summary.trim() : "";
2559
+ const bSummary = typeof record.bSummary === "string" ? record.bSummary.trim() : typeof record.b_summary === "string" ? record.b_summary.trim() : "";
2560
+ const summaries = {
2561
+ ...aSummary === "" ? {} : { aSummary },
2562
+ ...bSummary === "" ? {} : { bSummary }
2563
+ };
2375
2564
  if (!shouldMerge || mode === "keep") return {
2376
2565
  ok: true,
2377
2566
  decision: {
@@ -2379,7 +2568,8 @@ function parseDecision(text, pair) {
2379
2568
  mode: "keep",
2380
2569
  target,
2381
2570
  sources,
2382
- rationale
2571
+ rationale,
2572
+ ...summaries
2383
2573
  }
2384
2574
  };
2385
2575
  if (target === "") return {
@@ -2405,6 +2595,7 @@ function parseDecision(text, pair) {
2405
2595
  target,
2406
2596
  sources,
2407
2597
  rationale,
2598
+ ...summaries,
2408
2599
  ...typeof record.confidence === "number" ? { confidence: record.confidence } : {}
2409
2600
  }
2410
2601
  };
@@ -2890,17 +3081,6 @@ async function assembleStagedCommit(io, home, runId, port, options) {
2890
3081
  });
2891
3082
  }
2892
3083
  //#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
3084
  //#region lib/types/commit-execute.js
2905
3085
  async function commitStagedRun(io, home, runId, port, options) {
2906
3086
  const assembled = await assembleStagedCommit(io, home, runId, port, {
@@ -2911,12 +3091,21 @@ async function commitStagedRun(io, home, runId, port, options) {
2911
3091
  ok: false,
2912
3092
  message: assembled.message
2913
3093
  };
3094
+ try {
3095
+ await writeCommitSnapshot(io, home, runId, assembled.plan, options.now(), port);
3096
+ } catch (error) {
3097
+ return {
3098
+ ok: false,
3099
+ message: "commit snapshot failed; refusing to write: " + (error instanceof Error ? error.message : String(error)),
3100
+ plan: assembled.plan
3101
+ };
3102
+ }
2914
3103
  const journal = {
2915
3104
  runId,
2916
3105
  target: assembled.plan.target,
2917
3106
  create: !assembled.plan.targetExists,
2918
3107
  sources: assembled.plan.sources,
2919
- phase: "prepared",
3108
+ phase: "sources-archived",
2920
3109
  updatedAt: new Date(options.now()).toISOString()
2921
3110
  };
2922
3111
  try {
@@ -2937,6 +3126,14 @@ async function commitStagedRun(io, home, runId, port, options) {
2937
3126
  plan: assembled.plan
2938
3127
  };
2939
3128
  }
3129
+ const committed = {
3130
+ ...journal,
3131
+ phase: "committed",
3132
+ updatedAt: new Date(options.now()).toISOString()
3133
+ };
3134
+ try {
3135
+ await writeJournal(io, home, committed);
3136
+ } catch {}
2940
3137
  if (result.unitId === void 0) return {
2941
3138
  ok: true,
2942
3139
  plan: assembled.plan,
@@ -2956,14 +3153,6 @@ async function commitStagedRun(io, home, runId, port, options) {
2956
3153
  };
2957
3154
  try {
2958
3155
  await writeCommitReceipt(io, home, receipt);
2959
- try {
2960
- await writeJournal(io, home, {
2961
- ...journal,
2962
- unitId: result.unitId,
2963
- phase: "committed",
2964
- updatedAt: new Date(options.now()).toISOString()
2965
- });
2966
- } catch {}
2967
3156
  return {
2968
3157
  ok: true,
2969
3158
  plan: assembled.plan,
@@ -3050,6 +3239,17 @@ async function runAuthoring(options) {
3050
3239
  };
3051
3240
  }
3052
3241
  //#endregion
3242
+ //#region lib/types/revision.js
3243
+ /** Stable source revision for a pair: names sorted, body hashed in that order. */
3244
+ function sourceRevisionOf(cards, a, b) {
3245
+ const byName = new Map(cards.map((card) => [card.name, card]));
3246
+ const names = [a, b].sort();
3247
+ const first = byName.get(names[0] ?? "");
3248
+ const second = byName.get(names[1] ?? "");
3249
+ if (first === void 0 || second === void 0) return void 0;
3250
+ return contentHash(first.name + "\0" + first.body + "\0" + second.name + "\0" + second.body);
3251
+ }
3252
+ //#endregion
3053
3253
  //#region lib/types/scan-ports.js
3054
3254
  function createChannelScanPorts(options) {
3055
3255
  const sessions = /* @__PURE__ */ new Map();
@@ -3107,10 +3307,12 @@ function createChannelScanPorts(options) {
3107
3307
  phase: "author"
3108
3308
  });
3109
3309
  }
3310
+ const sourceRevision = result.outcome.kind === "merge" ? sourceRevisionOf(options.cards, candidate.a, candidate.b) : void 0;
3110
3311
  return result.outcome.kind === "merge" ? {
3111
3312
  ...result.outcome,
3112
3313
  stagingRef: stageKey,
3113
- stagingExpiresAt: options.now() + 7 * 864e5
3314
+ stagingExpiresAt: options.now() + 7 * 864e5,
3315
+ ...sourceRevision === void 0 ? {} : { sourceRevision }
3114
3316
  } : result.outcome;
3115
3317
  },
3116
3318
  async round2(row, decision, signal) {
@@ -3346,7 +3548,16 @@ function skillLibraryPort(skills) {
3346
3548
  const names = await archivedNames.call(skills);
3347
3549
  return names === null ? true : names.includes(name);
3348
3550
  },
3349
- commitMerge: (plan) => skills.commitMerge(plan)
3551
+ commitMerge: (plan) => skills.commitMerge(plan),
3552
+ async readSupportFiles(name) {
3553
+ if (typeof skills.listSupportFiles !== "function") return [];
3554
+ const probe = await skills.listSupportFiles(name);
3555
+ if (probe.kind !== "present") return [];
3556
+ return await Promise.all(probe.value.map(async (path) => ({
3557
+ path,
3558
+ content: await skills.readSupportFile(name, path) ?? ""
3559
+ })));
3560
+ }
3350
3561
  };
3351
3562
  }
3352
3563
  //#endregion
@@ -3356,6 +3567,16 @@ function skillLibraryPort(skills) {
3356
3567
  * Plugin wiring supplies the channel, model and library; this module keeps the orchestration
3357
3568
  * out of apply() and makes the mutating verbs testable with fakes.
3358
3569
  */
3570
+ function forcedDecisionFor(a, b) {
3571
+ const sorted = [a, b].sort();
3572
+ return {
3573
+ shouldMerge: true,
3574
+ mode: "create-umbrella",
3575
+ target: ("merge-" + sorted.join("-")).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64),
3576
+ sources: [a, b],
3577
+ rationale: "forced pair merge of " + sorted.join(" and ")
3578
+ };
3579
+ }
3359
3580
  function createMergeRuntime(deps) {
3360
3581
  const readOnly = createReadOnlyMergeService(deps.io, deps.home);
3361
3582
  const timeout = deps.timeoutMinutes > 0 ? deps.timeoutMinutes : void 0;
@@ -3382,20 +3603,27 @@ function createMergeRuntime(deps) {
3382
3603
  if (command.verb === "pair") {
3383
3604
  const signal = new AbortController().signal;
3384
3605
  const cards = await deps.cards(signal);
3606
+ const cardA = cards.find((card) => card.name === command.a);
3607
+ const cardB = cards.find((card) => card.name === command.b);
3608
+ if (cardA === void 0 || cardB === void 0 || cardA.managed !== true || cardB.managed !== true) return {
3609
+ ok: false,
3610
+ text: "merge pair 仅支持 family-managed 技能: " + command.a + " / " + command.b
3611
+ };
3385
3612
  const pair = [{
3386
3613
  a: command.a,
3387
3614
  b: command.b
3388
3615
  }];
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;
3616
+ const existing = (await loadMergeState(deps.io, deps.home)).state.rows.find((row) => row.pairKey === pairKey(command.a, command.b));
3617
+ if (existing?.state === "pending") return {
3618
+ ok: false,
3619
+ text: "该 pair 已在待批准队列,先 approve/reject/discard: " + existing.id
3620
+ };
3621
+ if (existing?.state === "approved" || existing?.state === "fuse") return {
3622
+ ok: false,
3623
+ text: "该 pair 已在待合并队列,用 merge resume --retry 复用: " + (existing.runId ?? existing.id)
3624
+ };
3625
+ const forced = command.force === true ? forcedDecisionFor(command.a, command.b) : void 0;
3626
+ const approval = deps.params.mergeApproval;
3399
3627
  const result = await runScanCommand({
3400
3628
  ...common,
3401
3629
  approval,
@@ -3421,7 +3649,7 @@ function createMergeRuntime(deps) {
3421
3649
  ...ports,
3422
3650
  round1: () => Promise.resolve({
3423
3651
  kind: "merge",
3424
- decision: forced.decision
3652
+ decision: forced
3425
3653
  })
3426
3654
  };
3427
3655
  }
@@ -3446,12 +3674,15 @@ function createMergeRuntime(deps) {
3446
3674
  if (command.verb === "scan") {
3447
3675
  const signal = new AbortController().signal;
3448
3676
  const cards = await deps.cards(signal);
3677
+ const managed = new Set(cards.filter((card) => card.managed === true).map((card) => card.name));
3449
3678
  const loadedState = await loadMergeState(deps.io, deps.home);
3450
3679
  const skipped = skippedPairKeys(loadedState.state.rows, deps.params.mergeSkippedTtlDays, deps.now());
3451
- const held = new Set(loadedState.state.rows.filter((row) => row.queue === "pending" || !canRetry(row)).map((row) => row.pairKey));
3680
+ const held = new Set(loadedState.state.rows.filter((row) => row.queue === "pending" || row.queue === "fuse" || !canRetry(row)).map((row) => row.pairKey));
3681
+ 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
3682
  const result = await runScanCommand({
3453
3683
  ...common,
3454
3684
  approval: deps.params.mergeApproval,
3685
+ notes: conflictNotes,
3455
3686
  ...detached ? { detached: true } : {},
3456
3687
  makePorts: (runId) => createChannelScanPorts({
3457
3688
  io: deps.io,
@@ -3462,7 +3693,7 @@ function createMergeRuntime(deps) {
3462
3693
  const raw = await deps.screeningForRun(cards, runId, turnSignal);
3463
3694
  const filtered = raw.filter((candidate) => {
3464
3695
  const key = pairKey(candidate.a, candidate.b);
3465
- return !skipped.has(key) && !held.has(key);
3696
+ return managed.has(candidate.a) && managed.has(candidate.b) && !skipped.has(key) && !held.has(key);
3466
3697
  });
3467
3698
  const candidatePairs = raw.candidatePairs;
3468
3699
  return candidatePairs === void 0 ? filtered : Object.assign(filtered, { candidatePairs });
@@ -3561,7 +3792,7 @@ function createMergeRuntime(deps) {
3561
3792
  text: "report is missing: " + run.resultRef
3562
3793
  } : {
3563
3794
  ok: true,
3564
- text: report.value.slice(0, 12e3)
3795
+ text: "report: " + run.resultRef + "\n" + fitToolText(report.value, 7600)
3565
3796
  };
3566
3797
  }
3567
3798
  if (command.verb === "discard") {
@@ -3707,6 +3938,40 @@ function createMergeRuntime(deps) {
3707
3938
  if (command.verb === "approve") {
3708
3939
  const signal = new AbortController().signal;
3709
3940
  const cards = await deps.cards(signal);
3941
+ const candidate = (await loadMergeState(deps.io, deps.home)).state.rows.find((row) => row.id === command.id);
3942
+ if (candidate !== void 0 && candidate.state === "pending" && candidate.decision !== void 0) {
3943
+ const missingSource = [candidate.a, candidate.b].find((name) => !cards.some((card) => card.name === name && card.managed === true));
3944
+ if (missingSource !== void 0) {
3945
+ await dropRow(deps.io, deps.home, deps.now, candidate.id);
3946
+ return {
3947
+ ok: false,
3948
+ text: "source-gone:" + missingSource + " 已不在 family-managed 技能库,已从 active 队列清除;可 merge skipped --discard 清理"
3949
+ };
3950
+ }
3951
+ const currentRevision = sourceRevisionOf(cards, candidate.a, candidate.b);
3952
+ if (candidate.sourceRevision !== void 0 && currentRevision !== void 0 && currentRevision !== candidate.sourceRevision) {
3953
+ const changed = transition(candidate, {
3954
+ type: "source-changed",
3955
+ at: (/* @__PURE__ */ new Date()).toISOString()
3956
+ });
3957
+ if (changed.ok && changed.row !== null) await upsertRow(deps.io, deps.home, deps.now, changed.row);
3958
+ return {
3959
+ ok: false,
3960
+ text: "source-changed:源技能内容在决策后变化,拒绝照跑;出口:重新 merge pair,或 merge skipped --discard"
3961
+ };
3962
+ }
3963
+ if (candidate.decision.mode === "merge-into-existing" && !cards.some((card) => card.name === candidate.decision?.target)) {
3964
+ const expired = transition(candidate, {
3965
+ type: "target-taken",
3966
+ at: (/* @__PURE__ */ new Date()).toISOString()
3967
+ });
3968
+ if (expired.ok && expired.row !== null) await upsertRow(deps.io, deps.home, deps.now, expired.row);
3969
+ return {
3970
+ ok: false,
3971
+ text: "target-taken:目标 " + candidate.decision.target + " 已不存在,行保留 pending --expired;出口:重新 merge pair,或 merge skipped --discard"
3972
+ };
3973
+ }
3974
+ }
3710
3975
  const result = await runApproveCommand({
3711
3976
  ...common,
3712
3977
  rowId: command.id,
@@ -4217,6 +4482,13 @@ function apply(ctx, rawConfig = {}) {
4217
4482
  ...policyStageLimits(policySnapshot?.get?.())
4218
4483
  }
4219
4484
  });
4485
+ recoverCommitJournals(io, home, Date.now(), skillsRoot(), async (name) => {
4486
+ return (await skills.restoreFromArchive(name)).ok;
4487
+ }).then((reports) => {
4488
+ if (reports.length > 0) ctx.logger.info("merge commit recovery: " + reports.map((report) => report.runId + "=" + report.action).join(", "));
4489
+ }).catch((error) => {
4490
+ ctx.logger.warn("merge commit recovery failed: " + (error instanceof Error ? error.message : String(error)));
4491
+ });
4220
4492
  const channel = subagentChannelAdapter(createSubagentChannel(ctx));
4221
4493
  const stage = new StageToolRegistry();
4222
4494
  const engineConfig = engineConfigFromEnv(params.mergeModelsRoot);
@@ -4339,7 +4611,8 @@ function apply(ctx, rawConfig = {}) {
4339
4611
  body: item.content ?? "",
4340
4612
  files,
4341
4613
  protectedBy: item.protectedBy,
4342
- protectionUnknown: item.protectionUnknown
4614
+ protectionUnknown: item.protectionUnknown,
4615
+ managed: item.managed
4343
4616
  };
4344
4617
  }));
4345
4618
  },
@@ -9,6 +9,14 @@ export function skillLibraryPort(skills) {
9
9
  return names === null ? true : names.includes(name);
10
10
  },
11
11
  commitMerge: plan => skills.commitMerge(plan),
12
+ async readSupportFiles(name) {
13
+ if (typeof skills.listSupportFiles !== 'function')
14
+ return [];
15
+ const probe = await skills.listSupportFiles(name);
16
+ if (probe.kind !== 'present')
17
+ return [];
18
+ return await Promise.all(probe.value.map(async (path) => ({ path, content: await skills.readSupportFile(name, path) ?? '' })));
19
+ },
12
20
  };
13
21
  }
14
22
  //# sourceMappingURL=library.js.map
@@ -8,6 +8,10 @@ import { type CommitAssemblyPort } from './commit-assembly.ts';
8
8
  import { type CommitReceipt } from './commit-receipt.ts';
9
9
  export interface CommitPort extends CommitAssemblyPort {
10
10
  commitMerge(plan: CommitMergePlan): Promise<CommitMergeResult>;
11
+ readSupportFiles?(name: string): Promise<readonly {
12
+ readonly path: string;
13
+ readonly content: string;
14
+ }[]>;
11
15
  }
12
16
  export interface CommitStagedOptions {
13
17
  readonly origin: CommitMergePlan['origin'];