@lmzhen/dsh-evolution-curator 0.1.0-rc.9 → 0.2.0-rc.1

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
@@ -28,4 +28,10 @@ Independent of request-prefix construction. This package does not alter the asse
28
28
 
29
29
  - `archive` never deletes: skills move to `.archive/` with a `.archive-reason` marker.
30
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 skill tree first (`pre-consolidate` / `pre-restore`).
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,26 +3,45 @@ 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_HEALTH_THRESHOLDS, DEFAULT_MIN_IDLE_HOURS, DEFAULT_STALE_AFTER_DAYS, EvolutionGateSet, SkillLibrary, buildCuratorRunReport, computeDedupGroups, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, emptyRecord, evolutionHome, evolutionIoAdapter, foldCuratorFields, loadSuppressedNames, loadUsage, mutateUsage, parseCuratorNominations, relatedSkillNames, renderCuratorReportMarkdown, 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.
10
10
  * @module @lmzhen/dsh-evolution-curator
11
11
  */
12
+ /** Quality-warned skills may turn stale after this many idle days (package-private tunable, P2-8). */
13
+ const DEFAULT_QUALITY_WARN_STALE_AFTER_DAYS = 7;
14
+ /**
15
+ * Block LLM-nominated consolidations that would touch a gate-protected name:
16
+ * exclude / referenced / suppressed skills must never merge (neither as the
17
+ * source being archived nor as the umbrella being edited). Mirrors the control
18
+ * plane's `consolidate()` guard; automatic nominations must pass the same gate.
19
+ */
20
+ function gateConsolidations(consolidations, gates) {
21
+ const gateSet = gates instanceof EvolutionGateSet ? gates : new EvolutionGateSet(gates);
22
+ return consolidations.filter((n) => !gateSet.isBlocked(n.from) && !gateSet.isBlocked(n.into));
23
+ }
12
24
  var EvolutionCurator = class extends Service {
13
25
  static inject = ["evolutionIo"];
14
26
  static Config = z.object({
15
27
  enabled: z.boolean().default(true),
16
- intervalHours: z.number().default(168),
17
- staleAfterDays: z.number().default(30),
18
- archiveAfterDays: z.number().default(90),
28
+ intervalHours: z.number().default(DEFAULT_CURATOR_INTERVAL_HOURS),
29
+ staleAfterDays: z.number().default(DEFAULT_STALE_AFTER_DAYS),
30
+ archiveAfterDays: z.number().default(DEFAULT_ARCHIVE_AFTER_DAYS),
19
31
  llmReview: z.boolean().default(false),
20
32
  curatorProvider: z.string().default("deepseek-official"),
21
- qualityWarnStaleAfterDays: z.number().default(7),
22
- minIdleHours: z.number().default(0),
33
+ qualityWarnStaleAfterDays: z.number().default(DEFAULT_QUALITY_WARN_STALE_AFTER_DAYS),
34
+ minIdleHours: z.number().default(DEFAULT_MIN_IDLE_HOURS),
23
35
  excludeSkillNames: z.array(z.string()).default([]),
24
36
  manageUnmanaged: z.boolean().default(false),
25
- curatorReviewMaxTokens: z.number().default(2048)
37
+ pruneBuiltins: z.boolean().default(false),
38
+ referencedSkillNames: z.array(z.string()).default([]),
39
+ autoStart: z.boolean().default(true),
40
+ bootGraceSeconds: z.number().default(10),
41
+ curatorReviewMaxTokens: z.number().default(2048),
42
+ healthSoftBodyChars: z.number().default(DEFAULT_HEALTH_THRESHOLDS.softBodyChars),
43
+ healthStampDensityPerKb: z.number().default(DEFAULT_HEALTH_THRESHOLDS.stampDensityPerKb),
44
+ healthChurnMinPatches: z.number().default(DEFAULT_HEALTH_THRESHOLDS.churnMinPatches)
26
45
  });
27
46
  skills;
28
47
  io;
@@ -36,30 +55,47 @@ var EvolutionCurator = class extends Service {
36
55
  minIdleHours;
37
56
  excludeSkillNames;
38
57
  manageUnmanaged;
58
+ pruneBuiltins;
59
+ referencedSkillNames;
60
+ bootGraceSeconds;
39
61
  curatorReviewMaxTokens;
62
+ healthSoftBodyChars;
63
+ healthStampDensityPerKb;
64
+ healthChurnMinPatches;
40
65
  lastRun = 0;
41
66
  timer;
67
+ bootCheck;
68
+ running = false;
42
69
  constructor(ctx, config = {}) {
43
70
  super(ctx, "evolutionCurator");
44
71
  this.io = evolutionIoAdapter(() => ctx.evolutionIo.provider());
45
- this.skills = new SkillLibrary(void 0, this.io);
72
+ this.skills = new SkillLibrary(void 0, this.io, void 0, (event) => {
73
+ this.ctx.emit("evolution/skill-mutated", event);
74
+ });
46
75
  this.enabled = config.enabled ?? true;
47
- this.intervalHours = config.intervalHours ?? 168;
48
- this.staleAfterDays = config.staleAfterDays ?? 30;
49
- this.archiveAfterDays = config.archiveAfterDays ?? 90;
76
+ this.intervalHours = config.intervalHours ?? DEFAULT_CURATOR_INTERVAL_HOURS;
77
+ this.staleAfterDays = config.staleAfterDays ?? DEFAULT_STALE_AFTER_DAYS;
78
+ this.archiveAfterDays = config.archiveAfterDays ?? DEFAULT_ARCHIVE_AFTER_DAYS;
50
79
  this.llmReview = config.llmReview ?? false;
51
80
  this.curatorProvider = config.curatorProvider ?? "deepseek-official";
52
- this.qualityWarnStaleAfterDays = config.qualityWarnStaleAfterDays ?? 7;
53
- this.minIdleHours = config.minIdleHours ?? 0;
81
+ this.qualityWarnStaleAfterDays = config.qualityWarnStaleAfterDays ?? DEFAULT_QUALITY_WARN_STALE_AFTER_DAYS;
82
+ this.minIdleHours = config.minIdleHours ?? DEFAULT_MIN_IDLE_HOURS;
54
83
  this.excludeSkillNames = new Set(config.excludeSkillNames ?? []);
55
84
  this.manageUnmanaged = config.manageUnmanaged ?? false;
85
+ this.pruneBuiltins = config.pruneBuiltins ?? false;
86
+ this.referencedSkillNames = new Set(config.referencedSkillNames ?? []);
87
+ this.bootGraceSeconds = config.bootGraceSeconds ?? 10;
56
88
  this.curatorReviewMaxTokens = config.curatorReviewMaxTokens ?? 2048;
89
+ this.healthSoftBodyChars = config.healthSoftBodyChars ?? DEFAULT_HEALTH_THRESHOLDS.softBodyChars;
90
+ this.healthStampDensityPerKb = config.healthStampDensityPerKb ?? DEFAULT_HEALTH_THRESHOLDS.stampDensityPerKb;
91
+ this.healthChurnMinPatches = config.healthChurnMinPatches ?? DEFAULT_HEALTH_THRESHOLDS.churnMinPatches;
57
92
  this.lastRun = Date.now();
58
93
  this.ctx.effect(() => {
59
94
  return () => {
60
95
  this.stop();
61
96
  };
62
97
  }, "evolution-curator.stop");
98
+ if (config.autoStart ?? true) this.start();
63
99
  }
64
100
  lifecycle() {
65
101
  const snapshot = this.ctx.get("evolutionPolicy")?.get();
@@ -71,33 +107,85 @@ var EvolutionCurator = class extends Service {
71
107
  }
72
108
  start() {
73
109
  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);
110
+ this.bootCheck = setTimeout(() => {
111
+ this.bootCheck = void 0;
112
+ this.autoCheck();
113
+ }, this.bootGraceSeconds * 1e3);
114
+ this.bootCheck.unref();
115
+ this.timer = setInterval(() => void this.autoCheck(), 3600 * 1e3);
77
116
  this.timer.unref();
78
117
  }
79
118
  stop() {
80
119
  if (this.timer) clearInterval(this.timer);
81
120
  this.timer = void 0;
121
+ if (this.bootCheck) clearTimeout(this.bootCheck);
122
+ this.bootCheck = void 0;
123
+ }
124
+ /**
125
+ * Pause or resume automatic curation (B-line G2, Hermes `set_paused`
126
+ * parity): the flag is persisted on the curator state record and the
127
+ * `run()` paused gate skips automatic passes while it holds. Manual runs
128
+ * (`ignoreGates`) are unaffected — pause is a soft stop for the scheduler,
129
+ * not a lock on the operator.
130
+ *
131
+ * Pausing on a state-less curator state seeds the record with `lastRunAt:
132
+ * now`, so a later resume re-enters through the interval gate and defers a
133
+ * full cycle instead of firing immediately (first-run defer interaction,
134
+ * kept deliberately: an unattended resume must not auto-run mid-boot).
135
+ */
136
+ async setPaused(paused) {
137
+ const stateService = this.curatorStateService();
138
+ const persisted = await stateService?.loadCuratorState() ?? null;
139
+ await stateService?.saveCuratorState({
140
+ schemaVersion: 1,
141
+ lastRunAt: persisted?.lastRunAt ?? Date.now(),
142
+ runCount: persisted?.runCount ?? 0,
143
+ lastSummary: persisted?.lastSummary ?? (paused ? "paused" : "resumed"),
144
+ paused
145
+ });
146
+ }
147
+ /** Current persisted curator state (read-only view for /evolution curator status). */
148
+ async status() {
149
+ return await this.curatorStateService()?.loadCuratorState() ?? null;
82
150
  }
83
151
  /**
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.
152
+ * One automatic schedule check: run a pass when the persisted curator state
153
+ * (falling back to the in-memory clock for state-less compositions) is at
154
+ * least one interval old. All gates interval, idle, first-run defer,
155
+ * reentrancy stay inside `run()`, so this method only decides whether to
156
+ * wake it, and never duplicates gate logic.
88
157
  */
89
- async recommend(candidates) {
90
- if (candidates.length === 0) return [];
158
+ async autoCheck() {
159
+ const last = (await this.curatorStateService()?.loadCuratorState())?.lastRunAt ?? this.lastRun;
160
+ if (Date.now() - last >= this.lifecycle().intervalHours * 36e5) await this.run();
161
+ }
162
+ /**
163
+ * Optional Hermes-curator LLM pass. The model may only NOMINATE pruning and
164
+ * consolidation; every move stays a control-plane operation and each
165
+ * nomination is re-validated against the tree and protected markers before
166
+ * any file move. `dryRun` prepends the report-only banner.
167
+ */
168
+ async recommend(candidates, options = {}) {
169
+ const empty = {
170
+ prunings: [],
171
+ consolidations: []
172
+ };
173
+ if (candidates.length === 0) return empty;
91
174
  const llm = this.ctx.get("llm");
92
- if (!llm) return [];
175
+ if (!llm) return empty;
93
176
  const model = this.ctx.get("evolutionPolicy")?.get().curatorModel ?? "deepseek-v4-pro";
177
+ const clusters = computePrefixClusters(candidates);
178
+ const clusterLines = clusters.length === 0 ? ["Prefix clusters observed in the candidate list: (none)"] : ["Prefix clusters observed in the candidate list (orientation only — verify against the names above; you may also flag additional clusters):", ...clusters.map((cluster) => `- '${cluster.key}': ${cluster.members.join(", ")}`)];
94
179
  const prompt = [
180
+ options.dryRun ? CURATOR_DRY_RUN_BANNER : "",
95
181
  CURATOR_PROMPT,
96
182
  "",
97
- "Stale candidates observed by the deterministic lifecycle scanner:",
183
+ `Stale candidates observed by the deterministic lifecycle scanner:${candidates.length === 0 ? " (none)" : ""}`,
98
184
  ...candidates.map((name) => `- ${name}`),
99
185
  "",
100
- "Return a YAML summary with a prunings list. Nominate only candidates whose archival is clearly safe."
186
+ ...clusterLines,
187
+ "",
188
+ "Return a YAML summary with consolidations and prunings lists. Nominate only actions whose archival/merge is clearly safe."
101
189
  ].join("\n");
102
190
  try {
103
191
  const assembler = new BlockAssembler();
@@ -116,17 +204,57 @@ var EvolutionCurator = class extends Service {
116
204
  summary: "curator review"
117
205
  }
118
206
  })],
119
- maxTokens: this.curatorReviewMaxTokens,
120
- purpose: "evolution-curator"
207
+ maxTokens: this.curatorReviewMaxTokens
121
208
  })) 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));
