@lmzhen/dsh-evolution-commands 0.16.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -2,11 +2,296 @@ import { createRequire } from "node:module";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { effectiveSessionPolicy } from "@lmzhen/dsh-evolution-approval";
4
4
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
5
- import { DEFAULT_SKILL_LIMITS, MAX_TIMER_DELAY_MS, PARAM_EXPOSURE, PARAM_GROUP_LABELS, PARAM_NAMESPACES, appendEvolutionEvent, assertSkillsRootAliasRetired, buildLearnPrompt, canonicalWriteId, clampedNumber, composePresetComposition, elapsedSince, errorText, eventsFile, evolutionRoot, isDeprecatedParamId, isMissingPath, newSkillLibrary, partitionVersions, policyStageLimits, resolveParamId, resolveRootConfig, scopedProbeReport } from "@lmzhen/dsh-evolution-core";
5
+ import { DEFAULT_SKILL_LIMITS, MAX_TIMER_DELAY_MS, PARAM_EXPOSURE, PARAM_GROUP_LABELS, appendEvolutionEvent, applyNamespaceMigration, assertSkillsRootAliasRetired, buildLearnPrompt, canonicalWriteId, clampedNumber, classifyNamespaceMigration, composePresetEntry, elapsedSince, errorText, eventsFile, evolutionRoot, isDeprecatedParamId, isMissingPath, mergePresetRow, newSkillLibrary, paramSettingsId, partitionVersions, planNamespaceMigration, policyStageLimits, presetPatchText, presetRowBlock, presetRowBody, presetRowId, resolveBasePresetPatch, resolveParamId, resolveRootConfig, scopedProbeReport } from "@lmzhen/dsh-evolution-core";
6
6
  import { buildEnrichment, buildMaintainFacts, runMaintain, snapshotFromLibrary } from "@lmzhen/dsh-evolution-maintenance";
