@lmzhen/dsh-evolution-commands 0.22.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, 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";
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
  }
@@ -579,7 +580,7 @@ function liveRowTails(plugins) {
579
580
  * @returns the mapping, or undefined for anything else.
580
581
  */
581
582
  function asMapping(value) {
582
- return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
583
+ return asRecord(value);
583
584
  }
584
585
  /** Profile bundle rows for evolution-family packages across all profiles.
585
586
  *
@@ -1288,9 +1289,24 @@ const SOURCE_LABELS = Object.freeze({
1288
1289
  });
1289
1290
  /** What the E-tiers mean, so the count line reads without the docs. */
1290
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
+ }
1291
1307
  function renderParamRows(rows, options) {
1292
1308
  const header = pad("参数", 36) + pad("分组", 12) + pad("档位", 6) + pad("生效", 8) + pad("来源", 10) + "当前值";
1293
- 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));
1294
1310
  const tiers = [
1295
1311
  "E0",
1296
1312
  "E1",
@@ -1347,116 +1363,160 @@ function renderParamJson(rows) {
1347
1363
  }
1348
1364
  //#endregion
1349
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
+ }
1350
1382
  const COMMAND_ENTRIES = [
1351
1383
  {
1384
+ readonly: true,
1352
1385
  usage: "pending [--detail]",
1353
1386
  summary: "列出暂存的写入(--detail 显示参数)"
1354
1387
  },
1355
1388
  {
1389
+ readonly: false,
1356
1390
  usage: "approve <id>",
1357
1391
  summary: "执行一条已批准的写入"
1358
1392
  },
1359
1393
  {
1394
+ readonly: false,
1360
1395
  usage: "reject <id>",
1361
1396
  summary: "丢弃一条暂存写入,不执行"
1362
1397
  },
1363
1398
  {
1399
+ readonly: false,
1364
1400
  usage: "release <id>",
1365
1401
  summary: "把卡在执行中的写入放回待批窗口(先确认效果再批准 or reject)"
1366
1402
  },
1367
1403
  {
1404
+ readonly: true,
1368
1405
  usage: "doctor [--json]",
1369
1406
  summary: "只读自检:安装形态、冲突、环境、服务(--json 供脚本用)"
1370
1407
  },
1371
1408
  {
1409
+ readonly: false,
1372
1410
  usage: "curator run|pause|resume|status|report|scope",
1373
1411
  summary: "立即运行一次自动整理,或暂停/恢复/查看状态"
1374
1412
  },
1375
1413
  {
1414
+ readonly: true,
1376
1415
  usage: "mutations",
1377
1416
  summary: "列出技能改动记录"
1378
1417
  },
1379
1418
  {
1419
+ readonly: false,
1380
1420
  usage: "restore",
1381
1421
  summary: "从最近的快照恢复技能"
1382
1422
  },
1383
1423
  {
1424
+ readonly: false,
1384
1425
  usage: "consolidate <target> <sources...> [--plan <runId>]",
1385
1426
  summary: "把若干技能合并进一个总括技能"
1386
1427
  },
1387
1428
  {
1429
+ readonly: false,
1388
1430
  usage: "skill restore <name>",
1389
1431
  summary: "按名字恢复一个已归档的技能"
1390
1432
  },
1391
1433
  {
1434
+ readonly: true,
1392
1435
  usage: "skill history <name>",
1393
1436
  summary: "列出某个技能的内容版本(含时间、动作、字符数、哈希前缀)"
1394
1437
  },
1395
1438
  {
1439
+ readonly: false,
1396
1440
  usage: "skill undo <name> [--to v<N>]",
1397
1441
  summary: "把某个技能的内容回退到某一版(默认上一版;只动内容,撤销本身也记为一版)"
1398
1442
  },
1399
1443
  {
1444
+ readonly: false,
1400
1445
  usage: "skill pin <name>",
1401
1446
  summary: "钉住一个技能:免于删除、后台审查与生命周期整理(立即生效、随时可解钉;与 skill_manage 的 pin 动作同一语义,不走审批)"
1402
1447
  },
1403
1448
  {
1449
+ readonly: false,
1404
1450
  usage: "skill unpin <name>",
1405
1451
  summary: "解钉一个技能:撤销上面的冻结(立即生效、不走审批)"
1406
1452
  },
1407
1453
  {
1454
+ readonly: true,
1408
1455
  usage: "skills health",
1409
1456
  summary: "技能库结构体检"
1410
1457
  },
1411
1458
  {
1459
+ readonly: false,
1412
1460
  usage: "skills refresh",
1413
1461
  summary: "丢弃目录缓存并重新读取技能树"
1414
1462
  },
1415
1463
  {
1464
+ readonly: false,
1416
1465
  usage: "learn [request]",
1417
1466
  summary: "向本会话发一条学习请求"
1418
1467
  },
1419
1468
  {
1469
+ readonly: false,
1420
1470
  usage: "maintain [--timeout=<ms> | --facts]",
1421
1471
  summary: "起一次维护扫描并立即回 run id(后台跑;--facts 为 0 token 预览)"
1422
1472
  },
1423
1473
  {
1474
+ readonly: true,
1424
1475
  usage: "maintain status [<id>]",
1425
1476
  summary: "看维护扫描:在飞的那次(含已跑多久)或某个 run 的终态"
1426
1477
  },
1427
1478
  {
1479
+ readonly: true,
1428
1480
  usage: "maintain report [<id>]",
1429
1481
  summary: "读某个 run 的结果正文(省略 id 取本 home 最新一条;没有结果就明说,不装成空结果)"
1430
1482
  },
1431
1483
  {
1484
+ readonly: false,
1432
1485
  usage: "maintain handoff [<id>]",
1433
1486
  summary: "把某次维护报告交给模型:注入指针与汇报协议,模型只汇报、等你确认后再动手"
1434
1487
  },
1435
1488
  {
1489
+ readonly: false,
1436
1490
  usage: "maintain cancel [<id>]",
1437
1491
  summary: "停掉在飞的那次维护扫描(省略 id 仅当最新一条是本进程在跑的那次)"
1438
1492
  },
1439
1493
  {
1494
+ readonly: false,
1440
1495
  usage: "migrate",
1441
1496
  summary: "把旧设置文档里家族的旧分区迁进现在的行(幂等;已迁移过就报「已一致」)"
1442
1497
  },
1443
1498
  {
1499
+ readonly: false,
1444
1500
  usage: "policy set <id> <value> [--expect <revision>]",
1445
1501
  summary: "通过设置服务写入一个你可改的参数 (E3 only; E1/E2 stay in cordis.yml)"
1446
1502
  },
1447
1503
  {
1504
+ readonly: true,
1448
1505
  usage: "params [--group <name>] [--json]",
1449
1506
  summary: "列出全部参数:分组、档位、生效时机、来源与当前值(--json feeds scripts)"
1450
1507
  },
1451
1508
  {
1509
+ readonly: false,
1452
1510
  usage: "preset install [--base <name>[,<name>...]]",
1453
1511
  summary: "把自进化 Agent 预设**行**写进当前 profile 的 patch 层(`profiles/<p>/cordis.patch.yml`;bases 来自 agent 包的 bases.json)"
1454
1512
  },
1455
1513
  {
1514
+ readonly: false,
1456
1515
  usage: "restructure <name> \"<heading>\" <to_file> [--plan <runId>]",
1457
1516
  summary: "把技能正文的一节移到 references 文件"
1458
1517
  },
1459
1518
  {
1519
+ readonly: true,
1460
1520
  usage: "replay",
1461
1521
  summary: "比较不同会话与重启之间的计划结果(从活动记录回填)"
1462
1522
  }
@@ -1474,6 +1534,137 @@ function renderHelpText() {
1474
1534
  ].join("\n");
1475
1535
  }
