@lmzhen/dsh-evolution-merge 0.30.10 → 0.30.11

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
@@ -3,7 +3,7 @@ import z from "@deepseek-ai/schemastery";
3
3
  import { pairKeyOf, screen, selectCandidates, toEngineCandidate } from "@lmzhen/dsh-evolution-merge-engine";
4
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
- import { createHash } from "node:crypto";
6
+ import { createHash, randomUUID } from "node:crypto";
7
7
  //#region lib/types/adapters/select.js
8
8
  /** Host selector correction: engine selector does not route judge errors to candidates. */
9
9
  function selectWithErrors(report) {
@@ -799,7 +799,18 @@ async function recoverCommitJournals(io, home, now, skillRoot, restoreSource, is
799
799
  });
800
800
  continue;
801
801
  }
802
- const snapshot = await readCommitSnapshot(io, home, journal.runId);
802
+ let snapshot;
803
+ try {
804
+ snapshot = await readCommitSnapshot(io, home, journal.runId);
805
+ } catch (error) {
806
+ reports.push({
807
+ runId: journal.runId,
808
+ phase: journal.phase,
809
+ action: "snapshot-read-failed: " + (error instanceof Error ? error.message : String(error)),
810
+ ok: false
811
+ });
812
+ continue;
813
+ }
803
814
  let action = "report-only";
804
815
  let ok = true;
805
816
  if (recovery === "retry-commit") action = "no-host-write";
@@ -2216,6 +2227,56 @@ async function rejectPending(io, home, now, id) {
2216
2227
  return await act(io, home, now, id, "reject");
2217
2228
  }
2218
2229
  //#endregion
2230
+ //#region lib/types/domain/claim.js
2231
+ function acquireClaim(current, now, leaseMs, token) {
2232
+ if (current !== void 0 && current.expiresAt > now) return void 0;
2233
+ return {
2234
+ id: "claim",
2235
+ token,
2236
+ expiresAt: now + Math.max(1, leaseMs)
2237
+ };
2238
+ }
2239
+ function releaseClaim(current, token) {
2240
+ return current?.token === token ? void 0 : current;
2241
+ }
2242
+ //#endregion
2243
+ //#region lib/types/target-claim.js
2244
+ /** Durable target claim: one approve writer per merge target at a time. */
2245
+ const LEASE_MS = 5 * 6e4;
2246
+ function claimPath(home, target) {
2247
+ return join(home, "merge", "claims", contentHash(target) + ".json");
2248
+ }
2249
+ async function readClaim(io, home, target) {
2250
+ const path = claimPath(home, target);
2251
+ if (!await io.exists(path)) return void 0;
2252
+ const raw = await io.readText(path);
2253
+ if (raw === null) return void 0;
2254
+ const parsed = JSON.parse(raw);
2255
+ if (typeof parsed.token !== "string" || typeof parsed.expiresAt !== "number") throw new Error("corrupt target claim: " + target);
2256
+ return {
2257
+ id: "claim",
2258
+ token: parsed.token,
2259
+ expiresAt: parsed.expiresAt
2260
+ };
2261
+ }
2262
+ async function acquireTargetClaim(io, home, target, now) {
2263
+ const current = await readClaim(io, home, target);
2264
+ const token = randomUUID();
2265
+ const claim = acquireClaim(current, now, LEASE_MS, token);
2266
+ if (claim === void 0) return null;
2267
+ await io.writeText(claimPath(home, target), JSON.stringify(claim) + "\n");
2268
+ return {
2269
+ token,
2270
+ async release() {
2271
+ const next = releaseClaim(await readClaim(io, home, target), token);
2272
+ if (next === void 0) try {
2273
+ await io.remove(claimPath(home, target));
2274
+ } catch {}
2275
+ else await io.writeText(claimPath(home, target), JSON.stringify(next) + "\n");
2276
+ }
2277
+ };
2278
+ }
2279
+ //#endregion
2219
2280
  //#region lib/types/round2-outcome.js
