@lmzhen/dsh-evolution-curator 0.1.0-rc.4 → 0.1.0-rc.41

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
@@ -21,4 +21,17 @@ Independent of request-prefix construction. This package does not alter the asse
21
21
  ## Known Limitations and Deferred Work
22
22
 
23
23
 
24
- - - LLM nomination pass is advisory and disabled by default; deterministic lifecycle remains authoritative.
24
+ - LLM nomination pass is advisory and disabled by default; deterministic lifecycle remains authoritative.
25
+ - Consolidation is control-plane only (`/evolution consolidate <target> <source...>`): no LLM pass proposes merge groups yet, and merged source bodies are appended verbatim rather than rewritten into a synthesized skill.
26
+
27
+ ## Recovery and consolidation
28
+
29
+ - `archive` never deletes: skills move to `.archive/` with a `.archive-reason` marker.
30
+ - `restore(name)` (service) / `/evolution skill restore <name>` brings one archived skill back to the active root and resets its usage state.
31
+ - `consolidate(target, sources)` (service) / `/evolution consolidate` merges source bodies into the target, archives the sources with an absorbed-into marker, and folds their usage records into `archived` state. Both operations snapshot the full state first (`pre-consolidate` / `pre-restore`).
32
+ - `restoreSnapshot` (service) / `/evolution restore` rolls the FULL state back to the latest snapshot: active tree, usage/suppression sidecars, `.archive/` and the curator state carried in the snapshot (`curator-state.json`), so the interval gate does not immediately re-fire after a rollback. The restore itself is undoable — the pre-rollback safety snapshot preserves the current tree plus its state.
33
+
34
+ ## Automatic scheduling
35
+
36
+ - `autoStart` (default true) arms an hourly interval check plus a deferred catch-up check `bootGraceSeconds` (default 10) after host boot. Both decide due-ness from the **persisted** `lastRunAt`, so a restart with an overdue schedule runs the first pass within the boot grace instead of waiting a full interval. `bootGraceSeconds: 0` disables the deferral (not recommended: the check may run against a half-mounted host). All scheduling gates — interval, idle, first-run deferral, and the reentrancy guard — remain inside `run()`.
37
+ - `autoStart: false` disables both automatic checks; `/evolution curator run` (manual, gate-skipping) still works.
package/lib/index.js CHANGED
@@ -3,25 +3,39 @@ 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_PROMPT, SkillLibrary, buildCuratorRunReport, computeLifecycleTransitions, evolutionHome, evolutionIoAdapter, loadUsage, saveUsage } from "@lmzhen/dsh-evolution-core";
6
+ import { CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_MIN_IDLE_HOURS, DEFAULT_STALE_AFTER_DAYS, SKILL_NAME_RE, SkillLibrary, buildCuratorRunReport, computeLifecycleTransitions, computeQualityScores, computeScopeView, emptyRecord, evolutionHome, evolutionIoAdapter, loadSuppressedNames, loadUsage, parseCuratorNominations, parseFrontmatter, saveSuppressedNames, saveUsage } 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.
10
10
  * @module @lmzhen/dsh-evolution-curator
11
11
  */