1476
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
1477
1668
  //#region lib/types/index.js
1478
1669
  /**
1479
1670
  * Human commands for the evolution family: /evolution learn|pending|curator|restore|consolidate|skills refresh.
@@ -1650,7 +1841,7 @@ function apply(ctx, rawConfig = {}) {
1650
1841
  const ioRegistry = ioCtx.get("evolutionIo");
1651
1842
  if (!ioRegistry) return;
1652
1843
  const registry = newRunRegistry({
1653
- io: ioRegistry.provider(),
1844
+ io: preferProvider(ioRegistry, ["fs"]),
1654
1845
  home: evolutionHome(),
1655
1846
  warn: (message) => {
1656
1847
  ctx.logger.warn(message);
@@ -1691,15 +1882,15 @@ function apply(ctx, rawConfig = {}) {
1691
1882
  return err(errorText("e-306-this-deployment-stages-foreground", { a1: what }));
1692
1883
  };
1693
1884
  const approval = ctx.get("evolutionApproval");
1694
- const pendingMatch = /^pending(?: --detail)?$/.exec(input);
1695
- if (pendingMatch) {
1885
+ const pendingParsed = parseSubcommand(input);
1886
+ if (!isParseFailure(pendingParsed) && pendingParsed.name === "pending") {
1696
1887
  if (!approval) return err(errorText("e-301-approval-service-not-mounted"));
1697
1888
  const listed = [...await approval.list("pending"), ...await approval.list("executing")];
1698
1889
  const pending = [...new Map(listed.map((row) => [row.id, row])).values()];
1699
1890
  const ttlMs = typeof approval.ttlMs === "number" && Number.isFinite(approval.ttlMs) ? approval.ttlMs : 0;
1700
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)";
1701
1892
  if (pending.length === 0) return ok("No pending evolution writes." + ttlNote);
1702
- const detailed = pendingMatch[0] === "pending --detail";
1893
+ const detailed = pendingParsed.flags.get("detail") === true;
1703
1894
  const nowMs = Date.now();
1704
1895
  const attribution = (p) => {
1705
1896
  const age = elapsedSince(p.createdAt, nowMs);
@@ -1882,7 +2073,7 @@ function apply(ctx, rawConfig = {}) {
1882
2073
  if (!ioRegistry) return err("Evolution IO registry not mounted — skill pinning unavailable.");
1883
2074
  const result = await newSkillLibrary({
1884
2075
  config: { root: skillsRootValue },
1885
- io: ioRegistry.provider(),
2076
+ io: preferProvider(ioRegistry, ["fs"]),
1886
2077
  ctx,
1887
2078
  limits: {
1888
2079
  ...DEFAULT_SKILL_LIMITS,
@@ -1936,12 +2127,13 @@ function apply(ctx, rawConfig = {}) {
1936
2127
  });
1937
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.");
1938
2129
  }
1939
- 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) {
1940
2132
  const ioRegistry = ctx.get("evolutionIo");
1941
2133
  if (!ioRegistry) return err("Evolution IO registry not mounted — maintenance facts unavailable.");
1942
2134
  const library = newSkillLibrary({
1943
2135
  config: { root: skillsRootValue },
1944
- io: ioRegistry.provider()
2136
+ io: preferProvider(ioRegistry, ["fs"])
1945
2137
  });
1946
2138
  const enrichment = await buildEnrichment(ctx, library);
1947
2139
  const readFailures = [];
@@ -1953,15 +2145,17 @@ function apply(ctx, rawConfig = {}) {
1953
2145
  }
1954
2146
  });
1955
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`);
1956
- 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`);
1957
2149
  if (snapshots.length === 0) return ok("Maintenance scan: empty skill library. Nothing to do.");
1958
2150
  const { facts } = buildMaintainFacts(snapshots, enrichment.usageObservedValue, void 0);
1959
2151
  return ok(`Maintenance facts (0-token preview):\n${facts}`);
1960
2152
  }
1961
- const maintainArgs = /^maintain(?:\s+--timeout[ =](\d+))?\s*$/.exec(input);
1962
- if (maintainArgs) {
1963
- const runTimeoutMs = maintainArgs[1] ? Number(maintainArgs[1]) : config.maintainTimeoutMs ?? 6e5;
1964
- 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.`);
1965
2159
  const registry = runs;
1966
2160
  if (!registry) return err("Evolution IO registry not mounted — maintenance scan unavailable.");
1967
2161
  const synced = await syncRuns(registry);
@@ -1992,7 +2186,7 @@ function apply(ctx, rawConfig = {}) {
1992
2186
  try {
1993
2187
  const library = newSkillLibrary({
1994
2188
  config: { root: skillsRootValue },
1995
- io: ioRegistry.provider()
2189
+ io: preferProvider(ioRegistry, ["fs"])
1996
2190
  });
1997
2191
  const enrichment = await buildEnrichment(ctx, library);
1998
2192
  const outcome = await runMaintain({
@@ -2004,7 +2198,7 @@ function apply(ctx, rawConfig = {}) {
2004
2198
  logger: { warn: (message) => {
2005
2199
  ctx.logger.warn(message);
2006
2200
  } },
2007
- rootExists: skillsRootValue === "" ? void 0 : () => ioRegistry.provider().exists(skillsRootValue),
2201
+ rootExists: skillsRootValue === "" ? void 0 : () => preferProvider(ioRegistry, ["fs"]).exists(skillsRootValue),
2008
2202
  skillRoot: skillsRootValue
2009
2203
  }, {
2010
2204
  timeoutMs: runTimeoutMs,
@@ -2013,7 +2207,7 @@ function apply(ctx, rawConfig = {}) {
2013
2207
  enrichment: () => enrichment
2014
2208
  });
2015
2209
  const endedAt = Date.now();
2016
- const resultRef = await writeMaintainReport(ioRegistry.provider(), {
2210
+ const resultRef = await writeMaintainReport(preferProvider(ioRegistry, ["fs"]), {
2017
2211
  runId: handle.id,
2018
2212
  ...outcome.ok ? {
2019
2213
  verdict: outcome.verdict,
@@ -2037,7 +2231,7 @@ function apply(ctx, rawConfig = {}) {
2037
2231
  ...outcome.ok ? {} : { failure: handle.signal.aborted ? "cancelled: the scan was stopped before it settled" : outcome.error ?? "Maintenance scan failed." }
2038
2232
  });
2039
2233
  if (outcome.ok) {
2040
- appendEvolutionEvent(ioRegistry.provider(), eventsFile(runRoot), {
2234
+ appendEvolutionEvent(preferProvider(ioRegistry, ["fs"]), eventsFile(runRoot), {
2041
2235
  type: "maintain",
2042
2236
  source: "manual",
2043
2237
  runId: handle.id,
@@ -2091,12 +2285,12 @@ function apply(ctx, rawConfig = {}) {
2091
2285
  `- stop: /evolution maintain cancel ${handle.id}`
2092
2286
  ].join("\n"));
2093
2287
  }
2094
- const maintainStatus = /^maintain\s+status(?:\s+(\S+))?\s*$/.exec(input);
2095
- if (maintainStatus) {
2288
+ const maintainStatus = subcommandOf(input, "maintain", "status", flagsOf("maintain"));
2289
+ if (maintainStatus !== null) {
2096
2290
  const registry = runs;
2097
2291
  if (!registry) return err("Evolution IO registry not mounted — run status unavailable.");
2098
2292
  const synced = await syncRuns(registry);
2099
- const wanted = maintainStatus[1];
2293
+ const wanted = maintainStatus.positionals[1];
2100
2294
  if (wanted !== void 0) {
2101
2295
  const record = registry.find(wanted);
2102
2296
  if (!record) {
@@ -2112,12 +2306,12 @@ function apply(ctx, rawConfig = {}) {
2112
2306
  const recent = all.filter((record) => record.state !== "running").slice(0, 5);
2113
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"));
2114
2308
  }
2115
- const maintainReport = /^maintain\s+report(?:\s+(\S+))?\s*$/.exec(input);
2116
- if (maintainReport) {
2309
+ const maintainReport = subcommandOf(input, "maintain", "report", flagsOf("maintain"));
2310
+ if (maintainReport !== null) {
2117
2311
  const registry = runs;
2118
2312
  if (!registry) return err("Evolution IO registry not mounted — run results unavailable.");
2119
2313
  const synced = await syncRuns(registry);
2120
- const wanted = maintainReport[1];
2314
+ const wanted = maintainReport.positionals[1];
2121
2315
  if (wanted === void 0 && !synced.ok) return err(unreadableIndex(synced.note));
2122
2316
  const record = wanted === void 0 ? newestRun(registry) : registry.find(wanted);
2123
2317
  if (!record) {
@@ -2130,17 +2324,17 @@ function apply(ctx, rawConfig = {}) {
2130
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.`);
2131
2325
  const ioRegistry = ctx.get("evolutionIo");
2132
2326
  if (!ioRegistry) return err("Evolution IO registry not mounted — run results unavailable.");
2133
- const probed = await readRunReport(ioRegistry.provider(), record);
2327
+ const probed = await readRunReport(preferProvider(ioRegistry, ["fs"]), record);
2134
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})`}.`);
2135
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".`);
2136
2330
  return ok(probed.text);
2137
2331
  }
2138
- const maintainCancel = /^maintain\s+cancel(?:\s+(\S+))?\s*$/.exec(input);
2139
- if (maintainCancel) {
2332
+ const maintainCancel = subcommandOf(input, "maintain", "cancel", flagsOf("maintain"));
2333
+ if (maintainCancel !== null) {
2140
2334
  const registry = runs;
2141
2335
  if (!registry) return err("Evolution IO registry not mounted — run cancellation unavailable.");
2142
2336
  const synced = await syncRuns(registry);
2143
- const wanted = maintainCancel[1];
2337
+ const wanted = maintainCancel.positionals[1];
2144
2338
  if (wanted === void 0 && !synced.ok) return err(unreadableIndex(synced.note));
2145
2339
  const record = wanted === void 0 ? newestRun(registry) : registry.find(wanted);
2146
2340
  if (!record) {
@@ -2157,12 +2351,12 @@ function apply(ctx, rawConfig = {}) {
2157
2351
  }
2158
2352
  return ok(`Maintenance run ${record.id} cancelled — the scan stops at its next checkpoint. /evolution maintain status ${record.id} shows the terminal state.`);
2159
2353
  }
2160
- const maintainHandoff = /^maintain\s+handoff(?:\s+(\S+))?\s*$/.exec(input);
2161
- if (maintainHandoff) {
2354
+ const maintainHandoff = subcommandOf(input, "maintain", "handoff", flagsOf("maintain"));
2355
+ if (maintainHandoff !== null) {
2162
2356
  const registry = runs;
2163
2357
  if (!registry) return err("Evolution IO registry not mounted — the report cannot be handed over.");
2164
2358
  const synced = await syncRuns(registry);
2165
- const wanted = maintainHandoff[1];
2359
+ const wanted = maintainHandoff.positionals[1];
2166
2360
  if (wanted === void 0 && !synced.ok) return err(unreadableIndex(synced.note));
2167
2361
  const record = wanted === void 0 ? newestRun(registry) : registry.find(wanted);
2168
2362
  if (!record) {
@@ -2175,7 +2369,7 @@ function apply(ctx, rawConfig = {}) {
2175
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.`);
2176
2370
  const ioRegistry = ctx.get("evolutionIo");
2177
2371
  if (!ioRegistry) return err("Evolution IO registry not mounted — the report cannot be handed over.");
2178
- const probed = await readRunReport(ioRegistry.provider(), record);
2372
+ const probed = await readRunReport(preferProvider(ioRegistry, ["fs"]), record);
2179
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})`}.`);
2180
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".`);
2181
2375
  const missingAgent = agentMissing("handoff");
@@ -2201,9 +2395,9 @@ function apply(ctx, rawConfig = {}) {
2201
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.`);
2202
2396
  }
2203
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);
2204
- const presetInstall = /^preset install(?: --base (\S+))?$/.exec(input);
2205
- if (presetInstall !== null || /^preset\b/.test(input)) {
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);
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) {
2207
2401
  {
2208
2402
  const mounted = presetProfileTarget(ctx);
2209
2403
  const exclusion = installExclusionTarget(mounted === void 0 ? void 0 : {
@@ -2220,7 +2414,8 @@ function apply(ctx, rawConfig = {}) {
2220
2414
  try {
2221
2415
  const source = resolveAgentPresetDir(import.meta.url);
2222
2416
  const table = readAgentPresetBases(source);
2223
- const requestedRaw = presetInstall[1] ?? table.defaultBase;
2417
+ const baseFlag = presetInstall.flags.get("base");
2418
+ const requestedRaw = typeof baseFlag === "string" ? baseFlag : table.defaultBase;
2224
2419
  const requested = [...new Set(requestedRaw.split(",").map((part) => part.trim()).filter((part) => part !== ""))];
2225
2420
  if (requested.length === 0) return err(`No agent-preset base named — ${join(source, "bases.json")} lists ${table.entries.map((entry) => entry.name).join(", ")}`);
2226
2421
  const selected = [];
@@ -2234,45 +2429,51 @@ function apply(ctx, rawConfig = {}) {
2234
2429
  const profile = presetProfileTarget(ctx);
2235
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>`.");
2236
2431
  const delta = readFileSync(deltaPath, "utf8");
2237
- const current = existsSync(profile.patchPath) ? readFileSync(profile.patchPath, "utf8") : "";
2238
- let merged = current;
2239
- const written = [];
2240
- for (const base of selected) {
2241
- const refusal = baseRefusalReason(base, (name) => ctx.get(name) !== void 0);
2242
- if (refusal !== void 0) return err(refusal);
2243
- const basePatch = resolvePresetBasePatch(base.name, profile);
2244
- const rowId = presetRowId(base.id);
2245
- const entryText = composePresetEntry(readFileSync(basePatch, "utf8"), delta, {
2246
- rowId,
2247
- id: base.id,
2248
- name: base.display.name,
2249
- description: base.display.description,
2250
- 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);
2251
2458
  });
2252
- const next = mergePresetRow(merged, entryText, rowId);
2253
- written.push(next === merged ? `${base.name} → ${profile.patchPath} (already current)` : `${base.name} → ${rowId} in ${profile.patchPath}`);
2254
- merged = next;
2255
- }
2256
- if (merged !== current) atomicWriteFiles(dirname(profile.patchPath), [{
2257
- name: PROFILE_PATCH_FILENAME$1,
2258
- content: presetPatchText(merged)
2259
- }], void 0, (message) => {
2260
- ctx.logger.warn(message);
2261
- });
2262
- 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);
2263
2464
  } catch (error) {
2264
2465
  return err(`Preset install failed: ${error instanceof Error ? error.message : String(error)}`);
2265
2466
  }
2266
2467
  }
2267
2468
  if (input.startsWith("restructure ")) {
2268
- const planTail = /\s--plan\s+(\S+)\s*$/.exec(rawInputTrimmed) ?? null;
2269
- const planRunId = planTail?.[1] ?? void 0;
2270
- const rest = planTail ? rawInputTrimmed.slice(0, planTail.index) : rawInputTrimmed;
2271
- const match = /^restructure\s+(\S+)\s+"([^"]+)"\s+(\S+)$/.exec(rest);
2272
- if (!match) return err("Usage: /evolution restructure <name> \"<## heading>\" <to_file>");
2273
- const name = match[1] ?? "";
2274
- const heading = match[2] ?? "";
2275
- 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;
2276
2477
  if (!toFile.startsWith("references/")) return err("to_file must live under references/ (log/detail destination).");
2277
2478
  const ioRegistry = ctx.get("evolutionIo");
2278
2479
  if (!ioRegistry) return err("Evolution IO registry not mounted — restructure unavailable.");
@@ -2308,7 +2509,7 @@ function apply(ctx, rawConfig = {}) {
2308
2509
  }
2309
2510
  const result = await newSkillLibrary({
2310
2511
  config: { root: skillsRootValue },
2311
- io: ioRegistry.provider(),
2512
+ io: preferProvider(ioRegistry, ["fs"]),
2312
2513
  ctx,
2313
2514
  threatExemptLabels: config.threatExemptLabels,
2314
2515
  limits: {
@@ -2328,8 +2529,8 @@ function apply(ctx, rawConfig = {}) {
2328
2529
  if (!replay) return err(errorText("e-303-replay-service-not-mounted"));
2329
2530
  return ok(replay.compare().report);
2330
2531
  }
2331
- const paramsMatch = /^params(?: --group ([a-z-]+))?(?: --json)?$/.exec(input);
2332
- if (paramsMatch) {
2532
+ const paramsParsed = parseSubcommand(input);
2533
+ if (!isParseFailure(paramsParsed) && paramsParsed.name === "params") {
2333
2534
  const provider = ctx.get("settings");
2334
2535
  const sections = /* @__PURE__ */ new Map();