209
+ const parsed = parseCuratorNominations(assembler.blocks().filter((block) => block.type === "text").map((block) => block.text).join("\n"));
210
+ return {
211
+ prunings: parsed.prunings.filter((name) => candidates.includes(name)),
212
+ consolidations: parsed.consolidations.filter((item) => candidates.includes(item.from))
213
+ };
127
214
  } catch {
128
- return [];
215
+ return empty;
216
+ }
217
+ }
218
+ /** Optional curator-state service (evolution-state-json / storage-domain). */
219
+ curatorStateService() {
220
+ return this.ctx.get("evolutionState");
221
+ }
222
+ /**
223
+ * Full-state snapshot: the skills tree plus the current curator state as an
224
+ * `extras/curator-state.json` side file. Every pre-mutation snapshot in the
225
+ * curator goes through here so a later `restoreSnapshot()` can rewind both
226
+ * the tree and the state (Hermes curator_backup backs up `.curator_state`).
227
+ */
228
+ async snapshotFull(reason = "pre-mutation") {
229
+ const state = await this.curatorStateService()?.loadCuratorState();
230
+ const extras = state === null || state === void 0 ? [] : [{
231
+ name: "curator-state.json",
232
+ content: JSON.stringify(state, null, 2)
233
+ }];
234
+ return await this.skills.snapshotAll(reason, extras);
235
+ }
236
+ /**
237
+ * Full-state rollback: restore the latest snapshot's tree/sidecars/archive
238
+ * AND the curator state it carried. The pre-rollback safety snapshot keeps
239
+ * the current tree plus current state (as extras), so the rollback itself
240
+ * is reversible.
241
+ */
242
+ async restoreSnapshot() {
243
+ const stateService = this.curatorStateService();
244
+ const currentState = await stateService?.loadCuratorState();
245
+ const extras = currentState === null || currentState === void 0 ? [] : [{
246
+ name: "curator-state.json",
247
+ content: JSON.stringify(currentState, null, 2)
248
+ }];
249
+ const result = await this.skills.restoreLatestSnapshot(extras);
250
+ if (!result.ok) return result;
251
+ const stateExtra = result.extras?.find((extra) => extra.name === "curator-state.json");
252
+ if (stateExtra && stateService) try {
253
+ await stateService.saveCuratorState(JSON.parse(stateExtra.content));
254
+ } catch (error) {
255
+ this.ctx.logger.warn(`evolution-curator: failed to restore curator state: ${error instanceof Error ? error.message : String(error)}`);
129
256
  }
257
+ return result;
130
258
  }