12
+ /**
13
+ * Block LLM-nominated consolidations that would touch a gate-protected name:
14
+ * exclude / referenced / suppressed skills must never merge (neither as the
15
+ * source being archived nor as the umbrella being edited). Mirrors the control
16
+ * plane's `consolidate()` guard; automatic nominations must pass the same gate.
17
+ */
18
+ function gateConsolidations(consolidations, gates) {
19
+ const blocked = (name) => gates.exclude?.has(name) === true || gates.referenced?.has(name) === true || gates.suppressed?.has(name) === true;
20
+ return consolidations.filter((n) => !blocked(n.from) && !blocked(n.into));
21
+ }
12
22
  var EvolutionCurator = class extends Service {
13
23
  static inject = ["evolutionIo"];
14
24
  static Config = z.object({
15
25
  enabled: z.boolean().default(true),
16
- intervalHours: z.number().default(168),
17
- staleAfterDays: z.number().default(30),
18
- archiveAfterDays: z.number().default(90),
26
+ intervalHours: z.number().default(DEFAULT_CURATOR_INTERVAL_HOURS),
27
+ staleAfterDays: z.number().default(DEFAULT_STALE_AFTER_DAYS),
28
+ archiveAfterDays: z.number().default(DEFAULT_ARCHIVE_AFTER_DAYS),
19
29
  llmReview: z.boolean().default(false),
20
30
  curatorProvider: z.string().default("deepseek-official"),
21
31
  qualityWarnStaleAfterDays: z.number().default(7),
22
- minIdleHours: z.number().default(0),
32
+ minIdleHours: z.number().default(DEFAULT_MIN_IDLE_HOURS),
23
33
  excludeSkillNames: z.array(z.string()).default([]),
24
34
  manageUnmanaged: z.boolean().default(false),
35
+ pruneBuiltins: z.boolean().default(false),
36
+ referencedSkillNames: z.array(z.string()).default([]),
37
+ autoStart: z.boolean().default(true),
38
+ bootGraceSeconds: z.number().default(10),
25
39
  curatorReviewMaxTokens: z.number().default(2048)
26
40
  });
27
41
  skills;
@@ -36,23 +50,31 @@ var EvolutionCurator = class extends Service {
36
50
  minIdleHours;
37
51
  excludeSkillNames;
38
52
  manageUnmanaged;
53
+ pruneBuiltins;
54
+ referencedSkillNames;
55
+ bootGraceSeconds;
39
56
  curatorReviewMaxTokens;
40
57
  lastRun = 0;
41
58
  timer;
59
+ bootCheck;
60
+ running = false;
42
61
  constructor(ctx, config = {}) {
43
62
  super(ctx, "evolutionCurator");
44
63
  this.io = evolutionIoAdapter(() => ctx.evolutionIo.provider());
45
64
  this.skills = new SkillLibrary(void 0, this.io);
46
65
  this.enabled = config.enabled ?? true;
47
- this.intervalHours = config.intervalHours ?? 168;
48
- this.staleAfterDays = config.staleAfterDays ?? 30;
49
- this.archiveAfterDays = config.archiveAfterDays ?? 90;
66
+ this.intervalHours = config.intervalHours ?? DEFAULT_CURATOR_INTERVAL_HOURS;
67
+ this.staleAfterDays = config.staleAfterDays ?? DEFAULT_STALE_AFTER_DAYS;
68
+ this.archiveAfterDays = config.archiveAfterDays ?? DEFAULT_ARCHIVE_AFTER_DAYS;
50
69
  this.llmReview = config.llmReview ?? false;
51
70
  this.curatorProvider = config.curatorProvider ?? "deepseek-official";
52
71
  this.qualityWarnStaleAfterDays = config.qualityWarnStaleAfterDays ?? 7;
53
- this.minIdleHours = config.minIdleHours ?? 0;
72
+ this.minIdleHours = config.minIdleHours ?? DEFAULT_MIN_IDLE_HOURS;
54
73
  this.excludeSkillNames = new Set(config.excludeSkillNames ?? []);
55
74
  this.manageUnmanaged = config.manageUnmanaged ?? false;
75
+ this.pruneBuiltins = config.pruneBuiltins ?? false;
76
+ this.referencedSkillNames = new Set(config.referencedSkillNames ?? []);
77
+ this.bootGraceSeconds = config.bootGraceSeconds ?? 10;
56
78
  this.curatorReviewMaxTokens = config.curatorReviewMaxTokens ?? 2048;
57
79
  this.lastRun = Date.now();
58
80
  this.ctx.effect(() => {
@@ -60,6 +82,7 @@ var EvolutionCurator = class extends Service {
60
82
  this.stop();
61
83
  };
62
84
  }, "evolution-curator.stop");
85
+ if (config.autoStart ?? true) this.start();
63
86
  }
64
87
  lifecycle() {
65
88
  const snapshot = this.ctx.get("evolutionPolicy")?.get();
@@ -71,33 +94,54 @@ var EvolutionCurator = class extends Service {
71
94
  }
72
95
  start() {
73
96
  if (!this.enabled || this.timer) return;
74
- this.timer = setInterval(() => {
75
- if (Date.now() - this.lastRun >= this.lifecycle().intervalHours * 36e5) this.run();
76
- }, 3600 * 1e3);
97
+ this.bootCheck = setTimeout(() => {
98
+ this.bootCheck = void 0;
99
+ this.autoCheck();
100
+ }, this.bootGraceSeconds * 1e3);
101
+ this.bootCheck.unref();
102
+ this.timer = setInterval(() => void this.autoCheck(), 3600 * 1e3);
77
103
  this.timer.unref();
78
104
  }
79
105
  stop() {
80
106
  if (this.timer) clearInterval(this.timer);
81
107
  this.timer = void 0;
108
+ if (this.bootCheck) clearTimeout(this.bootCheck);
109
+ this.bootCheck = void 0;
82
110
  }
83
111
  /**
84
- * Optional Hermes-curator LLM pass. The model may only NOMINATE pruning
85
- * candidates; archive/restore remains a control-plane operation and every
86
- * nominated name is still checked against lifecycle thresholds and
87
- * protected markers before any file move.
112
+ * One automatic schedule check: run a pass when the persisted curator state
113
+ * (falling back to the in-memory clock for state-less compositions) is at
114
+ * least one interval old. All gates interval, idle, first-run defer,
115
+ * reentrancy stay inside `run()`, so this method only decides whether to
116
+ * wake it, and never duplicates gate logic.
88
117
  */
89
- async recommend(candidates) {
90
- if (candidates.length === 0) return [];
118
+ async autoCheck() {
119
+ const last = (await this.curatorStateService()?.loadCuratorState())?.lastRunAt ?? this.lastRun;
120
+ if (Date.now() - last >= this.lifecycle().intervalHours * 36e5) await this.run();
121
+ }
122
+ /**
123
+ * Optional Hermes-curator LLM pass. The model may only NOMINATE pruning and
124
+ * consolidation; every move stays a control-plane operation and each
125
+ * nomination is re-validated against the tree and protected markers before
126
+ * any file move. `dryRun` prepends the report-only banner.
127
+ */
128
+ async recommend(candidates, options = {}) {
129
+ const empty = {
130
+ prunings: [],
131
+ consolidations: []
132
+ };
133
+ if (candidates.length === 0) return empty;
91
134
  const llm = this.ctx.get("llm");
92
- if (!llm) return [];
135
+ if (!llm) return empty;
93
136
  const model = this.ctx.get("evolutionPolicy")?.get().curatorModel ?? "deepseek-v4-pro";
94
137
  const prompt = [
138
+ options.dryRun ? CURATOR_DRY_RUN_BANNER : "",
95
139
  CURATOR_PROMPT,
96
140
  "",
97
- "Stale candidates observed by the deterministic lifecycle scanner:",
141
+ `Stale candidates observed by the deterministic lifecycle scanner:${candidates.length === 0 ? " (none)" : ""}`,
98
142
  ...candidates.map((name) => `- ${name}`),
99
143
  "",
100
- "Return a YAML summary with a prunings list. Nominate only candidates whose archival is clearly safe."
144
+ "Return a YAML summary with consolidations and prunings lists. Nominate only actions whose archival/merge is clearly safe."
101
145
  ].join("\n");
102
146
  try {
103
147
  const assembler = new BlockAssembler();
@@ -116,18 +160,58 @@ var EvolutionCurator = class extends Service {
116
160
  summary: "curator review"
117
161
  }
118
162
  })],
119
- maxTokens: this.curatorReviewMaxTokens,
120
- purpose: "evolution-curator"
163
+ maxTokens: this.curatorReviewMaxTokens
121
164
  })) assembler.push(chunk);