2335
2536
  if (provider?.describe !== void 0) try {
@@ -2344,21 +2545,23 @@ function apply(ctx, rawConfig = {}) {
2344
2545
  return err(errorText("e-307-the-settings-service-could", { a1: error instanceof Error ? error.message : String(error) }));
2345
2546
  }
2346
2547
  const rows = paramSurfaceRows(sections, PARAM_EXPOSURE);
2347
- const group = paramsMatch[1];
2548
+ const groupFlag = paramsParsed.flags.get("group");
2549
+ const group = typeof groupFlag === "string" ? groupFlag : void 0;
2348
2550
  const selected = group === void 0 ? rows : rows.filter((row) => row.group === group);
2349
2551
  if (group !== void 0 && selected.length === 0) return err(errorText("e-308-unknown-parameter-group-group", {
2350
2552
  a1: group,
2351
2553
  a2: paramGroups(PARAM_EXPOSURE).join(", ")
2352
2554
  }));
2353
- if (input.endsWith("--json")) return ok(renderParamJson(selected));
2555
+ if (paramsParsed.flags.get("json") === true) return ok(renderParamJson(selected));
2354
2556
  return ok(renderParamRows(selected, { providerMounted: provider !== void 0 }));
2355
2557
  }
2356
- const policySetMatch = /^policy set ([A-Za-z][A-Za-z0-9.-]*) (.+?)(?: --expect (\d+))?$/.exec(input);
2357
- 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") {
2358
2561
  const provider = ctx.get("settings");
