@lmzhen/dsh-evolution-commands 0.20.0 → 0.22.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,7 +2,7 @@ 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, absentHostServices, appendEvolutionEvent, applyNamespaceMigration, assertSkillsRootAliasRetired, buildLearnPrompt, canonicalWriteId, clampedNumber, classifyNamespaceMigration, composePresetEntry, elapsedSince, errorText, eventsFile, evolutionHome, evolutionRoot, isDeprecatedParamId, isMissingPath, isPresent, isUnknown, mergePresetRow, newRunRegistry, newSkillLibrary, paramSettingsId, partitionVersions, planNamespaceMigration, policyStageLimits, presetPatchText, presetRowBlock, presetRowBody, presetRowId, probeText, reportsSweepLockTarget, resolveBasePresetPatch, resolveParamId, resolveRootConfig, scopedProbeReport, sweepReports, transactIo } from "@lmzhen/dsh-evolution-core";
5
+ import { DEFAULT_SKILL_LIMITS, MAX_TIMER_DELAY_MS, PARAM_EXPOSURE, PARAM_GROUP_LABELS, absentHostServices, appendEvolutionEvent, applyNamespaceMigration, assertSkillsRootAliasRetired, buildLearnPrompt, canonicalWriteId, clampedNumber, classifyNamespaceMigration, composePresetEntry, elapsedSince, errorText, eventsFile, evolutionHome, evolutionRoot, isDeprecatedParamId, isMissingPath, isPresent, isUnknown, liveBool, mergePresetRow, newRunRegistry, newSkillLibrary, paramSettingsId, partitionVersions, planNamespaceMigration, policyStageLimits, presetPatchText, presetRowBlock, presetRowBody, presetRowId, probeText, reportsSweepLockTarget, resolveBasePresetPatch, resolveParamId, resolveRootConfig, scopedProbeReport, sweepReports, transactIo } from "@lmzhen/dsh-evolution-core";
6
6
  import { buildEnrichment, buildMaintainFacts, enrichmentSnapshotOptions, runMaintain, snapshotFromLibrary } from "@lmzhen/dsh-evolution-maintenance";
