@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 +259 -41
- package/lib/types/commit-execute.js +9 -2
- package/lib/types/commit-recovery.d.ts +10 -0
- package/lib/types/commit-recovery.js +61 -0
- package/lib/types/commit-snapshot.d.ts +18 -0
- package/lib/types/commit-snapshot.js +30 -0
- package/lib/types/coordinator.d.ts +6 -0
- package/lib/types/coordinator.js +5 -0
- package/lib/types/decision.js +5 -1
- package/lib/types/index.js +4 -1
- package/lib/types/presentation-budget.d.ts +3 -0
- package/lib/types/presentation-budget.js +11 -0
- package/lib/types/revision.d.ts +4 -0
- package/lib/types/revision.js +12 -0
- package/lib/types/routes.d.ts +3 -0
- package/lib/types/routes.js +6 -3
- package/lib/types/runtime.js +49 -11
- package/lib/types/scan-command.d.ts +1 -0
- package/lib/types/scan-command.js +1 -0
- package/lib/types/scan-ports.js +6 -1
- package/lib/types/scan-run.d.ts +1 -0
- package/lib/types/scan-run.js +1 -1
- package/lib/types/skill-card.d.ts +1 -0
- package/lib/types/state.d.ts +8 -1
- package/lib/types/state.js +12 -5
- package/lib/types/status.js +3 -2
- package/package.json +3 -3
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 (
|
|
371
|
+
if (current === void 0) next.push(normalized);
|
|
357
372
|
else next[index] = {
|
|
358
373
|
...normalized,
|
|
359
|
-
createdAt:
|
|
374
|
+
createdAt: current.createdAt
|
|
360
375
|
};
|
|
361
376
|
return {
|
|
362
377
|
rows: next,
|
|
363
|
-
result:
|
|
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.
|
|
827
|
-
row.decision?.
|
|
828
|
-
row.
|
|
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: "
|
|
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
|
-
|
|
3390
|
-
if (
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
}
|
|
3398
|
-
const
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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;
|
package/lib/types/coordinator.js
CHANGED
|
@@ -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')
|
package/lib/types/decision.js
CHANGED
|
@@ -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
|
};
|
package/lib/types/index.js
CHANGED
|
@@ -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,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
|
package/lib/types/routes.d.ts
CHANGED
|
@@ -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;
|
package/lib/types/routes.js
CHANGED
|
@@ -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.
|
|
27
|
-
row.decision?.
|
|
28
|
-
row.
|
|
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]
|
package/lib/types/runtime.js
CHANGED
|
@@ -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
|
-
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
});
|
package/lib/types/scan-ports.js
CHANGED
|
@@ -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
|
-
? {
|
|
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) {
|
package/lib/types/scan-run.d.ts
CHANGED
|
@@ -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
|
package/lib/types/scan-run.js
CHANGED
package/lib/types/state.d.ts
CHANGED
|
@@ -22,7 +22,14 @@ export interface MoveRowPatch {
|
|
|
22
22
|
readonly origin?: MergeRow['origin'];
|
|
23
23
|
readonly attempts?: number;
|
|
24
24
|
}
|
|
25
|
-
export
|
|
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[]>;
|
package/lib/types/state.js
CHANGED
|
@@ -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 (
|
|
70
|
+
if (current === undefined)
|
|
64
71
|
next.push(normalized);
|
|
65
72
|
else
|
|
66
|
-
next[index] = { ...normalized, createdAt:
|
|
67
|
-
return { rows: next, result:
|
|
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) {
|
package/lib/types/status.js
CHANGED
|
@@ -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.
|
|
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.
|
|
31
|
-
"@lmzhen/dsh-evolution-merge-engine": "^0.30.
|
|
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"
|