@lmzhen/dsh-evolution-merge 0.30.2 → 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
@@ -356,7 +356,7 @@ async function upsertRow(io, home, now, row, expect = "any") {
356
356
  current
357
357
  }
358
358
  };
359
- if (current === void 0 && expect !== "any" && expect !== "absent") return {
359
+ if (current === void 0 && expect !== "any" && expect !== "absent" && expect !== "verify") return {
360
360
  rows,
361
361
  result: {
362
362
  ok: false,
@@ -607,7 +607,16 @@ async function writeJournal(io, home, journal) {
607
607
  function snapshotPath(home, runId) {
608
608
  return join(home, "merge", "snapshots", runId.replace(/[^A-Za-z0-9_-]+/g, "_") + ".json");
609
609
  }
610
- async function writeCommitSnapshot(io, home, runId, plan, now) {
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
+ });
611
620
  const snapshot = {
612
621
  runId,
613
622
  target: plan.target,
@@ -618,6 +627,9 @@ async function writeCommitSnapshot(io, home, runId, plan, now) {
618
627
  name: item.name,
619
628
  body: item.body
620
629
  })),
630
+ ...targetSupport.length === 0 ? {} : { targetSupport },
631
+ ...plan.supportFiles.length === 0 ? {} : { targetSupportAfter: plan.supportFiles },
632
+ ...sourceSupport.length === 0 ? {} : { sourceSupport },
621
633
  at: new Date(now).toISOString()
622
634
  };
623
635
  await io.writeText(snapshotPath(home, runId), JSON.stringify(snapshot, null, 2));
@@ -632,13 +644,27 @@ async function readCommitSnapshot(io, home, runId) {
632
644
  return null;
633
645
  }
634
646
  }
635
- function skillBodyPath(home, name) {
636
- return join(home, "skills", name, "SKILL.md");
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;
637
663
  }
638
664
  //#endregion
639
665
  //#region lib/types/commit-recovery.js
640
666
  /** Startup replay for non-terminal commit journals. */
641
- async function recoverCommitJournals(io, home, now) {
667
+ async function recoverCommitJournals(io, home, now, skillRoot, restoreSource) {
642
668
  const dir = join(home, "merge", "journals");
643
669
  if (!await io.exists(dir)) return [];
644
670
  const files = (await io.list(dir)).filter((name) => name.endsWith(".json"));
@@ -660,11 +686,23 @@ async function recoverCommitJournals(io, home, now) {
660
686
  action = "snapshot-missing";
661
687
  ok = false;
662
688
  } else try {
663
- if (recovery === "rollback-sources") for (const source of snapshot.sourceBodies) await io.writeText(skillBodyPath(home, source.name), source.body);
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
+ }
664
696
  if (snapshot.create && snapshot.targetBody === null) try {
665
- await io.remove(skillBodyPath(home, snapshot.target));
697
+ await io.remove(skillBodyPath(skillRoot, snapshot.target));
666
698
  } catch {}
667
- else if (snapshot.targetBody !== null) await io.writeText(skillBodyPath(home, snapshot.target), snapshot.targetBody);
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);
668
706
  action = recovery === "rollback-sources" ? "restored-sources-and-target" : "restored-target";
669
707
  } catch (error) {
670
708
  action = "restore-failed: " + (error instanceof Error ? error.message : String(error));
@@ -866,11 +904,15 @@ function transition(current, event) {
866
904
  return event.approval === "on" ? next(current, event.at, "pending", {
867
905
  ...event.decision === void 0 ? {} : { decision: event.decision },
868
906
  ...event.stagingRef === void 0 ? {} : { stagingRef: event.stagingRef },
869
- ...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 }
870
910
  }) : next(current, event.at, "approved", {
871
911
  ...event.decision === void 0 ? {} : { decision: event.decision },
872
912
  ...event.stagingRef === void 0 ? {} : { stagingRef: event.stagingRef },
873
- ...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 }
874
916
  });