131
259
  skippedReport(runId, startedAt) {
132
260
  return buildCuratorRunReport({
@@ -137,57 +265,132 @@ var EvolutionCurator = class extends Service {
137
265
  llmNominations: [],
138
266
  archiveCandidates: [],
139
267
  archived: [],
140
- failed: []
268
+ failed: [],
269
+ llmReviewEnabled: this.llmReview
141
270
  });
142
271
  }
143
- async run() {
272
+ /**
273
+ * Run one curator pass. `ignoreGates` skips the interval and idle gates so an
274
+ * explicit `/evolution curator run` always executes (manual-run semantics):
275
+ * `dryRun` computes the lifecycle and the LLM nominations but performs no
276
+ * mutation, reports what WOULD happen, and does not push out the next run.
277
+ * Reentrant calls (autoStart timer + manual command at the same instant) are
278
+ * skipped with an explicit `already-running` outcome.
279
+ */
280
+ async run(options = {}) {
281
+ if (this.running) return {
282
+ stale: [],
283
+ archived: [],
284
+ errors: [],
285
+ report: this.skippedReport("already-running", (/* @__PURE__ */ new Date()).toISOString()),
286
+ skipped: "already-running"
287
+ };
288
+ this.running = true;
289
+ try {
290
+ return await this.runCore(options);
291
+ } finally {
292
+ this.running = false;
293
+ }
294
+ }
295
+ async runCore(options = {}) {
296
+ const { ignoreGates = false, dryRun = false } = options;
144
297
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
145
298
  const runId = randomUUID();
146
- const stateService = this.ctx.get("evolutionState");
299
+ const stateService = this.curatorStateService();
147
300
  const lifecycle = this.lifecycle();
148
- const persisted = await stateService?.loadCuratorState();
149
- if (persisted && Date.now() - persisted.lastRunAt < lifecycle.intervalHours * 36e5) return {
301
+ const persisted = await stateService?.loadCuratorState() ?? null;
302
+ if (!ignoreGates && persisted?.paused === true) return {
303
+ stale: [],
304
+ archived: [],
305
+ errors: [],
306
+ report: this.skippedReport(runId, startedAt),
307
+ skipped: "paused"
308
+ };
309
+ if (!ignoreGates && persisted && Date.now() - persisted.lastRunAt < lifecycle.intervalHours * 36e5) return {
150
310
  stale: [],
151
311
  archived: [],
152
312
  errors: [],
153
313
  report: this.skippedReport(runId, startedAt),
154
314
  skipped: "interval"
155
315
  };
156
- if (this.minIdleHours > 0 && this.recentSessionActive()) return {
316
+ if (!ignoreGates && this.minIdleHours > 0 && this.recentSessionActive()) return {
157
317
  stale: [],
158
318
  archived: [],
159
319
  errors: [],
160
320
  report: this.skippedReport(runId, startedAt),
161
321
  skipped: "active-session"
162
322
  };
323
+ if (!ignoreGates && persisted === null) {
324
+ await stateService?.saveCuratorState({
325
+ schemaVersion: 1,
326
+ lastRunAt: Date.now(),
327
+ runCount: 0,
328
+ lastSummary: "first-run-deferred",
329
+ paused: false
330
+ });
331
+ return {
332
+ stale: [],
333
+ archived: [],
334
+ errors: [],
335
+ report: this.skippedReport(runId, startedAt),
336
+ skipped: "first-run-deferred"
337
+ };
338
+ }
163
339
  const root = this.skills.root;
164
- const snapshotPath = await this.skills.snapshotAll("pre-curator-run");
165
- const usage = await loadUsage(root, this.io);
340
+ const rawUsage = await loadUsage(root, this.io);
341
+ const usage = dryRun ? new Map([...rawUsage].map(([name, record]) => [name, { ...record }])) : rawUsage;
342
+ const snapshotPath = dryRun ? void 0 : await this.snapshotFull("pre-curator-run");
343
+ const suppressedNames = new Set(await loadSuppressedNames(root, this.io));
344
+ const gates = new EvolutionGateSet({
345
+ exclude: this.excludeSkillNames,
346
+ referenced: this.referencedSkillNames,
347
+ suppressed: suppressedNames
348
+ });
349
+ const { bundledNames, treeNames } = await this.seedBaseline(usage);
350
+ const contents = /* @__PURE__ */ new Map();
351
+ for (const name of treeNames) {
352
+ const text = await this.skills.read(name);
353
+ if (text) contents.set(name, text);
354
+ }
355
+ const dedupMembers = [...new Set(computeDedupGroups({ contents }).filter((group) => group.length >= 2).flat())];
356
+ await this.scoreTree(usage, treeNames);
166
357
  const result = computeLifecycleTransitions(usage, {
167
358
  staleAfterDays: lifecycle.staleAfterDays,
168
359
  archiveAfterDays: lifecycle.archiveAfterDays,
169
360
  qualityWarnStaleAfterDays: this.qualityWarnStaleAfterDays,
170
361
  excludeSkillNames: this.excludeSkillNames,
171
- manageUnmanaged: this.manageUnmanaged
172
- });
173
- const errors = [];
174
- const archivedSkills = [];
175
- const llmNominations = this.llmReview ? await this.recommend(result.markStale) : [];
362
+ manageUnmanaged: this.manageUnmanaged,
363
+ pruneBuiltins: this.pruneBuiltins,
364
+ bundledNames,
365
+ suppressedNames,
366
+ referencedSkillNames: this.referencedSkillNames
367
+ }, /* @__PURE__ */ new Date(), gates);
368
+ const recommendPool = [...new Set([...result.markStale, ...dedupMembers])];
369
+ const nominations = this.llmReview ? await this.recommend(recommendPool, { dryRun }) : {
370
+ prunings: [],
371
+ consolidations: []
372
+ };
373
+ const gatedNominations = {
374
+ ...nominations,
375
+ prunings: nominations.prunings.filter((name) => result.markStale.includes(name)),
376
+ consolidations: gateConsolidations(nominations.consolidations, gates)
377
+ };
378
+ const llmNominations = gatedNominations.prunings;
176
379
  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();
380
+ const { archivedSkills, errors, consolidated } = await this.applyMutations({
381
+ dryRun,
382
+ archiveCandidates,
383
+ nominations: gatedNominations,
384
+ treeNames,
385
+ usage,
386
+ bundledNames,
387
+ suppressedNames,
388
+ root,
389
+ recommendPool: new Set(recommendPool),
390
+ stateOwned: new Set([...result.transitions.map((t) => t.name), ...archiveCandidates]),
391
+ failedFrom: new Map(result.transitions.filter((t) => t.to === "archived").map((t) => [t.name, t.from]))
392
+ });
393
+ if (!dryRun) this.lastRun = Date.now();
191
394
  const report = buildCuratorRunReport({
192
395
  runId,
193
396
  startedAt,
@@ -196,32 +399,206 @@ var EvolutionCurator = class extends Service {
196
399
  llmNominations,
197
400
  archiveCandidates,
198
401
  archived: archivedSkills,
199
- failed: archiveCandidates.filter((name) => errors.some((error) => error.startsWith(`${name}:`))).map((name) => {
402
+ failed: [...new Set([...archiveCandidates, ...gatedNominations.consolidations.map((item) => item.from)])].filter((name) => errors.some((error) => error.startsWith(`${name}:`))).map((name) => {
200
403
  return {
201
404
  name,
202
405
  reason: errors.find((item) => item.startsWith(`${name}:`))?.slice(name.length + 2) ?? "unknown"
203
406
  };
204
407
  }),
205
- snapshotPath
408
+ consolidated,
409
+ ...snapshotPath === void 0 ? {} : { snapshotPath },
410
+ llmReviewEnabled: this.llmReview
206
411
  });
207
412
  const reportsRoot = join(evolutionHome(), "reports");
208
413
  try {
209
414
  await this.io.writeText(join(reportsRoot, `curator-${runId}.json`), JSON.stringify(report, null, 2));
415
+ await this.io.writeText(join(reportsRoot, `curator-${runId}.md`), renderCuratorReportMarkdown(report));
416
+ await this.retainReports(20);
210
417
  } catch (error) {
211
418
  this.ctx.logger.warn(`evolution-curator: failed to persist report ${runId}`);
212
419
  this.ctx.logger.warn(error);
213
420
  }
421
+ const llmHint = !this.llmReview && result.markStale.length > 0 ? " (llmReview: off - deterministic archive only; set llmReview: true for the LLM merge channel)" : "";
422
+ const summary = `${dryRun ? "dry-run" : "auto"}: stale:${result.markStale.length} archived:${archivedSkills.length} consolidated:${gatedNominations.consolidations.length}${llmHint}`;
423
+ const pausedNow = (await stateService?.loadCuratorState())?.paused ?? false;
214
424
  await stateService?.saveCuratorState({
215
- lastRunAt: this.lastRun,
216
- runCount: (persisted?.runCount ?? 0) + 1,
217
- lastSummary: `stale:${result.markStale.length} archived:${archivedSkills.length}`,
218
- paused: false
425
+ schemaVersion: 1,
426
+ lastRunAt: dryRun ? persisted?.lastRunAt ?? this.lastRun : this.lastRun,
427
+ runCount: dryRun ? persisted?.runCount ?? 0 : (persisted?.runCount ?? 0) + 1,
428
+ lastSummary: summary,
429
+ paused: pausedNow
219
430
  });
220
431
  return {
221
432
  stale: result.markStale,
222
433
  archived: archivedSkills.map((item) => item.name),
223
434
  errors,
224
- report
435
+ report,
436
+ ...this.llmReview ? { nominations: gatedNominations } : {}
437
+ };
438
+ }
439
+ /**
440
+ * Seed baseline records for tree skills the sidecar has not seen yet, so
441
+ * their inactivity clock starts now (first-sight defer) and bundled skills
442
+ * become known candidates only when prune-builtins opts them in. Also
443
+ * returns the full active tree names for nomination validation.
444
+ */
445
+ async seedBaseline(usage) {
446
+ const bundledNames = /* @__PURE__ */ new Set();
447
+ const treeNames = /* @__PURE__ */ new Set();
448
+ for (const summary of await this.skills.list()) {
449
+ treeNames.add(summary.name);
450
+ if (!usage.has(summary.name)) usage.set(summary.name, emptyRecord());
451
+ if (await this.skills.isBundled(summary.name)) bundledNames.add(summary.name);
452
+ const record = usage.get(summary.name);
453
+ if (record) record.pinned = await this.skills.isPinned(summary.name);
454
+ }
455
+ return {
456
+ bundledNames,
457
+ treeNames
458
+ };
459
+ }
460
+ /**
461
+ * F13 six-factor quality scoring, persisted onto the usage records.
462
+ */
463
+ async scoreTree(usage, treeNames) {
464
+ const supportDirs = /* @__PURE__ */ new Map();
465
+ for (const name of treeNames) supportDirs.set(name, await this.skills.countSupportDirs(name));
466
+ const quality = computeQualityScores({
467
+ usage,
468
+ supportDirs,
469
+ referenceCounts: await this.referenceCounts(treeNames)
470
+ });
471
+ for (const [name, score] of quality) {
472
+ const record = usage.get(name);
473
+ if (record) {
474
+ record.quality_score = score.score;
475
+ record.quality_warn = score.warn;
476
+ }
477
+ }
478
+ }
479
+ /**
480
+ * In-degree over explicit `related_skills` frontmatter references (the DSH
481
+ * equivalent of the graph-in-degree references factor): a skill listing
482
+ * other skill names counts as one reference to each of them, so hub skills
483
+ * that are explicitly named by peers get a non-zero references factor.
484
+ */
485
+ async referenceCounts(treeNames) {
486
+ const counts = /* @__PURE__ */ new Map();
487
+ for (const name of treeNames) {
488
+ const content = await this.skills.read(name);
489
+ if (!content) continue;
490
+ for (const target of relatedSkillNames(content, name)) counts.set(target, (counts.get(target) ?? 0) + 1);
491
+ }
492
+ return counts;
493
+ }
494
+ /**
495
+ * Execute lifecycle archives and consolidation nominations, then persist the
496
+ * suppression and usage sidecars best-effort. A dry-run short-circuits: no
497
+ * file moves and no state persistence — the caller still writes the report.
498
+ */
499
+ async applyMutations(input) {
500
+ if (input.dryRun) return {
501
+ archivedSkills: [],
502
+ errors: [],
503
+ suppressedChanged: false,
504
+ consolidated: []
505
+ };
506
+ const { archiveCandidates, nominations, treeNames, usage, bundledNames, suppressedNames, root, failedFrom } = input;
507
+ const errors = [];
508
+ const archivedSkills = [];
509
+ const executedConsolidations = [];
510
+ let suppressedChanged = false;
511
+ const suppressedAdded = /* @__PURE__ */ new Set();
512
+ const stateOwned = new Set(input.stateOwned ?? []);
513
+ for (const name of archiveCandidates) {
514
+ const archived = await this.skills.archive(name, {
515
+ reason: "Lifecycle: reached archive threshold",
516
+ allowBundled: this.pruneBuiltins
517
+ });
518
+ if (!archived.ok) {
519
+ const record = usage.get(name);
520
+ const from = failedFrom?.get(name);
521
+ if (record && (from === "stale" || from === "active")) {
522
+ record.state = from;
523
+ record.archived_at = null;
524
+ stateOwned.add(name);
525
+ }
526
+ errors.push(`${name}: ${archived.message}`);
527
+ } else {
528
+ const record = usage.get(name);
529
+ if (record) {
530
+ record.state = "archived";
531
+ record.archived_at = (/* @__PURE__ */ new Date()).toISOString();
532
+ stateOwned.add(name);
533
+ }
534
+ archivedSkills.push({
535
+ name,
536
+ path: archived.path ?? "",
537
+ reason: "Lifecycle: reached archive threshold"
538
+ });
539
+ if (bundledNames.has(name)) {
540
+ suppressedNames.add(name);
541
+ suppressedAdded.add(name);
542
+ suppressedChanged = true;
543
+ }
544
+ }
545
+ }
546
+ const alreadyArchived = new Set(archiveCandidates);
547
+ for (const nomination of nominations.consolidations) {
548
+ if (alreadyArchived.has(nomination.from)) continue;
549
+ if (!treeNames.has(nomination.from) || !treeNames.has(nomination.into)) {
550
+ errors.push(`${nomination.from}: consolidation target or source missing from the skill tree`);
551
+ continue;
552
+ }
553
+ if (!input.recommendPool.has(nomination.from)) {
554
+ errors.push(`${nomination.from}: consolidation nomination outside the candidate pool — refused (advisory text has no executability authority)`);
555
+ continue;
556
+ }
557
+ const consolidated = await this.skills.consolidate(nomination.into, [nomination.from], "background_review", { ...nomination.mode === void 0 ? {} : { mode: nomination.mode } });
558
+ if (!consolidated.ok) {
559
+ errors.push(`${nomination.from}: ${consolidated.message}`);
560
+ continue;
561
+ }
562
+ const record = usage.get(nomination.from);
563
+ if (record) {
564
+ record.state = "archived";
565
+ record.archived_at = (/* @__PURE__ */ new Date()).toISOString();
566
+ stateOwned.add(nomination.from);
567
+ }
568
+ alreadyArchived.add(nomination.from);
569
+ executedConsolidations.push({
570
+ from: nomination.from,
571
+ into: nomination.into
572
+ });
573
+ archivedSkills.push({
574
+ name: nomination.from,
575
+ path: join(this.skills.root, ".archive", nomination.from),
576
+ reason: `Consolidated into ${nomination.into}`
577
+ });
578
+ }
579
+ if (suppressedChanged) try {
580
+ await updateSuppressedNames(root, this.io, (current) => {
581
+ for (const name of suppressedAdded) current.add(name);
582
+ });
583
+ } catch {
584
+ this.ctx.logger.warn("evolution-curator: failed to persist suppressed names; archived built-ins may re-enter the lifecycle");
585
+ }
586
+ try {
587
+ await mutateUsage(root, this.io, (disk) => {
588
+ foldCuratorFields(disk, usage, stateOwned);
589
+ });
590
+ } catch {
591
+ this.ctx.logger.warn("evolution-curator: failed to persist usage sidecar");
592
+ }
593
+ const usageRegistry = this.ctx.get("skillUsage");
594
+ try {
595
+ await usageRegistry?.invalidate?.();
596
+ } catch {}
597
+ return {
598
+ archivedSkills,
599
+ errors,
600
+ suppressedChanged,
601
+ consolidated: executedConsolidations
225
602
  };
226
603
  }
227
604
  recentSessionActive() {
@@ -235,11 +612,61 @@ var EvolutionCurator = class extends Service {
235
612
  }
236
613
  return latest > 0 && Date.now() - latest < this.minIdleHours * 36e5;
237
614
  }
615
+ /**
616
+ * Keep only the newest N curator reports, ordered by the report's own
617
+ * `startedAt` (the runId is a UUID and cannot order history). Best-effort
618
+ * like `retainSnapshots`: a failed removal must not fail the run that just
619
+ * persisted its report. The paired `.md` digest is pruned with its JSON.
620
+ */
621
+ async retainReports(keep) {
622
+ const reportsRoot = join(evolutionHome(), "reports");
623
+ let entries;
624
+ try {
625
+ entries = await this.io.list(reportsRoot);
626
+ } catch {
627
+ return;
628
+ }
629
+ const dated = [];
630
+ for (const name of entries.filter((entry) => entry.startsWith("curator-") && entry.endsWith(".json"))) try {
631
+ const raw = await this.io.readText(join(reportsRoot, name));
632
+ if (raw === null) continue;
633
+ const parsed = JSON.parse(raw);
634
+ const startedAt = typeof parsed.startedAt === "string" ? Date.parse(parsed.startedAt) : NaN;
635
+ if (Number.isFinite(startedAt)) dated.push({
636
+ name,
637
+ startedAt
638
+ });
639
+ } catch {}
640
+ dated.sort((a, b) => b.startedAt - a.startedAt);
641
+ for (const oldReport of dated.slice(keep)) {
642
+ const stem = oldReport.name.replace(/\.json$/, "");
643
+ try {
644
+ await this.io.remove(join(reportsRoot, oldReport.name));
645
+ } catch {}
646
+ try {
647
+ await this.io.remove(join(reportsRoot, `${stem}.md`));
648
+ } catch {}
649
+ }
650
+ }
238
651
  async latestReport() {
239
652
  const reportsRoot = join(evolutionHome(), "reports");
240
- const latest = (await this.io.list(reportsRoot)).filter((name) => name.startsWith("curator-") && name.endsWith(".json")).sort().reverse()[0];
241
- if (!latest) return null;
242
- const raw = await this.io.readText(join(reportsRoot, latest));
653
+ const names = (await this.io.list(reportsRoot)).filter((name) => name.startsWith("curator-") && name.endsWith(".json")).sort();
654
+ let latest = null;
655
+ for (const name of names) {
656
+ const raw = await this.io.readText(join(reportsRoot, name));
657
+ if (raw === null) continue;
658
+ try {
659
+ const parsed = JSON.parse(raw);
660
+ const startedAt = typeof parsed.startedAt === "string" ? Date.parse(parsed.startedAt) : 0;
661
+ if (!Number.isFinite(startedAt)) continue;
662
+ if (latest === null || startedAt > latest.startedAt) latest = {
663
+ name,
664
+ startedAt
665
+ };
666
+ } catch {}
667
+ }
668
+ if (latest === null) return null;
669
+ const raw = await this.io.readText(join(reportsRoot, latest.name));
243
670
  if (raw === null) return null;
244
671
  try {
245
672
  return JSON.parse(raw);
@@ -248,25 +675,99 @@ var EvolutionCurator = class extends Service {
248
675
  }
249
676
  }
250
677
  /**
678
+ * Read-only lifecycle scope classification: which skills are in scope,
679
+ * watched (stale/quality-warned), exempted, or protected. Uses the same
680
+ * candidate gate as `run()` (`computeScopeView` / `lifecycleCandidate`),
681
+ * so the view always predicts what a curator pass may touch.
682
+ */
683
+ async scopeView() {
684
+ const root = this.skills.root;
685
+ const usage = await loadUsage(root, this.io);
686
+ const { bundledNames } = await this.seedBaseline(usage);
687
+ const gates = new EvolutionGateSet({
688
+ exclude: this.excludeSkillNames,
689
+ referenced: this.referencedSkillNames,
690
+ suppressed: new Set(await loadSuppressedNames(root, this.io))
691
+ });
692
+ return computeScopeView(usage, {
693
+ staleAfterDays: this.lifecycle().staleAfterDays,
694
+ archiveAfterDays: this.lifecycle().archiveAfterDays,
695
+ excludeSkillNames: this.excludeSkillNames,
696
+ referencedSkillNames: this.referencedSkillNames,
697
+ suppressedNames: new Set(gates.suppressed),
698
+ manageUnmanaged: this.manageUnmanaged,
699
+ pruneBuiltins: this.pruneBuiltins,
700
+ bundledNames
701
+ }, await this.protectedNameMap(), gates);
702
+ }
703
+ /**
704
+ * Structure-health view (rc.73 A1, 008 design): degraded skills only,
705
+ * derived on demand — never persisted. Signals for review/curate proposals;
706
+ * the deterministic assessment stays here, refinement stays in the judgment
707
+ * layer.
708
+ */
709
+ async healthView() {
710
+ const thresholds = {
711
+ softBodyChars: this.healthSoftBodyChars,
712
+ stampDensityPerKb: this.healthStampDensityPerKb,
713
+ churnMinPatches: this.healthChurnMinPatches
714
+ };
715
+ const usage = await loadUsage(this.skills.root, this.io);
716
+ const observed = usageObserved(usage);
717
+ const rows = [];
718
+ for (const summary of await this.skills.list()) {
719
+ const record = usage.get(summary.name);
720
+ const assessment = await this.skills.assessHealth(summary.name, thresholds, observed && record ? {
721
+ patchCount: record.patch_count,
722
+ readCount: record.view_count
723
+ } : void 0);
724
+ if (assessment && assessment.verdict !== "healthy") rows.push({
725
+ name: summary.name,
726
+ verdict: assessment.verdict,
727
+ reasons: assessment.reasons
728
+ });
729
+ }
730
+ return rows;
731
+ }
732
+ /** Whether the library has ANY observed read evidence (C observation-window gate for churn signals). */
733
+ async usageObserved() {
734
+ return usageObserved(await loadUsage(this.skills.root, this.io));
735
+ }
736
+ /** marker info (pinned/bundled/hub-installed) per skill, from the library list. */
737
+ async protectedNameMap() {
738
+ const map = /* @__PURE__ */ new Map();
739
+ for (const summary of await this.skills.list()) if (summary.protectedBy !== null) map.set(summary.name, summary.protectedBy);
740
+ return map;
741
+ }
742
+ /**
251
743
  * Control-plane consolidation: merge source skill bodies into `target`,
252
744
  * archive the sources with an absorbed-into marker, and fold their usage
253
745
  * records into `archived` state. Snapshot-then-mutate, never a hard delete.
254
746
  */
255
747
  async consolidate(target, sources) {
256
- const blocked = [...this.excludeSkillNames].filter((name) => name === target || sources.includes(name));
748
+ const suppressedNames = new Set(await loadSuppressedNames(this.skills.root, this.io));
749
+ const gates = new EvolutionGateSet({
750
+ exclude: this.excludeSkillNames,
751
+ referenced: this.referencedSkillNames,
752
+ suppressed: suppressedNames
753
+ });
754
+ const blocked = [...new Set([target, ...sources])].filter((name) => gates.isBlocked(name));
257
755
  if (blocked.length > 0) return {
258
756
  ok: false,
259
- message: `Skill(s) excluded from lifecycle management: ${blocked.join(", ")}`
757
+ message: `Skill(s) protected from consolidation (excluded / referenced / suppressed / protected builtin): ${blocked.join(", ")}`
260
758
  };
261
- await this.skills.snapshotAll("pre-consolidate");
759
+ await this.snapshotFull("pre-consolidate");
262
760
  const result = await this.skills.consolidate(target, sources);
263
761
  if (!result.ok) return result;
264
- const usage = await loadUsage(this.skills.root, this.io);
265
- for (const source of sources) {
266
- const record = usage.get(source);
267
- if (record) record.state = "archived";
268
- }
269
- await saveUsage(this.skills.root, usage, this.io);
762
+ await mutateUsage(this.skills.root, this.io, (disk) => {
763
+ for (const source of sources) {
764
+ const record = disk.get(source);
765
+ if (record) {
766
+ record.state = "archived";
767
+ record.archived_at = (/* @__PURE__ */ new Date()).toISOString();
768
+ }
769
+ }
770
+ });
270
771
  return result;
271
772
  }
272
773
  /**
@@ -274,15 +775,25 @@ var EvolutionCurator = class extends Service {
274
775
  * and reset its usage state, keeping the recoverable-archive invariant.
275
776
  */
276
777
  async restore(name) {
277
- await this.skills.snapshotAll("pre-restore");
778
+ await this.snapshotFull("pre-restore");
278
779
  const result = await this.skills.restoreFromArchive(name);
279
780
  if (!result.ok) return result;
280
- const usage = await loadUsage(this.skills.root, this.io);
281
- const record = usage.get(name);
282
- if (record) record.state = "active";
283
- await saveUsage(this.skills.root, usage, this.io);
781
+ await mutateUsage(this.skills.root, this.io, (disk) => {
782
+ const record = disk.get(name);
783
+ if (record) {
784
+ record.state = "active";
785
+ record.archived_at = null;
786
+ }
787
+ });
788
+ if (new Set(await loadSuppressedNames(this.skills.root, this.io)).has(name)) try {
789
+ await updateSuppressedNames(this.skills.root, this.io, (current) => {
790
+ current.delete(name);
791
+ });
792
+ } catch {
793
+ this.ctx.logger.warn(`evolution-curator: failed to persist suppressed names after restoring ${name}`);
794
+ }
284
795
  return result;
285
796
  }
286
797
  };
287
798
  //#endregion
288
- export { EvolutionCurator, EvolutionCurator as default };
799
+ export { EvolutionCurator, EvolutionCurator as default, gateConsolidations };
@@ -4,8 +4,8 @@
4
4
  */
5
5
  import { Context, Service } from '@deepseek-ai/cordis';
6
6
  import type Schema from '@deepseek-ai/schemastery';
7
- import { SkillLibrary } from '@deepseek-ai/dsh-evolution-core';
8
- import { type CuratorRunReport, type SkillActionResult } from '@deepseek-ai/dsh-evolution-core';
7
+ import { EvolutionGateSet, SkillLibrary } from '@deepseek-ai/dsh-evolution-core';
8
+ import { type CuratorConsolidation, type CuratorNominations, type CuratorRunReport, type ScopeView, type SkillActionResult, type SkillHealthVerdict } from '@deepseek-ai/dsh-evolution-core';
9
9
  declare module '@deepseek-ai/cordis' {
10
10
  interface Context {
11
11
  evolutionCurator: EvolutionCurator;
@@ -27,9 +27,52 @@ 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;
40
+ /** Structure-health soft body limit (chars) — see DEFAULT_HEALTH_THRESHOLDS (rc.73 A1). */
41
+ healthSoftBodyChars?: number;
42
+ /** Structure-health stamp-density ceiling per KB — see DEFAULT_HEALTH_THRESHOLDS. */
43
+ healthStampDensityPerKb?: number;
44
+ /** Structure-health write-ghost floor: patches at/above with zero reads (A2). */
45
+ healthChurnMinPatches?: number;
32
46
  }
47
+ /** Outcome of one curator run pass. */
48
+ export interface CuratorRunOutcome {
49
+ stale: string[];
50
+ archived: string[];
51
+ errors: string[];
52
+ report: CuratorRunReport;
53
+ skipped?: string;
54
+ /** LLM nominations when the optional review pass is enabled (audit visibility). */
55
+ nominations?: CuratorNominations;
56
+ }
57
+ /** Persisted curator-state record shape (schemaVersion optional for legacy reads). */
58
+ export interface CuratorStateRecordShape {
59
+ schemaVersion?: number;
60
+ lastRunAt: number;
61
+ runCount: number;
62
+ lastSummary: string;
63
+ paused: boolean;
64
+ }
65
+ /**
66
+ * Block LLM-nominated consolidations that would touch a gate-protected name:
67
+ * exclude / referenced / suppressed skills must never merge (neither as the
68
+ * source being archived nor as the umbrella being edited). Mirrors the control
69
+ * plane's `consolidate()` guard; automatic nominations must pass the same gate.
70
+ */
71
+ export declare function gateConsolidations(consolidations: CuratorConsolidation[], gates: EvolutionGateSet | {
72
+ exclude?: ReadonlySet<string>;
73
+ referenced?: ReadonlySet<string>;
74
+ suppressed?: ReadonlySet<string>;
75
+ }): CuratorConsolidation[];
33
76
  export declare class EvolutionCurator extends Service {
34
77
  static inject: string[];
35
78
  static Config: Schema<Config>;
@@ -45,30 +88,143 @@ export declare class EvolutionCurator extends Service {
45
88
  private readonly minIdleHours;
46
89
  private readonly excludeSkillNames;
47
90
  private readonly manageUnmanaged;
91
+ private readonly pruneBuiltins;
92
+ private readonly referencedSkillNames;
93
+ private readonly bootGraceSeconds;
48
94
  private readonly curatorReviewMaxTokens;
95
+ private readonly healthSoftBodyChars;
96
+ private readonly healthStampDensityPerKb;
97
+ private readonly healthChurnMinPatches;
49
98
  private lastRun;
50
99
  private timer;
100
+ private bootCheck;
101
+ private running;
51
102
  constructor(ctx: Context, config?: Config);
52
103
  private lifecycle;
53
104
  start(): void;
54
105
  stop(): void;
55
106
  /**
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.
107
+ * Pause or resume automatic curation (B-line G2, Hermes `set_paused`
108
+ * parity): the flag is persisted on the curator state record and the
109
+ * `run()` paused gate skips automatic passes while it holds. Manual runs
110
+ * (`ignoreGates`) are unaffected pause is a soft stop for the scheduler,
111
+ * not a lock on the operator.
112
+ *
113
+ * Pausing on a state-less curator state seeds the record with `lastRunAt:
114
+ * now`, so a later resume re-enters through the interval gate and defers a
115
+ * full cycle instead of firing immediately (first-run defer interaction,
116
+ * kept deliberately: an unattended resume must not auto-run mid-boot).
60
117
  */
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;
118
+ setPaused(paused: boolean): Promise<void>;
119
+ /** Current persisted curator state (read-only view for /evolution curator status). */
120
+ status(): Promise<CuratorStateRecordShape | null>;
121
+ /**
122
+ * One automatic schedule check: run a pass when the persisted curator state
123
+ * (falling back to the in-memory clock for state-less compositions) is at
124
+ * least one interval old. All gates — interval, idle, first-run defer,
125
+ * reentrancy — stay inside `run()`, so this method only decides whether to
126
+ * wake it, and never duplicates gate logic.
127
+ */
128
+ private autoCheck;
129
+ /**
130
+ * Optional Hermes-curator LLM pass. The model may only NOMINATE pruning and
131
+ * consolidation; every move stays a control-plane operation and each
132
+ * nomination is re-validated against the tree and protected markers before
133
+ * any file move. `dryRun` prepends the report-only banner.
134
+ */
135
+ recommend(candidates: string[], options?: {
136
+ dryRun?: boolean;
137
+ }): Promise<CuratorNominations>;
138
+ /** Optional curator-state service (evolution-state-json / storage-domain). */
139
+ private curatorStateService;
140
+ /**
141
+ * Full-state snapshot: the skills tree plus the current curator state as an
142
+ * `extras/curator-state.json` side file. Every pre-mutation snapshot in the
143
+ * curator goes through here so a later `restoreSnapshot()` can rewind both
144
+ * the tree and the state (Hermes curator_backup backs up `.curator_state`).
145
+ */
146
+ snapshotFull(reason?: string): Promise<string>;
147
+ /**
148
+ * Full-state rollback: restore the latest snapshot's tree/sidecars/archive
149
+ * AND the curator state it carried. The pre-rollback safety snapshot keeps
150
+ * the current tree plus current state (as extras), so the rollback itself
151
+ * is reversible.
152
+ */
153
+ restoreSnapshot(): Promise<SkillActionResult & {
154
+ extras?: Array<{
155
+ name: string;
156
+ content: string;
157
+ }>;
69
158
  }>;
159
+ private skippedReport;
160
+ /**
161
+ * Run one curator pass. `ignoreGates` skips the interval and idle gates so an
162
+ * explicit `/evolution curator run` always executes (manual-run semantics):
163
+ * `dryRun` computes the lifecycle and the LLM nominations but performs no
164
+ * mutation, reports what WOULD happen, and does not push out the next run.
165
+ * Reentrant calls (autoStart timer + manual command at the same instant) are
166
+ * skipped with an explicit `already-running` outcome.
167
+ */
168
+ run(options?: {
169
+ ignoreGates?: boolean;
170
+ dryRun?: boolean;
171
+ }): Promise<CuratorRunOutcome>;
172
+ private runCore;
173
+ /**
174
+ * Seed baseline records for tree skills the sidecar has not seen yet, so
175
+ * their inactivity clock starts now (first-sight defer) and bundled skills
176
+ * become known candidates only when prune-builtins opts them in. Also
177
+ * returns the full active tree names for nomination validation.
178
+ */
179
+ private seedBaseline;
180
+ /**
181
+ * F13 six-factor quality scoring, persisted onto the usage records.
182
+ */
183
+ private scoreTree;
184
+ /**
185
+ * In-degree over explicit `related_skills` frontmatter references (the DSH
186
+ * equivalent of the graph-in-degree references factor): a skill listing
187
+ * other skill names counts as one reference to each of them, so hub skills
188
+ * that are explicitly named by peers get a non-zero references factor.
189
+ */
190
+ private referenceCounts;
191
+ /**
192
+ * Execute lifecycle archives and consolidation nominations, then persist the
193
+ * suppression and usage sidecars best-effort. A dry-run short-circuits: no
194
+ * file moves and no state persistence — the caller still writes the report.
195
+ */
196
+ private applyMutations;
70
197
  private recentSessionActive;
198
+ /**
199
+ * Keep only the newest N curator reports, ordered by the report's own
200
+ * `startedAt` (the runId is a UUID and cannot order history). Best-effort
201
+ * like `retainSnapshots`: a failed removal must not fail the run that just
202
+ * persisted its report. The paired `.md` digest is pruned with its JSON.
203
+ */
204
+ private retainReports;
71
205
  latestReport(): Promise<CuratorRunReport | null>;
206
+ /**
207
+ * Read-only lifecycle scope classification: which skills are in scope,
208
+ * watched (stale/quality-warned), exempted, or protected. Uses the same
209
+ * candidate gate as `run()` (`computeScopeView` / `lifecycleCandidate`),
210
+ * so the view always predicts what a curator pass may touch.
211
+ */
212
+ scopeView(): Promise<ScopeView>;
213
+ /**
214
+ * Structure-health view (rc.73 A1, 008 design): degraded skills only,
215
+ * derived on demand — never persisted. Signals for review/curate proposals;
216
+ * the deterministic assessment stays here, refinement stays in the judgment
217
+ * layer.
218
+ */
219
+ healthView(): Promise<Array<{
220
+ name: string;
221
+ verdict: SkillHealthVerdict;
222
+ reasons: string[];
223
+ }>>;
224
+ /** Whether the library has ANY observed read evidence (C observation-window gate for churn signals). */
225
+ usageObserved(): Promise<boolean>;
226
+ /** marker info (pinned/bundled/hub-installed) per skill, from the library list. */
227
+ private protectedNameMap;
72
228
  /**
73
229
  * Control-plane consolidation: merge source skill bodies into `target`,
74
230
  * archive the sources with an absorbed-into marker, and fold their usage
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.9",
4
+ "version": "0.2.0-rc.1",
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.9"
36
+ "@lmzhen/dsh-evolution-core": "^0.2.0-rc.1"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@deepseek-ai/cordis": "^4.0.1",
40
- "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
41
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
42
- "@lmzhen/dsh-evolution-io": "^0.1.0-rc.9",
43
- "@lmzhen/dsh-evolution-state": "^0.1.0-rc.9"
40
+ "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
41
+ "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
42
+ "@lmzhen/dsh-evolution-io": "^0.2.0-rc.1",
43
+ "@lmzhen/dsh-evolution-state": "^0.2.0-rc.1"
44
44
  },
45
45
  "devDependencies": {
46
- "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
47
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
48
- "@lmzhen/dsh-evolution-core": "^0.1.0-rc.9",
49
- "@lmzhen/dsh-evolution-io": "^0.1.0-rc.9",
50
- "@lmzhen/dsh-evolution-state": "^0.1.0-rc.9"
46
+ "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
47
+ "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
48
+ "@lmzhen/dsh-evolution-core": "^0.2.0-rc.1",
49
+ "@lmzhen/dsh-evolution-io": "^0.2.0-rc.1",
50
+ "@lmzhen/dsh-evolution-state": "^0.2.0-rc.1"
51
51
  }
52
52
  }