2359
2562
  if (provider?.update === void 0 || provider.describe === void 0) return err(errorText("e-311-no-settings-service-is"));
2360
- const rawId = policySetMatch[1] ?? "";
2361
- const rawValue = policySetMatch[2] ?? "";
2563
+ const rawId = policyParsed.positionals[1] ?? "";
2564
+ const rawValue = policyParsed.positionals[2] ?? "";
2362
2565
  let id;
2363
2566
  try {
2364
2567
  id = canonicalWriteId(rawId);
@@ -2389,13 +2592,24 @@ function apply(ctx, rawConfig = {}) {
2389
2592
  }));
2390
2593
  const current = descriptor.value;
2391
2594
  const resolved = typeof current === "object" && current !== null ? current : void 0;
2392
- const expectedRaw = policySetMatch[3];
2595
+ const expectFlag = policyParsed.flags.get("expect");
2596
+ const expectedRaw = typeof expectFlag === "string" ? expectFlag : void 0;
2393
2597
  const expected = expectedRaw === void 0 ? descriptor.revision : Number(expectedRaw);
2394
2598
  if (expectedRaw !== void 0 && descriptor.revision !== void 0 && Number(expectedRaw) !== descriptor.revision) return err(errorText("e-309-revision-conflict-you-sent", {
2395
2599
  a1: expectedRaw,
2396
2600
  a2: descriptor.revision
2397
2601
  }));