875
917
  case "round1-fail": return current.state === "verify" ? next(current, event.at, "verify", {
876
918
  failure: event.failure,
@@ -1678,7 +1720,8 @@ async function runScan(options) {
1678
1720
  });
1679
1721
  if (!created.ok || created.row === null) throw new Error(created.ok ? "candidate row missing" : created.message);
1680
1722
  row = created.row;
1681
- 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);
1682
1725
  } catch (error) {
1683
1726
  totals.failed += 1;
1684
1727
  pairs.push({
@@ -3049,7 +3092,7 @@ async function commitStagedRun(io, home, runId, port, options) {
3049
3092
  message: assembled.message
3050
3093
  };
3051
3094
  try {
3052
- await writeCommitSnapshot(io, home, runId, assembled.plan, options.now());
3095
+ await writeCommitSnapshot(io, home, runId, assembled.plan, options.now(), port);
3053
3096
  } catch (error) {
3054
3097
  return {
3055
3098
  ok: false,
@@ -3073,6 +3116,7 @@ async function commitStagedRun(io, home, runId, port, options) {
3073
3116
  try {
3074
3117
  await writeJournal(io, home, {
3075
3118
  ...journal,
3119
+ phase: "failed",
3076
3120
  updatedAt: new Date(options.now()).toISOString()
3077
3121
  });
3078
3122
  } catch {}
@@ -3082,6 +3126,14 @@ async function commitStagedRun(io, home, runId, port, options) {
3082
3126
  plan: assembled.plan
3083
3127
  };
3084
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 {}
3085
3137
  if (result.unitId === void 0) return {
3086
3138
  ok: true,
3087
3139
  plan: assembled.plan,
@@ -3101,14 +3153,6 @@ async function commitStagedRun(io, home, runId, port, options) {
3101
3153
  };
3102
3154
  try {
3103
3155
  await writeCommitReceipt(io, home, receipt);
3104
- try {
3105
- await writeJournal(io, home, {
3106
- ...journal,
3107
- unitId: result.unitId,
3108
- phase: "committed",
3109
- updatedAt: new Date(options.now()).toISOString()
3110
- });
3111
- } catch {}
3112
3156
  return {
3113
3157
  ok: true,
3114
3158
  plan: assembled.plan,
@@ -3504,7 +3548,16 @@ function skillLibraryPort(skills) {
3504
3548
  const names = await archivedNames.call(skills);
3505
3549
  return names === null ? true : names.includes(name);
3506
3550
  },
3507
- 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
+ }
3508
3561
  };
3509
3562
  }
3510
3563
  //#endregion
@@ -3624,7 +3677,7 @@ function createMergeRuntime(deps) {
3624
3677
  const managed = new Set(cards.filter((card) => card.managed === true).map((card) => card.name));
3625
3678
  const loadedState = await loadMergeState(deps.io, deps.home);
3626
3679
  const skipped = skippedPairKeys(loadedState.state.rows, deps.params.mergeSkippedTtlDays, deps.now());
3627
- 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));
3628
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);
3629
3682
  const result = await runScanCommand({
3630
3683
  ...common,
@@ -4404,11 +4457,6 @@ function apply(ctx, rawConfig = {}) {
4404
4457
  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) => {
4405
4458
  ctx.logger.warn("merge staging cleanup failed: " + (error instanceof Error ? error.message : String(error)));
4406
4459
  });
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
- });
4412
4460
  const runs = newRunRegistry({
4413
4461
  io,
4414
4462
  home,
@@ -4434,6 +4482,13 @@ function apply(ctx, rawConfig = {}) {
4434
4482
  ...policyStageLimits(policySnapshot?.get?.())
4435
4483
  }
4436
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
+ });
4437
4492
  const channel = subagentChannelAdapter(createSubagentChannel(ctx));
4438
4493
  const stage = new StageToolRegistry();
4439
4494
  const engineConfig = engineConfigFromEnv(params.mergeModelsRoot);
@@ -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'];
@@ -8,7 +8,7 @@ export async function commitStagedRun(io, home, runId, port, options) {
8
8
  if (!assembled.ok)
9
9
  return { ok: false, message: assembled.message };
10
10
  try {
11
- await writeCommitSnapshot(io, home, runId, assembled.plan, options.now());
11
+ await writeCommitSnapshot(io, home, runId, assembled.plan, options.now(), port);
12
12
  }
13
13
  catch (error) {
14
14
  return { ok: false, message: 'commit snapshot failed; refusing to write: ' + (error instanceof Error ? error.message : String(error)), plan: assembled.plan };
@@ -21,11 +21,16 @@ export async function commitStagedRun(io, home, runId, port, options) {
21
21
  const result = await port.commitMerge(assembled.plan);
22
22
  if (!result.ok) {
23
23
  try {
24
- await writeJournal(io, home, { ...journal, updatedAt: new Date(options.now()).toISOString() });
24
+ await writeJournal(io, home, { ...journal, phase: 'failed', updatedAt: new Date(options.now()).toISOString() });
25
25
  }
26
26
  catch { }
27
27
  return { ok: false, message: result.message, plan: assembled.plan };
28
28
  }
29
+ const committed = { ...journal, phase: 'committed', updatedAt: new Date(options.now()).toISOString() };
30
+ try {
31
+ await writeJournal(io, home, committed);
32
+ }
33
+ catch { }
29
34
  if (result.unitId === undefined)
30
35
  return { ok: true, plan: assembled.plan, result, warning: 'commit landed without a unitId; receipt not written' };
31
36
  const receipt = {
@@ -41,10 +46,6 @@ export async function commitStagedRun(io, home, runId, port, options) {
41
46
  };
42
47
  try {
43
48
  await writeCommitReceipt(io, home, receipt);
44
- try {
45
- await writeJournal(io, home, { ...journal, unitId: result.unitId, phase: 'committed', updatedAt: new Date(options.now()).toISOString() });
46
- }
47
- catch { }
48
49
  return { ok: true, plan: assembled.plan, result, receipt };
49
50
  }
50
51
  catch (error) {
@@ -6,5 +6,5 @@ export interface CommitRecoveryReport {
6
6
  readonly action: string;
7
7
  readonly ok: boolean;
8
8
  }
9
- export declare function recoverCommitJournals(io: EvolutionIoLike, home: string, now: number): Promise<readonly CommitRecoveryReport[]>;
9
+ export declare function recoverCommitJournals(io: EvolutionIoLike, home: string, now: number, skillRoot: string, restoreSource?: (name: string) => Promise<boolean>): Promise<readonly CommitRecoveryReport[]>;
10
10
  //# sourceMappingURL=commit-recovery.d.ts.map
@@ -2,8 +2,8 @@
2
2
  import { join } from 'node:path';
3
3
  import { recoveryFor } from "./domain/commit-journal.js";
4
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) {
5
+ import { readCommitSnapshot, skillBodyPath, skillFilePath } from "./commit-snapshot.js";
6
+ export async function recoverCommitJournals(io, home, now, skillRoot, restoreSource) {
7
7
  const dir = join(home, 'merge', 'journals');
8
8
  if (!(await io.exists(dir)))
9
9
  return [];
@@ -33,18 +33,41 @@ export async function recoverCommitJournals(io, home, now) {
33
33
  else {
34
34
  try {
35
35
  if (recovery === 'rollback-sources') {
36
- for (const source of snapshot.sourceBodies)
37
- await io.writeText(skillBodyPath(home, source.name), source.body);
36
+ let restoredArchived = restoreSource !== undefined;
37
+ if (restoreSource !== undefined) {
38
+ for (const source of snapshot.sourceBodies) {
39
+ if (!(await restoreSource(source.name)))
40
+ restoredArchived = false;
41
+ }
42
+ }
43
+ if (!restoredArchived) {
44
+ for (const source of snapshot.sourceBodies)
45
+ await io.writeText(skillBodyPath(skillRoot, source.name), source.body);
46
+ }
38
47
  }
39
48
  if (snapshot.create && snapshot.targetBody === null) {
40
49
  try {
41
- await io.remove(skillBodyPath(home, snapshot.target));
50
+ await io.remove(skillBodyPath(skillRoot, snapshot.target));
42
51
  }
43
52
  catch { /* already gone */ }
44
53
  }
45
54
  else if (snapshot.targetBody !== null) {
46
- await io.writeText(skillBodyPath(home, snapshot.target), snapshot.targetBody);
55
+ await io.writeText(skillBodyPath(skillRoot, snapshot.target), snapshot.targetBody);
56
+ }
57
+ const oldPaths = new Set((snapshot.targetSupport ?? []).map(file => file.path));
58
+ for (const file of snapshot.targetSupport ?? []) {
59
+ await io.writeText(skillFilePath(skillRoot, snapshot.target, file.path), file.content);
60
+ }
61
+ for (const file of snapshot.targetSupportAfter ?? []) {
62
+ if (!oldPaths.has(file.path)) {
63
+ try {
64
+ await io.remove(skillFilePath(skillRoot, snapshot.target, file.path));
65
+ }
66
+ catch { /* already gone */ }
67
+ }
47
68
  }
69
+ for (const file of snapshot.sourceSupport ?? [])
70
+ await io.writeText(skillFilePath(skillRoot, file.name, file.path), file.content);
48
71
  action = recovery === 'rollback-sources' ? 'restored-sources-and-target' : 'restored-target';
49
72
  }
50
73
  catch (error) {
@@ -9,10 +9,30 @@ export interface CommitSnapshot {
9
9
  readonly name: string;
10
10
  readonly body: string;
11
11
  }[];
12
+ readonly targetSupport?: readonly {
13
+ readonly path: string;
14
+ readonly content: string;
15
+ }[];
16
+ readonly targetSupportAfter?: readonly {
17
+ readonly path: string;
18
+ readonly content: string;
19
+ }[];
20
+ readonly sourceSupport?: readonly {
21
+ readonly name: string;
22
+ readonly path: string;
23
+ readonly content: string;
24
+ }[];
12
25
  readonly at: string;
13
26
  }
14
27
  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>;
28
+ export declare function writeCommitSnapshot(io: EvolutionIoLike, home: string, runId: string, plan: CommitMergePlan, now: number, port?: {
29
+ readSupportFiles?(name: string): Promise<readonly {
30
+ readonly path: string;
31
+ readonly content: string;
32
+ }[]>;
33
+ }): Promise<CommitSnapshot>;
16
34
  export declare function readCommitSnapshot(io: EvolutionIoLike, home: string, runId: string): Promise<CommitSnapshot | null>;
17
- export declare function skillBodyPath(home: string, name: string): string;
35
+ export declare function isSafeSkillName(name: string): boolean;
36
+ export declare function skillFilePath(root: string, name: string, path: string): string;
37
+ export declare function skillBodyPath(root: string, name: string): string;
18
38
  //# sourceMappingURL=commit-snapshot.d.ts.map
@@ -1,13 +1,30 @@
1
1
  /** Pre-commit snapshot: enough to restore the target and archived sources after a crash. */
2
- import { join } from 'node:path';
2
+ import { join, resolve, sep } from 'node:path';
3
3
  export function snapshotPath(home, runId) {
4
4
  return join(home, 'merge', 'snapshots', runId.replace(/[^A-Za-z0-9_-]+/g, '_') + '.json');
5
5
  }
6
- export async function writeCommitSnapshot(io, home, runId, plan, now) {
6
+ export async function writeCommitSnapshot(io, home, runId, plan, now, port) {
7
+ if (!isSafeSkillName(plan.target) || plan.sources.some(name => !isSafeSkillName(name))) {
8
+ throw new Error('unsafe skill name in commit plan: ' + plan.target);
9
+ }
10
+ const readSupport = port?.readSupportFiles === undefined
11
+ ? undefined
12
+ : (name) => port.readSupportFiles?.(name) ?? Promise.resolve([]);
13
+ const targetSupport = readSupport === undefined || !plan.targetExists ? [] : await readSupport(plan.target);
14
+ const sourceSupport = [];
15
+ if (readSupport !== undefined) {
16
+ for (const name of plan.sources) {
17
+ for (const file of await readSupport(name))
18
+ sourceSupport.push({ name, path: file.path, content: file.content });
19
+ }
20
+ }
7
21
  const snapshot = {
8
22
  runId, target: plan.target, create: !plan.targetExists, sources: plan.sources,
9
23
  targetBody: plan.expectedTargetBody,
10
24
  sourceBodies: plan.expectedSourceBodies.map(item => ({ name: item.name, body: item.body })),
25
+ ...(targetSupport.length === 0 ? {} : { targetSupport }),
26
+ ...(plan.supportFiles.length === 0 ? {} : { targetSupportAfter: plan.supportFiles }),
27
+ ...(sourceSupport.length === 0 ? {} : { sourceSupport }),
11
28
  at: new Date(now).toISOString(),
12
29
  };
13
30
  await io.writeText(snapshotPath(home, runId), JSON.stringify(snapshot, null, 2));
@@ -24,7 +41,25 @@ export async function readCommitSnapshot(io, home, runId) {
24
41
  return null;
25
42
  }
26
43
  }
27
- export function skillBodyPath(home, name) {
28
- return join(home, 'skills', name, 'SKILL.md');
44
+ export function isSafeSkillName(name) {
45
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) && name !== '.' && name !== '..';
46
+ }
47
+ export function skillFilePath(root, name, path) {
48
+ if (!isSafeSkillName(name) || path.startsWith('/') || path.includes('..'))
49
+ throw new Error('unsafe skill file: ' + name + '/' + path);
50
+ const base = resolve(root, name);
51
+ const file = resolve(base, path);
52
+ if (!file.startsWith(base + sep))
53
+ throw new Error('skill file escapes skill: ' + name + '/' + path);
54
+ return file;
55
+ }
56
+ export function skillBodyPath(root, name) {
57
+ if (!isSafeSkillName(name))
58
+ throw new Error('unsafe skill name: ' + name);
59
+ const base = resolve(root);
60
+ const path = resolve(base, name, 'SKILL.md');
61
+ if (!path.startsWith(base + sep))
62
+ throw new Error('skill path escapes root: ' + name);
63
+ return path;
29
64
  }
30
65
  //# sourceMappingURL=commit-snapshot.js.map
@@ -20,6 +20,8 @@ export type MergeEvent = {
20
20
  readonly decision?: MergeDecision;
21
21
  readonly stagingRef?: string;
22
22
  readonly stagingExpiresAt?: number;
23
+ readonly sourceRevision?: string;
24
+ readonly targetRevision?: string;
23
25
  readonly at: string;
24
26
  } | {
25
27
  readonly type: 'round1-fail';
@@ -98,8 +98,8 @@ export function transition(current, event) {
98
98
  if (current.state !== 'verify')
99
99
  return invalid(event, current.state);
100
100
  return event.approval === 'on'
101
- ? next(current, event.at, 'pending', { ...(event.decision === undefined ? {} : { decision: event.decision }), ...(event.stagingRef === undefined ? {} : { stagingRef: event.stagingRef }), ...(event.stagingExpiresAt === undefined ? {} : { stagingExpiresAt: event.stagingExpiresAt }) })
102
- : next(current, event.at, 'approved', { ...(event.decision === undefined ? {} : { decision: event.decision }), ...(event.stagingRef === undefined ? {} : { stagingRef: event.stagingRef }), ...(event.stagingExpiresAt === undefined ? {} : { stagingExpiresAt: event.stagingExpiresAt }) });
101
+ ? next(current, event.at, 'pending', { ...(event.decision === undefined ? {} : { decision: event.decision }), ...(event.stagingRef === undefined ? {} : { stagingRef: event.stagingRef }), ...(event.stagingExpiresAt === undefined ? {} : { stagingExpiresAt: event.stagingExpiresAt }), ...(event.sourceRevision === undefined ? {} : { sourceRevision: event.sourceRevision }), ...(event.targetRevision === undefined ? {} : { targetRevision: event.targetRevision }) })
102
+ : next(current, event.at, 'approved', { ...(event.decision === undefined ? {} : { decision: event.decision }), ...(event.stagingRef === undefined ? {} : { stagingRef: event.stagingRef }), ...(event.stagingExpiresAt === undefined ? {} : { stagingExpiresAt: event.stagingExpiresAt }), ...(event.sourceRevision === undefined ? {} : { sourceRevision: event.sourceRevision }), ...(event.targetRevision === undefined ? {} : { targetRevision: event.targetRevision }) });
103
103
  case 'round1-fail':
104
104
  return current.state === 'verify'
105
105
  ? next(current, event.at, 'verify', { failure: event.failure, attempts: (current.attempts ?? 0) + 1 })
@@ -4,7 +4,7 @@ import { screen as engineScreen } from '@lmzhen/dsh-evolution-merge-engine';
4
4
  import { selectWithErrors } from "./adapters/select.js";
5
5
  import { createEngineScreening } from "./adapters/engine-screen.js";
6
6
  import { createEngineRunner } from "./adapters/model-runner.js";
7
- import { authorityFrom, createSubagentChannel, DEFAULT_SKILL_LIMITS, evolutionHome, evolutionIoAdapter, newRunRegistry, newSkillLibrary, policyStageLimits } from '@lmzhen/dsh-evolution-core';
7
+ import { authorityFrom, createSubagentChannel, DEFAULT_SKILL_LIMITS, evolutionHome, evolutionIoAdapter, newRunRegistry, newSkillLibrary, policyStageLimits, skillsRoot } from '@lmzhen/dsh-evolution-core';
8
8
  import { registerHostRoutes } from '@lmzhen/dsh-evolution-core/http-routes';
9
9
  import { loadMergeState, upsertRow } from "./state.js";
10
10
  import { cleanupExpiredStaging } from "./staging.js";
@@ -122,8 +122,6 @@ export function apply(ctx, rawConfig = {}) {
122
122
  const io = evolutionIoAdapter(() => registry.provider());
123
123
  const home = evolutionHome();
124
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))); });
127
125
  const runs = newRunRegistry({ io, home, warn: (message) => { ctx.logger.warn(message); } });
128
126
  void runs.load();
129
127
  ctx.effect(() => {
@@ -140,6 +138,11 @@ export function apply(ctx, rawConfig = {}) {
140
138
  io, ctx, authority: authorityFrom({ origin: 'subagent' }),
141
139
  limits: { ...DEFAULT_SKILL_LIMITS, ...policyStageLimits(policySnapshot?.get?.()) },
142
140
  });
141
+ void recoverCommitJournals(io, home, Date.now(), skillsRoot(), async (name) => {
142
+ const result = await skills.restoreFromArchive(name);
143
+ return result.ok;
144
+ }).then((reports) => { if (reports.length > 0)
145
+ ctx.logger.info('merge commit recovery: ' + reports.map(report => report.runId + '=' + report.action).join(', ')); }).catch((error) => { ctx.logger.warn('merge commit recovery failed: ' + (error instanceof Error ? error.message : String(error))); });
143
146
  const channel = subagentChannelAdapter(createSubagentChannel(ctx));
144
147
  const stage = new StageToolRegistry();
145
148
  const engineConfig = engineConfigFromEnv(params.mergeModelsRoot);
@@ -95,7 +95,7 @@ export function createMergeRuntime(deps) {
95
95
  const managed = new Set(cards.filter(card => card.managed === true).map(card => card.name));
96
96
  const loadedState = await loadMergeState(deps.io, deps.home);
97
97
  const skipped = skippedPairKeys(loadedState.state.rows, deps.params.mergeSkippedTtlDays, deps.now());
98
- const held = new Set(loadedState.state.rows.filter(row => row.queue === 'pending' || !canRetry(row)).map(row => row.pairKey));
98
+ const held = new Set(loadedState.state.rows.filter(row => row.queue === 'pending' || row.queue === 'fuse' || !canRetry(row)).map(row => row.pairKey));
99
99
  const conflictNotes = loadedState.state.rows
100
100
  .filter(row => row.state === 'pending' || row.state === 'approved' || row.state === 'fuse' || row.state === 'skipped')
101
101
  .map(row => 'conflict:' + row.state + ':' + row.pairKey + ':' + row.id);
@@ -65,7 +65,9 @@ export async function runScan(options) {
65
65
  if (!created.ok || created.row === null)
66
66
  throw new Error(created.ok ? 'candidate row missing' : created.message);
67
67
  row = created.row;
68
- await upsertRow(options.io, options.home, options.now, row);
68
+ const placed = await upsertRow(options.io, options.home, options.now, row, 'verify');
69
+ if (!placed.ok)
70
+ throw new Error('pair already in ' + placed.current.state);
69
71
  }
70
72
  catch (error) {
71
73
  totals.failed += 1;
@@ -62,7 +62,7 @@ export async function upsertRow(io, home, now, row, expect = 'any') {
62
62
  if (current !== undefined && expect !== 'any' && (expect === 'absent' || queueOf(current.state) !== expect)) {
63
63
  return { rows, result: { ok: false, current } };
64
64
  }
65
- if (current === undefined && expect !== 'any' && expect !== 'absent') {
65
+ if (current === undefined && expect !== 'any' && expect !== 'absent' && expect !== 'verify') {
66
66
  return { rows, result: { ok: false, current: row } };
67
67
  }
68
68
  const normalized = { ...row, queue: queueOf(row.state) };
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.2",
4
+ "version": "0.30.3",
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.2",
31
- "@lmzhen/dsh-evolution-merge-engine": "^0.30.2"
30
+ "@lmzhen/dsh-evolution-core": "^0.30.3",
31
+ "@lmzhen/dsh-evolution-merge-engine": "^0.30.3"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "@deepseek-ai/cordis": "^4.0.5"