@lmzhen/dsh-evolution-commands 0.21.0 → 0.23.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,9 +2,10 @@ 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, asRecord, assertSkillsRootAliasRetired, atomicWriteFiles, buildLearnPrompt, canonicalWriteId, clampedNumber, classifyNamespaceMigration, composePresetEntry, elapsedSince, errorText, eventsFile, evolutionHome, evolutionRoot, isDeprecatedParamId, isPresent, isRecord, 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
- import { copyFileSync, existsSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
7
+ import { preferProvider } from "@lmzhen/dsh-evolution-io";
8
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
8
9
  import { dirname, join } from "node:path";
9
10
  import { fileURLToPath } from "node:url";
10
11
  //#region lib/types/migration.js
@@ -194,7 +195,7 @@ async function objectOf(read) {
194
195
  if (read === void 0) return void 0;
195
196
  try {
196
197
  const value = await read();
197
- return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
198
+ return isRecord(value) ? value : void 0;
198
199
  } catch {
199
200
  return;
200
201
  }
@@ -235,7 +236,7 @@ async function readPlatformView(ctx) {
235
236
  */
236
237
  /** The patch file name inside a profile directory (`packages/boot/app-boot/src/profile.ts`,
237
238
  * `PROFILE_PATCH_FILENAME`). */
238
- const PROFILE_PATCH_FILENAME = "cordis.patch.yml";
239
+ const PROFILE_PATCH_FILENAME$1 = "cordis.patch.yml";
239
240
  /**
240
241
  * The profile patch this process writes into, or undefined outside a profile.
241
242
  *
@@ -251,7 +252,7 @@ function presetProfileTarget(ctx) {
251
252
  return {
252
253
  profile: profile.name,
253
254
  profileDir: profile.dir,
254
- patchPath: typeof profile.patchPath === "string" && profile.patchPath !== "" ? profile.patchPath : join(profile.dir, PROFILE_PATCH_FILENAME),
255
+ patchPath: typeof profile.patchPath === "string" && profile.patchPath !== "" ? profile.patchPath : join(profile.dir, PROFILE_PATCH_FILENAME$1),
255
256
  bundles: Array.isArray(profile.startedBundles) ? profile.startedBundles : []
256
257
  };
257
258
  }
@@ -314,6 +315,135 @@ function moduleGraphPath(specifier) {
314
315
  return fileURLToPath(resolve(specifier));
315
316
  }
316
317
  //#endregion
318
+ //#region lib/types/profiles.js
319
+ /**
320
+ * The ONE reader of `<home>/profiles` (G3, 0.22).
321
+ *
322
+ * Two very different questions used to share one bare helper: diagnostics want EVERY profile under
323
+ * the home (doctor's cross-profile summary), while the preset-install exclusion must only ever look
324
+ * at THIS plane — a leftover profile from the other plane's home used to make the exclusion refuse a
325
+ * legitimate install (the two-plane split made that reachable: `~/.dsh/profiles/web` sat next
326
+ * to `profiles/desktop`). The directory is therefore read here and nowhere else:
327
+ * `scanProfiles` is the single `readdirSync` of it, `homeProfiles` answers the diagnostic
328
+ * question, and `installExclusionTarget` answers the write-path one — fail-closed, because an
329
+ * unreadable home must never read as "nothing is installed".
330
+ *
331
+ * @module evolution-commands/src/profiles
332
+ */
333
+ /** The patch file name inside a profile directory (the platform's own spelling). */
334
+ const PROFILE_PATCH_FILENAME = "cordis.patch.yml";
335
+ /** The ONLY directory read of `<home>/profiles` in the family.
336
+ * `profiles/node_modules` is a platform-created directory that never carries a manifest (S2.1), dot-entries
337
+ * are not profiles, and a directory without `package.json` is not one either.
338
+ * @param home - the resolved DSH home.
339
+ * @returns the scanned entries, or the enumeration error (never a silent empty list).
340
+ */
341
+ function scanProfiles(home) {
342
+ const root = join(home, "profiles");
343
+ if (!existsSync(root)) return {
344
+ kind: "scanned",
345
+ profiles: []
346
+ };
347
+ let entries;
348
+ try {
349
+ entries = readdirSync(root, { withFileTypes: true });
350
+ } catch (error) {
351
+ return {
352
+ kind: "unreadable",
353
+ error
354
+ };
355
+ }
356
+ const profiles = [];
357
+ for (const entry of entries) {
358
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
359
+ if (entry.name === "node_modules") continue;
360
+ const dir = join(root, entry.name);
361
+ if (!existsSync(join(dir, "package.json"))) continue;
362
+ profiles.push({
363
+ profile: entry.name,
364
+ dir
365
+ });
366
+ }
367
+ return {
368
+ kind: "scanned",
369
+ profiles
370
+ };
371
+ }
372
+ /** Every profile directory under one home, by name — the DIAGNOSTIC question (doctor's cross-profile
373
+ * summary). An unreadable home answers the empty list here because doctor reports the failure on its
374
+ * own DEGRADED row; a write path must ask {@link installExclusionTarget} instead.
375
+ * @param home - the resolved DSH home.
376
+ * @returns one entry per profile directory carrying a manifest.
377
+ */
378
+ function homeProfiles(home) {
379
+ const scan = scanProfiles(home);
380
+ return scan.kind === "scanned" ? scan.profiles : [];
381
+ }
382
+ /** The bundle names one profile's manifest declares.
383
+ * @param profileDir - the profile directory.
384
+ * @param onError - called with the read/parse failure; a torn manifest is NOT "no bundles".
385
+ * @returns the declared bundle names (empty when the manifest is absent).
386
+ */
387
+ function profileBundles(profileDir, onError) {
388
+ try {
389
+ const bundles = JSON.parse(readFileSync(join(profileDir, "package.json"), "utf8")).dsh?.profile?.bundles;
390
+ return Array.isArray(bundles) ? bundles.filter((name) => typeof name === "string") : [];
391
+ } catch (error) {
392
+ onError?.(error);
393
+ return [];
394
+ }
395
+ }
396
+ /** One optional file's text, or `''` when it is absent or unreadable.
397
+ * @param path - the file to read.
398
+ * @returns the text.
399
+ */
400
+ function readTextOrEmpty(path) {
401
+ try {
402
+ return readFileSync(path, "utf8");
403
+ } catch {
404
+ return "";
405
+ }
406
+ }
407
+ /** The profile's own patch text, or `''` when it has none.
408
+ * @param profileDir - the profile directory.
409
+ * @returns the patch text.
410
+ */
411
+ function profilePatchText(profileDir) {
412
+ return readTextOrEmpty(join(profileDir, PROFILE_PATCH_FILENAME));
413
+ }
414
+ /** Where the preset-install exclusion must look: THIS plane only, or an explicit refusal.
415
+ *
416
+ * The write path does NOT scan the home: the mounted profile is the whole question (its started
417
+ * bundles plus the ones its own manifest declares — either can double-mount), and a question that
418
+ * cannot be answered still REFUSES. That is what makes a leftover profile from the other plane
419
+ * harmless here: it is not this plane's profile, so it is never consulted.
420
+ * @param mounted - the mounted profile, or undefined outside a profile (a bare library mount).
421
+ * @returns the profile to judge with its bundle rows, or the reason a refusal is mandatory.
422
+ */
423
+ function installExclusionTarget(mounted) {
424
+ if (mounted === void 0 || mounted.dir === "") return {
425
+ kind: "undecidable",
426
+ reason: "no profile is mounted in this process, so the double-mount question cannot be answered here"
427
+ };
428
+ let manifestFailure;
429
+ const declared = profileBundles(mounted.dir, (error) => {
430
+ manifestFailure = error;
431
+ });
432
+ const manifestCode = manifestFailure?.code;
433
+ if (manifestFailure !== void 0 && manifestCode !== "ENOENT") return {
434
+ kind: "undecidable",
435
+ reason: "the mounted profile manifest could not be read"
436
+ };
437
+ return {
438
+ kind: "mounted",
439
+ profile: {
440
+ profile: mounted.profile,
441
+ dir: mounted.dir
442
+ },
443
+ bundles: [...mounted.bundles, ...declared]
444
+ };
445
+ }
446
+ //#endregion
317
447
  //#region lib/types/doctor.js
318
448
  /**
319
449
  * `/evolution doctor` — read-only self-check (0.3.55, WB2).
@@ -450,7 +580,7 @@ function liveRowTails(plugins) {
450
580
  * @returns the mapping, or undefined for anything else.
451
581
  */
452
582
  function asMapping(value) {
453
- return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
583
+ return asRecord(value);
454
584
  }
455
585
  /** Profile bundle rows for evolution-family packages across all profiles.
456
586
  *
@@ -460,22 +590,16 @@ function asMapping(value) {
460
590
  * list alone, and treating it as "no bundles" is exactly the fail-open state
461
591
  * the double-mount exclusion exists to prevent. */
462
592
  function collectEvolutionBundles(home, onReadError) {
463
- const profilesDir = join(home, "profiles");
464
- if (!existsSync(profilesDir)) return [];
465
- const bundles = [];
466
- let profileEntries;
467
- try {
468
- profileEntries = readdirSync(profilesDir, { withFileTypes: true });
469
- } catch (error) {
470
- onReadError?.(error, "profiles-dir");
471
- return bundles;
593
+ const scan = scanProfiles(home);
594
+ if (scan.kind === "unreadable") {
595
+ onReadError?.(scan.error, "profiles-dir");
596
+ return [];
472
597
  }
473
- for (const profile of profileEntries) {
474
- if (!profile.isDirectory()) continue;
475
- if (profile.name === "node_modules" || profile.name.startsWith(".")) continue;
598
+ const bundles = [];
599
+ for (const { dir } of scan.profiles) {
476
600
  let raw;
477
601
  try {
478
- raw = readFileSync(join(profilesDir, profile.name, "package.json"), "utf8");
602
+ raw = readFileSync(join(dir, "package.json"), "utf8");
479
603
  } catch (error) {
480
604
  if (error?.code === "ENOENT") continue;
481
605
  onReadError?.(error, "profile-manifest");
@@ -579,62 +703,6 @@ function readAgentPresetBases$1(path) {
579
703
  return null;
580
704
  }
581
705
  }
582
- /** The bundle rows one profile manifest mounts (scope-agnostic, as written) — the target a preset
583
- * recompute resolves its platform base patch through.
584
- * @param profileDir - the profile directory.
585
- * @returns the bundle names; empty when the manifest is missing or torn.
586
- */
587
- function profileBundles(profileDir) {
588
- try {
589
- const bundles = JSON.parse(readFileSync(join(profileDir, "package.json"), "utf8")).dsh?.profile?.bundles;
590
- return Array.isArray(bundles) ? bundles.filter((name) => typeof name === "string") : [];
591
- } catch {
592
- return [];
593
- }
594
- }
595
- /** Every profile directory under one DSH_HOME, by name.
596
- * @param home - resolved DSH_HOME.
597
- * @returns `{ profile, dir }` per directory under `profiles/` that carries a manifest.
598
- */
599
- function profileDirectories(home) {
600
- const out = [];
601
- const root = join(home, "profiles");
602
- let entries;
603
- try {
604
- entries = readdirSync(root, { withFileTypes: true });
605
- } catch {
606
- return out;
607
- }
608
- for (const entry of entries) {
609
- if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
610
- if (entry.name === "node_modules") continue;
611
- const dir = join(root, entry.name);
612
- if (!existsSync(join(dir, "package.json"))) continue;
613
- out.push({
614
- profile: entry.name,
615
- dir
616
- });
617
- }
618
- return out;
619
- }
620
- /** One optional file's text, or `''` when it is absent or unreadable.
621
- * @param path - the file to read.
622
- * @returns the text.
623
- */
624
- function readTextOrEmpty(path) {
625
- try {
626
- return readFileSync(path, "utf8");
627
- } catch {
628
- return "";
629
- }
630
- }
631
- /** The profile patch text, or `''` when the profile has none.
632
- * @param profileDir - the profile directory.
633
- * @returns the patch text.
634
- */
635
- function profilePatchText(profileDir) {
636
- return readTextOrEmpty(join(profileDir, PROFILE_PATCH_FILENAME));
637
- }
638
706
  /** Every USER-layer patch text that can carry a family preset row: each profile's own patch, then
639
707
  * the home layer (`<home>/cordis.patch.yml`), which the platform composes AFTER the profile layer
640
708
  * (`readProfilePatches` in `packages/boot/app-boot/src/profile-context.ts`). A row written there
@@ -644,7 +712,7 @@ function profilePatchText(profileDir) {
644
712
  * @returns the patch texts that exist, in profile-then-home order.
645
713
  */
646
714
  function userPatchTexts(home) {
647
- const texts = profileDirectories(home).map(({ dir }) => profilePatchText(dir));
715
+ const texts = homeProfiles(home).map(({ dir }) => profilePatchText(dir));
648
716
  texts.push(readTextOrEmpty(join(home, PROFILE_PATCH_FILENAME)));
649
717
  return texts.filter((text) => text !== "");
650
718
  }
@@ -718,7 +786,7 @@ function probePresetFreshness(home, servicePresent) {
718
786
  } catch (error) {
719
787
  deltaFailure = `${deltaPath} could not be read (${error instanceof Error ? error.message : String(error)})`;
720
788
  }
721
- const profiles = profileDirectories(home);
789
+ const profiles = homeProfiles(home);
722
790
  if (bases === null) {
723
791
  const tablePath = basesPath ?? `${AGENT_PRESET_PACKAGE}/bases.json`;
724
792
  return profiles.flatMap(({ profile, dir }) => {
@@ -849,6 +917,7 @@ async function legacyMigrationRow(ctx, home) {
849
917
  }
850
918
  async function diagnose(ctx, options = {}) {
851
919
  const home = options.home ?? evolutionRoot();
920
+ const mounted = options.mounted;
852
921
  const conflicts = [];
853
922
  const bundles = collectEvolutionBundles(home, (error, scope) => {
854
923
  const detail = error instanceof Error ? error.message : String(error);
@@ -944,6 +1013,7 @@ async function diagnose(ctx, options = {}) {
944
1013
  installForm,
945
1014
  deploymentForm,
946
1015
  bundles,
1016
+ foreignProfiles: homeProfiles(home).filter(({ profile, dir }) => mounted === void 0 || profile !== mounted.profile && dir !== mounted.dir).map(({ profile }) => profile),
947
1017
  conflicts,
948
1018
  envIssues: env,
949
1019
  memoryIssues,
@@ -1102,6 +1172,7 @@ function renderDoctorText(report) {
1102
1172
  lines.push(`preset: ${row.base} → ${row.destination} ${verdict}`);
1103
1173
  }
1104
1174
  if (report.presetIssues.length > 0) lines.push("agent presets:", ...report.presetIssues.map((line) => ` ! ${line}`));
1175
+ 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");
1105
1176
  if (report.conflicts.length > 0) lines.push("conflicts:", ...report.conflicts.map((line) => ` ! ${line}`));
1106
1177
  if (report.envIssues.length > 0) lines.push("env:", ...report.envIssues.map((line) => ` ! ${line}`));
1107
1178
  if (report.memoryIssues.length > 0) lines.push("memory:", ...report.memoryIssues.map((line) => ` ! ${line}`));
@@ -1218,9 +1289,24 @@ const SOURCE_LABELS = Object.freeze({
1218
1289
  });
1219
1290
  /** What the E-tiers mean, so the count line reads without the docs. */
1220
1291
  const TIER_LEGEND = "档位:E0 固定/E1 只读/E2 安装时定/E3 你可改/E4 装完固化。";
1292
+ /**
1293
+ * The source column's word for one row (F-1).
1294
+ *
1295
+ * `unregistered` covers TWO different facts: the owner publishes no settings namespace (the value
1296
+ * really cannot be written — E-315/E-316) and the settings service itself is missing (then whether
1297
+ * the value could be written is UNKNOWN). Without a provider the column must not claim "不可改", or it
1298
+ * contradicts the legend and the footer of the very same output.
1299
+ * @param row - the row's surface facts.
1300
+ * @param providerMounted - whether a settings provider answered.
1301
+ * @returns the label for the source column.
1302
+ */
1303
+ function sourceLabel(row, providerMounted) {
1304
+ const label = SOURCE_LABELS[row.source] ?? row.source;
1305
+ return providerMounted || row.source !== "unregistered" ? label : "未知";
1306
+ }
1221
1307
  function renderParamRows(rows, options) {
1222
1308
  const header = pad("参数", 36) + pad("分组", 12) + pad("档位", 6) + pad("生效", 8) + pad("来源", 10) + "当前值";
1223
- const body = rows.map((row) => pad(row.id, 36) + pad(PARAM_GROUP_LABELS[row.group], 12) + pad(row.tier, 6) + pad(APPLIES_LABELS[row.applies] ?? row.applies, 8) + pad(SOURCE_LABELS[row.source] ?? row.source, 10) + renderValue(row.value));
1309
+ const body = rows.map((row) => pad(row.id, 36) + pad(PARAM_GROUP_LABELS[row.group], 12) + pad(row.tier, 6) + pad(APPLIES_LABELS[row.applies] ?? row.applies, 8) + pad(sourceLabel(row, options.providerMounted), 10) + renderValue(row.value));
1224
1310
  const tiers = [
1225
1311
  "E0",
1226
1312
  "E1",
@@ -1277,116 +1363,160 @@ function renderParamJson(rows) {
1277
1363
  }
1278
1364
  //#endregion
1279
1365
  //#region lib/types/registry.js
1366
+ /** The flags the TABLE declares for one subcommand (name → `--flag`), DERIVED from its usage. */
1367
+ function flagsOf(name) {
1368
+ const entry = COMMAND_ENTRIES.find((candidate) => entryName(candidate) === name);
1369
+ return entry === void 0 ? [] : entryFlags(entry);
1370
+ }
1371
+ /** C7 shape, DERIVED: the subcommand name is the first token of `usage` (variants use `|`). */
1372
+ function entryName(entry) {
1373
+ const [head = ""] = entry.usage.split(" ");
1374
+ return head.split("|")[0] ?? "";
1375
+ }
1376
+ /** C7 shape, DERIVED: the flags a `usage` declares, in the form `--flag` (value flags keep their name). */
1377
+ function entryFlags(entry) {
1378
+ const found = [];
1379
+ for (const match of entry.usage.matchAll(/--[a-z][a-z-]*/g)) if (!found.includes(match[0])) found.push(match[0]);
1380
+ return found;
1381
+ }
1280
1382
  const COMMAND_ENTRIES = [
1281
1383
  {
1384
+ readonly: true,
1282
1385
  usage: "pending [--detail]",
1283
1386
  summary: "列出暂存的写入(--detail 显示参数)"
1284
1387
  },
1285
1388
  {
1389
+ readonly: false,
1286
1390
  usage: "approve <id>",
1287
1391
  summary: "执行一条已批准的写入"
1288
1392
  },
1289
1393
  {
1394
+ readonly: false,
1290
1395
  usage: "reject <id>",
1291
1396
  summary: "丢弃一条暂存写入,不执行"
1292
1397
  },
1293
1398
  {
1399
+ readonly: false,
1294
1400
  usage: "release <id>",
1295
1401
  summary: "把卡在执行中的写入放回待批窗口(先确认效果再批准 or reject)"
1296
1402
  },
1297
1403
  {
1404
+ readonly: true,
1298
1405
  usage: "doctor [--json]",
1299
1406
  summary: "只读自检:安装形态、冲突、环境、服务(--json 供脚本用)"
1300
1407
  },
1301
1408
  {
1409
+ readonly: false,
1302
1410
  usage: "curator run|pause|resume|status|report|scope",
1303
1411
  summary: "立即运行一次自动整理,或暂停/恢复/查看状态"
1304
1412
  },
1305
1413
  {
1414
+ readonly: true,
1306
1415
  usage: "mutations",
1307
1416
  summary: "列出技能改动记录"
1308
1417
  },
1309
1418
  {
1419
+ readonly: false,
1310
1420
  usage: "restore",
1311
1421
  summary: "从最近的快照恢复技能"
1312
1422
  },
1313
1423
  {
1424
+ readonly: false,
1314
1425
  usage: "consolidate <target> <sources...> [--plan <runId>]",
1315
1426
  summary: "把若干技能合并进一个总括技能"
1316
1427
  },
1317
1428
  {
1429
+ readonly: false,
1318
1430
  usage: "skill restore <name>",
1319
1431
  summary: "按名字恢复一个已归档的技能"
1320
1432
  },
1321
1433
  {
1434
+ readonly: true,
1322
1435
  usage: "skill history <name>",
1323
1436
  summary: "列出某个技能的内容版本(含时间、动作、字符数、哈希前缀)"
1324
1437
  },
1325
1438
  {
1439
+ readonly: false,
1326
1440
  usage: "skill undo <name> [--to v<N>]",
1327
1441
  summary: "把某个技能的内容回退到某一版(默认上一版;只动内容,撤销本身也记为一版)"
1328
1442
  },
1329
1443
  {
1444
+ readonly: false,
1330
1445
  usage: "skill pin <name>",
1331
1446
  summary: "钉住一个技能:免于删除、后台审查与生命周期整理(立即生效、随时可解钉;与 skill_manage 的 pin 动作同一语义,不走审批)"
1332
1447
  },
1333
1448
  {
1449
+ readonly: false,
1334
1450
  usage: "skill unpin <name>",
1335
1451
  summary: "解钉一个技能:撤销上面的冻结(立即生效、不走审批)"
1336
1452
  },
1337
1453
  {
1454
+ readonly: true,
1338
1455
  usage: "skills health",
1339
1456
  summary: "技能库结构体检"
1340
1457
  },
1341
1458
  {
1459
+ readonly: false,
1342
1460
  usage: "skills refresh",
1343
1461
  summary: "丢弃目录缓存并重新读取技能树"
1344
1462
  },
1345
1463
  {
1464
+ readonly: false,
1346
1465
  usage: "learn [request]",
1347
1466
  summary: "向本会话发一条学习请求"
1348
1467
  },
1349
1468
  {
1469
+ readonly: false,
1350
1470
  usage: "maintain [--timeout=<ms> | --facts]",
1351
1471
  summary: "起一次维护扫描并立即回 run id(后台跑;--facts 为 0 token 预览)"
1352
1472
  },
1353
1473
  {
1474
+ readonly: true,
1354
1475
  usage: "maintain status [<id>]",
1355
1476
  summary: "看维护扫描:在飞的那次(含已跑多久)或某个 run 的终态"
1356
1477
  },
1357
1478
  {
1479
+ readonly: true,
1358
1480
  usage: "maintain report [<id>]",
1359
1481
  summary: "读某个 run 的结果正文(省略 id 取本 home 最新一条;没有结果就明说,不装成空结果)"
1360
1482
  },
1361
1483
  {
1484
+ readonly: false,
1362
1485
  usage: "maintain handoff [<id>]",
1363
1486
  summary: "把某次维护报告交给模型:注入指针与汇报协议,模型只汇报、等你确认后再动手"
1364
1487
  },
1365
1488
  {
1489
+ readonly: false,
1366
1490
  usage: "maintain cancel [<id>]",
1367
1491
  summary: "停掉在飞的那次维护扫描(省略 id 仅当最新一条是本进程在跑的那次)"
1368
1492
  },
1369
1493
  {
1494
+ readonly: false,
1370
1495
  usage: "migrate",
1371
1496
  summary: "把旧设置文档里家族的旧分区迁进现在的行(幂等;已迁移过就报「已一致」)"
1372
1497
  },
1373
1498
  {
1499
+ readonly: false,
1374
1500
  usage: "policy set <id> <value> [--expect <revision>]",
1375
1501
  summary: "通过设置服务写入一个你可改的参数 (E3 only; E1/E2 stay in cordis.yml)"
1376
1502
  },
1377
1503
  {
1504
+ readonly: true,
1378
1505
  usage: "params [--group <name>] [--json]",
1379
1506
  summary: "列出全部参数:分组、档位、生效时机、来源与当前值(--json feeds scripts)"
1380
1507
  },
1381
1508
  {
1509
+ readonly: false,
1382
1510
  usage: "preset install [--base <name>[,<name>...]]",
1383
1511
  summary: "把自进化 Agent 预设**行**写进当前 profile 的 patch 层(`profiles/<p>/cordis.patch.yml`;bases 来自 agent 包的 bases.json)"
1384
1512
  },
1385
1513
  {
1514
+ readonly: false,
1386
1515
  usage: "restructure <name> \"<heading>\" <to_file> [--plan <runId>]",
1387
1516
  summary: "把技能正文的一节移到 references 文件"
1388
1517
  },
1389
1518
  {
1519
+ readonly: true,
1390
1520
  usage: "replay",
1391
1521
  summary: "比较不同会话与重启之间的计划结果(从活动记录回填)"
1392
1522
  }
@@ -1404,6 +1534,137 @@ function renderHelpText() {
1404
1534
  ].join("\n");
1405
1535
  }
1406
1536
  //#endregion
1537
+ //#region lib/types/parse.js
1538
+ /**
1539
+ * F-4/G7.4 (C7): the ONE parser for the /evolution command surface.
1540
+ *
1541
+ * The surface used to answer each subcommand with its own regex (`^params(?: --group ([a-z-]+))?(?: --json)?$`
1542
+ * and nine siblings), so `params --json --group review` and `params --group=review` were refused as
1543
+ * "not a subcommand", while a sibling regex accepted `=` — and a mistyped group name degraded into the
1544
+ * generic error instead of the one that lists the known groups.
1545
+ *
1546
+ * This parser is order- and `=`-tolerant, matches the subcommand name case-insensitively, keeps a flag
1547
+ * VALUE's case intact (the domain layer is the one that validates it), and answers a SYNTAX failure when a
1548
+ * declared value flag is left without its value (F-3: `policy set a.b --expect 3` must not read
1549
+ * `--expect 3` as the value).
1550
+ *
1551
+ * @module @lmzhen/dsh-evolution-commands/parse
1552
+ */
1553
+ /** The flags whose declared form takes a value (`--group <name>`, `--expect <revision>`, …). */
1554
+ /**
1555
+ * Exported so the command-table gate can tie it to the table's usage lines (P2: two declarations of
1556
+ * "which flags take a value" would otherwise be free to drift apart).
1557
+ */
1558
+ const VALUE_FLAGS = [
1559
+ "group",
1560
+ "expect",
1561
+ "base",
1562
+ "to",
1563
+ "timeout",
1564
+ "plan"
1565
+ ];
1566
+ /**
1567
+ * Narrow a parse result: true when it is the syntax failure rather than a command.
1568
+ * @param result - what {@link parseSubcommand} answered.
1569
+ * @returns whether the line was refused.
1570
+ */
1571
+ function isParseFailure(result) {
1572
+ return result.kind !== void 0;
1573
+ }
1574
+ /**
1575
+ * Split a command line into tokens: whitespace separates, and a double-quoted run is ONE token with its
1576
+ * quotes removed (the `restructure <name> "<heading>" <to_file>` form needs that; the old regexes each
1577
+ * carried their own quoting rule).
1578
+ * @param input - the text after `/evolution`.
1579
+ * @returns the tokens, in order.
1580
+ */
1581
+ function tokenize(input) {
1582
+ const tokens = [];
1583
+ let current = "";
1584
+ let quoted = false;
1585
+ for (const char of input.trim()) {
1586
+ if (char === String.fromCharCode(34)) {
1587
+ quoted = !quoted;
1588
+ continue;
1589
+ }
1590
+ if (!quoted && /\s/.test(char)) {
1591
+ if (current !== "") {
1592
+ tokens.push(current);
1593
+ current = "";
1594
+ }
1595
+ continue;
1596
+ }
1597
+ current += char;
1598
+ }
1599
+ if (current !== "") tokens.push(current);
1600
+ return tokens;
1601
+ }
1602
+ /**
1603
+ * Read one SUB-FORM line: `maintain status`, `maintain report`, … — the shape the regexes used to own.
1604
+ *
1605
+ * A sub-form matches only when: the line parses, the name matches, the first positional is the sub-form,
1606
+ * and every flag the line carries is DECLARED by the table (an undeclared flag keeps the old
1607
+ * "unknown arguments" answer instead of silently becoming a bare flag).
1608
+ * @param input - the text after `/evolution`.
1609
+ * @param name - the subcommand name the table declares (e.g. `maintain`).
1610
+ * @param sub - the sub-form word (e.g. `status`).
1611
+ * @param declaredFlags - the flags the table declares for `name` (`flagsOf(name)`).
1612
+ * @returns the parsed command, or null when this line is not that sub-form.
1613
+ */
1614
+ function subcommandOf(input, name, sub, declaredFlags) {
1615
+ const parsed = parseSubcommand(input);
1616
+ if (isParseFailure(parsed) || parsed.name !== name) return null;
1617
+ if (parsed.positionals[0] !== sub) return null;
1618
+ for (const flag of parsed.flags.keys()) if (!declaredFlags.includes("--" + flag)) return null;
1619
+ return parsed;
1620
+ }
1621
+ /**
1622
+ * Parse one command line into its name, flags and positionals.
1623
+ * @param input - the text after `/evolution`.
1624
+ * @returns the parsed command, or the syntax failure that refused it.
1625
+ */
1626
+ function parseSubcommand(input) {
1627
+ const [first, ...rest] = tokenize(input);
1628
+ if (first === void 0) return {
1629
+ kind: "unknown-subcommand",
1630
+ name: ""
1631
+ };
1632
+ const name = first.toLowerCase();
1633
+ const flags = /* @__PURE__ */ new Map();
1634
+ const positionals = [];
1635
+ for (let index = 0; index < rest.length; index += 1) {
1636
+ const token = rest[index];
1637
+ if (token === void 0) continue;
1638
+ if (!token.startsWith("--")) {
1639
+ positionals.push(token);
1640
+ continue;
1641
+ }
1642
+ const equals = token.indexOf("=");
1643
+ const flag = (equals < 0 ? token : token.slice(0, equals)).slice(2);
1644
+ if (equals >= 0) {
1645
+ flags.set(flag, token.slice(equals + 1));
1646
+ continue;
1647
+ }
1648
+ if (!VALUE_FLAGS.includes(flag)) {
1649
+ flags.set(flag, true);
1650
+ continue;
1651
+ }
1652
+ const next = rest[index + 1];
1653
+ if (next === void 0 || next.startsWith("--")) return {
1654
+ kind: "missing-value",
1655
+ name,
1656
+ flag
1657
+ };
1658
+ flags.set(flag, next);
1659
+ index += 1;
1660
+ }
1661
+ return {
1662
+ name,
1663
+ flags,
1664
+ positionals
1665
+ };
1666
+ }
1667
+ //#endregion
1407
1668
  //#region lib/types/index.js
1408
1669
  /**
1409
1670
  * Human commands for the evolution family: /evolution learn|pending|curator|restore|consolidate|skills refresh.
@@ -1565,19 +1826,6 @@ async function readRunReport(io, record) {
1565
1826
  };
1566
1827
  }
1567
1828
  }
1568
- /**
1569
- * Read a switch that may be a live settings reference.
1570
- *
1571
- * The loader hands a `.volatile()` field as `{ get() }` and the value is read at USE time; a
1572
- * directly constructed plugin passes a plain boolean (the schema is the loader's face — the same
1573
- * split the numeric knobs document as their second clamp layer). A single-form read is wrong
1574
- * either way: `=== true` is never true for a reference, and `?.get()` ignores the plain value.
1575
- * @param value - the configured switch, in either form, or undefined.
1576
- * @returns the switch; undefined reads as off.
1577
- */
1578
- function liveFlag(value) {
1579
- return typeof value === "boolean" ? value : value?.get() ?? false;
1580
- }
1581
1829
  function apply(ctx, rawConfig = {}) {
1582
1830
  const config = rawConfig;
1583
1831
  assertSkillsRootAliasRetired(rawConfig);
@@ -1593,7 +1841,7 @@ function apply(ctx, rawConfig = {}) {
1593
1841
  const ioRegistry = ioCtx.get("evolutionIo");
1594
1842
  if (!ioRegistry) return;
1595
1843
  const registry = newRunRegistry({
1596
- io: ioRegistry.provider(),
1844
+ io: preferProvider(ioRegistry, ["fs"]),
1597
1845
  home: evolutionHome(),
1598
1846
  warn: (message) => {
1599
1847
  ctx.logger.warn(message);
@@ -1634,15 +1882,15 @@ function apply(ctx, rawConfig = {}) {
1634
1882
  return err(errorText("e-306-this-deployment-stages-foreground", { a1: what }));
1635
1883
  };
1636
1884
  const approval = ctx.get("evolutionApproval");
1637
- const pendingMatch = /^pending(?: --detail)?$/.exec(input);
1638
- if (pendingMatch) {
1885
+ const pendingParsed = parseSubcommand(input);
1886
+ if (!isParseFailure(pendingParsed) && pendingParsed.name === "pending") {
1639
1887
  if (!approval) return err(errorText("e-301-approval-service-not-mounted"));
1640
1888
  const listed = [...await approval.list("pending"), ...await approval.list("executing")];
1641
1889
  const pending = [...new Map(listed.map((row) => [row.id, row])).values()];
1642
1890
  const ttlMs = typeof approval.ttlMs === "number" && Number.isFinite(approval.ttlMs) ? approval.ttlMs : 0;
1643
1891
  const ttlNote = ttlMs > 0 ? `\n(staged writes expire after ${ttlMs} ms: an expired write is closed as REJECTED and is never executed — stage it again if it is still wanted)` : "\n(staged writes never expire — the window waits for a decision; set the evolution-approval row pendingTtlMs to bound it)";
1644
1892
  if (pending.length === 0) return ok("No pending evolution writes." + ttlNote);
1645
- const detailed = pendingMatch[0] === "pending --detail";
1893
+ const detailed = pendingParsed.flags.get("detail") === true;
1646
1894
  const nowMs = Date.now();
1647
1895
  const attribution = (p) => {
1648
1896
  const age = elapsedSince(p.createdAt, nowMs);
@@ -1825,7 +2073,7 @@ function apply(ctx, rawConfig = {}) {
1825
2073
  if (!ioRegistry) return err("Evolution IO registry not mounted — skill pinning unavailable.");
1826
2074
  const result = await newSkillLibrary({
1827
2075
  config: { root: skillsRootValue },
1828
- io: ioRegistry.provider(),
2076
+ io: preferProvider(ioRegistry, ["fs"]),
1829
2077
  ctx,
1830
2078
  limits: {
1831
2079
  ...DEFAULT_SKILL_LIMITS,
@@ -1879,12 +2127,13 @@ function apply(ctx, rawConfig = {}) {
1879
2127
  });
1880
2128
  return woke ? ok("Learning request sent to this session. Follow it now.") : ok("Learning request queued for this session — this host exposes no wake-up channel, so it is read on your next message.");
1881
2129
  }
1882
- if (input === "maintain --facts") {
2130
+ const factsParsed = parseSubcommand(input);
2131
+ if (!isParseFailure(factsParsed) && factsParsed.name === "maintain" && factsParsed.flags.get("facts") === true && factsParsed.positionals.length === 0) {
1883
2132
  const ioRegistry = ctx.get("evolutionIo");
1884
2133
  if (!ioRegistry) return err("Evolution IO registry not mounted — maintenance facts unavailable.");
1885
2134
  const library = newSkillLibrary({
1886
2135
  config: { root: skillsRootValue },
1887
- io: ioRegistry.provider()
2136
+ io: preferProvider(ioRegistry, ["fs"])
1888
2137
  });
1889
2138
  const enrichment = await buildEnrichment(ctx, library);
1890
2139
  const readFailures = [];
@@ -1896,15 +2145,17 @@ function apply(ctx, rawConfig = {}) {
1896
2145
  }
1897
2146
  });
1898
2147
  if (snapshots.length === 0 && readFailures.length > 0) return err(`maintenance scan could not read ${readFailures.length} listed skill(s) (${readFailures.slice(0, 10).join(", ")}${readFailures.length > 10 ? ", …" : ""}) and read no skill at all — the library was NOT audited; fix the unreadable skills (or their directory names) and run the scan again`);
1899
- if (snapshots.length === 0 && skillsRootValue !== "" && !await ioRegistry.provider().exists(skillsRootValue)) return err(`maintenance scan found an empty skill library, but the configured skill root does not exist (${skillsRootValue}) — point the skillsRoot config at a real directory (a literal \`~\` is not expanded) and run the scan again`);
2148
+ if (snapshots.length === 0 && skillsRootValue !== "" && !await preferProvider(ioRegistry, ["fs"]).exists(skillsRootValue)) return err(`maintenance scan found an empty skill library, but the configured skill root does not exist (${skillsRootValue}) — point the skillsRoot config at a real directory (a literal \`~\` is not expanded) and run the scan again`);
1900
2149
  if (snapshots.length === 0) return ok("Maintenance scan: empty skill library. Nothing to do.");
1901
2150
  const { facts } = buildMaintainFacts(snapshots, enrichment.usageObservedValue, void 0);
1902
2151
  return ok(`Maintenance facts (0-token preview):\n${facts}`);
1903
2152
  }
1904
- const maintainArgs = /^maintain(?:\s+--timeout[ =](\d+))?\s*$/.exec(input);
1905
- if (maintainArgs) {
1906
- const runTimeoutMs = maintainArgs[1] ? Number(maintainArgs[1]) : config.maintainTimeoutMs ?? 6e5;
1907
- if (!Number.isSafeInteger(runTimeoutMs) || runTimeoutMs <= 0 || runTimeoutMs > MAX_TIMER_DELAY_MS) return err(maintainArgs[1] ? `Invalid --timeout value: expected a positive integer number of milliseconds up to ${MAX_TIMER_DELAY_MS} (e.g. /evolution maintain --timeout 600000).` : `The maintainTimeoutMs config is invalid: expected a positive integer number of milliseconds up to ${MAX_TIMER_DELAY_MS}. Fix the evolution-commands row config (maintainTimeoutMs), then retry.`);
2153
+ const maintainParsed = parseSubcommand(input);
2154
+ const maintainFlagsDeclared = [...isParseFailure(maintainParsed) ? [] : maintainParsed.flags.keys()].every((flag) => flagsOf("maintain").includes("--" + flag));
2155
+ if (!isParseFailure(maintainParsed) && maintainParsed.name === "maintain" && maintainParsed.positionals.length === 0 && maintainFlagsDeclared) {
2156
+ const timeoutFlag = maintainParsed.flags.get("timeout");
2157
+ const runTimeoutMs = typeof timeoutFlag === "string" ? Number(timeoutFlag) : config.maintainTimeoutMs ?? 6e5;
2158
+ if (!Number.isSafeInteger(runTimeoutMs) || runTimeoutMs <= 0 || runTimeoutMs > MAX_TIMER_DELAY_MS) return err(timeoutFlag !== void 0 ? `Invalid --timeout value: expected a positive integer number of milliseconds up to ${MAX_TIMER_DELAY_MS} (e.g. /evolution maintain --timeout 600000).` : `The maintainTimeoutMs config is invalid: expected a positive integer number of milliseconds up to ${MAX_TIMER_DELAY_MS}. Fix the evolution-commands row config (maintainTimeoutMs), then retry.`);
1908
2159
  const registry = runs;
1909
2160
  if (!registry) return err("Evolution IO registry not mounted — maintenance scan unavailable.");
1910
2161
  const synced = await syncRuns(registry);
@@ -1935,7 +2186,7 @@ function apply(ctx, rawConfig = {}) {
1935
2186
  try {
1936
2187
  const library = newSkillLibrary({
1937
2188
  config: { root: skillsRootValue },
1938
- io: ioRegistry.provider()
2189
+ io: preferProvider(ioRegistry, ["fs"])
1939
2190
  });
1940
2191
  const enrichment = await buildEnrichment(ctx, library);
1941
2192
  const outcome = await runMaintain({
@@ -1947,7 +2198,7 @@ function apply(ctx, rawConfig = {}) {
1947
2198
  logger: { warn: (message) => {
1948
2199
  ctx.logger.warn(message);
1949
2200
  } },
1950
- rootExists: skillsRootValue === "" ? void 0 : () => ioRegistry.provider().exists(skillsRootValue),
2201
+ rootExists: skillsRootValue === "" ? void 0 : () => preferProvider(ioRegistry, ["fs"]).exists(skillsRootValue),
1951
2202
  skillRoot: skillsRootValue
1952
2203
  }, {
1953
2204
  timeoutMs: runTimeoutMs,
@@ -1956,7 +2207,7 @@ function apply(ctx, rawConfig = {}) {
1956
2207
  enrichment: () => enrichment
1957
2208
  });
1958
2209
  const endedAt = Date.now();
1959
- const resultRef = await writeMaintainReport(ioRegistry.provider(), {
2210
+ const resultRef = await writeMaintainReport(preferProvider(ioRegistry, ["fs"]), {
1960
2211
  runId: handle.id,
1961
2212
  ...outcome.ok ? {
1962
2213
  verdict: outcome.verdict,
@@ -1980,7 +2231,7 @@ function apply(ctx, rawConfig = {}) {
1980
2231
  ...outcome.ok ? {} : { failure: handle.signal.aborted ? "cancelled: the scan was stopped before it settled" : outcome.error ?? "Maintenance scan failed." }
1981
2232
  });
1982
2233
  if (outcome.ok) {
1983
- appendEvolutionEvent(ioRegistry.provider(), eventsFile(runRoot), {
2234
+ appendEvolutionEvent(preferProvider(ioRegistry, ["fs"]), eventsFile(runRoot), {
1984
2235
  type: "maintain",
1985
2236
  source: "manual",
1986
2237
  runId: handle.id,
@@ -1989,7 +2240,7 @@ function apply(ctx, rawConfig = {}) {
1989
2240
  }).catch((error) => {
1990
2241
  ctx.logger.warn(`evolution-commands: failed to record maintain event: ${String(error)}`);
1991
2242
  });
1992
- if (liveFlag(config.maintainHandoffOnSettle) && resultRef !== void 0 && runAgent !== void 0) try {
2243
+ if (liveBool(config.maintainHandoffOnSettle) && resultRef !== void 0 && runAgent !== void 0) try {
1993
2244
  if (deliverHandoff(runAgent, createUserMessage({
1994
2245
  content: [{
1995
2246
  type: "text",
@@ -2034,12 +2285,12 @@ function apply(ctx, rawConfig = {}) {
2034
2285
  `- stop: /evolution maintain cancel ${handle.id}`
2035
2286
  ].join("\n"));
2036
2287
  }
2037
- const maintainStatus = /^maintain\s+status(?:\s+(\S+))?\s*$/.exec(input);
2038
- if (maintainStatus) {
2288
+ const maintainStatus = subcommandOf(input, "maintain", "status", flagsOf("maintain"));
2289
+ if (maintainStatus !== null) {
2039
2290
  const registry = runs;
2040
2291
  if (!registry) return err("Evolution IO registry not mounted — run status unavailable.");
2041
2292
  const synced = await syncRuns(registry);
2042
- const wanted = maintainStatus[1];
2293
+ const wanted = maintainStatus.positionals[1];
2043
2294
  if (wanted !== void 0) {
2044
2295
  const record = registry.find(wanted);
2045
2296
  if (!record) {
@@ -2055,12 +2306,12 @@ function apply(ctx, rawConfig = {}) {
2055
2306
  const recent = all.filter((record) => record.state !== "running").slice(0, 5);
2056
2307
  return ok([live.length === 0 ? "No maintenance run in flight." : `In flight:\n${live.map(renderRunRecord).join("\n")}`, recent.length === 0 ? "" : `Recent:\n${recent.map(renderRunRecord).join("\n")}`].filter((part) => part !== "").join("\n"));
2057
2308
  }
2058
- const maintainReport = /^maintain\s+report(?:\s+(\S+))?\s*$/.exec(input);
2059
- if (maintainReport) {
2309
+ const maintainReport = subcommandOf(input, "maintain", "report", flagsOf("maintain"));
2310
+ if (maintainReport !== null) {
2060
2311
  const registry = runs;
2061
2312
  if (!registry) return err("Evolution IO registry not mounted — run results unavailable.");
2062
2313
  const synced = await syncRuns(registry);
2063
- const wanted = maintainReport[1];
2314
+ const wanted = maintainReport.positionals[1];
2064
2315
  if (wanted === void 0 && !synced.ok) return err(unreadableIndex(synced.note));
2065
2316
  const record = wanted === void 0 ? newestRun(registry) : registry.find(wanted);
2066
2317
  if (!record) {
@@ -2073,17 +2324,17 @@ function apply(ctx, rawConfig = {}) {
2073
2324
  if (wanted === void 0 && record.state === "running") return err(`The newest run ${record.id} is still running — its report appears when it settles; /evolution maintain status ${record.id} shows where it is.`);
2074
2325
  const ioRegistry = ctx.get("evolutionIo");
2075
2326
  if (!ioRegistry) return err("Evolution IO registry not mounted — run results unavailable.");
2076
- const probed = await readRunReport(ioRegistry.provider(), record);
2327
+ const probed = await readRunReport(preferProvider(ioRegistry, ["fs"]), record);
2077
2328
  if (probed.kind === "missing") return err(`Run ${record.id} (${record.state}) has NO result: no report was written${record.failure === void 0 ? "" : ` (${record.failure})`}.`);
2078
2329
  if (probed.kind === "unknown") return err(`Run ${record.id} has a report that could not be read (${probed.reason}) — this is not "no result".`);
2079
2330
  return ok(probed.text);
2080
2331
  }
2081
- const maintainCancel = /^maintain\s+cancel(?:\s+(\S+))?\s*$/.exec(input);
2082
- if (maintainCancel) {
2332
+ const maintainCancel = subcommandOf(input, "maintain", "cancel", flagsOf("maintain"));
2333
+ if (maintainCancel !== null) {
2083
2334
  const registry = runs;
2084
2335
  if (!registry) return err("Evolution IO registry not mounted — run cancellation unavailable.");
2085
2336
  const synced = await syncRuns(registry);
2086
- const wanted = maintainCancel[1];
2337
+ const wanted = maintainCancel.positionals[1];
2087
2338
  if (wanted === void 0 && !synced.ok) return err(unreadableIndex(synced.note));
2088
2339
  const record = wanted === void 0 ? newestRun(registry) : registry.find(wanted);
2089
2340
  if (!record) {
@@ -2100,12 +2351,12 @@ function apply(ctx, rawConfig = {}) {
2100
2351
  }
2101
2352
  return ok(`Maintenance run ${record.id} cancelled — the scan stops at its next checkpoint. /evolution maintain status ${record.id} shows the terminal state.`);
2102
2353
  }
2103
- const maintainHandoff = /^maintain\s+handoff(?:\s+(\S+))?\s*$/.exec(input);
2104
- if (maintainHandoff) {
2354
+ const maintainHandoff = subcommandOf(input, "maintain", "handoff", flagsOf("maintain"));
2355
+ if (maintainHandoff !== null) {
2105
2356
  const registry = runs;
2106
2357
  if (!registry) return err("Evolution IO registry not mounted — the report cannot be handed over.");
2107
2358
  const synced = await syncRuns(registry);
2108
- const wanted = maintainHandoff[1];
2359
+ const wanted = maintainHandoff.positionals[1];
2109
2360
  if (wanted === void 0 && !synced.ok) return err(unreadableIndex(synced.note));
2110
2361
  const record = wanted === void 0 ? newestRun(registry) : registry.find(wanted);
2111
2362
  if (!record) {
@@ -2118,7 +2369,7 @@ function apply(ctx, rawConfig = {}) {
2118
2369
  if (wanted === void 0 && record.state === "running") return err(`The newest run ${record.id} is still running — its report appears when it settles; /evolution maintain status ${record.id} shows where it is.`);
2119
2370
  const ioRegistry = ctx.get("evolutionIo");
2120
2371
  if (!ioRegistry) return err("Evolution IO registry not mounted — the report cannot be handed over.");
2121
- const probed = await readRunReport(ioRegistry.provider(), record);
2372
+ const probed = await readRunReport(preferProvider(ioRegistry, ["fs"]), record);
2122
2373
  if (probed.kind === "missing") return err(`Run ${record.id} (${record.state}) has NO result: no report was written${record.failure === void 0 ? "" : ` (${record.failure})`}.`);
2123
2374
  if (probed.kind === "unknown") return err(`Run ${record.id} has a report that could not be read (${probed.reason}) — this is not "no result".`);
2124
2375
  const missingAgent = agentMissing("handoff");
@@ -2144,26 +2395,27 @@ function apply(ctx, rawConfig = {}) {
2144
2395
  return ok(woke ? `Handed run ${record.id} to the model — report: ${reportDigestPath(record)}. It will read the file and follow the action lines.` : `Handed run ${record.id} to the model, queued — this host exposes no wake-up channel, so it is read on your next message.`);
2145
2396
  }
2146
2397
  if (/^maintain\b/.test(input)) return err("Unknown maintain arguments: expected `maintain`, `maintain --timeout <ms>` / `maintain --timeout=<ms>`, `maintain status [<id>]`, `maintain report [<id>]`, `maintain handoff [<id>]` or `maintain cancel [<id>]`. Got: " + input);
2147
- const presetInstall = /^preset install(?: --base (\S+))?$/.exec(input);
2148
- if (presetInstall !== null || /^preset\b/.test(input)) {
2149
- 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);
2398
+ const presetParsed = parseSubcommand(input);
2399
+ const presetInstall = isParseFailure(presetParsed) || presetParsed.name !== "preset" ? null : presetParsed.positionals[0] === "install" && presetParsed.positionals.length === 1 && [...presetParsed.flags.keys()].every((flag) => flagsOf("preset").includes("--" + flag)) ? presetParsed : false;
2400
+ if (presetInstall !== null && presetInstall !== false) {
2150
2401
  {
2151
- let installedBundles;
2152
- try {
2153
- installedBundles = collectEvolutionBundles(evolutionRoot(), (error) => {
2154
- throw error;
2155
- });
2156
- } catch (enumerationError) {
2157
- 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.`);
2158
- }
2402
+ const mounted = presetProfileTarget(ctx);
2403
+ const exclusion = installExclusionTarget(mounted === void 0 ? void 0 : {
2404
+ profile: mounted.profile,
2405
+ dir: mounted.profileDir,
2406
+ bundles: mounted.bundles
2407
+ });
2408
+ 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.`);
2409
+ const installedBundles = exclusion.bundles;
2159
2410
  const allBundle = installedBundles.find((name) => name === "dsh-evolution-all" || name.endsWith("/dsh-evolution-all"));
2160
2411
  const presetBundle = installedBundles.find((name) => name === "dsh-evolution-preset" || name.endsWith("/dsh-evolution-preset"));
2161
- 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).`);
2412
+ 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).`);
2162
2413
  }
2163
2414
  try {
2164
2415
  const source = resolveAgentPresetDir(import.meta.url);
2165
2416
  const table = readAgentPresetBases(source);
2166
- const requestedRaw = presetInstall[1] ?? table.defaultBase;
2417
+ const baseFlag = presetInstall.flags.get("base");
2418
+ const requestedRaw = typeof baseFlag === "string" ? baseFlag : table.defaultBase;
2167
2419
  const requested = [...new Set(requestedRaw.split(",").map((part) => part.trim()).filter((part) => part !== ""))];
2168
2420
  if (requested.length === 0) return err(`No agent-preset base named — ${join(source, "bases.json")} lists ${table.entries.map((entry) => entry.name).join(", ")}`);
2169
2421
  const selected = [];
@@ -2177,45 +2429,51 @@ function apply(ctx, rawConfig = {}) {
2177
2429
  const profile = presetProfileTarget(ctx);
2178
2430
  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>`.");
2179
2431
  const delta = readFileSync(deltaPath, "utf8");
2180
- const current = existsSync(profile.patchPath) ? readFileSync(profile.patchPath, "utf8") : "";
2181
- let merged = current;
2182
- const written = [];
2183
- for (const base of selected) {
2184
- const refusal = baseRefusalReason(base, (name) => ctx.get(name) !== void 0);
2185
- if (refusal !== void 0) return err(refusal);
2186
- const basePatch = resolvePresetBasePatch(base.name, profile);
2187
- const rowId = presetRowId(base.id);
2188
- const entryText = composePresetEntry(readFileSync(basePatch, "utf8"), delta, {
2189
- rowId,
2190
- id: base.id,
2191
- name: base.display.name,
2192
- description: base.display.description,
2193
- order: base.display.order
2432
+ const hmr = ctx.get("hmr");
2433
+ const commit = () => {
2434
+ const current = existsSync(profile.patchPath) ? readFileSync(profile.patchPath, "utf8") : "";
2435
+ let merged = current;
2436
+ const written = [];
2437
+ for (const base of selected) {
2438
+ const refusal = baseRefusalReason(base, (name) => ctx.get(name) !== void 0);
2439
+ if (refusal !== void 0) return err(refusal);
2440
+ const basePatch = resolvePresetBasePatch(base.name, profile);
2441
+ const rowId = presetRowId(base.id);
2442
+ const entryText = composePresetEntry(readFileSync(basePatch, "utf8"), delta, {
2443
+ rowId,
2444
+ id: base.id,
2445
+ name: base.display.name,
2446
+ description: base.display.description,
2447
+ order: base.display.order
2448
+ });
2449
+ const next = mergePresetRow(merged, entryText, rowId);
2450
+ written.push(next === merged ? `${base.name} → ${profile.patchPath} (already current)` : `${base.name} → ${rowId} in ${profile.patchPath}`);
2451
+ merged = next;
2452
+ }
2453
+ if (merged !== current) atomicWriteFiles(dirname(profile.patchPath), [{
2454
+ name: PROFILE_PATCH_FILENAME$1,
2455
+ content: presetPatchText(merged)
2456
+ }], void 0, (message) => {
2457
+ ctx.logger.warn(message);
2194
2458
  });
2195
- const next = mergePresetRow(merged, entryText, rowId);
2196
- written.push(next === merged ? `${base.name} → ${profile.patchPath} (already current)` : `${base.name} → ${rowId} in ${profile.patchPath}`);
2197
- merged = next;
2198
- }
2199
- if (merged !== current) atomicWriteFiles(dirname(profile.patchPath), [{
2200
- name: PROFILE_PATCH_FILENAME,
2201
- content: presetPatchText(merged)
2202
- }], void 0, (message) => {
2203
- ctx.logger.warn(message);
2204
- });
2205
- return ok(`Evolution agent preset row installed (${written.join("; ")}). Restart the session switcher to select it.`);
2459
+ return ok(`Evolution agent preset row installed (${written.join("; ")}). Restart the session switcher to select it.`);
2460
+ };
2461
+ const runExclusive = hmr?.runExclusive;
2462
+ if (runExclusive === void 0) return commit();
2463
+ return await runExclusive.call(hmr, commit);
2206
2464
  } catch (error) {
2207
2465
  return err(`Preset install failed: ${error instanceof Error ? error.message : String(error)}`);
2208
2466
  }
2209
2467
  }
2210
2468
  if (input.startsWith("restructure ")) {
2211
- const planTail = /\s--plan\s+(\S+)\s*$/.exec(rawInputTrimmed) ?? null;
2212
- const planRunId = planTail?.[1] ?? void 0;
2213
- const rest = planTail ? rawInputTrimmed.slice(0, planTail.index) : rawInputTrimmed;
2214
- const match = /^restructure\s+(\S+)\s+"([^"]+)"\s+(\S+)$/.exec(rest);
2215
- if (!match) return err("Usage: /evolution restructure <name> \"<## heading>\" <to_file>");
2216
- const name = match[1] ?? "";
2217
- const heading = match[2] ?? "";
2218
- const toFile = match[3] ?? "";
2469
+ const restParsed = parseSubcommand(rawInputTrimmed);
2470
+ if (isParseFailure(restParsed) || restParsed.name !== "restructure") return err("Usage: /evolution restructure <name> \"<## heading>\" <to_file>");
2471
+ const name = restParsed.positionals[0] ?? "";
2472
+ const heading = restParsed.positionals[1] ?? "";
2473
+ const toFile = restParsed.positionals[2] ?? "";
2474
+ if (name === "" || heading === "" || toFile === "") return err("Usage: /evolution restructure <name> \"<## heading>\" <to_file>");
2475
+ const planFlag = restParsed.flags.get("plan");
2476
+ const planRunId = typeof planFlag === "string" ? planFlag : void 0;
2219
2477
  if (!toFile.startsWith("references/")) return err("to_file must live under references/ (log/detail destination).");
2220
2478
  const ioRegistry = ctx.get("evolutionIo");
2221
2479
  if (!ioRegistry) return err("Evolution IO registry not mounted — restructure unavailable.");
@@ -2251,7 +2509,7 @@ function apply(ctx, rawConfig = {}) {
2251
2509
  }
2252
2510
  const result = await newSkillLibrary({
2253
2511
  config: { root: skillsRootValue },
2254
- io: ioRegistry.provider(),
2512
+ io: preferProvider(ioRegistry, ["fs"]),
2255
2513
  ctx,
2256
2514
  threatExemptLabels: config.threatExemptLabels,
2257
2515
  limits: {
@@ -2271,8 +2529,8 @@ function apply(ctx, rawConfig = {}) {
2271
2529
  if (!replay) return err(errorText("e-303-replay-service-not-mounted"));
2272
2530
  return ok(replay.compare().report);
2273
2531
  }
2274
- const paramsMatch = /^params(?: --group ([a-z-]+))?(?: --json)?$/.exec(input);
2275
- if (paramsMatch) {
2532
+ const paramsParsed = parseSubcommand(input);
2533
+ if (!isParseFailure(paramsParsed) && paramsParsed.name === "params") {
2276
2534
  const provider = ctx.get("settings");
2277
2535
  const sections = /* @__PURE__ */ new Map();
2278
2536
  if (provider?.describe !== void 0) try {
@@ -2287,21 +2545,23 @@ function apply(ctx, rawConfig = {}) {
2287
2545
  return err(errorText("e-307-the-settings-service-could", { a1: error instanceof Error ? error.message : String(error) }));
2288
2546
  }
2289
2547
  const rows = paramSurfaceRows(sections, PARAM_EXPOSURE);
2290
- const group = paramsMatch[1];
2548
+ const groupFlag = paramsParsed.flags.get("group");
2549
+ const group = typeof groupFlag === "string" ? groupFlag : void 0;
2291
2550
  const selected = group === void 0 ? rows : rows.filter((row) => row.group === group);
2292
2551
  if (group !== void 0 && selected.length === 0) return err(errorText("e-308-unknown-parameter-group-group", {
2293
2552
  a1: group,
2294
2553
  a2: paramGroups(PARAM_EXPOSURE).join(", ")
2295
2554
  }));
2296
- if (input.endsWith("--json")) return ok(renderParamJson(selected));
2555
+ if (paramsParsed.flags.get("json") === true) return ok(renderParamJson(selected));
2297
2556
  return ok(renderParamRows(selected, { providerMounted: provider !== void 0 }));
2298
2557
  }
2299
- const policySetMatch = /^policy set ([A-Za-z][A-Za-z0-9.-]*) (.+?)(?: --expect (\d+))?$/.exec(input);
2300
- if (policySetMatch) {
2558
+ const policyParsed = parseSubcommand(input);
2559
+ if (isParseFailure(policyParsed) && policyParsed.name === "policy" && policyParsed.kind === "missing-value") return err("/evolution policy set needs a value after --" + (policyParsed.flag ?? "") + "; usage: policy set <id> <value> [--expect <revision>]");
2560
+ if (!isParseFailure(policyParsed) && policyParsed.name === "policy" && policyParsed.positionals[0] === "set") {
2301
2561
  const provider = ctx.get("settings");
2302
2562
  if (provider?.update === void 0 || provider.describe === void 0) return err(errorText("e-311-no-settings-service-is"));
2303
- const rawId = policySetMatch[1] ?? "";
2304
- const rawValue = policySetMatch[2] ?? "";
2563
+ const rawId = policyParsed.positionals[1] ?? "";
2564
+ const rawValue = policyParsed.positionals[2] ?? "";
2305
2565
  let id;
2306
2566
  try {
2307
2567
  id = canonicalWriteId(rawId);
@@ -2332,13 +2592,24 @@ function apply(ctx, rawConfig = {}) {
2332
2592
  }));
2333
2593
  const current = descriptor.value;
2334
2594
  const resolved = typeof current === "object" && current !== null ? current : void 0;
2335
- const expectedRaw = policySetMatch[3];
2595
+ const expectFlag = policyParsed.flags.get("expect");
2596
+ const expectedRaw = typeof expectFlag === "string" ? expectFlag : void 0;
2336
2597
  const expected = expectedRaw === void 0 ? descriptor.revision : Number(expectedRaw);
2337
2598
  if (expectedRaw !== void 0 && descriptor.revision !== void 0 && Number(expectedRaw) !== descriptor.revision) return err(errorText("e-309-revision-conflict-you-sent", {
2338
2599
  a1: expectedRaw,
2339
2600
  a2: descriptor.revision
2340
2601
  }));
2341
2602
  const value = parseParamValue(rawValue);
2603
+ const floorId = entry.relation?.atLeast;
2604
+ if (floorId !== void 0 && typeof value === "number") {
2605
+ const sibling = resolved?.[floorId];
2606
+ const floor = typeof sibling === "number" ? sibling : NaN;
2607
+ if (Number.isFinite(floor) && value < floor) return err(errorText("e-322-a-parameter-would-break-a", {
2608
+ a1: id,
2609
+ a2: String(floor),
2610
+ a3: String(value)
2611
+ }));
2612
+ }
2342
2613
  try {
2343
2614
  await provider.update(namespace, { [id]: value }, expected);
2344
2615
  } catch (error) {
@@ -2366,7 +2637,16 @@ function apply(ctx, rawConfig = {}) {
2366
2637
  return ok(renderNamespaceMigration(attempt.outcome));
2367
2638
  }
2368
2639
  if (input === "doctor" || input === "doctor --json") {
2369
- const report = await diagnose(ctx, { home: evolutionRoot() });
2640
+ const mountedForDoctor = presetProfileTarget(ctx);
2641
+ const mounted = mountedForDoctor === void 0 ? {} : { mounted: {
2642
+ profile: mountedForDoctor.profile,
2643
+ dir: mountedForDoctor.profileDir,
2644
+ bundles: mountedForDoctor.bundles
2645
+ } };
2646
+ const report = await diagnose(ctx, {
2647
+ home: evolutionRoot(),
2648
+ ...mounted
2649
+ });
2370
2650
  if (input === "doctor --json") return ok(JSON.stringify(report, null, 2));
2371
2651
  return ok(renderDoctorText(report));
2372
2652
  }
@@ -2377,7 +2657,6 @@ function apply(ctx, rawConfig = {}) {
2377
2657
  const evolutionCommand = {
2378
2658
  name: "evolution",
2379
2659
  description: "自进化:状态与待批准写入",
2380
- recordInput: false,
2381
2660
  input: { hint: renderHint() },
2382
2661
  handler: (invocation) => commandBody.run(invocation).catch((error) => ({
2383
2662
  kind: "error",
@@ -2492,121 +2771,6 @@ function resolveAgentPresetDir(importMetaUrl) {
2492
2771
  return overlayCandidate;
2493
2772
  }
2494
2773
  }
2495
- const defaultFs = {
2496
- writeFileSync: (path, data) => {
2497
- writeFileSync(path, data);
2498
- },
2499
- existsSync: (path) => existsSync(path),
2500
- copyFileSync: (from, to) => {
2501
- copyFileSync(from, to);
2502
- },
2503
- renameSync: (from, to) => {
2504
- renameSync(from, to);
2505
- },
2506
- rmSync: (path, options) => {
2507
- rmSync(path, options);
2508
- },
2509
- mtime: (path) => {
2510
- try {
2511
- return statSync(path).mtimeMs;
2512
- } catch (error) {
2513
- if (isMissingPath(error)) return null;
2514
- throw error;
2515
- }
2516
- }
2517
- };
2518
- /**
2519
- * S6.3 (E-40): atomically replace a set of files within one directory.
2520
- * Each file is staged to a sibling `<name>.tmp`, then renamed into place
2521
- * (atomic on the same filesystem); a pre-existing file keeps a single
2522
- * `<name>.bak`. A failure at ANY staging step removes the staged temps and
2523
- * leaves the previous files untouched — a half-written target is impossible
2524
- * up to the staging and backup phases.
2525
- *
2526
- * F-211: the rename COMMIT phase does not carry the same guarantee. When a
2527
- * filesystem refuses to overwrite the destination, the code removes it and
2528
- * retries the rename; if the retry ALSO fails the destination is gone. The
2529
- * single `.bak` created above still holds the previous content, so the target
2530
- * is restored from it (best-effort) before the original rename error is
2531
- * rethrown, so a failed commit never leaves the file missing.
2532
- *
2533
- * V4-17: the `.bak` is refreshed to the committed content after every
2534
- * successful commit, so a later failed commit always recovers to the most
2535
- * recent good generation (a once-at-first-install `.bak` could otherwise
2536
- * restore content several installs old). The recovery error names the `.bak`
2537
- * source (and mtime when available) so the caller knows the generation it
2538
- * restored from.
2539
- */
2540
- function atomicWriteFiles(targetDir, writes, fs = defaultFs, logger = console.warn) {
2541
- const stage = (name) => join(targetDir, `${name}.tmp`);
2542
- const dupes = [...new Set(writes.map((write) => write.name).filter((name, index, all) => all.indexOf(name) !== index))];
2543
- if (dupes.length > 0) throw new Error(`atomicWriteFiles: duplicate input names: ${dupes.join(", ")}`);
2544
- try {
2545
- for (const { name, content } of writes) fs.writeFileSync(stage(name), content);
2546
- for (const { name } of writes) {
2547
- const finalPath = join(targetDir, name);
2548
- const bakPath = join(targetDir, `${name}.bak`);
2549
- if (fs.existsSync(finalPath) && !fs.existsSync(bakPath)) fs.copyFileSync(finalPath, bakPath);
2550
- }
2551
- const committed = [];
2552
- for (const { name } of writes) {
2553
- const finalPath = join(targetDir, name);
2554
- const bakPath = join(targetDir, `${name}.bak`);
2555
- const tmp = stage(name);
2556
- try {
2557
- fs.renameSync(tmp, finalPath);
2558
- committed.push(name);
2559
- } catch {
2560
- try {
2561
- fs.rmSync(finalPath, { force: true });
2562
- } catch (removeError) {
2563
- const already = committed.length > 0 ? `; already committed: ${committed.join(", ")}` : "";
2564
- throw new Error(`atomicWriteFiles: could not remove "${name}" to replace it (${removeError instanceof Error ? removeError.message : String(removeError)})${already}`);
2565
- }
2566
- try {
2567
- fs.renameSync(tmp, finalPath);
2568
- committed.push(name);
2569
- } catch (renameError) {
2570
- const already = committed.length > 0 ? `; already committed: ${committed.join(", ")}` : "";
2571
- if (fs.existsSync(bakPath)) {
2572
- try {
2573
- fs.copyFileSync(bakPath, finalPath);
2574
- } catch {
2575
- throw new Error(`atomicWriteFiles: "${name}" was removed but recovery from ${bakPath} failed (${renameError instanceof Error ? renameError.message : String(renameError)}); verify the file manually${already}`);
2576
- }
2577
- const bakMtime = fs.mtime?.(bakPath);
2578
- const when = bakMtime !== null && bakMtime !== void 0 && Number.isFinite(bakMtime) ? ` (bak mtime ${new Date(bakMtime).toISOString()})` : "";
2579
- throw new Error(`atomicWriteFiles: "${name}" was removed during commit and restored from the last good generation ${bakPath}${when}; original error: ${renameError instanceof Error ? renameError.message : String(renameError)}${already}`);
2580
- }
2581
- throw new Error(`${renameError instanceof Error ? renameError.message : String(renameError)}${already}`);
2582
- }
2583
- }
2584
- }
2585
- for (const { name } of writes) {
2586
- const finalPath = join(targetDir, name);
2587
- try {
2588
- if (fs.existsSync(finalPath)) fs.copyFileSync(finalPath, join(targetDir, `${name}.bak`));
2589
- } catch (refreshError) {
2590
- logger(`atomicWriteFiles: committed "${name}" but failed to refresh its .bak (${refreshError instanceof Error ? refreshError.message : String(refreshError)}); a later failed commit will recover to an OLDER generation`);
2591
- }
2592
- }
2593
- } catch (error) {
2594
- const cleanupFailures = [];
2595
- for (const { name } of writes) try {
2596
- fs.rmSync(stage(name), {
2597
- force: true,
2598
- recursive: true
2599
- });
2600
- } catch (cleanupError) {
2601
- cleanupFailures.push(`${name}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
2602
- }
2603
- if (cleanupFailures.length > 0) {
2604
- const original = error instanceof Error ? error.message : String(error);
2605
- throw new Error(`${original} (staged-temp cleanup also failed: ${cleanupFailures.join("; ")})`);
2606
- }
2607
- throw error;
2608
- }
2609
- }
2610
2774
  /** F-328: render staged args for `pending --detail`, truncated to 500 chars.
2611
2775
  * args may be non-JSON (a tool produced garbage), so the render is fail-safe.
2612
2776
  * V4-19: a truncation is marked with `…(truncated N chars)` so the operator is