@lmzhen/dsh-evolution-curator 0.3.81 → 0.3.83

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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # @deepseek-ai/dsh-evolution-curator
1
+ # @lmzhen/dsh-evolution-curator
2
2
 
3
3
  Deterministic skill lifecycle and recovery
4
4
 
@@ -8,7 +8,7 @@ Deterministic skill lifecycle and recovery
8
8
 
9
9
  #### What the model sees
10
10
 
11
- `@deepseek-ai/dsh-evolution-curator` registers no direct prompt or tool schema itself. Model-visible effects are owned by the packages that consume this service.
11
+ `@lmzhen/dsh-evolution-curator` registers no direct prompt or tool schema itself. Model-visible effects are owned by the packages that consume this service.
12
12
 
13
13
  #### Token effect
14
14
 
package/lib/index.js CHANGED
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { join } from "node:path";
4
4
  import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
5
5
  import z from "@deepseek-ai/schemastery";
6
- import { CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_CURATOR_BOOT_GRACE_SECONDS, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_CURATOR_MODEL, DEFAULT_CURATOR_REVIEW_MAX_TOKENS, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_MIN_IDLE_HOURS, DEFAULT_STALE_AFTER_DAYS, EvolutionGateSet, INSTANCE_KEYS, MAX_TIMER_DELAY_MS, SKILL_NAME_RE, buildCuratorRunReport, claimInstance, clampedNumber, computeDedupGroups, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, emptyRecord, evolutionHome, evolutionIoAdapter, foldCuratorFields, isPresent, isUnknown, loadSuppressedNames, loadUsage, markerEntryName, mutateUsage, newSkillLibrary, parseCuratorNominations, parseFrontmatter, probeList, probeMtime, relatedSkillNames, releaseInstance, renderCuratorReportMarkdown, updateSuppressedNames, usageObserved } from "@lmzhen/dsh-evolution-core";
6
+ import { CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_CURATOR_BOOT_GRACE_SECONDS, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_CURATOR_MODEL, DEFAULT_CURATOR_REVIEW_MAX_TOKENS, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_MIN_IDLE_HOURS, DEFAULT_STALE_AFTER_DAYS, EvolutionGateSet, INSTANCE_KEYS, MAX_TIMER_DELAY_MS, SKILL_NAME_RE, buildCuratorRunReport, claimInstance, clampedNumber, computeDedupGroups, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, emptyRecord, evolutionHome, evolutionIoAdapter, foldCuratorFields, isPresent, isUnknown, loadSuppressedNames, loadUsage, markerEntryName, mutateUsage, newSkillLibrary, parseCuratorNominations, parseFrontmatter, probeList, probeMtime, relatedSkillNames, releaseInstance, renderCuratorReportMarkdown, transactIo, updateSuppressedNames, usageObserved } from "@lmzhen/dsh-evolution-core";
7
7
  //#region lib/types/index.js
8
8
  /**
9
9
  * Deterministic skill lifecycle curator with interval gate and archive.
@@ -96,7 +96,9 @@ var EvolutionCurator = class extends Service {
96
96
  /** P2-5 (v14): one-shot warning that the interval baseline is process-only. */
97
97
  statelessStateWarned = false;
98
98
  /** B3 / G4: the home this instance claimed, plus whether the claim was
99
- * granted. A non-owning instance schedules nothing and runs nothing. */
99
+ * granted. A non-owning instance schedules nothing and runs nothing. The
100
+ * claim is PER PROCESS (v43 FLOW2-1): another process's holder is invisible
101
+ * here, so `holdsInstance` never proves this instance is alone on the home. */
100
102
  instanceHome = "";
101
103
  holdsInstance = false;