7
7
  import { copyFileSync, existsSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
8
8
  import { dirname, join } from "node:path";
@@ -235,7 +235,7 @@ async function readPlatformView(ctx) {
235
235
  */
236
236
  /** The patch file name inside a profile directory (`packages/boot/app-boot/src/profile.ts`,
237
237
  * `PROFILE_PATCH_FILENAME`). */
238
- const PROFILE_PATCH_FILENAME = "cordis.patch.yml";
238
+ const PROFILE_PATCH_FILENAME$1 = "cordis.patch.yml";
239
239
  /**
240
240
  * The profile patch this process writes into, or undefined outside a profile.
241
241
  *
@@ -251,7 +251,7 @@ function presetProfileTarget(ctx) {
251
251
  return {
252
252
  profile: profile.name,
253
253
  profileDir: profile.dir,
254
- patchPath: typeof profile.patchPath === "string" && profile.patchPath !== "" ? profile.patchPath : join(profile.dir, PROFILE_PATCH_FILENAME),
254
+ patchPath: typeof profile.patchPath === "string" && profile.patchPath !== "" ? profile.patchPath : join(profile.dir, PROFILE_PATCH_FILENAME$1),
255
255
  bundles: Array.isArray(profile.startedBundles) ? profile.startedBundles : []
256
256
  };
257
257
  }
@@ -314,6 +314,135 @@ function moduleGraphPath(specifier) {
314
314
  return fileURLToPath(resolve(specifier));
315
315
  }
316
316
  //#endregion
317
+ //#region lib/types/profiles.js
318
+ /**
319
+ * The ONE reader of `<home>/profiles` (G3, 0.22).
320
+ *
321
+ * Two very different questions used to share one bare helper: diagnostics want EVERY profile under
322
+ * the home (doctor's cross-profile summary), while the preset-install exclusion must only ever look
323
+ * at THIS plane — a leftover profile from the other plane's home used to make the exclusion refuse a
324
+ * legitimate install (the two-plane split made that reachable: `~/.dsh/profiles/web` sat next
325
+ * to `profiles/desktop`). The directory is therefore read here and nowhere else:
326
+ * `scanProfiles` is the single `readdirSync` of it, `homeProfiles` answers the diagnostic
327
+ * question, and `installExclusionTarget` answers the write-path one — fail-closed, because an
328
+ * unreadable home must never read as "nothing is installed".
329
+ *
330
+ * @module evolution-commands/src/profiles
331
+ */
332
+ /** The patch file name inside a profile directory (the platform's own spelling). */
333
+ const PROFILE_PATCH_FILENAME = "cordis.patch.yml";
334
+ /** The ONLY directory read of `<home>/profiles` in the family.
335
+ * `profiles/node_modules` is a platform-created directory that never carries a manifest (S2.1), dot-entries
336
+ * are not profiles, and a directory without `package.json` is not one either.
337
+ * @param home - the resolved DSH home.
338
+ * @returns the scanned entries, or the enumeration error (never a silent empty list).
339
+ */
340
+ function scanProfiles(home) {
341
+ const root = join(home, "profiles");
342
+ if (!existsSync(root)) return {
343
+ kind: "scanned",
344
+ profiles: []
345
+ };
346
+ let entries;
347
+ try {
348
+ entries = readdirSync(root, { withFileTypes: true });
349
+ } catch (error) {
350
+ return {
351
+ kind: "unreadable",
352
+ error
353
+ };
354
+ }
355
+ const profiles = [];
356
+ for (const entry of entries) {
357
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
358
+ if (entry.name === "node_modules") continue;
359
+ const dir = join(root, entry.name);
360
+ if (!existsSync(join(dir, "package.json"))) continue;
361
+ profiles.push({
362
+ profile: entry.name,
363
+ dir
364
+ });
365
+ }
366
+ return {
367
+ kind: "scanned",
368
+ profiles
369
+ };
370
+ }
371
+ /** Every profile directory under one home, by name — the DIAGNOSTIC question (doctor's cross-profile
372
+ * summary). An unreadable home answers the empty list here because doctor reports the failure on its
373
+ * own DEGRADED row; a write path must ask {@link installExclusionTarget} instead.
374
+ * @param home - the resolved DSH home.
375
+ * @returns one entry per profile directory carrying a manifest.
376
+ */
377
+ function homeProfiles(home) {
378
+ const scan = scanProfiles(home);
379
+ return scan.kind === "scanned" ? scan.profiles : [];
380
+ }
381
+ /** The bundle names one profile's manifest declares.
382
+ * @param profileDir - the profile directory.
383
+ * @param onError - called with the read/parse failure; a torn manifest is NOT "no bundles".
384
+ * @returns the declared bundle names (empty when the manifest is absent).
385
+ */
386
+ function profileBundles(profileDir, onError) {
387
+ try {
388
+ const bundles = JSON.parse(readFileSync(join(profileDir, "package.json"), "utf8")).dsh?.profile?.bundles;
389
+ return Array.isArray(bundles) ? bundles.filter((name) => typeof name === "string") : [];
390
+ } catch (error) {
391
+ onError?.(error);
392
+ return [];
393
+ }
394
+ }
395
+ /** One optional file's text, or `''` when it is absent or unreadable.
396
+ * @param path - the file to read.
397
+ * @returns the text.
398
+ */
399
+ function readTextOrEmpty(path) {
400
+ try {
401
+ return readFileSync(path, "utf8");
402
+ } catch {
403
+ return "";
404
+ }
405
+ }
406
+ /** The profile's own patch text, or `''` when it has none.
407
+ * @param profileDir - the profile directory.
408
+ * @returns the patch text.
409
+ */
410
+ function profilePatchText(profileDir) {
411
+ return readTextOrEmpty(join(profileDir, PROFILE_PATCH_FILENAME));
412
+ }
413
+ /** Where the preset-install exclusion must look: THIS plane only, or an explicit refusal.
414
+ *
415
+ * The write path does NOT scan the home: the mounted profile is the whole question (its started
416
+ * bundles plus the ones its own manifest declares — either can double-mount), and a question that
417
+ * cannot be answered still REFUSES. That is what makes a leftover profile from the other plane
418
+ * harmless here: it is not this plane's profile, so it is never consulted.
419
+ * @param mounted - the mounted profile, or undefined outside a profile (a bare library mount).
420
+ * @returns the profile to judge with its bundle rows, or the reason a refusal is mandatory.
421
+ */
422
+ function installExclusionTarget(mounted) {
423
+ if (mounted === void 0 || mounted.dir === "") return {
424
+ kind: "undecidable",
425
+ reason: "no profile is mounted in this process, so the double-mount question cannot be answered here"
426
+ };
427
+ let manifestFailure;
428
+ const declared = profileBundles(mounted.dir, (error) => {
429
+ manifestFailure = error;
430
+ });
431
+ const manifestCode = manifestFailure?.code;
432
+ if (manifestFailure !== void 0 && manifestCode !== "ENOENT") return {
433
+ kind: "undecidable",
434
+ reason: "the mounted profile manifest could not be read"
435
+ };
436
+ return {
437
+ kind: "mounted",
438
+ profile: {
439
+ profile: mounted.profile,
440
+ dir: mounted.dir
441
+ },
442
+ bundles: [...mounted.bundles, ...declared]
443
+ };
444
+ }
445
+ //#endregion
317
446
  //#region lib/types/doctor.js
318
447
  /**
319
448
  * `/evolution doctor` — read-only self-check (0.3.55, WB2).
@@ -380,8 +509,10 @@ function presetIssues(presets) {
380
509
  * build-time guards cannot. Three classes, each with its own move:
381
510
  * - a user override (the effective value is no longer the deployment's),
382
511
  * - a deprecated alias still written in a user section (writes refuse it),
383
- * - a declared user-writable parameter whose owner publishes no user layer in
384
- * this composition (the registry promises a face this deployment never mounts).
512
+ * - a declared user-writable parameter whose owner publishes no settings namespace AT ALL
513
+ * (a registry fact: writes answer E-315), and
514
+ * - a declared user-writable parameter whose owning row this runtime does not mount
515
+ * (the registry promises a face this composition never carries).
385
516
  * G5: the two layers and the live rows come from the platform (`configEditor.configuration()`,
386
517
  * `pluginManager.listPlugins()`); the registry side (which spellings are deprecated, which rows
387
518
  * declare an E3 parameter) stays the family's own table. A surface that is not mounted is
@@ -411,14 +542,20 @@ function paramDivergences(platform) {
411
542
  return issues;
412
543
  }
413
544
  const live = liveRowTails(platform.plugins);
414
- const unreachable = PARAM_EXPOSURE.filter((entry) => entry.tier === "E3").filter((entry) => {
545
+ const noUserLayer = [];
546
+ const unmounted = [];
547
+ for (const entry of PARAM_EXPOSURE) {
548
+ if (entry.tier !== "E3") continue;
415
549
  const namespace = paramSettingsId(entry.owner);
416
- return namespace === void 0 || !live.has(namespace);
417
- }).map((entry) => entry.id);
418
- if (unreachable.length > 0) {
419
- const shown = unreachable.slice(0, 6).join(", ");
420
- const more = unreachable.length > 6 ? ` (+${unreachable.length - 6} more)` : "";
421
- issues.push(`declared user-writable but unreachable here: ${shown}${more} — the owning row is not mounted in this runtime`);
550
+ if (namespace === void 0) noUserLayer.push(entry.id);
551
+ else if (!live.has(namespace)) unmounted.push(entry.id);
552
+ }
553
+ for (const [ids, sentence] of [[noUserLayer, "declared user-writable but the owner publishes no settings namespace"], [unmounted, "declared user-writable but unreachable here"]]) {
554
+ if (ids.length === 0) continue;
555
+ const shown = ids.slice(0, 6).join(", ");
556
+ const more = ids.length > 6 ? ` (+${ids.length - 6} more)` : "";
557
+ const why = sentence.includes("no settings namespace") ? "those parameters have no user layer at all (writes answer E-315) — register the owner in PARAM_NAMESPACES or move its rows to E2" : "the owning row is not mounted in this runtime";
558
+ issues.push(`${sentence}: ${shown}${more} — ${why}`);
422
559
  }
423
560
  return issues;
424
561
  }
@@ -452,22 +589,16 @@ function asMapping(value) {
452
589
  * list alone, and treating it as "no bundles" is exactly the fail-open state
453
590
  * the double-mount exclusion exists to prevent. */
454
591
  function collectEvolutionBundles(home, onReadError) {
455
- const profilesDir = join(home, "profiles");
456
- if (!existsSync(profilesDir)) return [];
457
- const bundles = [];
458
- let profileEntries;
459
- try {
460
- profileEntries = readdirSync(profilesDir, { withFileTypes: true });
461
- } catch (error) {
462
- onReadError?.(error, "profiles-dir");
463
- return bundles;
592
+ const scan = scanProfiles(home);
593
+ if (scan.kind === "unreadable") {
594
+ onReadError?.(scan.error, "profiles-dir");
595
+ return [];
464
596
  }
465
- for (const profile of profileEntries) {
466
- if (!profile.isDirectory()) continue;
467
- if (profile.name === "node_modules" || profile.name.startsWith(".")) continue;
597
+ const bundles = [];
598
+ for (const { dir } of scan.profiles) {
468
599
  let raw;
469
600
  try {
470
- raw = readFileSync(join(profilesDir, profile.name, "package.json"), "utf8");
601
+ raw = readFileSync(join(dir, "package.json"), "utf8");
471
602
  } catch (error) {
472
603
  if (error?.code === "ENOENT") continue;
473
604
  onReadError?.(error, "profile-manifest");
@@ -571,62 +702,6 @@ function readAgentPresetBases$1(path) {
571
702
  return null;
572
703
  }
573
704
  }
574
- /** The bundle rows one profile manifest mounts (scope-agnostic, as written) — the target a preset
575
- * recompute resolves its platform base patch through.
576
- * @param profileDir - the profile directory.
577
- * @returns the bundle names; empty when the manifest is missing or torn.
578
- */
579
- function profileBundles(profileDir) {
580
- try {
581
- const bundles = JSON.parse(readFileSync(join(profileDir, "package.json"), "utf8")).dsh?.profile?.bundles;
582
- return Array.isArray(bundles) ? bundles.filter((name) => typeof name === "string") : [];
583
- } catch {
584
- return [];
585
- }
586
- }
587
- /** Every profile directory under one DSH_HOME, by name.
588
- * @param home - resolved DSH_HOME.
589
- * @returns `{ profile, dir }` per directory under `profiles/` that carries a manifest.
590
- */
591
- function profileDirectories(home) {
592
- const out = [];
593
- const root = join(home, "profiles");
594
- let entries;
595
- try {
596
- entries = readdirSync(root, { withFileTypes: true });
597
- } catch {
598
- return out;
599
- }
600
- for (const entry of entries) {
601
- if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
602
- if (entry.name === "node_modules") continue;
603
- const dir = join(root, entry.name);
604
- if (!existsSync(join(dir, "package.json"))) continue;
605
- out.push({
606
- profile: entry.name,
607
- dir
608
- });
609
- }
610
- return out;
611
- }
612
- /** One optional file's text, or `''` when it is absent or unreadable.
613
- * @param path - the file to read.
614
- * @returns the text.
615
- */
616
- function readTextOrEmpty(path) {
617
- try {
618
- return readFileSync(path, "utf8");
619
- } catch {
620
- return "";
621
- }
622
- }
623
- /** The profile patch text, or `''` when the profile has none.
624
- * @param profileDir - the profile directory.
625
- * @returns the patch text.
626
- */
627
- function profilePatchText(profileDir) {
628
- return readTextOrEmpty(join(profileDir, PROFILE_PATCH_FILENAME));
629
- }
630
705
  /** Every USER-layer patch text that can carry a family preset row: each profile's own patch, then
631
706
  * the home layer (`<home>/cordis.patch.yml`), which the platform composes AFTER the profile layer
632
707
  * (`readProfilePatches` in `packages/boot/app-boot/src/profile-context.ts`). A row written there
@@ -636,7 +711,7 @@ function profilePatchText(profileDir) {
636
711
  * @returns the patch texts that exist, in profile-then-home order.
637
712
  */
638
713
  function userPatchTexts(home) {
639
- const texts = profileDirectories(home).map(({ dir }) => profilePatchText(dir));
714
+ const texts = homeProfiles(home).map(({ dir }) => profilePatchText(dir));
640
715
  texts.push(readTextOrEmpty(join(home, PROFILE_PATCH_FILENAME)));
641
716
  return texts.filter((text) => text !== "");
642
717
  }
@@ -710,7 +785,7 @@ function probePresetFreshness(home, servicePresent) {
710
785
  } catch (error) {
711
786
  deltaFailure = `${deltaPath} could not be read (${error instanceof Error ? error.message : String(error)})`;
712
787
  }
713
- const profiles = profileDirectories(home);
788
+ const profiles = homeProfiles(home);
714
789
  if (bases === null) {
715
790
  const tablePath = basesPath ?? `${AGENT_PRESET_PACKAGE}/bases.json`;
716
791
  return profiles.flatMap(({ profile, dir }) => {
@@ -841,6 +916,7 @@ async function legacyMigrationRow(ctx, home) {
841
916
  }
842
917
  async function diagnose(ctx, options = {}) {
843
918
  const home = options.home ?? evolutionRoot();
919
+ const mounted = options.mounted;
844
920
  const conflicts = [];
845
921
  const bundles = collectEvolutionBundles(home, (error, scope) => {
846
922
  const detail = error instanceof Error ? error.message : String(error);
@@ -936,6 +1012,7 @@ async function diagnose(ctx, options = {}) {
936
1012
  installForm,
937
1013
  deploymentForm,
938
1014
  bundles,
1015
+ foreignProfiles: homeProfiles(home).filter(({ profile, dir }) => mounted === void 0 || profile !== mounted.profile && dir !== mounted.dir).map(({ profile }) => profile),
939
1016
  conflicts,
940
1017
  envIssues: env,
941
1018
  memoryIssues,
@@ -1094,6 +1171,7 @@ function renderDoctorText(report) {
1094
1171
  lines.push(`preset: ${row.base} → ${row.destination} ${verdict}`);
1095
1172
  }
1096
1173
  if (report.presetIssues.length > 0) lines.push("agent presets:", ...report.presetIssues.map((line) => ` ! ${line}`));
1174
+ if (report.foreignProfiles.length > 0) lines.push("profiles-foreign: " + report.foreignProfiles.join(", ") + " — not part of this plane: /evolution preset install ignores them, while the bundles (all profiles) line above counts them");
1097
1175
  if (report.conflicts.length > 0) lines.push("conflicts:", ...report.conflicts.map((line) => ` ! ${line}`));
1098
1176
  if (report.envIssues.length > 0) lines.push("env:", ...report.envIssues.map((line) => ` ! ${line}`));
1099
1177
  if (report.memoryIssues.length > 0) lines.push("memory:", ...report.memoryIssues.map((line) => ` ! ${line}`));
@@ -1318,6 +1396,14 @@ const COMMAND_ENTRIES = [
1318
1396
  usage: "skill undo <name> [--to v<N>]",
1319
1397
  summary: "把某个技能的内容回退到某一版(默认上一版;只动内容,撤销本身也记为一版)"
1320
1398
  },
1399
+ {
1400
+ usage: "skill pin <name>",
1401
+ summary: "钉住一个技能:免于删除、后台审查与生命周期整理(立即生效、随时可解钉;与 skill_manage 的 pin 动作同一语义,不走审批)"
1402
+ },
1403
+ {
1404
+ usage: "skill unpin <name>",
1405
+ summary: "解钉一个技能:撤销上面的冻结(立即生效、不走审批)"
1406
+ },
1321
1407
  {
1322
1408
  usage: "skills health",
1323
1409
  summary: "技能库结构体检"
@@ -1400,7 +1486,7 @@ const Config = z.object({
1400
1486
  skillsRoot: z.string().default(""),
1401
1487
  maintainCooldownMs: z.number().min(0).default(3e4),
1402
1488
  maintainTimeoutMs: z.number().min(1).default(6e5),
1403
- maintainHandoffOnSettle: z.boolean().default(false),
1489
+ maintainHandoffOnSettle: z.boolean().default(false).volatile(),
1404
1490
  threatExemptLabels: z.array(z.string()).default([])
1405
1491
  });
1406
1492
  /** How one run reads in `maintain status` output. */
@@ -1553,6 +1639,12 @@ function apply(ctx, rawConfig = {}) {
1553
1639
  const config = rawConfig;
1554
1640
  assertSkillsRootAliasRetired(rawConfig);
1555
1641
  const skillsRootValue = resolveRootConfig(rawConfig).root;
1642
+ ctx.inject(["settings"], (scope) => {
1643
+ const settings = scope.settings;
1644
+ if (typeof settings?.configure !== "function") return;
1645
+ const disposer = settings.configure({ auto: false }, ctx.fiber);
1646
+ if (typeof disposer === "function") scope.effect(() => disposer, "evolution-commands: settings presentation");
1647
+ });
1556
1648
  let runs;
1557
1649
  ctx.inject(["evolutionIo"], (ioCtx) => {
1558
1650
  const ioRegistry = ioCtx.get("evolutionIo");
@@ -1782,6 +1874,23 @@ function apply(ctx, rawConfig = {}) {
1782
1874
  };
1783
1875
  return result.ok ? ok(result.message) : err(result.message);
1784
1876
  }
1877
+ if (input === "skill pin" || input.startsWith("skill pin ") || input === "skill unpin" || input.startsWith("skill unpin ")) {
1878
+ const pinned = !input.startsWith("skill unpin");
1879
+ const name = input.slice(pinned ? 10 : 12).trim();
1880
+ if (!name) return err(`Usage: /evolution skill ${pinned ? "pin" : "unpin"} <name>`);
1881
+ const ioRegistry = ctx.get("evolutionIo");
1882
+ if (!ioRegistry) return err("Evolution IO registry not mounted — skill pinning unavailable.");
1883
+ const result = await newSkillLibrary({
1884
+ config: { root: skillsRootValue },
1885
+ io: ioRegistry.provider(),
1886
+ ctx,
1887
+ limits: {
1888
+ ...DEFAULT_SKILL_LIMITS,
1889
+ ...policyStageLimits(ctx.get("evolutionPolicy")?.get?.())
1890
+ }
1891
+ }).setPinned(name, pinned);
1892
+ return result.ok ? ok(result.message) : err(result.message);
1893
+ }
1785
1894
  if (input === "skills health") {
1786
1895
  const curator = ctx.get("evolutionCurator");
1787
1896
  if (!curator) return err(errorText("e-302-curator-service-not-mounted"));
@@ -1937,7 +2046,7 @@ function apply(ctx, rawConfig = {}) {
1937
2046
  }).catch((error) => {
1938
2047
  ctx.logger.warn(`evolution-commands: failed to record maintain event: ${String(error)}`);
1939
2048
  });
1940
- if (config.maintainHandoffOnSettle === true && resultRef !== void 0 && runAgent !== void 0) try {
2049
+ if (liveBool(config.maintainHandoffOnSettle) && resultRef !== void 0 && runAgent !== void 0) try {
1941
2050
  if (deliverHandoff(runAgent, createUserMessage({
1942
2051
  content: [{
1943
2052
  type: "text",
@@ -2096,17 +2205,17 @@ function apply(ctx, rawConfig = {}) {
2096
2205
  if (presetInstall !== null || /^preset\b/.test(input)) {
2097
2206
  if (presetInstall === null) return err("Unknown preset arguments: expected `preset install` or `preset install --base <name>[,<name>...]` (the names live in the agent package's bases.json). Got: " + input);
2098
2207
  {
2099
- let installedBundles;
2100
- try {
2101
- installedBundles = collectEvolutionBundles(evolutionRoot(), (error) => {
2102
- throw error;
2103
- });
2104
- } catch (enumerationError) {
2105
- return err(`Refusing to install the layered Evolution preset: the home profile directory or one of its profile manifests could not be read (${enumerationError instanceof Error ? enumerationError.message : String(enumerationError)}) — the all/preset double-mount check could not run. Resolve access to the profiles directory and retry, or install via the layered installer, which enforces the same exclusion up front.`);
2106
- }
2208
+ const mounted = presetProfileTarget(ctx);
2209
+ const exclusion = installExclusionTarget(mounted === void 0 ? void 0 : {
2210
+ profile: mounted.profile,
2211
+ dir: mounted.profileDir,
2212
+ bundles: mounted.bundles
2213
+ });
2214
+ if (exclusion.kind === "undecidable") return err(`Refusing to install the layered Evolution preset: ${exclusion.reason} — the all/preset double-mount check could not run. Resolve access to this profile and retry, or install via the layered installer, which enforces the same exclusion up front.`);
2215
+ const installedBundles = exclusion.bundles;
2107
2216
  const allBundle = installedBundles.find((name) => name === "dsh-evolution-all" || name.endsWith("/dsh-evolution-all"));
2108
2217
  const presetBundle = installedBundles.find((name) => name === "dsh-evolution-preset" || name.endsWith("/dsh-evolution-preset"));
2109
- if (allBundle || presetBundle) return err(`Refusing to install the layered Evolution preset: "${allBundle ?? presetBundle}" is installed in this home — its model rows (tool-memory / tool-skill-manage / tool-session-query / skill-catalog) would double-mount with the preset's and the profile fails loud at startup. Keep ONE: remove the ${allBundle ? "evolution-all bundle" : "evolution-preset bundle"} before installing the layered preset (see /evolution doctor).`);
2218
+ if (allBundle || presetBundle) return err(`Refusing to install the layered Evolution preset: "${allBundle ?? presetBundle}" is installed in this profile — its model rows (tool-memory / tool-skill-manage / tool-session-query / skill-catalog) would double-mount with the preset's and the profile fails loud at startup. Keep ONE: remove the ${allBundle ? "evolution-all bundle" : "evolution-preset bundle"} before installing the layered preset (see /evolution doctor).`);
2110
2219
  }
2111
2220
  try {
2112
2221
  const source = resolveAgentPresetDir(import.meta.url);
@@ -2145,7 +2254,7 @@ function apply(ctx, rawConfig = {}) {
2145
2254
  merged = next;
2146
2255
  }
2147
2256
  if (merged !== current) atomicWriteFiles(dirname(profile.patchPath), [{
2148
- name: PROFILE_PATCH_FILENAME,
2257
+ name: PROFILE_PATCH_FILENAME$1,
2149
2258
  content: presetPatchText(merged)
2150
2259
  }], void 0, (message) => {
2151
2260
  ctx.logger.warn(message);
@@ -2314,7 +2423,16 @@ function apply(ctx, rawConfig = {}) {
2314
2423
  return ok(renderNamespaceMigration(attempt.outcome));
2315
2424
  }
2316
2425
  if (input === "doctor" || input === "doctor --json") {
2317
- const report = await diagnose(ctx, { home: evolutionRoot() });
2426
+ const mountedForDoctor = presetProfileTarget(ctx);
2427
+ const mounted = mountedForDoctor === void 0 ? {} : { mounted: {
2428
+ profile: mountedForDoctor.profile,
2429
+ dir: mountedForDoctor.profileDir,
2430
+ bundles: mountedForDoctor.bundles
2431
+ } };
2432
+ const report = await diagnose(ctx, {
2433
+ home: evolutionRoot(),
2434
+ ...mounted
2435
+ });
2318
2436
  if (input === "doctor --json") return ok(JSON.stringify(report, null, 2));
2319
2437
  return ok(renderDoctorText(report));
2320
2438
  }
@@ -1,4 +1,5 @@
1
1
  import { type PlatformServiceProbe, type ScopedProbeReport } from '@lmzhen/dsh-evolution-core';
2
+ import type { MountedProfile } from './profiles.ts';
2
3
  /** P2-20 (audit): the TS-side copy of the install-target tails. The script
3
4
  * side (install-layered.mjs, verify-profile-bundles.mjs) imports the list
4
5
  * from packages/scripts/lib-family-packages.mjs; guard-scripts.spec.ts pins
@@ -79,6 +80,11 @@ export interface DoctorReport {
79
80
  * counterpart (inferred from the install form, P0-1) and may disagree on a
80
81
  * multi-profile machine; the render marks the aggregation explicitly. */
81
82
  bundles: string[];
83
+ /** G3 (0.22): profile directories under this home that are NOT the profile this process runs in.
84
+ * On a one-home-two-planes machine (or after a home clone) they are leftovers from the other
85
+ * plane: the write-path exclusion ignores them, while {@link bundles} still counts them — this
86
+ * list is what makes that difference visible instead of mysterious. */
87
+ foreignProfiles: string[];
82
88
  conflicts: string[];
83
89
  envIssues: string[];
84
90
  /** S0.2 (v37 P0-1): memory files carrying the platform's `{{` prompt-variable
@@ -153,6 +159,7 @@ export declare function diagnose(ctx: {
153
159
  get(name: string): unknown;
154
160
  }, options?: {
155
161
  home?: string;
162
+ mounted?: MountedProfile;
156
163
  }): Promise<DoctorReport>;
157
164
  export declare function renderDoctorText(report: DoctorReport): string;
158
165
  //# sourceMappingURL=doctor.d.ts.map
@@ -2,7 +2,7 @@
2
2
  * Human commands for the evolution family: /evolution learn|pending|curator|restore|consolidate|skills refresh.
3
3
  * @module @lmzhen/dsh-evolution-commands
4
4
  */
5
- import type { Context } from '@deepseek-ai/cordis';
5
+ import type { Context, Volatile } from '@deepseek-ai/cordis';
6
6
  import z from '@deepseek-ai/schemastery';
7
7
  import type { ContextFormed } from '@deepseek-ai/dsh-llm';
8
8
  declare module '@deepseek-ai/dsh-llm' {
@@ -38,8 +38,13 @@ export interface Config {
38
38
  maintainTimeoutMs?: number | undefined;
39
39
  /** G2 (0.20.0): when a scan settles successfully, hand its report to the model
40
40
  * automatically (the same pointer+protocol injection `maintain handoff` performs).
41
- * Default false: the injection wakes a turn, which costs a model call. */
42
- maintainHandoffOnSettle?: boolean | undefined;
41
+ * Default false: the injection wakes a turn, which costs a model call.
42
+ *
43
+ * `.volatile()` (0.21.0, G3): the field carries a USER LAYER, so the loader hands the live
44
+ * reference and the switch is read at every settle — a settings write turns it on or off without
45
+ * a reload. A directly constructed plugin still passes a plain boolean (the schema's default is
46
+ * the loader's face), so the reader accepts both. */
47
+ maintainHandoffOnSettle?: boolean | Volatile<boolean> | undefined;
43
48
  /** Threat-scan exemption labels for WRITE-path skill mutations (P2-18).
44
49
  * Threaded into the SkillLibrary this package constructs for `restructure`
45
50
  * (its only write path); the core-side constructor option carries the same
@@ -55,14 +60,14 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
55
60
  skillsRoot: z<string, string, "defined">;
56
61
  maintainCooldownMs: z<number, number, "defined">;
57
62
  maintainTimeoutMs: z<number, number, "defined">;
58
- maintainHandoffOnSettle: z<boolean, boolean, "defined">;
63
+ maintainHandoffOnSettle: z<boolean, boolean, "volatile-defined">;
59
64
  threatExemptLabels: z<string[], string[], "defined">;
60
65
  }>>, Schemastery.ObjectT<NoInfer<{
61
66
  root: z<string, string, "defined">;
62
67
  skillsRoot: z<string, string, "defined">;
63
68
  maintainCooldownMs: z<number, number, "defined">;
64
69
  maintainTimeoutMs: z<number, number, "defined">;
65
- maintainHandoffOnSettle: z<boolean, boolean, "defined">;
70
+ maintainHandoffOnSettle: z<boolean, boolean, "volatile-defined">;
66
71
  threatExemptLabels: z<string[], string[], "defined">;
67
72
  }>>, "plain">;
68
73
  export declare function apply(ctx: Context, rawConfig?: Config): void;
@@ -0,0 +1,74 @@
1
+ /** The patch file name inside a profile directory (the platform's own spelling). */
2
+ export declare const PROFILE_PATCH_FILENAME = "cordis.patch.yml";
3
+ /** One profile directory under a home. */
4
+ export interface HomeProfile {
5
+ /** The profile's directory name (what `--profile` spells). */
6
+ readonly profile: string;
7
+ /** Its absolute (or home-relative-joined) directory. */
8
+ readonly dir: string;
9
+ }
10
+ /** The outcome of reading `<home>/profiles`: the entries, or why they could not be read. */
11
+ export type ProfilesScan = {
12
+ readonly kind: 'scanned';
13
+ readonly profiles: readonly HomeProfile[];
14
+ } | {
15
+ readonly kind: 'unreadable';
16
+ readonly error: unknown;
17
+ };
18
+ /** The ONLY directory read of `<home>/profiles` in the family.
19
+ * `profiles/node_modules` is a platform-created directory that never carries a manifest (S2.1), dot-entries
20
+ * are not profiles, and a directory without `package.json` is not one either.
21
+ * @param home - the resolved DSH home.
22
+ * @returns the scanned entries, or the enumeration error (never a silent empty list).
23
+ */
24
+ export declare function scanProfiles(home: string): ProfilesScan;
25
+ /** Every profile directory under one home, by name — the DIAGNOSTIC question (doctor's cross-profile
26
+ * summary). An unreadable home answers the empty list here because doctor reports the failure on its
27
+ * own DEGRADED row; a write path must ask {@link installExclusionTarget} instead.
28
+ * @param home - the resolved DSH home.
29
+ * @returns one entry per profile directory carrying a manifest.
30
+ */
31
+ export declare function homeProfiles(home: string): readonly HomeProfile[];
32
+ /** The bundle names one profile's manifest declares.
33
+ * @param profileDir - the profile directory.
34
+ * @param onError - called with the read/parse failure; a torn manifest is NOT "no bundles".
35
+ * @returns the declared bundle names (empty when the manifest is absent).
36
+ */
37
+ export declare function profileBundles(profileDir: string, onError?: (error: unknown) => void): string[];
38
+ /** One optional file's text, or `''` when it is absent or unreadable.
39
+ * @param path - the file to read.
40
+ * @returns the text.
41
+ */
42
+ export declare function readTextOrEmpty(path: string): string;
43
+ /** The profile's own patch text, or `''` when it has none.
44
+ * @param profileDir - the profile directory.
45
+ * @returns the patch text.
46
+ */
47
+ export declare function profilePatchText(profileDir: string): string;
48
+ /** The profile this PROCESS is mounted in, as the write-path exclusion must see it. */
49
+ export interface MountedProfile {
50
+ /** The mounted profile's name. */
51
+ readonly profile: string;
52
+ /** Its directory. */
53
+ readonly dir: string;
54
+ /** The bundles this plane actually STARTED (the platform's `startedBundles`). */
55
+ readonly bundles: readonly string[];
56
+ }
57
+ /** Where the preset-install exclusion must look: THIS plane only, or an explicit refusal.
58
+ *
59
+ * The write path does NOT scan the home: the mounted profile is the whole question (its started
60
+ * bundles plus the ones its own manifest declares — either can double-mount), and a question that
61
+ * cannot be answered still REFUSES. That is what makes a leftover profile from the other plane
62
+ * harmless here: it is not this plane's profile, so it is never consulted.
63
+ * @param mounted - the mounted profile, or undefined outside a profile (a bare library mount).
64
+ * @returns the profile to judge with its bundle rows, or the reason a refusal is mandatory.
65
+ */
66
+ export declare function installExclusionTarget(mounted: MountedProfile | undefined): {
67
+ readonly kind: 'mounted';
68
+ readonly profile: HomeProfile;
69
+ readonly bundles: readonly string[];
70
+ } | {
71
+ readonly kind: 'undecidable';
72
+ readonly reason: string;
73
+ };
74
+ //# sourceMappingURL=profiles.d.ts.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-commands",
3
3
  "description": "Human commands for the evolution family (/evolution pending|curator|maintain|doctor) (community build)",
4
- "version": "0.20.0",
4
+ "version": "0.22.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -27,12 +27,12 @@
27
27
  "license": "MIT",
28
28
  "dependencies": {
29
29
  "@deepseek-ai/schemastery": "^3.18.4",
30
- "@lmzhen/dsh-evolution-approval": "^0.20.0",
31
- "@lmzhen/dsh-evolution-core": "^0.20.0",
32
- "@lmzhen/dsh-evolution-maintenance": "^0.20.0"
30
+ "@lmzhen/dsh-evolution-approval": "^0.22.0",
31
+ "@lmzhen/dsh-evolution-core": "^0.22.0",
32
+ "@lmzhen/dsh-evolution-maintenance": "^0.22.0"
33
33
  },
34
34
  "optionalDependencies": {
35
- "@lmzhen/dsh-evolution-agent-preset": "^0.20.0"
35
+ "@lmzhen/dsh-evolution-agent-preset": "^0.22.0"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@deepseek-ai/cordis": "^4.0.1",