2398
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
+ }
2399
2613
  try {
2400
2614
  await provider.update(namespace, { [id]: value }, expected);
2401
2615
  } catch (error) {
@@ -2443,7 +2657,6 @@ function apply(ctx, rawConfig = {}) {
2443
2657
  const evolutionCommand = {
2444
2658
  name: "evolution",
2445
2659
  description: "自进化:状态与待批准写入",
2446
- recordInput: false,
2447
2660
  input: { hint: renderHint() },
2448
2661
  handler: (invocation) => commandBody.run(invocation).catch((error) => ({
2449
2662
  kind: "error",
@@ -2558,121 +2771,6 @@ function resolveAgentPresetDir(importMetaUrl) {
2558
2771
  return overlayCandidate;
2559
2772
  }
2560
2773
  }
2561
- const defaultFs = {
2562
- writeFileSync: (path, data) => {
2563
- writeFileSync(path, data);
2564
- },
2565
- existsSync: (path) => existsSync(path),
2566
- copyFileSync: (from, to) => {
2567
- copyFileSync(from, to);
2568
- },
2569
- renameSync: (from, to) => {
2570
- renameSync(from, to);
2571
- },
2572
- rmSync: (path, options) => {
2573
- rmSync(path, options);
2574
- },
2575
- mtime: (path) => {
2576
- try {
2577
- return statSync(path).mtimeMs;
2578
- } catch (error) {
2579
- if (isMissingPath(error)) return null;
2580
- throw error;
2581
- }
2582
- }
2583
- };
2584
- /**
2585
- * S6.3 (E-40): atomically replace a set of files within one directory.
2586
- * Each file is staged to a sibling `<name>.tmp`, then renamed into place
2587
- * (atomic on the same filesystem); a pre-existing file keeps a single
2588
- * `<name>.bak`. A failure at ANY staging step removes the staged temps and
2589
- * leaves the previous files untouched — a half-written target is impossible
2590
- * up to the staging and backup phases.
2591
- *
2592
- * F-211: the rename COMMIT phase does not carry the same guarantee. When a
2593
- * filesystem refuses to overwrite the destination, the code removes it and
2594
- * retries the rename; if the retry ALSO fails the destination is gone. The
2595
- * single `.bak` created above still holds the previous content, so the target
2596
- * is restored from it (best-effort) before the original rename error is
2597
- * rethrown, so a failed commit never leaves the file missing.
2598
- *
2599
- * V4-17: the `.bak` is refreshed to the committed content after every
2600
- * successful commit, so a later failed commit always recovers to the most
2601
- * recent good generation (a once-at-first-install `.bak` could otherwise
2602
- * restore content several installs old). The recovery error names the `.bak`
2603
- * source (and mtime when available) so the caller knows the generation it
2604
- * restored from.
2605
- */
2606
- function atomicWriteFiles(targetDir, writes, fs = defaultFs, logger = console.warn) {
2607
- const stage = (name) => join(targetDir, `${name}.tmp`);
2608
- const dupes = [...new Set(writes.map((write) => write.name).filter((name, index, all) => all.indexOf(name) !== index))];
2609
- if (dupes.length > 0) throw new Error(`atomicWriteFiles: duplicate input names: ${dupes.join(", ")}`);
2610
- try {
2611
- for (const { name, content } of writes) fs.writeFileSync(stage(name), content);
2612
- for (const { name } of writes) {
2613
- const finalPath = join(targetDir, name);
2614
- const bakPath = join(targetDir, `${name}.bak`);
2615
- if (fs.existsSync(finalPath) && !fs.existsSync(bakPath)) fs.copyFileSync(finalPath, bakPath);
2616
- }
2617
- const committed = [];
2618
- for (const { name } of writes) {
2619
- const finalPath = join(targetDir, name);
2620
- const bakPath = join(targetDir, `${name}.bak`);
2621
- const tmp = stage(name);
2622
- try {
2623
- fs.renameSync(tmp, finalPath);
2624
- committed.push(name);
2625
- } catch {
2626
- try {
2627
- fs.rmSync(finalPath, { force: true });
2628
- } catch (removeError) {
2629
- const already = committed.length > 0 ? `; already committed: ${committed.join(", ")}` : "";
2630
- throw new Error(`atomicWriteFiles: could not remove "${name}" to replace it (${removeError instanceof Error ? removeError.message : String(removeError)})${already}`);
2631
- }
2632
- try {
2633
- fs.renameSync(tmp, finalPath);
2634
- committed.push(name);
2635
- } catch (renameError) {
2636
- const already = committed.length > 0 ? `; already committed: ${committed.join(", ")}` : "";
2637
- if (fs.existsSync(bakPath)) {
2638
- try {
2639
- fs.copyFileSync(bakPath, finalPath);
2640
- } catch {
2641
- throw new Error(`atomicWriteFiles: "${name}" was removed but recovery from ${bakPath} failed (${renameError instanceof Error ? renameError.message : String(renameError)}); verify the file manually${already}`);
2642
- }
2643
- const bakMtime = fs.mtime?.(bakPath);
2644
- const when = bakMtime !== null && bakMtime !== void 0 && Number.isFinite(bakMtime) ? ` (bak mtime ${new Date(bakMtime).toISOString()})` : "";
2645
- 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}`);
2646
- }
2647
- throw new Error(`${renameError instanceof Error ? renameError.message : String(renameError)}${already}`);
2648
- }
2649
- }
2650
- }
2651
- for (const { name } of writes) {
2652
- const finalPath = join(targetDir, name);
2653
- try {
2654
- if (fs.existsSync(finalPath)) fs.copyFileSync(finalPath, join(targetDir, `${name}.bak`));
2655
- } catch (refreshError) {
2656
- 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`);
2657
- }
2658
- }
2659
- } catch (error) {
2660
- const cleanupFailures = [];
2661
- for (const { name } of writes) try {
2662
- fs.rmSync(stage(name), {
2663
- force: true,
2664
- recursive: true
2665
- });
2666
- } catch (cleanupError) {
2667
- cleanupFailures.push(`${name}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
2668
- }
2669
- if (cleanupFailures.length > 0) {
2670
- const original = error instanceof Error ? error.message : String(error);
2671
- throw new Error(`${original} (staged-temp cleanup also failed: ${cleanupFailures.join("; ")})`);
2672
- }
2673
- throw error;
2674
- }
2675
- }
2676
2774
  /** F-328: render staged args for `pending --detail`, truncated to 500 chars.
2677
2775
  * args may be non-JSON (a tool produced garbage), so the render is fail-safe.
2678
2776
  * V4-19: a truncation is marked with `…(truncated N chars)` so the operator is
@@ -12,6 +12,8 @@ declare module '@deepseek-ai/dsh-llm' {
12
12
  } & ContextFormed;
13
13
  }
14
14
  }
15
+ import { atomicWriteFiles } from '@lmzhen/dsh-evolution-core';
16
+ export { atomicWriteFiles };
15
17
  export declare const name = "evolution-commands";
16
18
  export interface Config {
17
19
  /** Skill-tree root for maintain/restructure; empty uses the default tree.
@@ -71,46 +73,4 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
71
73
  threatExemptLabels: z<string[], string[], "defined">;
72
74
  }>>, "plain">;
73
75
  export declare function apply(ctx: Context, rawConfig?: Config): void;
74
- /** F-211: the fs surface atomicWriteFiles uses — injectable so the commit-phase
75
- * rename-failure path is deterministically testable. Defaults to node:fs.
76
- * `mtime` (optional) probes a path's last-write time so the failed-commit
77
- * recovery message can name the generation it restored from. */
78
- interface FsOps {
79
- writeFileSync(path: string, data: string | Uint8Array): void;
80
- existsSync(path: string): boolean;
81
- copyFileSync(from: string, to: string): void;
82
- renameSync(from: string, to: string): void;
83
- rmSync(path: string, options?: {
84
- force?: boolean;
85
- recursive?: boolean;
86
- }): void;
87
- mtime?(path: string): number | null;
88
- }
89
- /**
90
- * S6.3 (E-40): atomically replace a set of files within one directory.
91
- * Each file is staged to a sibling `<name>.tmp`, then renamed into place
92
- * (atomic on the same filesystem); a pre-existing file keeps a single
93
- * `<name>.bak`. A failure at ANY staging step removes the staged temps and
94
- * leaves the previous files untouched — a half-written target is impossible
95
- * up to the staging and backup phases.
96
- *
97
- * F-211: the rename COMMIT phase does not carry the same guarantee. When a
98
- * filesystem refuses to overwrite the destination, the code removes it and
99
- * retries the rename; if the retry ALSO fails the destination is gone. The
100
- * single `.bak` created above still holds the previous content, so the target
101
- * is restored from it (best-effort) before the original rename error is
102
- * rethrown, so a failed commit never leaves the file missing.
103
- *
104
- * V4-17: the `.bak` is refreshed to the committed content after every
105
- * successful commit, so a later failed commit always recovers to the most
106
- * recent good generation (a once-at-first-install `.bak` could otherwise
107
- * restore content several installs old). The recovery error names the `.bak`
108
- * source (and mtime when available) so the caller knows the generation it
109
- * restored from.
110
- */
111
- export declare function atomicWriteFiles(targetDir: string, writes: Array<{
112
- name: string;
113
- content: string | Uint8Array;
114
- }>, fs?: FsOps, logger?: (message: string) => void): void;
115
- export {};
116
76
  //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,65 @@
1
+ /**
2
+ * F-4/G7.4 (C7): the ONE parser for the /evolution command surface.
3
+ *
4
+ * The surface used to answer each subcommand with its own regex (`^params(?: --group ([a-z-]+))?(?: --json)?$`
5
+ * and nine siblings), so `params --json --group review` and `params --group=review` were refused as
6
+ * "not a subcommand", while a sibling regex accepted `=` — and a mistyped group name degraded into the
7
+ * generic error instead of the one that lists the known groups.
8
+ *
9
+ * This parser is order- and `=`-tolerant, matches the subcommand name case-insensitively, keeps a flag
10
+ * VALUE's case intact (the domain layer is the one that validates it), and answers a SYNTAX failure when a
11
+ * declared value flag is left without its value (F-3: `policy set a.b --expect 3` must not read
12
+ * `--expect 3` as the value).
13
+ *
14
+ * @module @lmzhen/dsh-evolution-commands/parse
15
+ */
16
+ /** The flags whose declared form takes a value (`--group <name>`, `--expect <revision>`, …). */
17
+ /**
18
+ * Exported so the command-table gate can tie it to the table's usage lines (P2: two declarations of
19
+ * "which flags take a value" would otherwise be free to drift apart).
20
+ */
21
+ export declare const VALUE_FLAGS: readonly string[];
22
+ /** One parsed command line. */
23
+ export interface ParsedCommand {
24
+ /** The subcommand name, lower-cased (the table stores it lower-case too). */
25
+ readonly name: string;
26
+ /** Declared flags: a value when the flag takes one, `true` when it is bare. */
27
+ readonly flags: ReadonlyMap<string, string | true>;
28
+ /** Everything that was neither the name nor a flag, in order. */
29
+ readonly positionals: readonly string[];
30
+ }
31
+ /** A SYNTAX failure — the caller answers it as a usage error, never as a domain error. */
32
+ export interface ParseFailure {
33
+ /** Which syntax rule was broken. */
34
+ readonly kind: 'unknown-subcommand' | 'unknown-flag' | 'missing-value';
35
+ /** The subcommand name the caller saw (empty when the line was empty). */
36
+ readonly name: string;
37
+ /** The flag involved, for the two flag failures. */
38
+ readonly flag?: string;
39
+ }
40
+ /**
41
+ * Narrow a parse result: true when it is the syntax failure rather than a command.
42
+ * @param result - what {@link parseSubcommand} answered.
43
+ * @returns whether the line was refused.
44
+ */
45
+ export declare function isParseFailure(result: ParsedCommand | ParseFailure): result is ParseFailure;
46
+ /**
47
+ * Read one SUB-FORM line: `maintain status`, `maintain report`, … — the shape the regexes used to own.
48
+ *
49
+ * A sub-form matches only when: the line parses, the name matches, the first positional is the sub-form,
50
+ * and every flag the line carries is DECLARED by the table (an undeclared flag keeps the old
51
+ * "unknown arguments" answer instead of silently becoming a bare flag).
52
+ * @param input - the text after `/evolution`.
53
+ * @param name - the subcommand name the table declares (e.g. `maintain`).
54
+ * @param sub - the sub-form word (e.g. `status`).
55
+ * @param declaredFlags - the flags the table declares for `name` (`flagsOf(name)`).
56
+ * @returns the parsed command, or null when this line is not that sub-form.
57
+ */
58
+ export declare function subcommandOf(input: string, name: string, sub: string, declaredFlags: readonly string[]): ParsedCommand | null;
59
+ /**
60
+ * Parse one command line into its name, flags and positionals.
61
+ * @param input - the text after `/evolution`.
62
+ * @returns the parsed command, or the syntax failure that refused it.
63
+ */
64
+ export declare function parseSubcommand(input: string): ParsedCommand | ParseFailure;
65
+ //# sourceMappingURL=parse.d.ts.map
@@ -12,9 +12,17 @@
12
12
  export interface CommandEntry {
13
13
  /** The subcommand as typed after `/evolution`. Variants use `|` (curator run|pause). */
14
14
  usage: string;
15
+ /** C7: true when the subcommand only READS (no skill/param write). Stored: not derivable. */
16
+ readonly: boolean;
15
17
  /** One-line purpose rendered in the help output. */
16
18
  summary: string;
17
19
  }
20
+ /** The flags the TABLE declares for one subcommand (name → `--flag`), DERIVED from its usage. */
21
+ export declare function flagsOf(name: string): string[];
22
+ /** C7 shape, DERIVED: the subcommand name is the first token of `usage` (variants use `|`). */
23
+ export declare function entryName(entry: CommandEntry): string;
24
+ /** C7 shape, DERIVED: the flags a `usage` declares, in the form `--flag` (value flags keep their name). */
25
+ export declare function entryFlags(entry: CommandEntry): string[];
18
26
  export declare const COMMAND_ENTRIES: CommandEntry[];
19
27
  /** The input-declaration hint (single line, ` | `-separated). */
20
28
  export declare function renderHint(): string;
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.22.0",
4
+ "version": "0.23.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -27,17 +27,18 @@
27
27
  "license": "MIT",
28
28
  "dependencies": {
29
29
  "@deepseek-ai/schemastery": "^3.18.4",
30
- "@lmzhen/dsh-evolution-approval": "^0.22.0",
31
- "@lmzhen/dsh-evolution-core": "^0.22.0",
32
- "@lmzhen/dsh-evolution-maintenance": "^0.22.0"
30
+ "@lmzhen/dsh-evolution-approval": "^0.23.0",
31
+ "@lmzhen/dsh-evolution-core": "^0.23.0",
32
+ "@lmzhen/dsh-evolution-maintenance": "^0.23.0"
33
33
  },
34
34
  "optionalDependencies": {
35
- "@lmzhen/dsh-evolution-agent-preset": "^0.22.0"
35
+ "@lmzhen/dsh-evolution-agent-preset": "^0.23.0"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@deepseek-ai/cordis": "^4.0.1",
39
39
  "@deepseek-ai/dsh-commands": "^0.2.0-rc.1",
40
- "@deepseek-ai/dsh-llm": "^0.2.0-rc.1"
40
+ "@deepseek-ai/dsh-llm": "^0.2.0-rc.1",
41
+ "@lmzhen/dsh-evolution-io": "^0.23.0"
41
42
  },
42
43
  "devDependencies": {
43
44
  "@deepseek-ai/dsh-commands": "^0.2.0-rc.1"