122
- const text = assembler.blocks().filter((block) => block.type === "text").map((block) => block.text).join("\n");
123
- const names = /* @__PURE__ */ new Set();
124
- const section = text.slice(text.indexOf("prunings:"));
125
- for (const [, name] of section.matchAll(/^\s*-\s*name:\s*([a-z0-9][a-z0-9-]*)\s*$/gm)) if (name) names.add(name);
126
- return [...names].filter((name) => candidates.includes(name));
165
+ const parsed = parseCuratorNominations(assembler.blocks().filter((block) => block.type === "text").map((block) => block.text).join("\n"));
166
+ return {
167
+ prunings: parsed.prunings.filter((name) => candidates.includes(name)),
168
+ consolidations: parsed.consolidations
169
+ };
127
170
  } catch {
128
- return [];
171
+ return empty;
129
172
  }
130
173
  }
174
+ /** Optional curator-state service (evolution-state-json / storage-domain). */
175
+ curatorStateService() {
176
+ return this.ctx.get("evolutionState");
177
+ }
178
+ /**
179
+ * Full-state snapshot: the skills tree plus the current curator state as an
180
+ * `extras/curator-state.json` side file. Every pre-mutation snapshot in the
181
+ * curator goes through here so a later `restoreSnapshot()` can rewind both
182
+ * the tree and the state (Hermes curator_backup backs up `.curator_state`).
183
+ */
184
+ async snapshotFull(reason = "pre-mutation") {
185
+ const state = await this.curatorStateService()?.loadCuratorState();
186
+ const extras = state === null || state === void 0 ? [] : [{
187
+ name: "curator-state.json",
188
+ content: JSON.stringify(state, null, 2)
189
+ }];
190
+ return await this.skills.snapshotAll(reason, extras);
191
+ }
192
+ /**
193
+ * Full-state rollback: restore the latest snapshot's tree/sidecars/archive
194
+ * AND the curator state it carried. The pre-rollback safety snapshot keeps
195
+ * the current tree plus current state (as extras), so the rollback itself
196
+ * is reversible.
197
+ */
198
+ async restoreSnapshot() {
199
+ const stateService = this.curatorStateService();
200
+ const currentState = await stateService?.loadCuratorState();
201
+ const extras = currentState === null || currentState === void 0 ? [] : [{
202
+ name: "curator-state.json",
203
+ content: JSON.stringify(currentState, null, 2)
204
+ }];
205
+ const result = await this.skills.restoreLatestSnapshot(extras);
206
+ if (!result.ok) return result;
207
+ const stateExtra = result.extras?.find((extra) => extra.name === "curator-state.json");
208
+ if (stateExtra && stateService) try {
209
+ await stateService.saveCuratorState(JSON.parse(stateExtra.content));
210
+ } catch (error) {
211
+ this.ctx.logger.warn(`evolution-curator: failed to restore curator state: ${error instanceof Error ? error.message : String(error)}`);
212
+ }
213
+ return result;
214
+ }
131
215
  skippedReport(runId, startedAt) {
132
216
  return buildCuratorRunReport({
133
217
  runId,
@@ -137,57 +221,114 @@ var EvolutionCurator = class extends Service {
137
221
  llmNominations: [],
138
222
  archiveCandidates: [],
139
223
  archived: [],
140
- failed: []
224
+ failed: [],
225
+ llmReviewEnabled: this.llmReview
141
226
  });
142
227
  }
143
- async run() {
228
+ /**
229
+ * Run one curator pass. `ignoreGates` skips the interval and idle gates so an
230
+ * explicit `/evolution curator run` always executes (manual-run semantics):
231
+ * `dryRun` computes the lifecycle and the LLM nominations but performs no
232
+ * mutation, reports what WOULD happen, and does not push out the next run.
233
+ * Reentrant calls (autoStart timer + manual command at the same instant) are
234
+ * skipped with an explicit `already-running` outcome.
235
+ */
236
+ async run(options = {}) {
237
+ if (this.running) return {
238
+ stale: [],
239
+ archived: [],
240
+ errors: [],
241
+ report: this.skippedReport("already-running", (/* @__PURE__ */ new Date()).toISOString()),
242
+ skipped: "already-running"
243
+ };
244
+ this.running = true;
245
+ try {
246
+ return await this.runCore(options);
247
+ } finally {
248
+ this.running = false;
249
+ }
250
+ }
251
+ async runCore(options = {}) {
252
+ const { ignoreGates = false, dryRun = false } = options;
144
253
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
145
254
  const runId = randomUUID();
146
- const stateService = this.ctx.get("evolutionState");
255
+ const stateService = this.curatorStateService();
147
256
  const lifecycle = this.lifecycle();
148
257
  const persisted = await stateService?.loadCuratorState();
149
- if (persisted && Date.now() - persisted.lastRunAt < lifecycle.intervalHours * 36e5) return {
258
+ if (!ignoreGates && persisted && Date.now() - persisted.lastRunAt < lifecycle.intervalHours * 36e5) return {
150
259
  stale: [],
151
260
  archived: [],
152
261
  errors: [],
153
262
  report: this.skippedReport(runId, startedAt),
154
263
  skipped: "interval"
155
264
  };
156
- if (this.minIdleHours > 0 && this.recentSessionActive()) return {
265
+ if (!ignoreGates && this.minIdleHours > 0 && this.recentSessionActive()) return {
157
266
  stale: [],
158
267
  archived: [],
159
268
  errors: [],
160
269
  report: this.skippedReport(runId, startedAt),
161
270
  skipped: "active-session"
162
271
  };
272
+ if (!ignoreGates && persisted === null) {
273
+ await stateService?.saveCuratorState({
274
+ schemaVersion: 1,
275
+ lastRunAt: Date.now(),
276
+ runCount: 0,
277
+ lastSummary: "first-run-deferred",
278
+ paused: false
279
+ });
280
+ return {
281
+ stale: [],
282
+ archived: [],
283
+ errors: [],
284
+ report: this.skippedReport(runId, startedAt),
285
+ skipped: "first-run-deferred"
286
+ };
287
+ }
163
288
  const root = this.skills.root;
164
- const snapshotPath = await this.skills.snapshotAll("pre-curator-run");
165
- const usage = await loadUsage(root, this.io);
289
+ const rawUsage = await loadUsage(root, this.io);
290
+ const usage = dryRun ? new Map([...rawUsage].map(([name, record]) => [name, { ...record }])) : rawUsage;
291
+ const snapshotPath = dryRun ? void 0 : await this.snapshotFull("pre-curator-run");
292
+ const suppressedNames = new Set(await loadSuppressedNames(root, this.io));
293
+ const { bundledNames, treeNames } = await this.seedBaseline(usage);
166
294
  const result = computeLifecycleTransitions(usage, {
167
295
  staleAfterDays: lifecycle.staleAfterDays,
168
296
  archiveAfterDays: lifecycle.archiveAfterDays,
169
297
  qualityWarnStaleAfterDays: this.qualityWarnStaleAfterDays,
170
298
  excludeSkillNames: this.excludeSkillNames,
171
- manageUnmanaged: this.manageUnmanaged
299
+ manageUnmanaged: this.manageUnmanaged,
300
+ pruneBuiltins: this.pruneBuiltins,
301
+ bundledNames,
302
+ suppressedNames,
303
+ referencedSkillNames: this.referencedSkillNames
172
304
  });
173
- const errors = [];
174
- const archivedSkills = [];
175
- const llmNominations = this.llmReview ? await this.recommend(result.markStale) : [];
305
+ await this.scoreTree(usage, treeNames);
306
+ const nominations = this.llmReview ? await this.recommend(result.markStale, { dryRun }) : {
307
+ prunings: [],
308
+ consolidations: []
309
+ };
310
+ const gatedNominations = {
311
+ ...nominations,
312
+ consolidations: gateConsolidations(nominations.consolidations, {
313
+ exclude: this.excludeSkillNames,
314
+ referenced: this.referencedSkillNames,
315
+ suppressed: suppressedNames
316
+ })
317
+ };
318
+ const llmNominations = gatedNominations.prunings;
176
319
  const archiveCandidates = [...new Set([...result.archive, ...llmNominations])];
177
- for (const name of archiveCandidates) {
178
- const archived = await this.skills.archive(name, "Lifecycle: reached archive threshold");
179
- if (!archived.ok) {
180
- const record = usage.get(name);
181
- if (record) record.state = "active";
182
- errors.push(`${name}: ${archived.message}`);
183
- } else archivedSkills.push({
184
- name,
185
- path: archived.path ?? "",
186
- reason: "Lifecycle: reached archive threshold"
187
- });
188
- }
189
- await saveUsage(root, usage, this.io);
190
- this.lastRun = Date.now();
320
+ const { archivedSkills, errors } = await this.applyMutations({
321
+ dryRun,
322
+ archiveCandidates,
323
+ nominations: gatedNominations,
324
+ treeNames,
325
+ usage,
326
+ bundledNames,
327
+ suppressedNames,
328
+ root,
329
+ failedFrom: new Map(result.transitions.filter((t) => t.to === "archived").map((t) => [t.name, t.from]))
330
+ });
331
+ if (!dryRun) this.lastRun = Date.now();
191
332
  const report = buildCuratorRunReport({
192
333
  runId,
193
334
  startedAt,
@@ -196,13 +337,14 @@ var EvolutionCurator = class extends Service {
196
337
  llmNominations,
197
338
  archiveCandidates,
198
339
  archived: archivedSkills,
199
- failed: archiveCandidates.filter((name) => errors.some((error) => error.startsWith(`${name}:`))).map((name) => {
340
+ failed: [...new Set([...archiveCandidates, ...gatedNominations.consolidations.map((item) => item.from)])].filter((name) => errors.some((error) => error.startsWith(`${name}:`))).map((name) => {
200
341
  return {
201
342
  name,
202
343
  reason: errors.find((item) => item.startsWith(`${name}:`))?.slice(name.length + 2) ?? "unknown"
203
344
  };
204
345
  }),
205
- snapshotPath
346
+ ...snapshotPath === void 0 ? {} : { snapshotPath },
347
+ llmReviewEnabled: this.llmReview
206
348
  });
207
349
  const reportsRoot = join(evolutionHome(), "reports");
208
350
  try {
@@ -211,17 +353,169 @@ var EvolutionCurator = class extends Service {
211
353
  this.ctx.logger.warn(`evolution-curator: failed to persist report ${runId}`);
212
354
  this.ctx.logger.warn(error);
213
355
  }
356
+ const llmHint = !this.llmReview && result.markStale.length > 0 ? " (llmReview: off - deterministic archive only; set llmReview: true for the LLM merge channel)" : "";
357
+ const summary = `${dryRun ? "dry-run" : "auto"}: stale:${result.markStale.length} archived:${archivedSkills.length} consolidated:${gatedNominations.consolidations.length}${llmHint}`;
214
358
  await stateService?.saveCuratorState({
215
- lastRunAt: this.lastRun,
216
- runCount: (persisted?.runCount ?? 0) + 1,
217
- lastSummary: `stale:${result.markStale.length} archived:${archivedSkills.length}`,
359
+ schemaVersion: 1,
360
+ lastRunAt: dryRun ? persisted?.lastRunAt ?? this.lastRun : this.lastRun,
361
+ runCount: dryRun ? persisted?.runCount ?? 0 : (persisted?.runCount ?? 0) + 1,
362
+ lastSummary: summary,
218
363
  paused: false
219
364
  });
220
365
  return {
221
366
  stale: result.markStale,
222
367
  archived: archivedSkills.map((item) => item.name),
223
368
  errors,
224
- report
369
+ report,
370
+ ...this.llmReview ? { nominations: gatedNominations } : {}
371
+ };
372
+ }
373
+ /**
374
+ * Seed baseline records for tree skills the sidecar has not seen yet, so
375
+ * their inactivity clock starts now (first-sight defer) and bundled skills
376
+ * become known candidates only when prune-builtins opts them in. Also
377
+ * returns the full active tree names for nomination validation.
378
+ */
379
+ async seedBaseline(usage) {
380
+ const bundledNames = /* @__PURE__ */ new Set();
381
+ const treeNames = /* @__PURE__ */ new Set();
382
+ for (const summary of await this.skills.list()) {
383
+ treeNames.add(summary.name);
384
+ if (!usage.has(summary.name)) usage.set(summary.name, emptyRecord());
385
+ if (await this.skills.isBundled(summary.name)) bundledNames.add(summary.name);
386
+ const record = usage.get(summary.name);
387
+ if (record) record.pinned = await this.skills.isPinned(summary.name);
388
+ }
389
+ return {
390
+ bundledNames,
391
+ treeNames
392
+ };
393
+ }
394
+ /**
395
+ * F13 six-factor quality scoring, persisted onto the usage records.
396
+ */
397
+ async scoreTree(usage, treeNames) {
398
+ const supportDirs = /* @__PURE__ */ new Map();
399
+ for (const name of treeNames) supportDirs.set(name, await this.skills.countSupportDirs(name));
400
+ const quality = computeQualityScores({
401
+ usage,
402
+ supportDirs,
403
+ referenceCounts: await this.referenceCounts(treeNames)
404
+ });
405
+ for (const [name, score] of quality) {
406
+ const record = usage.get(name);
407
+ if (record) {
408
+ record.quality_score = score.score;
409
+ record.quality_warn = score.warn;
410
+ }
411
+ }
412
+ }
413
+ /**
414
+ * In-degree over explicit `related_skills` frontmatter references (the DSH
415
+ * equivalent of the graph-in-degree references factor): a skill listing
416
+ * other skill names counts as one reference to each of them, so hub skills
417
+ * that are explicitly named by peers get a non-zero references factor.
418
+ */
419
+ async referenceCounts(treeNames) {
420
+ const counts = /* @__PURE__ */ new Map();
421
+ for (const name of treeNames) {
422
+ const content = await this.skills.read(name);
423
+ if (!content) continue;
424
+ const parsed = parseFrontmatter(content);
425
+ if (!parsed) continue;
426
+ const raw = parsed.frontmatter["related_skills"];
427
+ if (typeof raw !== "string") continue;
428
+ for (const match of Array.from(raw.matchAll(/[a-z0-9][a-z0-9-]*/g))) {
429
+ const target = match[0];
430
+ if (target && SKILL_NAME_RE.test(target) && target !== name) counts.set(target, (counts.get(target) ?? 0) + 1);
431
+ }
432
+ }
433
+ return counts;
434
+ }
435
+ /**
436
+ * Execute lifecycle archives and consolidation nominations, then persist the
437
+ * suppression and usage sidecars best-effort. A dry-run short-circuits: no
438
+ * file moves and no state persistence — the caller still writes the report.
439
+ */
440
+ async applyMutations(input) {
441
+ if (input.dryRun) return {
442
+ archivedSkills: [],
443
+ errors: [],
444
+ suppressedChanged: false
445
+ };
446
+ const { archiveCandidates, nominations, treeNames, usage, bundledNames, suppressedNames, root, failedFrom } = input;
447
+ const errors = [];
448
+ const archivedSkills = [];
449
+ let suppressedChanged = false;
450
+ for (const name of archiveCandidates) {
451
+ const archived = await this.skills.archive(name, {
452
+ reason: "Lifecycle: reached archive threshold",
453
+ allowBundled: this.pruneBuiltins
454
+ });
455
+ if (!archived.ok) {
456
+ const record = usage.get(name);
457
+ const from = failedFrom?.get(name);
458
+ if (record && (from === "stale" || from === "active")) record.state = from;
459
+ errors.push(`${name}: ${archived.message}`);
460
+ } else {
461
+ const record = usage.get(name);
462
+ if (record) {
463
+ record.state = "archived";
464
+ record.archived_at = (/* @__PURE__ */ new Date()).toISOString();
465
+ }
466
+ archivedSkills.push({
467
+ name,
468
+ path: archived.path ?? "",
469
+ reason: "Lifecycle: reached archive threshold"
470
+ });
471
+ if (bundledNames.has(name)) {
472
+ suppressedNames.add(name);
473
+ suppressedChanged = true;
474
+ }
475
+ }
476
+ }
477
+ const alreadyArchived = new Set(archiveCandidates);
478
+ for (const nomination of nominations.consolidations) {
479
+ if (alreadyArchived.has(nomination.from)) continue;
480
+ if (!treeNames.has(nomination.from) || !treeNames.has(nomination.into)) {
481
+ errors.push(`${nomination.from}: consolidation target or source missing from the skill tree`);
482
+ continue;
483
+ }
484
+ const consolidated = await this.skills.consolidate(nomination.into, [nomination.from], "background_review");
485
+ if (!consolidated.ok) {
486
+ errors.push(`${nomination.from}: ${consolidated.message}`);
487
+ continue;
488
+ }
489
+ const record = usage.get(nomination.from);
490
+ if (record) {
491
+ record.state = "archived";
492
+ record.archived_at = (/* @__PURE__ */ new Date()).toISOString();
493
+ }
494
+ alreadyArchived.add(nomination.from);
495
+ archivedSkills.push({
496
+ name: nomination.from,
497
+ path: join(this.skills.root, ".archive", nomination.from),
498
+ reason: `Consolidated into ${nomination.into}`
499
+ });
500
+ }
501
+ if (suppressedChanged) try {
502
+ await saveSuppressedNames(root, suppressedNames, this.io);
503
+ } catch {
504
+ this.ctx.logger.warn("evolution-curator: failed to persist suppressed names; archived built-ins may re-enter the lifecycle");
505
+ }
506
+ try {
507
+ await saveUsage(root, usage, this.io);
508
+ } catch {
509
+ this.ctx.logger.warn("evolution-curator: failed to persist usage sidecar");
510
+ }
511
+ const usageRegistry = this.ctx.get("skillUsage");
512
+ try {
513
+ await usageRegistry?.invalidate?.();
514
+ } catch {}
515
+ return {
516
+ archivedSkills,
517
+ errors,
518
+ suppressedChanged
225
519
  };
226
520
  }
227
521
  recentSessionActive() {
@@ -247,6 +541,77 @@ var EvolutionCurator = class extends Service {
247
541
  return null;
248
542
  }
249
543
  }
544
+ /**
545
+ * Read-only lifecycle scope classification: which skills are in scope,
546
+ * watched (stale/quality-warned), exempted, or protected. Uses the same
547
+ * candidate gate as `run()` (`computeScopeView` / `lifecycleCandidate`),
548
+ * so the view always predicts what a curator pass may touch.
549
+ */
550
+ async scopeView() {
551
+ const root = this.skills.root;
552
+ const usage = await loadUsage(root, this.io);
553
+ const { bundledNames } = await this.seedBaseline(usage);
554
+ return computeScopeView(usage, {
555
+ staleAfterDays: this.lifecycle().staleAfterDays,
556
+ archiveAfterDays: this.lifecycle().archiveAfterDays,
557
+ excludeSkillNames: this.excludeSkillNames,
558
+ referencedSkillNames: this.referencedSkillNames,
559
+ suppressedNames: new Set(await loadSuppressedNames(root, this.io)),
560
+ manageUnmanaged: this.manageUnmanaged,
561
+ pruneBuiltins: this.pruneBuiltins,
562
+ bundledNames
563
+ }, await this.protectedNameMap());
564
+ }
565
+ /** marker info (pinned/bundled/hub-installed) per skill, from the library list. */
566
+ async protectedNameMap() {
567
+ const map = /* @__PURE__ */ new Map();
568
+ for (const summary of await this.skills.list()) if (summary.protectedBy !== null) map.set(summary.name, summary.protectedBy);
569
+ return map;
570
+ }
571
+ /**
572
+ * Control-plane consolidation: merge source skill bodies into `target`,
573
+ * archive the sources with an absorbed-into marker, and fold their usage
574
+ * records into `archived` state. Snapshot-then-mutate, never a hard delete.
575
+ */
576
+ async consolidate(target, sources) {
577
+ const blocked = [...this.excludeSkillNames].filter((name) => name === target || sources.includes(name));
578
+ if (blocked.length > 0) return {
579
+ ok: false,
580
+ message: `Skill(s) excluded from lifecycle management: ${blocked.join(", ")}`
581
+ };
582
+ await this.snapshotFull("pre-consolidate");
583
+ const result = await this.skills.consolidate(target, sources);
584
+ if (!result.ok) return result;
585
+ const usage = await loadUsage(this.skills.root, this.io);
586
+ for (const source of sources) {
587
+ const record = usage.get(source);
588
+ if (record) record.state = "archived";
589
+ if (record) record.archived_at = (/* @__PURE__ */ new Date()).toISOString();
590
+ }
591
+ await saveUsage(this.skills.root, usage, this.io);
592
+ return result;
593
+ }
594
+ /**
595
+ * Control-plane restore: bring one archived skill back to the active root
596
+ * and reset its usage state, keeping the recoverable-archive invariant.
597
+ */
598
+ async restore(name) {
599
+ await this.snapshotFull("pre-restore");
600
+ const result = await this.skills.restoreFromArchive(name);
601
+ if (!result.ok) return result;
602
+ const usage = await loadUsage(this.skills.root, this.io);
603
+ const record = usage.get(name);
604
+ if (record) record.state = "active";
605
+ if (record) record.archived_at = null;
606
+ await saveUsage(this.skills.root, usage, this.io);
607
+ const suppressed = new Set(await loadSuppressedNames(this.skills.root, this.io));
608
+ if (suppressed.delete(name)) try {
609
+ await saveSuppressedNames(this.skills.root, suppressed, this.io);
610
+ } catch {
611
+ this.ctx.logger.warn(`evolution-curator: failed to persist suppressed names after restoring ${name}`);
612
+ }
613
+ return result;
614
+ }
250
615
  };
251
616
  //#endregion
252
- export { EvolutionCurator, EvolutionCurator as default };
617
+ export { EvolutionCurator, EvolutionCurator as default, gateConsolidations };
@@ -5,7 +5,7 @@
5
5
  import { Context, Service } from '@deepseek-ai/cordis';
6
6
  import type Schema from '@deepseek-ai/schemastery';
7
7
  import { SkillLibrary } from '@deepseek-ai/dsh-evolution-core';
8
- import { type CuratorRunReport } from '@deepseek-ai/dsh-evolution-core';
8
+ import { type CuratorConsolidation, type CuratorNominations, type CuratorRunReport, type ScopeView, type SkillActionResult } from '@deepseek-ai/dsh-evolution-core';
9
9
  declare module '@deepseek-ai/cordis' {
10
10
  interface Context {
11
11
  evolutionCurator: EvolutionCurator;
@@ -27,9 +27,46 @@ export interface Config {
27
27
  excludeSkillNames?: string[];
28
28
  /** Include usage records whose created_by is not 'agent' in lifecycle decisions. */
29
29
  manageUnmanaged?: boolean;
30
+ /** Archive long-unused bundled skills too (with suppression against re-seeds). */
31
+ pruneBuiltins?: boolean;
32
+ /** Static scheduled-task skill references; such skills never auto-transition. */
33
+ referencedSkillNames?: string[];
34
+ /** Start the interval timer on context ready (auto-curation). Default true. */
35
+ autoStart?: boolean;
36
+ /** Seconds between host boot and the first automatic schedule check (restart catch-up). */
37
+ bootGraceSeconds?: number;
30
38
  /** Max tokens for the optional LLM nomination pass. */
31
39
  curatorReviewMaxTokens?: number;
32
40
  }
41
+ /** Outcome of one curator run pass. */
42
+ export interface CuratorRunOutcome {
43
+ stale: string[];
44
+ archived: string[];
45
+ errors: string[];
46
+ report: CuratorRunReport;
47
+ skipped?: string;
48
+ /** LLM nominations when the optional review pass is enabled (audit visibility). */
49
+ nominations?: CuratorNominations;
50
+ }
51
+ /** Persisted curator-state record shape (schemaVersion optional for legacy reads). */
52
+ export interface CuratorStateRecordShape {
53
+ schemaVersion?: number;
54
+ lastRunAt: number;
55
+ runCount: number;
56
+ lastSummary: string;
57
+ paused: boolean;
58
+ }
59
+ /**
60
+ * Block LLM-nominated consolidations that would touch a gate-protected name:
61
+ * exclude / referenced / suppressed skills must never merge (neither as the
62
+ * source being archived nor as the umbrella being edited). Mirrors the control
63
+ * plane's `consolidate()` guard; automatic nominations must pass the same gate.
64
+ */
65
+ export declare function gateConsolidations(consolidations: CuratorConsolidation[], gates: {
66
+ exclude?: ReadonlySet<string>;
67
+ referenced?: ReadonlySet<string>;
68
+ suppressed?: ReadonlySet<string>;
69
+ }): CuratorConsolidation[];
33
70
  export declare class EvolutionCurator extends Service {
34
71
  static inject: string[];
35
72
  static Config: Schema<Config>;
@@ -45,30 +82,116 @@ export declare class EvolutionCurator extends Service {
45
82
  private readonly minIdleHours;
46
83
  private readonly excludeSkillNames;
47
84
  private readonly manageUnmanaged;
85
+ private readonly pruneBuiltins;
86
+ private readonly referencedSkillNames;
87
+ private readonly bootGraceSeconds;
48
88
  private readonly curatorReviewMaxTokens;
49
89
  private lastRun;
50
90
  private timer;
91
+ private bootCheck;
92
+ private running;
51
93
  constructor(ctx: Context, config?: Config);
52
94
  private lifecycle;
53
95
  start(): void;
54
96
  stop(): void;
55
97
  /**
56
- * Optional Hermes-curator LLM pass. The model may only NOMINATE pruning
57
- * candidates; archive/restore remains a control-plane operation and every
58
- * nominated name is still checked against lifecycle thresholds and
59
- * protected markers before any file move.
98
+ * One automatic schedule check: run a pass when the persisted curator state
99
+ * (falling back to the in-memory clock for state-less compositions) is at
100
+ * least one interval old. All gates interval, idle, first-run defer,
101
+ * reentrancy stay inside `run()`, so this method only decides whether to
102
+ * wake it, and never duplicates gate logic.
60
103
  */
61
- recommend(candidates: string[]): Promise<string[]>;
62
- private skippedReport;
63
- run(): Promise<{
64
- stale: string[];
65
- archived: string[];
66
- errors: string[];
67
- report: CuratorRunReport;
68
- skipped?: string;
104
+ private autoCheck;
105
+ /**
106
+ * Optional Hermes-curator LLM pass. The model may only NOMINATE pruning and
107
+ * consolidation; every move stays a control-plane operation and each
108
+ * nomination is re-validated against the tree and protected markers before
109
+ * any file move. `dryRun` prepends the report-only banner.
110
+ */
111
+ recommend(candidates: string[], options?: {
112
+ dryRun?: boolean;
113
+ }): Promise<CuratorNominations>;
114
+ /** Optional curator-state service (evolution-state-json / storage-domain). */
115
+ private curatorStateService;
116
+ /**
117
+ * Full-state snapshot: the skills tree plus the current curator state as an
118
+ * `extras/curator-state.json` side file. Every pre-mutation snapshot in the
119
+ * curator goes through here so a later `restoreSnapshot()` can rewind both
120
+ * the tree and the state (Hermes curator_backup backs up `.curator_state`).
121
+ */
122
+ snapshotFull(reason?: string): Promise<string>;
123
+ /**
124
+ * Full-state rollback: restore the latest snapshot's tree/sidecars/archive
125
+ * AND the curator state it carried. The pre-rollback safety snapshot keeps
126
+ * the current tree plus current state (as extras), so the rollback itself
127
+ * is reversible.
128
+ */
129
+ restoreSnapshot(): Promise<SkillActionResult & {
130
+ extras?: Array<{
131
+ name: string;
132
+ content: string;
133
+ }>;
69
134
  }>;
135
+ private skippedReport;
136
+ /**
137
+ * Run one curator pass. `ignoreGates` skips the interval and idle gates so an
138
+ * explicit `/evolution curator run` always executes (manual-run semantics):
139
+ * `dryRun` computes the lifecycle and the LLM nominations but performs no
140
+ * mutation, reports what WOULD happen, and does not push out the next run.
141
+ * Reentrant calls (autoStart timer + manual command at the same instant) are
142
+ * skipped with an explicit `already-running` outcome.
143
+ */
144
+ run(options?: {
145
+ ignoreGates?: boolean;
146
+ dryRun?: boolean;
147
+ }): Promise<CuratorRunOutcome>;
148
+ private runCore;
149
+ /**
150
+ * Seed baseline records for tree skills the sidecar has not seen yet, so
151
+ * their inactivity clock starts now (first-sight defer) and bundled skills
152
+ * become known candidates only when prune-builtins opts them in. Also
153
+ * returns the full active tree names for nomination validation.
154
+ */
155
+ private seedBaseline;
156
+ /**
157
+ * F13 six-factor quality scoring, persisted onto the usage records.
158
+ */
159
+ private scoreTree;
160
+ /**
161
+ * In-degree over explicit `related_skills` frontmatter references (the DSH
162
+ * equivalent of the graph-in-degree references factor): a skill listing
163
+ * other skill names counts as one reference to each of them, so hub skills
164
+ * that are explicitly named by peers get a non-zero references factor.
165
+ */
166
+ private referenceCounts;
167
+ /**
168
+ * Execute lifecycle archives and consolidation nominations, then persist the
169
+ * suppression and usage sidecars best-effort. A dry-run short-circuits: no
170
+ * file moves and no state persistence — the caller still writes the report.
171
+ */
172
+ private applyMutations;
70
173
  private recentSessionActive;
71
174
  latestReport(): Promise<CuratorRunReport | null>;
175
+ /**
176
+ * Read-only lifecycle scope classification: which skills are in scope,
177
+ * watched (stale/quality-warned), exempted, or protected. Uses the same
178
+ * candidate gate as `run()` (`computeScopeView` / `lifecycleCandidate`),
179
+ * so the view always predicts what a curator pass may touch.
180
+ */
181
+ scopeView(): Promise<ScopeView>;
182
+ /** marker info (pinned/bundled/hub-installed) per skill, from the library list. */
183
+ private protectedNameMap;
184
+ /**
185
+ * Control-plane consolidation: merge source skill bodies into `target`,
186
+ * archive the sources with an absorbed-into marker, and fold their usage
187
+ * records into `archived` state. Snapshot-then-mutate, never a hard delete.
188
+ */
189
+ consolidate(target: string, sources: string[]): Promise<SkillActionResult>;
190
+ /**
191
+ * Control-plane restore: bring one archived skill back to the active root
192
+ * and reset its usage state, keeping the recoverable-archive invariant.
193
+ */
194
+ restore(name: string): Promise<SkillActionResult>;
72
195
  }
73
196
  export default EvolutionCurator;
74
197
  //# sourceMappingURL=index.d.ts.map
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.1.0-rc.4",
4
+ "version": "0.1.0-rc.41",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -33,20 +33,20 @@
33
33
  "license": "MIT",
34
34
  "dependencies": {
35
35
  "@deepseek-ai/schemastery": "^3.18.1",
36
- "@lmzhen/dsh-evolution-core": "^0.1.0-rc.4"
36
+ "@lmzhen/dsh-evolution-core": "^0.1.0-rc.41"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@deepseek-ai/cordis": "^4.0.1",
40
40
  "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
41
41
  "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
42
- "@lmzhen/dsh-evolution-io": "^0.1.0-rc.4",
43
- "@lmzhen/dsh-evolution-state": "^0.1.0-rc.4"
42
+ "@lmzhen/dsh-evolution-io": "^0.1.0-rc.41",
43
+ "@lmzhen/dsh-evolution-state": "^0.1.0-rc.41"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
47
47
  "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
48
- "@lmzhen/dsh-evolution-core": "^0.1.0-rc.4",
49
- "@lmzhen/dsh-evolution-io": "^0.1.0-rc.4",
50
- "@lmzhen/dsh-evolution-state": "^0.1.0-rc.4"
48
+ "@lmzhen/dsh-evolution-core": "^0.1.0-rc.41",
49
+ "@lmzhen/dsh-evolution-io": "^0.1.0-rc.41",
50
+ "@lmzhen/dsh-evolution-state": "^0.1.0-rc.41"
51
51
  }
52
52
  }