7
- import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
7
+ import { copyFileSync, existsSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
8
8
  import { dirname, join } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
+ //#region lib/types/migration.js
11
+ /**
12
+ * G3 (caller half): find the legacy settings document and run the namespace migration.
13
+ *
14
+ * The platform's own one-time import knows only its private table of entries, so a family
15
+ * section left in the document only produces a warn there — the values stay in the file the
16
+ * import renamed. This module reads that file (the renamed one first, a not-yet-imported
17
+ * `settings.yaml` second), plans with core's pure planner, and merges through the platform's
18
+ * own write. Everything it touches is injected, so a spec drives it without a host.
19
+ * @module @lmzhen/dsh-evolution-commands/migration
20
+ */
21
+ /**
22
+ * The legacy document names the platform leaves behind, in read order.
23
+ * @param home - the profile home the document sits in.
24
+ * @returns both candidate paths, the renamed import first.
25
+ */
26
+ function legacySettingsPaths(home) {
27
+ return [join(home, "settings.yaml.imported"), join(home, "settings.yaml")];
28
+ }
29
+ /** Nothing to do: no document, or no settings seat to write through. */
30
+ const NOTHING = {
31
+ source: void 0,
32
+ plan: void 0,
33
+ report: void 0
34
+ };
35
+ /**
36
+ * Read the first legacy document that exists and merge what it holds.
37
+ *
38
+ * The write goes through the platform's own `settings.update`, so the merge semantics, the
39
+ * revision fence and the row's schema validation are the platform's — a row whose schema
40
+ * refuses a key refuses the whole patch, which is why the planner refuses unknown keys
41
+ * before this runs.
42
+ * @param run - the home, the reader, the settings seat and the warning sink.
43
+ * @returns the source file, its plan, and what the run did (all undefined when there was
44
+ * nothing to read or nowhere to write).
45
+ */
46
+ async function runNamespaceMigration(run) {
47
+ const settings = run.settings;
48
+ if (settings === void 0) return NOTHING;
49
+ const read = (rowId) => settings.describe?.({ redactSecrets: false }).find((entry) => entry.ns === rowId);
50
+ const update = async (rowId, patch) => {
51
+ if (settings.update === void 0) return;
52
+ await settings.update(rowId, patch);
53
+ };
54
+ if (settings.describe === void 0 || settings.update === void 0) return NOTHING;
55
+ for (const source of legacySettingsPaths(run.home)) {
56
+ const text = await run.io.readText(source);
57
+ if (text === null) continue;
58
+ const plan = planNamespaceMigration(text);
59
+ if (plan.entries.length === 0) return {
60
+ source,
61
+ plan,
62
+ report: void 0
63
+ };
64
+ return {
65
+ source,
66
+ plan,
67
+ report: await applyNamespaceMigration(plan, {
68
+ read,
69
+ update,
70
+ warn: run.warn
71
+ })
72
+ };
73
+ }
74
+ return NOTHING;
75
+ }
76
+ /**
77
+ * Read the legacy document and report what is still unmigrated.
78
+ *
79
+ * Read-only by construction: the answer comes from the pure classifier, never from the
80
+ * applier, so a doctor run cannot write. `runNamespaceMigration` and this read the SAME
81
+ * decision, which is what keeps the report and the next run's outcome from disagreeing.
82
+ * @param run - the home, the reader, the settings seat.
83
+ * @returns the state, or undefined when there is no legacy document or no settings seat.
84
+ */
85
+ async function readLegacyDocumentState(run) {
86
+ const settings = run.settings;
87
+ if (settings?.describe === void 0) return void 0;
88
+ for (const source of legacySettingsPaths(run.home)) {
89
+ const text = await run.io.readText(source);
90
+ if (text === null) continue;
91
+ const plan = planNamespaceMigration(text);
92
+ const read = (rowId) => settings.describe?.({ redactSecrets: false }).find((entry) => entry.ns === rowId);
93
+ const decided = classifyNamespaceMigration(plan, read);
94
+ return {
95
+ source,
96
+ sections: plan.entries.length,
97
+ pending: decided.write.flatMap((row) => Object.keys(row.patch).map((key) => row.rowId + "." + key)),
98
+ settled: [...decided.unchanged.map((entry) => entry.rowId + "." + entry.key), ...decided.conflicts.map((entry) => entry.rowId + "." + entry.key)]
99
+ };
100
+ }
101
+ }
102
+ /**
103
+ * Run the migration from a plugin context: probe the settings seat, the profile home and the
104
+ * IO seam, then delegate to {@link runNamespaceMigration}.
105
+ *
106
+ * The command reports `problem` as its own error text; a boot-time caller skips instead — the
107
+ * point of naming the missing prerequisite rather than throwing is that both readers can act.
108
+ * @param host - the context (services + logger).
109
+ * @returns the outcome, or the prerequisite that is absent.
110
+ */
111
+ async function migrateFromContext(host) {
112
+ const settings = host.get("settings");
113
+ if (settings?.update === void 0 || settings.describe === void 0) return {
114
+ ok: false,
115
+ problem: "settings"
116
+ };
117
+ const profile = host.get("profileContext");
118
+ const home = typeof profile?.home === "string" ? profile.home : "";
119
+ if (home === "") return {
120
+ ok: false,
121
+ problem: "home"
122
+ };
123
+ const io = host.get("evolutionIo")?.provider?.();
124
+ if (io === void 0) return {
125
+ ok: false,
126
+ problem: "io"
127
+ };
128
+ return {
129
+ ok: true,
130
+ outcome: await runNamespaceMigration({
131
+ home,
132
+ io,
133
+ settings,
134
+ warn: (message) => {
135
+ host.logger.warn(message);
136
+ }
137
+ })
138
+ };
139
+ }
140
+ /**
141
+ * Render one outcome for the command surface.
142
+ * @param outcome - the run's result.
143
+ * @returns the report text (Chinese copy, the family's user-facing language).
144
+ */
145
+ function renderNamespaceMigration(outcome) {
146
+ if (outcome.source === void 0) return "迁移:没有找到旧设置文档(settings.yaml.imported / settings.yaml),无需迁移。";
147
+ const report = outcome.report;
148
+ if (report === void 0) return "迁移:" + outcome.source + " 里没有家族的旧分区,无需迁移。";
149
+ const rows = report.written.map((row) => `${row.rowId}:${row.keys.join("/")}`).join(";");
150
+ const kept = report.conflicts.map((conflict) => `${conflict.rowId}.${conflict.key}`).join("、");
151
+ const skipped = report.skipped.map((skip) => `${skip.namespace}.${skip.key}`).join("、");
152
+ return [
153
+ `迁移源:${outcome.source}`,
154
+ `写入:${report.written.length} 行${rows === "" ? "" : `(${rows})`}`,
155
+ `已一致:${report.unchanged.length} 个键(值相同,未写入)`,
156
+ `保留现值:${report.conflicts.length} 个键${kept === "" ? "" : `(${kept})`}`,
157
+ `未迁移:${report.skipped.length} 个键${skipped === "" ? "" : `(${skipped})`}`
158
+ ].join("\n");
159
+ }
160
+ //#endregion
161
+ //#region lib/types/platform-view.js
162
+ /**
163
+ * G5: the platform's own view of the RUNNING profile, read through the services the platform
164
+ * mounts — `pluginManager` (bundles, plugin rows), `configEditor` (inherited vs override) and
165
+ * `pluginInventory` (live rows, agent presets).
166
+ *
167
+ * Read structurally on purpose: the family declares no dependency edge into the platform's
168
+ * management packages, because a plane that does not mount them must render a named line, not
169
+ * fail to load. Every reader answers `undefined` for an absent surface; the caller says what
170
+ * that means, and no caller may re-derive the same facts by scanning the profile off disk — one
171
+ * fact has one home.
172
+ * @module @lmzhen/dsh-evolution-commands/platform-view
173
+ */
174
+ /**
175
+ * Call one surface, awaiting it, and require an array back.
176
+ * @param read - the call, or undefined when the member is absent.
177
+ * @returns the array, or undefined when the member is absent, threw, or answered something else.
178
+ */
179
+ async function arrayOf(read) {
180
+ if (read === void 0) return void 0;
181
+ try {
182
+ const value = await read();
183
+ return Array.isArray(value) ? value : void 0;
184
+ } catch {
185
+ return;
186
+ }
187
+ }
188
+ /**
189
+ * Call one surface, awaiting it, and require an object back (the inventory answers one).
190
+ * @param read - the call, or undefined when the member is absent.
191
+ * @returns the object, or undefined when the member is absent, threw, or answered an array.
192
+ */
193
+ async function objectOf(read) {
194
+ if (read === void 0) return void 0;
195
+ try {
196
+ const value = await read();
197
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
198
+ } catch {
199
+ return;
200
+ }
201
+ }
202
+ /**
203
+ * Read every platform surface the doctor uses.
204
+ * @param ctx - the context (services), read structurally.
205
+ * @returns the view; each member is undefined when its surface is not mounted or did not answer.
206
+ */
207
+ async function readPlatformView(ctx) {
208
+ const manager = ctx.get("pluginManager");
209
+ const editor = ctx.get("configEditor");
210
+ const inventory = ctx.get("pluginInventory");
211
+ const listed = await objectOf(inventory?.list === void 0 ? void 0 : () => inventory.list?.());
212
+ const plugins = await arrayOf(manager?.listPlugins === void 0 ? void 0 : () => manager.listPlugins?.());
213
+ return {
214
+ bundles: await arrayOf(manager?.listBundles === void 0 ? void 0 : () => manager.listBundles?.()),
215
+ plugins: plugins ?? listed?.entries,
216
+ configuration: await arrayOf(editor?.configuration === void 0 ? void 0 : () => editor.configuration?.()),
217
+ presets: listed?.agentPresets
218
+ };
219
+ }
220
+ //#endregion
221
+ //#region lib/types/preset-source.js
222
+ /**
223
+ * Where a family preset ROW is written, and where the platform base rows it composes come from.
224
+ *
225
+ * 0.2.x replaced the preset directory with an ordinary composition row
226
+ * (`packages/preset/agent-preset/src/index.ts`), so a family preset is a row in the profile's own
227
+ * patch layer — the file the platform's Web editor also saves preset edits to — and the base rows
228
+ * are the bundle patch layer that declares the platform's preset
229
+ * (`packages/bundle/web-app/presets/<base>.patch.yml`).
230
+ *
231
+ * Platform anchors: `packages/boot/app-boot/src/profile-context.ts` (the launcher-owned
232
+ * `profileContext` — `{ name, dir, patchPath, startedBundles }` — this module reads) and
233
+ * `packages/boot/app-boot/src/profile.ts` (`PROFILE_PATCH_FILENAME`).
234
+ * @module
235
+ */
236
+ /** The patch file name inside a profile directory (`packages/boot/app-boot/src/profile.ts`,
237
+ * `PROFILE_PATCH_FILENAME`). */
238
+ const PROFILE_PATCH_FILENAME$1 = "cordis.patch.yml";
239
+ /**
240
+ * The profile patch this process writes into, or undefined outside a profile.
241
+ *
242
+ * `profileContext` is registered by the profile launcher only
243
+ * (`packages/boot/app-boot/src/profile-context.ts`); a bare library mount — the family's own specs,
244
+ * or a host started without a profile — has none, and then there is no file for a row to live in.
245
+ * @param ctx - the plugin context (a `get(name)` view is enough).
246
+ * @returns the target, or undefined when no profile is mounted.
247
+ */
248
+ function presetProfileTarget(ctx) {
249
+ const profile = ctx.get("profileContext");
250
+ if (profile === void 0 || typeof profile.dir !== "string" || profile.dir === "") return void 0;
251
+ return {
252
+ profile: profile.name,
253
+ profileDir: profile.dir,
254
+ patchPath: typeof profile.patchPath === "string" && profile.patchPath !== "" ? profile.patchPath : join(profile.dir, PROFILE_PATCH_FILENAME$1),
255
+ bundles: Array.isArray(profile.startedBundles) ? profile.startedBundles : []
256
+ };
257
+ }
258
+ /**
259
+ * Resolve the platform base preset patch one `--base` composes.
260
+ *
261
+ * The module graph is probed FIRST: a desktop/Electron host resolves the bundle package inside
262
+ * `app.asar`, where no file walk reaches — the platform packages are not on disk at all. The file
263
+ * candidates (the target profile's own `node_modules`, then the unpacked Electron resource scope,
264
+ * then a source checkout, then a global install, then `DSH_AGENT_PRESET_ROOT`) and the loud failure
265
+ * live in core's `resolveBasePresetPatch`.
266
+ * @param base - the base name from `evolution-agent/bases.json`.
267
+ * @param target - the profile target, when one is mounted.
268
+ * @returns the patch file's absolute path.
269
+ */
270
+ function resolvePresetBasePatch(base, target) {
271
+ return resolveBasePresetPatch(base, {
272
+ root: process.env["DSH_AGENT_PRESET_ROOT"],
273
+ resolve: moduleGraphPath,
274
+ profileDir: target?.profileDir,
275
+ bundles: target?.bundles,
276
+ fromDir: dirname(fileURLToPath(import.meta.url))
277
+ });
278
+ }
279
+ /**
280
+ * The module-graph probe used by {@link resolvePresetBasePatch}.
281
+ *
282
+ * `import.meta.resolve` is the only lookup an Electron main process performs asar-aware, so it is
283
+ * the candidate that reaches the platform packages inside `resources/app.asar`. The function is
284
+ * optional in the runtime and the probe is a candidate, not a requirement: core treats a throw as a
285
+ * miss and continues down the file chain.
286
+ * @param specifier - the package-relative specifier to resolve.
287
+ * @returns the resolved file path.
288
+ */
289
+ function moduleGraphPath(specifier) {
290
+ const resolve = import.meta.resolve;
291
+ if (typeof resolve !== "function") throw new Error("import.meta.resolve is unavailable in this runtime");
292
+ return fileURLToPath(resolve(specifier));
293
+ }
294
+ //#endregion
10
295
  //#region lib/types/doctor.js
11
296
  /**
12
297
  * `/evolution doctor` — read-only self-check (0.3.55, WB2).
@@ -31,51 +316,112 @@ const EVOLUTION_BUNDLE_TAILS = new Set([
31
316
  "dsh-evolution-preset"
32
317
  ]);
33
318
  /**
319
+ * G5: which of the three family bundle forms a bundle-name list carries.
320
+ * @param bundles - package names.
321
+ * @returns one flag per form.
322
+ */
323
+ function familyForms(bundles) {
324
+ return {
325
+ full: bundles.some((name) => tailOf(name) === "dsh-evolution-all"),
326
+ host: bundles.some((name) => tailOf(name) === "dsh-evolution-host"),
327
+ preset: bundles.some((name) => tailOf(name) === "dsh-evolution-preset")
328
+ };
329
+ }
330
+ /**
331
+ * G5: the live Loader entry carrying one family row.
332
+ * @param plugins - the platform's entry list.
333
+ * @param rowId - the row id the family knows.
334
+ * @returns the entry, or undefined when this runtime carries no such row.
335
+ */
336
+ function familyRow(plugins, rowId) {
337
+ return plugins.find((entry) => entry.entryId === rowId || entry.entryId.endsWith(":" + rowId) || entry.moduleName === rowId);
338
+ }
339
+ /**
340
+ * G5: the platform's own agent-preset verdicts.
341
+ *
342
+ * Only `broken` survives from the two candidates the design named. The other one — a row two
343
+ * presets both declare — was measured on a real deployment and dropped: every platform base
344
+ * composes the SAME base rows, so `persona` / `agent-instructions` / … are declared by every
345
+ * preset in a perfectly healthy install, which buries the one case worth reporting (the family's
346
+ * own bundle-vs-preset double mount, which the conflict matrix below already owns).
347
+ * @param presets - the inventory's preset list, or undefined without that surface.
348
+ * @returns one line per finding.
349
+ */
350
+ function presetIssues(presets) {
351
+ if (presets === void 0) return [];
352
+ const issues = [];
353
+ for (const preset of presets) if (preset.broken !== void 0) issues.push(`agent preset \`${preset.id}\` is BROKEN (${preset.broken}) — the platform could not compose it`);
354
+ return issues;
355
+ }
356
+ /**
34
357
  * S4.3: the parameter-surface divergences a RUNNING deployment can show and the
35
358
  * build-time guards cannot. Three classes, each with its own move:
36
359
  * - a user override (the effective value is no longer the deployment's),
37
360
  * - a deprecated alias still written in a user section (writes refuse it),
38
361
  * - a declared user-writable parameter whose owner publishes no user layer in
39
362
  * this composition (the registry promises a face this deployment never mounts).
40
- * An unreadable settings surface is reported AS SUCH: silence would read as "no
41
- * divergences", which is the one claim this section exists to check.
42
- * @param ctx - the plugin context; the settings service is read optionally.
363
+ * G5: the two layers and the live rows come from the platform (`configEditor.configuration()`,
364
+ * `pluginManager.listPlugins()`); the registry side (which spellings are deprecated, which rows
365
+ * declare an E3 parameter) stays the family's own table. A surface that is not mounted is
366
+ * reported AS SUCH: silence would read as "no divergences", the one claim this section checks.
367
+ * @param platform - the platform view.
43
368
  * @returns one readable line per divergence, empty when there is nothing to compare.
44
369
  */
45
- function paramDivergences(ctx) {
46
- const provider = ctx.get("settings");
47
- if (provider?.describe === void 0) return [];
48
- let descriptors;
49
- try {
50
- descriptors = provider.describe({ redactSecrets: false });
51
- } catch (error) {
52
- return [`settings surface unreadable (${error instanceof Error ? error.message : String(error)}) — parameter divergences were NOT checked`];
53
- }
370
+ function paramDivergences(platform) {
371
+ if (platform.configuration === void 0) return ["the platform configuration surface (configEditor.configuration) is not mounted here — parameter divergences were NOT checked"];
54
372
  const issues = [];
55
- const registered = new Map(descriptors.map((descriptor) => [descriptor.ns, descriptor.user ?? {}]));
56
- for (const [namespace, user] of registered) {
57
- const keys = Object.keys(user);
58
- if (keys.length > 0) {
59
- const shown = keys.slice(0, 5).map((key) => `${key}=${JSON.stringify(user[key])}`);
60
- const more = keys.length > shown.length ? ` and ${keys.length - shown.length} more` : "";
61
- issues.push(`user override: ${namespace} sets ${keys.length} parameter(s) — ${shown.join(", ")}${more}`);
62
- }
373
+ for (const row of platform.configuration) {
374
+ const override = asMapping(row.override);
375
+ if (override === void 0) continue;
376
+ const keys = Object.keys(override);
377
+ if (keys.length === 0) continue;
378
+ const entryId = row.entry.options?.id ?? row.entry.options?.name ?? "(unnamed entry)";
379
+ const shown = keys.slice(0, 5).map((key) => `${key}=${JSON.stringify(override[key])}`);
380
+ const more = keys.length > shown.length ? ` and ${keys.length - shown.length} more` : "";
381
+ issues.push(`user override: ${entryId} sets ${keys.length} parameter(s) — ${shown.join(", ")}${more}`);
63
382
  for (const key of keys) {
64
383
  if (!isDeprecatedParamId(key)) continue;
65
- issues.push(`deprecated name: ${namespace} still writes "${key}" — write "${resolveParamId(key)}" instead (writes refuse the alias; it is removed in 0.7.0)`);
384
+ issues.push(`deprecated name: ${entryId} still writes "${key}" — write "${resolveParamId(key)}" instead (writes refuse the alias; it is removed in 0.7.0)`);
66
385
  }
67
386
  }
387
+ if (platform.plugins === void 0) {
388
+ issues.push("the platform plugin-row surface (pluginManager.listPlugins) is not mounted here — declared-but-unreachable parameters were NOT checked");
389
+ return issues;
390
+ }
391
+ const live = liveRowTails(platform.plugins);
68
392
  const unreachable = PARAM_EXPOSURE.filter((entry) => entry.tier === "E3").filter((entry) => {
69
- const namespace = PARAM_NAMESPACES[entry.owner];
70
- return namespace === void 0 || !registered.has(namespace);
393
+ const namespace = paramSettingsId(entry.owner);
394
+ return namespace === void 0 || !live.has(namespace);
71
395
  }).map((entry) => entry.id);
72
396
  if (unreachable.length > 0) {
73
397
  const shown = unreachable.slice(0, 6).join(", ");
74
398
  const more = unreachable.length > 6 ? ` (+${unreachable.length - 6} more)` : "";
75
- issues.push(`declared user-writable but unreachable here: ${shown}${more} — the owning package publishes no user layer in this composition`);
399
+ issues.push(`declared user-writable but unreachable here: ${shown}${more} — the owning row is not mounted in this runtime`);
76
400
  }
77
401
  return issues;
78
402
  }
403
+ /**
404
+ * The row id a Loader entry id ends in (`include:memory-files` → `memory-files`).
405
+ * @param plugins - the platform's entry list.
406
+ * @returns the tails, plus every module name the platform reported.
407
+ */
408
+ function liveRowTails(plugins) {
409
+ const ids = /* @__PURE__ */ new Set();
410
+ for (const entry of plugins) {
411
+ ids.add(entry.entryId);
412
+ ids.add(entry.entryId.slice(entry.entryId.lastIndexOf(":") + 1));
413
+ if (entry.moduleName !== void 0) ids.add(entry.moduleName);
414
+ }
415
+ return ids;
416
+ }
417
+ /**
418
+ * Read a value as a plain mapping (an override layer with no keys is not a divergence).
419
+ * @param value - the parsed layer.
420
+ * @returns the mapping, or undefined for anything else.
421
+ */
422
+ function asMapping(value) {
423
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
424
+ }
79
425
  /** Profile bundle rows for evolution-family packages across all profiles.
80
426
  *
81
427
  * `onReadError` reports a profile whose manifest could not be READ or PARSED.
@@ -155,7 +501,8 @@ function memoryInterpolationIssues(home) {
155
501
  /** G3-② (B2): the family delta container. An optionalDependency of this
156
502
  * package, so resolution may legitimately fail; every failure degrades to
157
503
  * "unknown" — the doctor never throws.
158
- * Platform anchor: agentPresets.read(id) — preset/agent-presets/src/index.ts:501. */
504
+ * Platform anchor: `packages/boot/app-boot/src/profile.ts` — a family preset is a ROW in the
505
+ * profile's own patch layer there (`PROFILE_PATCH_FILENAME`), never a preset directory. */
159
506
  const AGENT_PRESET_PACKAGE = "@lmzhen/dsh-evolution-agent-preset";
160
507
  /** Resolve one asset the package declares in its exports map.
161
508
  * @param asset - bases.json or agent.cordis.yml.
@@ -182,9 +529,18 @@ function readAgentPresetBases$1(path) {
182
529
  for (const raw of parsed.bases) {
183
530
  const entry = raw;
184
531
  if (typeof entry.name !== "string" || typeof entry.id !== "string") return null;
532
+ const display = entry.display;
533
+ if (display === null || typeof display !== "object" || typeof display.name !== "string" || typeof display.description !== "string" || typeof display.order !== "number" || !Number.isInteger(display.order)) return null;
534
+ const requires = entry.requires;
185
535
  rows.push({
186
536
  name: entry.name,
187
537
  id: entry.id,
538
+ display: {
539
+ name: display.name,
540
+ description: display.description,
541
+ order: display.order
542
+ },
543
+ ...requires !== null && typeof requires === "object" && typeof requires.service === "string" ? { requires: { service: requires.service } } : {},
188
544
  ...typeof entry.unsupported === "string" ? { unsupported: entry.unsupported } : {}
189
545
  });
190
546
  }
@@ -193,67 +549,117 @@ function readAgentPresetBases$1(path) {
193
549
  return null;
194
550
  }
195
551
  }
196
- /** Preset directories already on disk, used to keep an installed variant
197
- * VISIBLE when the base table itself could not be read.
198
- * @param root - the .agent-presets directory.
199
- * @returns the directory names; empty when nothing is installed. */
200
- function installedPresetIds(root) {
201
- const ids = [];
552
+ /** The profile patch file name (`packages/boot/app-boot/src/profile.ts`,
553
+ * `PROFILE_PATCH_FILENAME`). */
554
+ const PROFILE_PATCH_FILENAME = "cordis.patch.yml";
555
+ /** Every profile directory under one DSH_HOME, by name.
556
+ * @param home - resolved DSH_HOME.
557
+ * @returns `{ profile, dir }` per directory under `profiles/` that carries a manifest.
558
+ */
559
+ function profileDirectories(home) {
560
+ const out = [];
561
+ const root = join(home, "profiles");
202
562
  let entries;
203
563
  try {
204
564
  entries = readdirSync(root, { withFileTypes: true });
205
565
  } catch {
206
- return ids;
566
+ return out;
207
567
  }
208
- for (const entry of entries) if (entry.isDirectory()) ids.push(entry.name);
209
- return ids;
568
+ for (const entry of entries) {
569
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
570
+ if (entry.name === "node_modules") continue;
571
+ const dir = join(root, entry.name);
572
+ if (!existsSync(join(dir, "package.json"))) continue;
573
+ out.push({
574
+ profile: entry.name,
575
+ dir
576
+ });
577
+ }
578
+ return out;
579
+ }
580
+ /** The bundle rows one profile manifest mounts (scope-agnostic, as written).
581
+ * @param profileDir - the profile directory.
582
+ * @returns the bundle names; empty when the manifest is missing or torn.
583
+ */
584
+ function profileBundles(profileDir) {
585
+ try {
586
+ const bundles = JSON.parse(readFileSync(join(profileDir, "package.json"), "utf8")).dsh?.profile?.bundles;
587
+ return Array.isArray(bundles) ? bundles.filter((name) => typeof name === "string") : [];
588
+ } catch {
589
+ return [];
590
+ }
591
+ }
592
+ /** The profile patch text, or `''` when the profile has none.
593
+ * @param profileDir - the profile directory.
594
+ * @returns the patch text.
595
+ */
596
+ function profilePatchText(profileDir) {
597
+ try {
598
+ return readFileSync(join(profileDir, PROFILE_PATCH_FILENAME), "utf8");
599
+ } catch {
600
+ return "";
601
+ }
602
+ }
603
+ /** Whether a base's `requires.service` precondition is met by one profile's bundles — the same
604
+ * judgement the installer applies (`baseUnavailableReason`), read from the same rows.
605
+ * @param base - the table row.
606
+ * @param bundles - the profile's bundle names.
607
+ * @returns true when the base can be installed here.
608
+ */
609
+ function baseInstallable(base, bundles) {
610
+ if (typeof base.unsupported === "string") return false;
611
+ const service = base.requires?.service;
612
+ if (typeof service !== "string" || service === "") return true;
613
+ return bundles.some((name) => name.trim().endsWith("dsh-web-app"));
210
614
  }
211
615
  /**
212
- * S1-F1: ids of the family's DELIVERED layered presets — every
213
- * `.agent-presets/<id>/agent.cordis.yml` artifact that can actually carry the
214
- * family model rows. The detection used to probe ONLY the default base id
215
- * (`evolution`), so a `--base ptc|cordis` layered install was invisible:
216
- * installForm degraded to `none`/`host` and the action ladder recommended
217
- * installing `evolution-all` on top — the exact double-mount the doctor exists
218
- * to prevent (V27 G6.4 closed it for the default id only).
616
+ * S1-F1: ids of the family's INSTALLED preset rows — every declared preset row in a profile patch
617
+ * that can actually carry the family model rows.
618
+ *
619
+ * The artifact is a declared preset ROW in a profile's own patch layer, so this probe reads that
620
+ * file (`PROFILE_PATCH_FILENAME`) in every profile of the home. A probe narrowed to one base id
621
+ * leaves a `--base ptc|cordis` install invisible: installForm then degrades to `none`/`host` and
622
+ * the action ladder recommends installing `evolution-all` on top — the exact double-mount the
623
+ * doctor exists to prevent.
219
624
  *
220
- * A directory counts only when the base table names it as a SUPPORTED family
221
- * base (the `unsupported` minimal base carries no model rows and cannot
222
- * double-mount anything). When the table itself cannot be read, every
223
- * delivered artifact counts — the visible-by-directory posture of
224
- * {@link probePresetFreshness}: flag rather than stay silent.
225
- * @param root - the `.agent-presets` home directory.
226
- * @returns installed family base ids whose `agent.cordis.yml` is on disk.
625
+ * A row counts only when the base table names its id as a SUPPORTED family base (the `unsupported`
626
+ * minimal base carries no model rows and cannot double-mount anything). When the table itself
627
+ * cannot be read, any `preset-<family stem>*` row counts — the flag-rather-than-stay-silent
628
+ * posture of {@link probePresetFreshness}.
629
+ * @param home - resolved DSH_HOME.
630
+ * @returns installed family base ids, in profile-then-table order.
227
631
  */
228
- function installedLayeredPresetIds(root) {
229
- const delivered = installedPresetIds(root).filter((id) => existsSync(join(root, id, "agent.cordis.yml")));
230
- if (delivered.length === 0) return [];
632
+ function installedLayeredPresetIds(home) {
231
633
  const basesPath = resolveAgentPresetAsset("bases.json");
232
634
  const bases = basesPath === null ? null : readAgentPresetBases$1(basesPath);
233
- if (bases === null) return delivered.filter((id) => {
234
- try {
235
- return readFileSync(join(root, id, "agent.cordis.yml"), "utf8").includes("@deepseek-ai/dsh-");
236
- } catch {
237
- return false;
635
+ const ids = /* @__PURE__ */ new Set();
636
+ for (const { dir } of profileDirectories(home)) {
637
+ const text = profilePatchText(dir);
638
+ if (text === "") continue;
639
+ if (bases === null) {
640
+ for (const match of text.matchAll(/^\s*- id:\s*(preset-[a-z0-9-]+)\s*$/gm)) if (match[1]?.startsWith(`preset-evolution`)) ids.add(match[1].slice(7));
641
+ continue;
238
642
  }
239
- });
240
- const supported = new Set(bases.filter((row) => row.unsupported === void 0).map((row) => row.id));
241
- return delivered.filter((id) => supported.has(id));
643
+ for (const base of bases) {
644
+ if (typeof base.unsupported === "string") continue;
645
+ if (presetRowBlock(text, presetRowId(base.id)) !== null) ids.add(base.id);
646
+ }
647
+ }
648
+ return [...ids];
242
649
  }
243
650
  /**
244
651
  * G3-② (B2): recompute what a fresh generation would write for every
245
652
  * installed variant and compare it byte-for-byte with the file on disk.
246
653
  *
247
- * The recomputation goes through composePresetComposition — the SAME rule
248
- * /evolution preset install and install-layered.mjs apply (collision guard +
249
- * row-overrides.json), so doctor cannot disagree with the installer about what
250
- * "fresh" means. Read-only by contract: the snapshot is never rewritten.
654
+ * The recomputation goes through composePresetEntry — the SAME composer
655
+ * /evolution preset install and install-layered.mjs apply (base patch rows +
656
+ * collision guard + row-overrides.json), so doctor cannot disagree with the
657
+ * installer about what "fresh" means. Read-only by contract: the snapshot is
658
+ * never rewritten.
251
659
  * @param home - resolved DSH_HOME.
252
- * @param registry - the platform agent-preset registry, undefined when unmounted.
253
- * @returns one row per base in the family bases.json.
660
+ * @returns one row per profile × installable base of the family bases.json.
254
661
  */
255
- async function probePresetFreshness(home, registry) {
256
- const presetsRoot = join(home, ".agent-presets");
662
+ function probePresetFreshness(home) {
257
663
  const basesPath = resolveAgentPresetAsset("bases.json");
258
664
  const deltaPath = resolveAgentPresetAsset("agent.cordis.yml");
259
665
  const bases = basesPath === null ? null : readAgentPresetBases$1(basesPath);
@@ -264,86 +670,79 @@ async function probePresetFreshness(home, registry) {
264
670
  } catch (error) {
265
671
  deltaFailure = `${deltaPath} could not be read (${error instanceof Error ? error.message : String(error)})`;
266
672
  }
673
+ const profiles = profileDirectories(home);
267
674
  if (bases === null) {
268
675
  const tablePath = basesPath ?? `${AGENT_PRESET_PACKAGE}/bases.json`;
269
- return installedPresetIds(presetsRoot).map((id) => ({
270
- base: id,
271
- destination: join(presetsRoot, id),
272
- status: "unknown",
273
- detail: `the family base table could not be read (${tablePath}) — a fresh generation cannot be recomputed`
274
- }));
676
+ return profiles.flatMap(({ profile, dir }) => {
677
+ const patchPath = join(dir, PROFILE_PATCH_FILENAME);
678
+ return [...profilePatchText(dir).matchAll(/^\s*- id:\s*(preset-[a-z0-9-]+)\s*$/gm)].map((match) => match[1]).map((id) => ({
679
+ profile,
680
+ base: id.slice(7),
681
+ destination: patchPath,
682
+ status: "unknown",
683
+ detail: `the family base table could not be read (${tablePath}) — a fresh generation cannot be recomputed`
684
+ }));
685
+ });
275
686
  }
276
687
  const rows = [];
277
- for (const base of bases) {
278
- const destination = join(presetsRoot, base.id);
279
- const compositionPath = join(destination, "agent.cordis.yml");
280
- if (!existsSync(compositionPath)) {
281
- rows.push({
282
- base: base.name,
283
- destination,
284
- status: "absent"
285
- });
286
- continue;
287
- }
288
- if (registry === void 0) {
289
- rows.push({
290
- base: base.name,
291
- destination,
292
- status: "unknown",
293
- detail: "the platform agent-preset registry is not mounted, so the runtime composition cannot be read"
294
- });
295
- continue;
296
- }
297
- if (delta === null) {
298
- rows.push({
299
- base: base.name,
300
- destination,
301
- status: "unknown",
302
- detail: deltaFailure
303
- });
304
- continue;
305
- }
306
- let platform;
307
- try {
308
- platform = await registry.read(base.name);
309
- } catch (error) {
310
- rows.push({
311
- base: base.name,
312
- destination,
313
- status: "unknown",
314
- detail: `the runtime composition for base "${base.name}" could not be read (${error instanceof Error ? error.message : String(error)})`
315
- });
316
- continue;
317
- }
318
- let fresh;
319
- try {
320
- fresh = composePresetComposition(platform, delta);
321
- } catch (error) {
322
- rows.push({
323
- base: base.name,
324
- destination,
325
- status: "unknown",
326
- detail: `a fresh generation refused to compose (${error instanceof Error ? error.message : String(error)})`
327
- });
328
- continue;
329
- }
330
- let onDisk;
331
- try {
332
- onDisk = readFileSync(compositionPath, "utf8");
333
- } catch (error) {
688
+ for (const { profile, dir } of profiles) {
689
+ const patchPath = join(dir, PROFILE_PATCH_FILENAME);
690
+ const text = profilePatchText(dir);
691
+ const bundles = profileBundles(dir);
692
+ for (const base of bases) {
693
+ if (!baseInstallable(base, bundles)) continue;
694
+ const block = presetRowBlock(text, presetRowId(base.id));
695
+ if (block === null) {
696
+ rows.push({
697
+ profile,
698
+ base: base.name,
699
+ destination: patchPath,
700
+ status: "absent"
701
+ });
702
+ continue;
703
+ }
704
+ if (delta === null) {
705
+ rows.push({
706
+ profile,
707
+ base: base.name,
708
+ destination: patchPath,
709
+ status: "unknown",
710
+ detail: deltaFailure
711
+ });
712
+ continue;
713
+ }
714
+ const target = {
715
+ profile,
716
+ profileDir: dir,
717
+ patchPath,
718
+ bundles
719
+ };
720
+ let fresh;
721
+ try {
722
+ fresh = composePresetEntry(readFileSync(resolvePresetBasePatch(base.name, target), "utf8"), delta, {
723
+ rowId: presetRowId(base.id),
724
+ id: base.id,
725
+ name: base.display.name,
726
+ description: base.display.description,
727
+ order: base.display.order
728
+ });
729
+ } catch (error) {
730
+ rows.push({
731
+ profile,
732
+ base: base.name,
733
+ destination: patchPath,
734
+ status: "unknown",
735
+ detail: error instanceof Error ? error.message : String(error)
736
+ });
737
+ continue;
738
+ }
334
739
  rows.push({
740
+ profile,
335
741
  base: base.name,
336
- destination,
337
- status: "unknown",
338
- detail: `${compositionPath} could not be read (${error instanceof Error ? error.message : String(error)})`
742
+ destination: patchPath,
743
+ status: presetRowBody(fresh) === presetRowBody(block) ? "fresh" : "differs"
339
744
  });
340
- continue;
341
745
  }
342
- rows.push({
343
- base: base.name,
344
- destination,
345
- status: fresh === onDisk ? "fresh" : "differs"
346
- });
347
746
  }
348
747
  return rows;
349
748
  }
@@ -352,6 +751,54 @@ async function probePresetFreshness(home, registry) {
352
751
  * only `get` is enough); `home` defaults to the evolution root so tests can
353
752
  * point doctor at a temp DSH_HOME.
354
753
  */
754
+ /**
755
+ * The legacy settings document's remaining work, for the doctor report.
756
+ *
757
+ * Every failure mode is a verdict, not an exception: a deployment without the settings seat
758
+ * or the family IO seam reports `unavailable`, and an unreadable document is reported through
759
+ * the same warn channel the boot migration uses.
760
+ * @param ctx - the context (services), read structurally.
761
+ * @param home - the profile home the legacy document sits in.
762
+ * @returns the row the report renders.
763
+ */
764
+ async function legacyMigrationRow(ctx, home) {
765
+ const settings = ctx.get("settings");
766
+ const io = ctx.get("evolutionIo")?.provider?.();
767
+ if (settings?.describe === void 0 || io === void 0) return {
768
+ state: "unavailable",
769
+ source: void 0,
770
+ pending: []
771
+ };
772
+ try {
773
+ const state = await readLegacyDocumentState({
774
+ home,
775
+ io,
776
+ settings
777
+ });
778
+ if (state === void 0) return {
779
+ state: "absent",
780
+ source: void 0,
781
+ pending: []
782
+ };
783
+ if (state.sections === 0) return {
784
+ state: "none",
785
+ source: state.source,
786
+ pending: []
787
+ };
788
+ return {
789
+ state: state.pending.length > 0 ? "pending" : "migrated",
790
+ source: state.source,
791
+ pending: state.pending
792
+ };
793
+ } catch (error) {
794
+ ctx.logger?.warn("evolution-commands: the legacy settings document could not be read — %s", error instanceof Error ? error.message : String(error));
795
+ return {
796
+ state: "unavailable",
797
+ source: void 0,
798
+ pending: []
799
+ };
800
+ }
801
+ }
355
802
  async function diagnose(ctx, options = {}) {
356
803
  const home = options.home ?? evolutionRoot();
357
804
  const conflicts = [];
@@ -360,11 +807,18 @@ async function diagnose(ctx, options = {}) {
360
807
  conflicts.push(scope === "profiles-dir" ? `the home profiles directory could not be enumerated (${detail}) — bundle-based install-form and conflict checks are DEGRADED and may miss installed bundles` : `a profile manifest could not be read or parsed (${detail}) — this profile's bundle rows are UNKNOWN, so the install-form and conflict checks are DEGRADED and may miss installed bundles`);
361
808
  });
362
809
  const has = (name) => ctx.get(name) !== void 0;
363
- const presetFreshness = await probePresetFreshness(home, ctx.get("agentPresets"));
364
- const full = bundles.some((name) => tailOf(name) === "dsh-evolution-all");
365
- const host = bundles.some((name) => tailOf(name) === "dsh-evolution-host");
366
- const preset = bundles.some((name) => tailOf(name) === "dsh-evolution-preset");
367
- const layeredIds = installedLayeredPresetIds(join(home, ".agent-presets"));
810
+ const presetFreshness = probePresetFreshness(home);
811
+ const platform = await readPlatformView(ctx);
812
+ const familyBundleRows = platform.bundles?.filter((row) => tailOf(row.name).startsWith("dsh-evolution-"));
813
+ const runtimeBundles = familyBundleRows === void 0 ? void 0 : familyBundleRows.filter((row) => row.enabled).map((row) => row.name);
814
+ const dormantBundles = familyBundleRows?.filter((row) => !row.enabled).map((row) => row.name) ?? [];
815
+ const runtimeFlags = runtimeBundles === void 0 ? void 0 : familyForms(runtimeBundles);
816
+ const aggregateFlags = familyForms(bundles);
817
+ const forms = runtimeFlags ?? aggregateFlags;
818
+ const full = forms.full;
819
+ const host = forms.host;
820
+ const preset = forms.preset;
821
+ const layeredIds = installedLayeredPresetIds(home);
368
822
  const presetDirInstalled = layeredIds.length > 0;
369
823
  const layeredArtifactLabel = presetDirInstalled ? layeredIds.join(", ") : "evolution";
370
824
  const layered = host && presetDirInstalled;
@@ -373,7 +827,8 @@ async function diagnose(ctx, options = {}) {
373
827
  if (host && preset) conflicts.push("evolution-host and evolution-preset are installed together — the infra rows double-mount. Keep ONE.");
374
828
  if (full && presetDirInstalled) conflicts.push(`evolution-all and the layered Evolution preset (${layeredArtifactLabel}) are both present — the four model rows AND the systemPrompt sections double-instance across the profile and preset layers; the preset loader does not fail on it, shadowing semantics take the nearest layer, so an Evolution-preset session silently runs the preset's copies of everything. Keep ONE (use layered without all, or drop the preset).`);
375
829
  if (preset && presetDirInstalled) conflicts.push(`evolution-preset and the layered Evolution preset (${layeredArtifactLabel}) are both present — the preset bundle carries the same model rows as all, so the four model rows AND the systemPrompt sections double-instance and shadowing semantics take the nearest layer (the preset loader does not fail on it). Keep ONE (drop the preset bundle, or remove the layered preset).`);
376
- const reviewMounted = full || host || preset;
830
+ const reviewRow = platform.plugins === void 0 ? void 0 : familyRow(platform.plugins, "evolution-review");
831
+ const reviewMounted = reviewRow?.enabled ?? (full || host || preset);
377
832
  const services = {
378
833
  review: reviewMounted,
379
834
  curator: has("evolutionCurator"),
@@ -408,15 +863,19 @@ async function diagnose(ctx, options = {}) {
408
863
  if (mountConflicts.length > 0) actions.push("Resolve the conflict first: keep exactly one of evolution-all / evolution-host / evolution-preset / layered.");
409
864
  else if (undecidable) actions.push("The install form is UNDECIDABLE (see the DEGRADED row above): an unreadable profiles directory or profile manifest hides whatever is installed, so this report must not add or remove a bundle. Fix the reported read failure, then re-run /evolution doctor.");
410
865
  else if (installForm === "none") actions.push("Install the default full bundle: dsh plugin --profile web add @lmzhen/dsh-evolution-all");
411
- else if (installForm === "preset-only") actions.push(`The Evolution preset is delivered but no profile mounts an evolution bundle — re-add @lmzhen/dsh-evolution-host for the layered layout (do NOT add all on top of the preset: that double-mounts the model rows), or remove .agent-presets/${layeredArtifactLabel} if the layered layout is no longer wanted.`);
866
+ else if (installForm === "preset-only") actions.push(`The Evolution preset row is installed but no profile mounts an evolution bundle — re-add @lmzhen/dsh-evolution-host for the layered layout (do NOT add all on top of the preset: that double-mounts the model rows), or drop the row (${layeredArtifactLabel}) from the profile patch if the layered layout is no longer wanted.`);
412
867
  else if (installForm === "layered") actions.push("Variant form (session opt-in): model tools follow the Evolution preset, and a session on a platform original preset carries no family rows; add @lmzhen/dsh-evolution-all instead if every session should have them.");
413
868
  const env = envIssues();
414
869
  if (env.length > 0) actions.push("Fix the DSH_EVOLUTION_* variable listed above.");
415
870
  if (services.review && !services.curator) actions.push("Curator service is not mounted — automatic curation is off; verify the host/all bundle row set is complete.");
416
871
  if (pendingCount === null && services.approval) actions.push("Approval service is mounted but pending listing failed — check the evolution state service.");
872
+ const presetIssueRows = presetIssues(platform.presets);
873
+ if (presetIssueRows.length > 0) actions.push("Resolve the agent-preset finding above: a broken preset composes no session, and a row two presets declare is resolved by layer order (keep the declaration in one preset).");
874
+ const legacy = await legacyMigrationRow(ctx, home);
875
+ if (legacy.state === "pending") actions.push(`The legacy settings document still holds ${legacy.pending.length} family value(s) — /evolution migrate moves them onto their rows (the document itself is left alone, so nothing is lost)`);
417
876
  const memoryIssues = memoryInterpolationIssues(home);
418
877
  const budgetIssues = memoryBudgetIssues(ctx);
419
- const paramIssues = paramDivergences(ctx);
878
+ const paramIssues = paramDivergences(platform);
420
879
  const queryIssues = await sessionQueryIssues(ctx);
421
880
  if (queryIssues.length > 0) actions.push("Session search is degraded: isolate the session named above (or wait for the platform fix described in the family maintenance notes) before retrying the same query");
422
881
  if (paramIssues.length > 0) actions.push("Review the parameter divergences above: /evolution params shows every row with its source, /evolution policy set writes a user value, and a declared-but-unreachable row needs its owning plugin row mounted in this profile.");
@@ -426,7 +885,7 @@ async function diagnose(ctx, options = {}) {
426
885
  if ((executingCount ?? 0) > 0) actions.push(`${executingCount} staged write(s) are EXECUTING (an approve crashed mid-run — or one is still in flight). Inspect with /evolution pending: if you started the approve, verify the landed write and do not reject it; only reject after verifying no write is intended. For a verified orphan (this build: S2-P2-22), /evolution release <id> returns it to the pending window instead.`);
427
886
  for (const row of presetFreshness) {
428
887
  if (row.status !== "differs") continue;
429
- actions.push(`Preset variant ${row.destination} DIFFERS from a fresh generation — it is an install-time snapshot; re-run the installer (/evolution preset install) to regenerate`);
888
+ actions.push(`Preset row for base "${row.base}" in ${row.destination} DIFFERS from a fresh generation — it is an install-time snapshot; re-run the installer (/evolution preset install) to regenerate`);
430
889
  }
431
890
  return {
432
891
  installForm,
@@ -443,6 +902,12 @@ async function diagnose(ctx, options = {}) {
443
902
  executingCount,
444
903
  presetFreshness,
445
904
  scopedProbe,
905
+ legacy,
906
+ runtimeBundles: runtimeBundles ?? null,
907
+ formSource: runtimeFlags === void 0 ? "aggregate" : "platform",
908
+ presetIssues: presetIssueRows,
909
+ dormantBundles,
910
+ serviceSource: reviewRow === void 0 ? "bundles" : "platform",
446
911
  actions
447
912
  };
448
913
  }
@@ -550,20 +1015,38 @@ function scopedProbeLine(check) {
550
1015
  const detail = check.verdict === "hit" ? `${check.hits} scoped evaluation(s) carried the family tools` : check.verdict === "never-hit" ? `${check.misses} scoped evaluation(s) resolved false — review injection and skill-usage telemetry are inert for every session observed; HOST-ONLY installs reach this because tool-memory / tool-skill-manage are not dependencies of evolution-host, VARIANT installs only match once a session runs the Evolution preset` : "the gate has not evaluated a session in this process yet — no session event has reached it since startup";
551
1016
  return `scoped rows: ${rows}, probe=${check.verdict} (${detail})`;
552
1017
  }
1018
+ /**
1019
+ * One line for the legacy settings document's state.
1020
+ * @param row - the report's legacy row.
1021
+ * @returns the rendered line.
1022
+ */
1023
+ function legacyLine(row) {
1024
+ if (row.state === "unavailable") return "legacy settings: unknown — no settings seat or no family IO seam is mounted here";
1025
+ if (row.state === "absent") return "legacy settings: no settings.yaml / settings.yaml.imported under this home";
1026
+ if (row.state === "none") return `legacy settings: ${row.source ?? "(unknown)"} carries no family section`;
1027
+ if (row.state === "migrated") return `legacy settings: every family value sits on its row (${row.source ?? "(unknown)"})`;
1028
+ const shown = row.pending.slice(0, 6).join(", ");
1029
+ const more = row.pending.length > 6 ? ` (+${row.pending.length - 6} more)` : "";
1030
+ return `legacy settings: ${row.pending.length} family value(s) still on ${row.source ?? "(unknown)"}: ${shown}${more} — /evolution migrate moves them`;
1031
+ }
553
1032
  function renderDoctorText(report) {
554
1033
  const lines = [
555
- `Evolution doctor — install form: ${report.installForm} (deployment: ${report.deploymentForm})`,
1034
+ `Evolution doctor — install form: ${report.installForm} (deployment: ${report.deploymentForm}; from ${report.formSource === "platform" ? "this runtime's bundle surface" : "the cross-profile aggregate"})`,
1035
+ report.runtimeBundles === null ? "runtime bundles: unknown — the platform bundle surface (pluginManager.listBundles) is not mounted here" : `runtime bundles (this profile): ${report.runtimeBundles.length > 0 ? report.runtimeBundles.join(", ") : "(none)"}`,
1036
+ report.dormantBundles.length === 0 ? null : `installed but not selected: ${report.dormantBundles.join(", ")} — the Plugins page lists them; this runtime mounts neither`,
556
1037
  `bundles (all profiles): ${report.bundles.length > 0 ? report.bundles.join(", ") : "(none)"}`,
557
- `services: review=${report.services.review} (inferred from bundles, all profiles) curator=${report.services.curator} approval=${report.services.approval} skillUsage=${report.services.skillUsage} io=${report.services.io}`,
1038
+ `services: review=${report.services.review} (${report.serviceSource === "platform" ? "the live row, this runtime" : "inferred from bundles, all profiles"}) curator=${report.services.curator} approval=${report.services.approval} skillUsage=${report.services.skillUsage} io=${report.services.io}`,
558
1039
  scopedProbeLine(report.scopedProbe),
1040
+ legacyLine(report.legacy),
559
1041
  `pending: ${report.pendingCount === null ? "unknown" : report.pendingCount}`,
560
1042
  `executing: ${report.executingCount === null ? "unknown" : report.executingCount}`
561
1043
  ];
562
1044
  for (const row of report.presetFreshness) {
563
1045
  if (row.status === "absent") continue;
564
1046
  const verdict = row.status === "fresh" ? "fresh — a fresh generation matches this file byte-for-byte" : row.status === "differs" ? "DIFFERS from a fresh generation — it is an install-time snapshot; re-run the installer (/evolution preset install) to regenerate" : `unknown — a fresh generation could not be recomputed (${row.detail ?? "reason not recorded"})`;
565
- lines.push(`preset: ${row.destination} ${verdict}`);
1047
+ lines.push(`preset: ${row.base} → ${row.destination} ${verdict}`);
566
1048
  }
1049
+ if (report.presetIssues.length > 0) lines.push("agent presets:", ...report.presetIssues.map((line) => ` ! ${line}`));
567
1050
  if (report.conflicts.length > 0) lines.push("conflicts:", ...report.conflicts.map((line) => ` ! ${line}`));
568
1051
  if (report.envIssues.length > 0) lines.push("env:", ...report.envIssues.map((line) => ` ! ${line}`));
569
1052
  if (report.memoryIssues.length > 0) lines.push("memory:", ...report.memoryIssues.map((line) => ` ! ${line}`));
@@ -571,7 +1054,7 @@ function renderDoctorText(report) {
571
1054
  if (report.paramIssues.length > 0) lines.push("parameters:", ...report.paramIssues.map((line) => ` ! ${line}`));
572
1055
  if (report.queryIssues.length > 0) lines.push("session search:", ...report.queryIssues.map((line) => ` ! ${line}`));
573
1056
  if (report.actions.length > 0) lines.push("next steps:", ...report.actions.map((line) => ` → ${line}`));
574
- return lines.join("\n");
1057
+ return lines.filter((line) => line !== null).join("\n");
575
1058
  }
576
1059
  //#endregion
577
1060
  //#region lib/types/params.js
@@ -580,10 +1063,10 @@ function renderDoctorText(report) {
580
1063
  * surface, so one text answers "which parameters exist, who may write them, and
581
1064
  * did I already override this one".
582
1065
  *
583
- * The join key is the OWNER PACKAGE: core's `PARAM_NAMESPACES` maps a package to
584
- * the one namespace it registers, and `settings.describe()` reports that
585
- * namespace's raw user section (a key's PRESENCE is the override) plus the
586
- * resolved value. Everything here is pure; the command handler supplies the data.
1066
+ * The join key is the OWNER PACKAGE: `settings.describe()` keys a section by the Loader
1067
+ * entry id, which is the row id (core's `paramSettingsId` names that mapping), and reports
1068
+ * that section's raw user section (a key's PRESENCE is the override) plus the resolved
1069
+ * value. Everything here is pure; the command handler supplies the data.
587
1070
  * @module @lmzhen/dsh-evolution-commands/params
588
1071
  */
589
1072
  /**
@@ -608,7 +1091,7 @@ function paramSurfaceRows(sections, entries = PARAM_EXPOSURE) {
608
1091
  applies: entry.applies,
609
1092
  owner: entry.owner
610
1093
  };
611
- const namespace = PARAM_NAMESPACES[entry.owner];
1094
+ const namespace = paramSettingsId(entry.owner);
612
1095
  const section = namespace === void 0 ? void 0 : sections.get(namespace);
613
1096
  if (section === void 0) return {
614
1097
  ...base,
@@ -804,6 +1287,10 @@ const COMMAND_ENTRIES = [
804
1287
  usage: "maintain [--timeout=<ms> | --facts]",
805
1288
  summary: "运行一次维护扫描(--facts 为 0 token 预览)"
806
1289
  },
1290
+ {
1291
+ usage: "migrate",
1292
+ summary: "把旧设置文档里家族的旧分区迁进现在的行(幂等;已迁移过就报「已一致」)"
1293
+ },
807
1294
  {
808
1295
  usage: "policy set <id> <value> [--expect <revision>]",
809
1296
  summary: "通过设置服务写入一个你可改的参数 (E3 only; E1/E2 stay in cordis.yml)"
@@ -814,7 +1301,7 @@ const COMMAND_ENTRIES = [
814
1301
  },
815
1302
  {
816
1303
  usage: "preset install [--base <name>[,<name>...]]",
817
- summary: "按指定基础生成自进化 Agent 预设到用户 root (bases from the agent package's bases.json)"
1304
+ summary: "把自进化 Agent 预设**行**写进当前 profile 的 patch 层(`profiles/<p>/cordis.patch.yml`;bases 来自 agent 包的 bases.json)"
818
1305
  },
819
1306
  {
820
1307
  usage: "restructure <name> \"<heading>\" <to_file> [--plan <runId>]",
@@ -1089,8 +1576,7 @@ function apply(ctx, rawConfig = {}) {
1089
1576
  text: buildLearnPrompt(request)
1090
1577
  }],
1091
1578
  source: {
1092
- kind: "plugin",
1093
- plugin: "dsh-evolution-commands",
1579
+ kind: "evolution-commands",
1094
1580
  form: "notice",
1095
1581
  summary: "learn request"
1096
1582
  }
@@ -1234,34 +1720,38 @@ function apply(ctx, rawConfig = {}) {
1234
1720
  if (entry === void 0) return err(`Unknown agent-preset base "${name}" — ${join(source, "bases.json")} lists ${table.entries.map((candidate) => candidate.name).join(", ")}`);
1235
1721
  selected.push(entry);
1236
1722
  }
1237
- const rawBases = JSON.parse(readFileSync(join(source, "bases.json"), "utf8"));
1238
1723
  const deltaPath = join(source, "agent.cordis.yml");
1239
1724
  if (!existsSync(deltaPath)) return err(`Preset file missing from ${source} — is the dsh-evolution-agent-preset package installed?`);
1240
- const registry = ctx.get("agentPresets");
1241
- if (!registry) return err("Agent preset registry not mounted — cannot compose the Evolution preset against the runtime standard.");
1725
+ const profile = presetProfileTarget(ctx);
1726
+ if (profile === void 0) return err("No profile is mounted in this process, so there is no profile patch to write the preset row into — run the command in a session started by `dsh --profile <name>`.");
1727
+ const delta = readFileSync(deltaPath, "utf8");
1728
+ const current = existsSync(profile.patchPath) ? readFileSync(profile.patchPath, "utf8") : "";
1729
+ let merged = current;
1242
1730
  const written = [];
1243
1731
  for (const base of selected) {
1244
- const ability = rawBases.bases?.find((candidate) => candidate.name === base.name);
1245
- if (typeof ability?.unsupported === "string") return err(`Base "${base.name}" is registered as UNSUPPORTED: ${ability.unsupported}`);
1246
- const requiredService = ability?.requires?.service;
1732
+ if (typeof base.unsupported === "string") return err(`Base "${base.name}" is registered as UNSUPPORTED: ${base.unsupported}`);
1733
+ const requiredService = base.requires?.service;
1247
1734
  if (typeof requiredService === "string" && ctx.get(requiredService) === void 0) return err(`Base "${base.name}" requires the "${requiredService}" service, which this deployment does not provide — the generated preset would refuse to mount.`);
1248
- const target = join(evolutionRoot(), ".agent-presets", base.id);
1249
- const presetPath = join(source, base.metadata);
1250
- if (!existsSync(presetPath)) return err(`Preset file missing from ${source} — is the dsh-evolution-agent-preset package installed?`);
1251
- const composition = composePresetComposition(await registry.read(base.name), readFileSync(deltaPath, "utf8"));
1252
- mkdirSync(target, { recursive: true });
1253
- atomicWriteFiles(target, [{
1254
- name: "agent.cordis.yml",
1255
- content: composition
1256
- }, {
1257
- name: "preset.yml",
1258
- content: readFileSync(presetPath)
1259
- }], void 0, (message) => {
1260
- ctx.logger.warn(message);
1735
+ const basePatch = resolvePresetBasePatch(base.name, profile);
1736
+ const rowId = presetRowId(base.id);
1737
+ const entryText = composePresetEntry(readFileSync(basePatch, "utf8"), delta, {
1738
+ rowId,
1739
+ id: base.id,
1740
+ name: base.display.name,
1741
+ description: base.display.description,
1742
+ order: base.display.order
1261
1743
  });
1262
- written.push(`${base.name} → ${target}`);
1744
+ const next = mergePresetRow(merged, entryText, rowId);
1745
+ written.push(next === merged ? `${base.name} → ${profile.patchPath} (already current)` : `${base.name} → ${rowId} in ${profile.patchPath}`);
1746
+ merged = next;
1263
1747
  }
1264
- return ok(`Evolution agent preset installed (${written.join("; ")}). Restart the session switcher to select it.`);
1748
+ if (merged !== current) atomicWriteFiles(dirname(profile.patchPath), [{
1749
+ name: PROFILE_PATCH_FILENAME$1,
1750
+ content: presetPatchText(merged)
1751
+ }], void 0, (message) => {
1752
+ ctx.logger.warn(message);
1753
+ });
1754
+ return ok(`Evolution agent preset row installed (${written.join("; ")}). Restart the session switcher to select it.`);
1265
1755
  } catch (error) {
1266
1756
  return err(`Preset install failed: ${error instanceof Error ? error.message : String(error)}`);
1267
1757
  }
@@ -1374,7 +1864,7 @@ function apply(ctx, rawConfig = {}) {
1374
1864
  a2: entry.tier,
1375
1865
  a3: entry.owner
1376
1866
  }));
1377
- const namespace = PARAM_NAMESPACES[entry.owner];
1867
+ const namespace = paramSettingsId(entry.owner);
1378
1868
  if (namespace === void 0) return err(errorText("e-315-id-has-no-user", {
1379
1869
  a1: id,
1380
1870
  a2: entry.owner
@@ -1415,6 +1905,15 @@ function apply(ctx, rawConfig = {}) {
1415
1905
  wasOverridden: descriptor.user !== void 0 && Object.hasOwn(descriptor.user, id)
1416
1906
  }));
1417
1907
  }
1908
+ if (input === "migrate") {
1909
+ const attempt = await migrateFromContext(ctx);
1910
+ if (!attempt.ok) {
1911
+ if (attempt.problem === "settings") return err(errorText("e-311-no-settings-service-is"));
1912
+ if (attempt.problem === "home") return err(errorText("e-320-the-profile-home-is-not"));
1913
+ return err(errorText("e-321-no-io-provider-is"));
1914
+ }
1915
+ return ok(renderNamespaceMigration(attempt.outcome));
1916
+ }
1418
1917
  if (input === "doctor" || input === "doctor --json") {
1419
1918
  const report = await diagnose(ctx, { home: evolutionRoot() });
1420
1919
  if (input === "doctor --json") return ok(JSON.stringify(report, null, 2));
@@ -1429,10 +1928,40 @@ function apply(ctx, rawConfig = {}) {
1429
1928
  };
1430
1929
  commandCtx.effect(() => commands.register(evolutionCommand));
1431
1930
  });
1931
+ let migrationRunning = false;
1932
+ const runMigrationOnce = () => {
1933
+ if (migrationRunning) return;
1934
+ migrationRunning = true;
1935
+ (async () => {
1936
+ try {
1937
+ const attempt = await migrateFromContext(ctx);
1938
+ if (!attempt.ok) {
1939
+ migrationRunning = false;
1940
+ ctx.logger.warn("evolution-commands: the legacy settings migration cannot run here (no %s) — run /evolution migrate after that service is mounted", attempt.problem);
1941
+ return;
1942
+ }
1943
+ const { outcome } = attempt;
1944
+ if (outcome.source !== void 0 && outcome.report !== void 0 && outcome.report.written.length > 0) ctx.logger.info("evolution-commands: migrated the legacy settings document (%s)", renderNamespaceMigration(outcome));
1945
+ } catch (error) {
1946
+ migrationRunning = false;
1947
+ ctx.logger.warn("evolution-commands: the legacy settings migration did not run — %s", error instanceof Error ? error.message : String(error));
1948
+ }
1949
+ })();
1950
+ };
1951
+ ctx.inject(["settings"], () => {
1952
+ runMigrationOnce();
1953
+ });
1954
+ (async () => {
1955
+ const loader = ctx.root.loader;
1956
+ try {
1957
+ await loader?.await?.();
1958
+ } catch {}
1959
+ runMigrationOnce();
1960
+ })();
1432
1961
  }
1433
1962
  /**
1434
1963
  * 0.3.14 (P1-1): locate the installed `dsh-evolution-agent-preset` package
1435
- * (the delivery container for agent.cordis.yml/preset.yml). Resolution order:
1964
+ * (the delivery container for agent.cordis.yml/bases.json). Resolution order:
1436
1965
  * npm-name sibling (published profile layout), dev-tree sibling (source/test
1437
1966
  * layout), then module resolution. The package dir name differs from the npm
1438
1967
  * name (`evolution-agent` vs `@lmzhen/dsh-evolution-agent-preset` — the rc.44
@@ -1449,11 +1978,22 @@ function readAgentPresetBases(source) {
1449
1978
  if (!Array.isArray(parsed.bases) || parsed.bases.length === 0) throw new Error(`${path} carries no bases[]`);
1450
1979
  const entries = parsed.bases.map((raw) => {
1451
1980
  const entry = raw;
1452
- if (typeof entry.name !== "string" || typeof entry.id !== "string" || typeof entry.metadata !== "string") throw new Error(`${path} entry ${JSON.stringify(raw)} needs name/id/metadata strings`);
1981
+ if (typeof entry.name !== "string" || typeof entry.id !== "string") throw new Error(`${path} entry ${JSON.stringify(raw)} needs name/id strings`);
1982
+ const display = entry.display;
1983
+ if (display === null || typeof display !== "object" || typeof display.name !== "string" || display.name === "" || typeof display.description !== "string" || display.description === "" || typeof display.order !== "number" || !Number.isInteger(display.order)) throw new Error(`${path} entry "${entry.name}" needs display { name, description, order }`);
1984
+ const requires = entry.requires;
1985
+ if (requires !== void 0 && (requires === null || typeof requires !== "object" || typeof requires.service !== "string")) throw new Error(`${path} entry "${entry.name}" needs requires.service as a string`);
1986
+ if (entry.unsupported !== void 0 && (typeof entry.unsupported !== "string" || entry.unsupported === "")) throw new Error(`${path} entry "${entry.name}" needs unsupported as a non-empty reason string`);
1453
1987
  return {
1454
1988
  name: entry.name,
1455
1989
  id: entry.id,
1456
- metadata: entry.metadata
1990
+ display: {
1991
+ name: display.name,
1992
+ description: display.description,
1993
+ order: display.order
1994
+ },
1995
+ ...requires === void 0 ? {} : { requires: { service: requires.service } },
1996
+ ...entry.unsupported === void 0 ? {} : { unsupported: entry.unsupported }
1457
1997
  };
1458
1998
  });
1459
1999
  const declared = parsed.default;
@@ -1465,7 +2005,7 @@ function readAgentPresetBases(source) {
1465
2005
  }
1466
2006
  function resolveAgentPresetDir(importMetaUrl) {
1467
2007
  const overlayCandidate = join(dirname(fileURLToPath(importMetaUrl)), "..", "..", "evolution-agent");
1468
- if (existsSync(join(overlayCandidate, "agent.cordis.yml")) && existsSync(join(overlayCandidate, "preset.yml"))) return overlayCandidate;
2008
+ if (existsSync(join(overlayCandidate, "agent.cordis.yml")) && existsSync(join(overlayCandidate, "bases.json"))) return overlayCandidate;
1469
2009
  try {
1470
2010
  return dirname(createRequire(importMetaUrl).resolve("@lmzhen/dsh-evolution-agent-preset/package.json"));
1471
2011
  } catch {