102
104
  /** B3 / G4: this instance's identity in the claim — pid + short token, so the
@@ -114,7 +116,7 @@ var EvolutionCurator = class extends Service {
114
116
  this.instanceHome = evolutionHome();
115
117
  const claim = claimInstance(this.instanceHome, INSTANCE_KEYS.curator, this.instanceOwner);
116
118
  this.holdsInstance = claim.granted;
117
- if (!claim.granted) this.ctx.logger.warn(`evolution-curator: this instance YIELDS — ${claim.key} is already held by ${claim.holder}; a second curator over one home would race the report retention sweep. It schedules nothing and every run() returns skipped "instance-held".`);
119
+ if (!claim.granted) this.ctx.logger.warn(`evolution-curator: this instance YIELDS — ${claim.key} is already held by ${claim.holder} IN THIS PROCESS (the claim is a module-scope Map, so it does not exclude another process); a second curator ROW over one home would run a second gate set and a second lifecycle pass over the same tree. It schedules nothing and every run() returns skipped "instance-held"; the report sweep itself is cross-process locked (v43 FLOW2-1).`);
118
120
  const clamped = [];
119
121
  const field = (name, value, fallback, min, max) => {
120
122
  const result = clampedNumber(value, fallback, max === void 0 ? { min } : {
@@ -353,6 +355,7 @@ var EvolutionCurator = class extends Service {
353
355
  async restoreSnapshot() {
354
356
  const release = await this.acquireMutex();
355
357
  try {
358
+ if (!this.holdsInstance) return this.instanceHeldRefusal("restoreSnapshot");
356
359
  return await this.restoreSnapshotCore();
357
360
  } finally {
358
361
  release();
@@ -466,6 +469,27 @@ var EvolutionCurator = class extends Service {
466
469
  };
467
470
  return prev.then(() => release);
468
471
  }
472
+ /**
473
+ * PLAN S4.5 (2026-09-16) (audit P2-16): the manual control-plane entries
474
+ * (restore/consolidate/restoreSnapshot) honor the same per-home instance
475
+ * claim `run()` checks before every pass. The claim is taken at MOUNT and
476
+ * released at DISPOSE (core/instance-scope.ts), so `holdsInstance` is
477
+ * stable for this instance's lifetime: a single-instance deployment always
478
+ * holds it and every manual entry behaves exactly as before. The refusal
479
+ * fires only on a YIELDING second row of one process, whose manual mutation
480
+ * would otherwise race the holder row's in-flight run over the same tree —
481
+ * this instance's control-plane mutex cannot see the holder's chain. The
482
+ * shape is the plain `SkillActionResult` failure the command face already
483
+ * renders through `err(result.message)` — no new error type — and the
484
+ * leading `instance-held` token names the same outcome run() reports via
485
+ * its `skipped` field.
486
+ */
487
+ instanceHeldRefusal(operation) {
488
+ return {
489
+ ok: false,
490
+ message: `instance-held: this curator row yielded the per-home claim to another curator row of this process — ${operation} is refused alongside run() so the holder row stays the only control-plane writer (run it on the holder row)`
491
+ };
492
+ }
469
493
  async runCore(options = {}) {
470
494
  const { ignoreGates = false, dryRun = false } = options;
471
495
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -496,13 +520,13 @@ var EvolutionCurator = class extends Service {
496
520
  };
497
521
  if (!ignoreGates && persisted === null && (stateService !== void 0 || !this.statelessFirstRunDeferred)) {
498
522
  if (stateService) try {
499
- await stateService.saveCuratorState({
523
+ await stateService.transactCuratorState((current) => current === null ? {
500
524
  schemaVersion: 1,
501
525
  lastRunAt: Date.now(),
502
526
  runCount: 0,
503
527
  lastSummary: "first-run-deferred",
504
528
  paused: false
505
- });
529
+ } : null);
506
530
  } catch (error) {
507
531
  this.ctx.logger.warn(`evolution-curator: failed to persist the first-run baseline: ${error instanceof Error ? error.message : String(error)}`);
508
532
  }
@@ -545,7 +569,7 @@ var EvolutionCurator = class extends Service {
545
569
  if (text) contents.set(name, text);
546
570
  }
547
571
  const protectedNames = await this.protectedNameMap();
548
- const dedupMembers = [...new Set(computeDedupGroups({ contents }).filter((group) => group.length >= 2).flat())].filter((name) => !protectedNames.has(name));
572
+ const dedupMembers = [...new Set(computeDedupGroups({ contents }).groups.filter((group) => group.length >= 2).flat())].filter((name) => !protectedNames.has(name));
549
573
  await this.scoreTree(usage, treeNames, contents);
550
574
  const result = computeLifecycleTransitions(usage, {
551
575
  staleAfterDays: lifecycle.staleAfterDays,
@@ -934,8 +958,26 @@ var EvolutionCurator = class extends Service {
934
958
  * (`curator-error-*.json`) are BUDGETED INDEPENDENTLY. Before this they shared
935
959
  * one keep-20 window, so after 25 consecutive failures the next successful
936
960
  * run's window held only a few real reports alongside the errors.
961
+ *
962
+ * v43 FLOW2-1 (P1): the sweep runs inside the IO backend's cross-process write
963
+ * lock. The inventory used to declare this site as serialized by the per-home
964
+ * instance claim, which is a module-scope Map (core/instance-scope.ts): a
965
+ * second ROW of this process yields, but a second PROCESS over the same home
966
+ * was never excluded — and "list the directory, then delete beyond the window"
967
+ * is exactly the multi-step shape that needs a real lock. A backend without
968
+ * `transact` has no lock at all: the sweep then degrades to the previous
969
+ * best-effort pass, which is safe because every deletion is idempotent and
970
+ * each report name is unique per runId.
937
971
  */
938
972
  async retainReports(keep = 20, errorKeep = 10) {
973
+ await transactIo(this.io, reportsSweepLockTarget(), async () => {
974
+ await this.sweepReports(keep, errorKeep);
975
+ return null;
976
+ });
977
+ }
978
+ /** The sweep `retainReports` holds the per-home lock for. A caller MUST hold
979
+ * that lock: the listing and the deletions are not atomic on their own. */
980
+ async sweepReports(keep, errorKeep) {
939
981
  const reportsRoot = join(evolutionHome(), "reports");
940
982
  const listed = await probeList(this.io, reportsRoot);
941
983
  if (isUnknown(listed)) {
@@ -1091,6 +1133,7 @@ var EvolutionCurator = class extends Service {
1091
1133
  async consolidate(target, sources) {
1092
1134
  const release = await this.acquireMutex();
1093
1135
  try {
1136
+ if (!this.holdsInstance) return this.instanceHeldRefusal("consolidate");
1094
1137
  return await this.consolidateMutate(target, sources);
1095
1138
  } finally {
1096
1139
  release();
@@ -1135,6 +1178,7 @@ var EvolutionCurator = class extends Service {
1135
1178
  async restore(name) {
1136
1179
  const release = await this.acquireMutex();
1137
1180
  try {
1181
+ if (!this.holdsInstance) return this.instanceHeldRefusal("restore");
1138
1182
  return await this.restoreMutate(name);
1139
1183
  } finally {
1140
1184
  release();
@@ -1167,5 +1211,17 @@ var EvolutionCurator = class extends Service {
1167
1211
  return result;
1168
1212
  }
1169
1213
  };
1214
+ /**
1215
+ * v43 FLOW2-1: the per-home lock target for the report retention sweep.
1216
+ * `transactIo` takes the IO write lock on THIS path (minting `<path>.lock` with
1217
+ * the io protocol's `pid:token` body) and holds it for the whole task; nothing
1218
+ * ever writes the target, because the sweep's exclusion is the point. It lives
1219
+ * inside `<home>/reports` next to the directory it protects: the sweep's own
1220
+ * name filter (`curator-*.json`) ignores it, and the lock file is removed on
1221
+ * release.
1222
+ */
1223
+ function reportsSweepLockTarget() {
1224
+ return join(evolutionHome(), "reports", ".retention");
1225
+ }
1170
1226
  //#endregion
1171
1227
  export { EvolutionCurator, EvolutionCurator as default, gateConsolidations };
@@ -131,7 +131,9 @@ export declare class EvolutionCurator extends Service {
131
131
  /** P2-5 (v14): one-shot warning that the interval baseline is process-only. */
132
132
  private statelessStateWarned;
133
133
  /** B3 / G4: the home this instance claimed, plus whether the claim was
134
- * granted. A non-owning instance schedules nothing and runs nothing. */
134
+ * granted. A non-owning instance schedules nothing and runs nothing. The
135
+ * claim is PER PROCESS (v43 FLOW2-1): another process's holder is invisible
136
+ * here, so `holdsInstance` never proves this instance is alone on the home. */
135
137
  private instanceHome;
136
138
  private holdsInstance;
137
139
  /** B3 / G4: this instance's identity in the claim — pid + short token, so the
@@ -241,6 +243,22 @@ export declare class EvolutionCurator extends Service {
241
243
  private mutexDepth;
242
244
  private mutexTail;
243
245
  private acquireMutex;
246
+ /**
247
+ * PLAN S4.5 (2026-09-16) (audit P2-16): the manual control-plane entries
248
+ * (restore/consolidate/restoreSnapshot) honor the same per-home instance
249
+ * claim `run()` checks before every pass. The claim is taken at MOUNT and
250
+ * released at DISPOSE (core/instance-scope.ts), so `holdsInstance` is
251
+ * stable for this instance's lifetime: a single-instance deployment always
252
+ * holds it and every manual entry behaves exactly as before. The refusal
253
+ * fires only on a YIELDING second row of one process, whose manual mutation
254
+ * would otherwise race the holder row's in-flight run over the same tree —
255
+ * this instance's control-plane mutex cannot see the holder's chain. The
256
+ * shape is the plain `SkillActionResult` failure the command face already
257
+ * renders through `err(result.message)` — no new error type — and the
258
+ * leading `instance-held` token names the same outcome run() reports via
259
+ * its `skipped` field.
260
+ */
261
+ private instanceHeldRefusal;
244
262
  private runCore;
245
263
  /**
246
264
  * Seed baseline records for tree skills the sidecar has not seen yet, so
@@ -283,8 +301,21 @@ export declare class EvolutionCurator extends Service {
283
301
  * (`curator-error-*.json`) are BUDGETED INDEPENDENTLY. Before this they shared
284
302
  * one keep-20 window, so after 25 consecutive failures the next successful
285
303
  * run's window held only a few real reports alongside the errors.
304
+ *
305
+ * v43 FLOW2-1 (P1): the sweep runs inside the IO backend's cross-process write
306
+ * lock. The inventory used to declare this site as serialized by the per-home
307
+ * instance claim, which is a module-scope Map (core/instance-scope.ts): a
308
+ * second ROW of this process yields, but a second PROCESS over the same home
309
+ * was never excluded — and "list the directory, then delete beyond the window"
310
+ * is exactly the multi-step shape that needs a real lock. A backend without
311
+ * `transact` has no lock at all: the sweep then degrades to the previous
312
+ * best-effort pass, which is safe because every deletion is idempotent and
313
+ * each report name is unique per runId.
286
314
  */
287
315
  private retainReports;
316
+ /** The sweep `retainReports` holds the per-home lock for. A caller MUST hold
317
+ * that lock: the listing and the deletions are not atomic on their own. */
318
+ private sweepReports;
288
319
  /** Remove the oldest reports beyond `keep` (each with its `.md` digest, best-effort). */
289
320
  private pruneReportList;
290
321
  latestReport(): Promise<CuratorRunReport | null>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-curator",
3
3
  "description": "Deterministic skill lifecycle and recovery (community build)",
4
- "version": "0.3.81",
4
+ "version": "0.3.83",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -27,20 +27,20 @@
27
27
  "license": "MIT",
28
28
  "dependencies": {
29
29
  "@deepseek-ai/schemastery": "^3.18.1",
30
- "@lmzhen/dsh-evolution-core": "^0.3.81"
30
+ "@lmzhen/dsh-evolution-core": "^0.3.83"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "@deepseek-ai/cordis": "^4.0.1",
34
34
  "@deepseek-ai/dsh-llm": "^0.1.5-rc.2",
35
35
  "@deepseek-ai/dsh-session": "^0.1.5-rc.2",
36
- "@lmzhen/dsh-evolution-io": "^0.3.81",
37
- "@lmzhen/dsh-evolution-state": "^0.3.81"
36
+ "@lmzhen/dsh-evolution-io": "^0.3.83",
37
+ "@lmzhen/dsh-evolution-state": "^0.3.83"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@deepseek-ai/dsh-llm": "^0.1.5-rc.2",
41
41
  "@deepseek-ai/dsh-session": "^0.1.5-rc.2",
42
- "@lmzhen/dsh-evolution-core": "^0.3.81",
43
- "@lmzhen/dsh-evolution-io": "^0.3.81",
44
- "@lmzhen/dsh-evolution-state": "^0.3.81"
42
+ "@lmzhen/dsh-evolution-core": "^0.3.83",
43
+ "@lmzhen/dsh-evolution-io": "^0.3.83",
44
+ "@lmzhen/dsh-evolution-state": "^0.3.83"
45
45
  }
46
46
  }