2220
2281
  async function recordRound2Outcome(io, home, now, row, outcome) {
2221
2282
  const at = new Date(now()).toISOString();
@@ -2289,79 +2350,89 @@ async function recordRound2Outcome(io, home, now, row, outcome) {
2289
2350
  //#endregion
2290
2351
  //#region lib/types/approve-command.js
2291
2352
  async function runApproveCommand(options) {
2292
- const taken = await approvePending(options.io, options.home, options.now, options.rowId);
2293
- if (!taken.ok) return {
2353
+ const target = (await loadMergeState(options.io, options.home)).state.rows.find((item) => item.id === options.rowId)?.decision?.target;
2354
+ const claim = target === void 0 ? null : await acquireTargetClaim(options.io, options.home, target, options.now());
2355
+ if (target !== void 0 && claim === null) return {
2294
2356
  ok: false,
2295
- message: taken.message
2357
+ message: "target already claimed by another approve: " + target
2296
2358
  };
2297
- const row = taken.row;
2298
- const decision = row.decision;
2299
- if (decision === void 0) return {
2300
- ok: false,
2301
- message: "pending row has no decision"
2302
- };
2303
- return await withMergeRun({
2304
- io: options.io,
2305
- home: options.home,
2306
- registry: options.registry,
2307
- command: "approve",
2308
- approval: {
2309
- platformWrite: options.platformWrite,
2310
- mergeApproval: true
2311
- },
2312
- now: options.now,
2313
- async body(ctx) {
2314
- const stage = (name) => ctx.stage(name, {
2315
- done: 0,
2316
- total: 1
2317
- });
2318
- await stage("author");
2319
- let outcome;
2320
- try {
2321
- outcome = await options.ports.round2(row, decision, ctx.signal, ctx.runId);
2322
- } catch (error) {
2323
- outcome = {
2324
- kind: "failed",
2325
- failure: {
2326
- stage: "author",
2327
- code: "merge-failed",
2328
- message: error instanceof Error ? error.message : String(error),
2329
- retryable: true,
2330
- retryFrom: "agent2"
2331
- }
2359
+ try {
2360
+ const taken = await approvePending(options.io, options.home, options.now, options.rowId);
2361
+ if (!taken.ok) return {
2362
+ ok: false,
2363
+ message: taken.message
2364
+ };
2365
+ const row = taken.row;
2366
+ const decision = row.decision;
2367
+ if (decision === void 0) return {
2368
+ ok: false,
2369
+ message: "pending row has no decision"
2370
+ };
2371
+ return await withMergeRun({
2372
+ io: options.io,
2373
+ home: options.home,
2374
+ registry: options.registry,
2375
+ command: "approve",
2376
+ approval: {
2377
+ platformWrite: options.platformWrite,
2378
+ mergeApproval: true
2379
+ },
2380
+ now: options.now,
2381
+ async body(ctx) {
2382
+ const stage = (name) => ctx.stage(name, {
2383
+ done: 0,
2384
+ total: 1
2385
+ });
2386
+ await stage("author");
2387
+ let outcome;
2388
+ try {
2389
+ outcome = await options.ports.round2(row, decision, ctx.signal, ctx.runId);
2390
+ } catch (error) {
2391
+ outcome = {
2392
+ kind: "failed",
2393
+ failure: {
2394
+ stage: "author",
2395
+ code: "merge-failed",
2396
+ message: error instanceof Error ? error.message : String(error),
2397
+ retryable: true,
2398
+ retryFrom: "agent2"
2399
+ }
2400
+ };
2401
+ }
2402
+ const base = {
2403
+ scope: {
2404
+ candidatePairs: 1,
2405
+ queueBefore: {
2406
+ verify: 0,
2407
+ pending: 0,
2408
+ fuse: 1,
2409
+ skipped: 0
2410
+ }
2411
+ },
2412
+ notes: []
2332
2413
  };
2333
- }
2334
- const base = {
2335
- scope: {
2336
- candidatePairs: 1,
2337
- queueBefore: {
2338
- verify: 0,
2414
+ const record = await recordRound2Outcome(options.io, options.home, options.now, row, outcome);
2415
+ return {
2416
+ ...base,
2417
+ stopReason: record.merged ? "completed" : "failed",
2418
+ pairs: [record.pair],
2419
+ totals: record.merged ? {
2420
+ merged: 1,
2421
+ kept: 0,
2339
2422
  pending: 0,
2340
- fuse: 1,
2341
- skipped: 0
2423
+ failed: 0
2424
+ } : {
2425
+ merged: 0,
2426
+ kept: 0,
2427
+ pending: 0,
2428
+ failed: 1
2342
2429
  }
2343
- },
2344
- notes: []
2345
- };
2346
- const record = await recordRound2Outcome(options.io, options.home, options.now, row, outcome);
2347
- return {
2348
- ...base,
2349
- stopReason: record.merged ? "completed" : "failed",
2350
- pairs: [record.pair],
2351
- totals: record.merged ? {
2352
- merged: 1,
2353
- kept: 0,
2354
- pending: 0,
2355
- failed: 0
2356
- } : {
2357
- merged: 0,
2358
- kept: 0,
2359
- pending: 0,
2360
- failed: 1
2361
- }
2362
- };
2363
- }
2364
- });
2430
+ };
2431
+ }
2432
+ });
2433
+ } finally {
2434
+ await claim?.release();
2435
+ }
2365
2436
  }
2366
2437
  //#endregion
2367
2438
  //#region lib/types/resume-command.js
@@ -3709,6 +3780,10 @@ function createMergeRuntime(deps) {
3709
3780
  ok: false,
3710
3781
  text: "merge pair 不接受受保护/保护状态未知技能: " + command.a + " / " + command.b
3711
3782
  };
3783
+ if (deps.isBlocked?.(command.a) === true || deps.isBlocked?.(command.b) === true) return {
3784
+ ok: false,
3785
+ text: "merge pair 不接受被范围门拦下的技能: " + command.a + " / " + command.b
3786
+ };
3712
3787
  const pair = [{
3713
3788
  a: command.a,
3714
3789
  b: command.b
@@ -3775,7 +3850,7 @@ function createMergeRuntime(deps) {
3775
3850
  if (command.verb === "scan") {
3776
3851
  const signal = new AbortController().signal;
3777
3852
  const cards = await deps.cards(signal);
3778
- const managed = new Set(cards.filter((card) => card.managed === true && card.protectedBy == null).map((card) => card.name));
3853
+ const managed = new Set(cards.filter((card) => card.managed === true && card.protectedBy == null && deps.isBlocked?.(card.name) !== true).map((card) => card.name));
3779
3854
  const loadedState = await loadMergeState(deps.io, deps.home);
3780
3855
  const skipped = skippedPairKeys(loadedState.state.rows, deps.params.mergeSkippedTtlDays, deps.now());
3781
3856
  const held = new Set(loadedState.state.rows.filter((row) => row.queue === "pending" || row.queue === "fuse" || row.queue === "verify" && !canRetry(row)).map((row) => row.pairKey));
@@ -4713,6 +4788,9 @@ function apply(ctx, rawConfig = {}) {
4713
4788
  parent: void 0,
4714
4789
  allow: ["merge_stage"],
4715
4790
  timeoutMinutes: params.mergeRunTimeoutMinutes,
4791
+ isBlocked: (name) => {
4792
+ return ctx.get("evolutionCurator")?.gates?.isBlocked?.(name) === true;
4793
+ },
4716
4794
  async cards(signal) {
4717
4795
  const listed = await skills.list({ withContent: true });
4718
4796
  if (signal.aborted) return [];
@@ -1,42 +1,55 @@
1
1
  import { approvePending } from "./pending-actions.js";
2
+ import { loadMergeState } from "./state.js";
3
+ import { acquireTargetClaim } from "./target-claim.js";
2
4
  import { withMergeRun } from "./run-shell.js";
3
5
  import { recordRound2Outcome } from "./round2-outcome.js";
4
6
  export async function runApproveCommand(options) {
5
- const taken = await approvePending(options.io, options.home, options.now, options.rowId);
6
- if (!taken.ok)
7
- return { ok: false, message: taken.message };
8
- const row = taken.row;
9
- const decision = row.decision;
10
- if (decision === undefined)
11
- return { ok: false, message: 'pending row has no decision' };
12
- return await withMergeRun({
13
- io: options.io,
14
- home: options.home,
15
- registry: options.registry,
16
- command: 'approve',
17
- approval: { platformWrite: options.platformWrite, mergeApproval: true },
18
- now: options.now,
19
- async body(ctx) {
20
- const stage = (name) => ctx.stage(name, { done: 0, total: 1 });
21
- await stage('author');
22
- let outcome;
23
- try {
24
- outcome = await options.ports.round2(row, decision, ctx.signal, ctx.runId);
25
- }
26
- catch (error) {
27
- outcome = { kind: 'failed', failure: { stage: 'author', code: 'merge-failed', message: error instanceof Error ? error.message : String(error), retryable: true, retryFrom: 'agent2' } };
28
- }
29
- const base = { scope: { candidatePairs: 1, queueBefore: { verify: 0, pending: 0, fuse: 1, skipped: 0 } }, notes: [] };
30
- const record = await recordRound2Outcome(options.io, options.home, options.now, row, outcome);
31
- return {
32
- ...base,
33
- stopReason: record.merged ? 'completed' : 'failed',
34
- pairs: [record.pair],
35
- totals: record.merged
36
- ? { merged: 1, kept: 0, pending: 0, failed: 0 }
37
- : { merged: 0, kept: 0, pending: 0, failed: 1 },
38
- };
39
- },
40
- });
7
+ const loaded = await loadMergeState(options.io, options.home);
8
+ const before = loaded.state.rows.find(item => item.id === options.rowId);
9
+ const target = before?.decision?.target;
10
+ const claim = target === undefined ? null : await acquireTargetClaim(options.io, options.home, target, options.now());
11
+ if (target !== undefined && claim === null)
12
+ return { ok: false, message: 'target already claimed by another approve: ' + target };
13
+ try {
14
+ const taken = await approvePending(options.io, options.home, options.now, options.rowId);
15
+ if (!taken.ok)
16
+ return { ok: false, message: taken.message };
17
+ const row = taken.row;
18
+ const decision = row.decision;
19
+ if (decision === undefined)
20
+ return { ok: false, message: 'pending row has no decision' };
21
+ return await withMergeRun({
22
+ io: options.io,
23
+ home: options.home,
24
+ registry: options.registry,
25
+ command: 'approve',
26
+ approval: { platformWrite: options.platformWrite, mergeApproval: true },
27
+ now: options.now,
28
+ async body(ctx) {
29
+ const stage = (name) => ctx.stage(name, { done: 0, total: 1 });
30
+ await stage('author');
31
+ let outcome;
32
+ try {
33
+ outcome = await options.ports.round2(row, decision, ctx.signal, ctx.runId);
34
+ }
35
+ catch (error) {
36
+ outcome = { kind: 'failed', failure: { stage: 'author', code: 'merge-failed', message: error instanceof Error ? error.message : String(error), retryable: true, retryFrom: 'agent2' } };
37
+ }
38
+ const base = { scope: { candidatePairs: 1, queueBefore: { verify: 0, pending: 0, fuse: 1, skipped: 0 } }, notes: [] };
39
+ const record = await recordRound2Outcome(options.io, options.home, options.now, row, outcome);
40
+ return {
41
+ ...base,
42
+ stopReason: record.merged ? 'completed' : 'failed',
43
+ pairs: [record.pair],
44
+ totals: record.merged
45
+ ? { merged: 1, kept: 0, pending: 0, failed: 0 }
46
+ : { merged: 0, kept: 0, pending: 0, failed: 1 },
47
+ };
48
+ },
49
+ });
50
+ }
51
+ finally {
52
+ await claim?.release();
53
+ }
41
54
  }
42
55
  //# sourceMappingURL=approve-command.js.map
@@ -42,7 +42,14 @@ export async function recoverCommitJournals(io, home, now, skillRoot, restoreSou
42
42
  reports.push({ runId: journal.runId, phase: journal.phase, action: 'receipt-committed', ok: true });
43
43
  continue;
44
44
  }
45
- const snapshot = await readCommitSnapshot(io, home, journal.runId);
45
+ let snapshot;
46
+ try {
47
+ snapshot = await readCommitSnapshot(io, home, journal.runId);
48
+ }
49
+ catch (error) {
50
+ reports.push({ runId: journal.runId, phase: journal.phase, action: 'snapshot-read-failed: ' + (error instanceof Error ? error.message : String(error)), ok: false });
51
+ continue;
52
+ }
46
53
  let action = 'report-only';
47
54
  let ok = true;
48
55
  if (recovery === 'retry-commit') {
@@ -218,6 +218,10 @@ export function apply(ctx, rawConfig = {}) {
218
218
  const runtime = createMergeRuntime({
219
219
  io, home, registry: runs, skills, now: () => Date.now(), params, platformWrite: true,
220
220
  channel, parent: undefined, allow: ['merge_stage'], timeoutMinutes: params.mergeRunTimeoutMinutes,
221
+ isBlocked: (name) => {
222
+ const curator = ctx.get('evolutionCurator');
223
+ return curator?.gates?.isBlocked?.(name) === true;
224
+ },
221
225
  async cards(signal) {
222
226
  const listed = await skills.list({ withContent: true });
223
227
  if (signal.aborted)
@@ -31,6 +31,8 @@ export interface MergeRuntimeDeps {
31
31
  /** Start scan/pair in the background and return the run id immediately. */
32
32
  readonly detached?: boolean;
33
33
  readonly cards: (signal: AbortSignal) => Promise<readonly SkillCard[]>;
34
+ /** Curator gate set: excluded/referenced/suppressed/protected builtins. */
35
+ readonly isBlocked?: (name: string) => boolean;
34
36
  /** Run-scoped engine screening. */
35
37
  readonly screeningForRun: (cards: readonly SkillCard[], runId: string, signal: AbortSignal) => Promise<readonly MergeCandidate[]>;
36
38
  readonly stage?: MergeStageBindingPort;
@@ -59,6 +59,9 @@ export function createMergeRuntime(deps) {
59
59
  || cardA.protectionUnknown === true || cardB.protectionUnknown === true) {
60
60
  return { ok: false, text: 'merge pair 不接受受保护/保护状态未知技能: ' + command.a + ' / ' + command.b };
61
61
  }
62
+ if (deps.isBlocked?.(command.a) === true || deps.isBlocked?.(command.b) === true) {
63
+ return { ok: false, text: 'merge pair 不接受被范围门拦下的技能: ' + command.a + ' / ' + command.b };
64
+ }
62
65
  const pair = [{ a: command.a, b: command.b }];
63
66
  const existing = (await loadMergeState(deps.io, deps.home)).state.rows.find(row => row.pairKey === pairKey(command.a, command.b));
64
67
  if (existing?.state === 'skipped') {
@@ -101,7 +104,9 @@ export function createMergeRuntime(deps) {
101
104
  if (command.verb === 'scan') {
102
105
  const signal = new AbortController().signal;
103
106
  const cards = await deps.cards(signal);
104
- const managed = new Set(cards.filter(card => card.managed === true && card.protectedBy == null).map(card => card.name));
107
+ const managed = new Set(cards
108
+ .filter(card => card.managed === true && card.protectedBy == null && deps.isBlocked?.(card.name) !== true)
109
+ .map(card => card.name));
105
110
  const loadedState = await loadMergeState(deps.io, deps.home);
106
111
  const skipped = skippedPairKeys(loadedState.state.rows, deps.params.mergeSkippedTtlDays, deps.now());
107
112
  const held = new Set(loadedState.state.rows.filter(row => row.queue === 'pending' || row.queue === 'fuse' || (row.queue === 'verify' && !canRetry(row))).map(row => row.pairKey));
@@ -0,0 +1,7 @@
1
+ import { type EvolutionIoLike } from '@lmzhen/dsh-evolution-core';
2
+ export interface TargetClaim {
3
+ readonly token: string;
4
+ release(): Promise<void>;
5
+ }
6
+ export declare function acquireTargetClaim(io: EvolutionIoLike, home: string, target: string, now: number): Promise<TargetClaim | null>;
7
+ //# sourceMappingURL=target-claim.d.ts.map
@@ -0,0 +1,46 @@
1
+ /** Durable target claim: one approve writer per merge target at a time. */
2
+ import { randomUUID } from 'node:crypto';
3
+ import { join } from 'node:path';
4
+ import { contentHash } from '@lmzhen/dsh-evolution-core';
5
+ import { acquireClaim, releaseClaim } from "./domain/claim.js";
6
+ const LEASE_MS = 5 * 60_000;
7
+ function claimPath(home, target) {
8
+ return join(home, 'merge', 'claims', contentHash(target) + '.json');
9
+ }
10
+ async function readClaim(io, home, target) {
11
+ const path = claimPath(home, target);
12
+ if (!(await io.exists(path)))
13
+ return undefined;
14
+ const raw = await io.readText(path);
15
+ if (raw === null)
16
+ return undefined;
17
+ const parsed = JSON.parse(raw);
18
+ if (typeof parsed.token !== 'string' || typeof parsed.expiresAt !== 'number')
19
+ throw new Error('corrupt target claim: ' + target);
20
+ return { id: 'claim', token: parsed.token, expiresAt: parsed.expiresAt };
21
+ }
22
+ export async function acquireTargetClaim(io, home, target, now) {
23
+ const current = await readClaim(io, home, target);
24
+ const token = randomUUID();
25
+ const claim = acquireClaim(current, now, LEASE_MS, token);
26
+ if (claim === undefined)
27
+ return null;
28
+ await io.writeText(claimPath(home, target), JSON.stringify(claim) + '\n');
29
+ return {
30
+ token,
31
+ async release() {
32
+ const latest = await readClaim(io, home, target);
33
+ const next = releaseClaim(latest, token);
34
+ if (next === undefined) {
35
+ try {
36
+ await io.remove(claimPath(home, target));
37
+ }
38
+ catch { /* already gone */ }
39
+ }
40
+ else {
41
+ await io.writeText(claimPath(home, target), JSON.stringify(next) + '\n');
42
+ }
43
+ },
44
+ };
45
+ }
46
+ //# sourceMappingURL=target-claim.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.10",
4
+ "version": "0.30.11",
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.10",
31
- "@lmzhen/dsh-evolution-merge-engine": "^0.30.10"
30
+ "@lmzhen/dsh-evolution-core": "^0.30.11",
31
+ "@lmzhen/dsh-evolution-merge-engine": "^0.30.11"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "@deepseek-ai/cordis": "^4.0.5"