dsh-rule-engine 0.6.3 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,11 +1,13 @@
1
1
  # dsh-rule-engine
2
2
 
3
3
  ![npm](https://img.shields.io/npm/v/dsh-rule-engine)
4
- ![version](https://img.shields.io/badge/version-0.6.3-blue)
4
+ ![version](https://img.shields.io/badge/version-0.6.4-blue)
5
5
 
6
- DSH 规则执行引擎 v3 的插件实现。它把 `~/.dsh/AGENTS.md` 当作唯一真相源,自动解析规则四要素与执行等级,再通过「工具守卫 + 文本检测 + 时序检查 + 审计台账」执行用户规则,而不是内置一套与用户无关的安全清单。
6
+ DSH 规则执行引擎 v3 的插件实现。它把 `~/.dsh/AGENTS.md` 当作唯一真相源,自动解析规则四要素与执行等级,再通过「工具守卫 + 文本检测 + 时序检查 + 审计台账」执行用户规则,而不是内置一套与用户无关的安全清单。**当前版本 0.6.4**(**适用环境:DSH `0.1.5-rc.2`**——本机实测:装配成功 + 冷加载探针 PASS + `npm test` ALL TESTS PASSED + `npm run verify` 13 层全绿;兼容声明面见 `package.json` 的 `dshCompat`:`>=0.1.0-rc.3 <0.2.0`,覆盖 0.1.5 线)。本版三项修复:**D2**(待决 ask 提升为会话级——修复"用户消息与 ask 答复并发"致授权全部未登记)+ **B1/C1**(规则 24 官方 bundle 豁免兑现;装配不一致的收敛豁免)+ **A2**(ask 授权登记改复数路径、`authMatches` 支持多路径匹配)。
7
7
 
8
8
  > 当前版本 **0.6.3**(0.1.2-rc.1 适配:Remote 新合同(typert-protocol `Remote(undefined, …)` 形态,typeof-null 陷阱修复)+ peer 锚扩 `>=0.1.2-rc.0`;白名单 3 轮扩充(gh 只读态 / `gh api` 只读 / `git ls-remote` / `cmdkey /list` + Do- 结构词回归修复);E7 豁免预插(release-plugin bump 后自动追加 pnpm minimumReleaseAgeExclude——⑬ 口径防发布后红灯窗口,单源 `scripts/lib/pnpm-exempt.mjs`))。本插件面向"规则机器化执行":规则写在 AGENTS.md 里,引擎负责让它们真的被遵守;所有规则动态解析,规则增删改后无需重写插件。
9
+ >
10
+ > **0.6.4(最新;取代上行版本号)**——**适用环境验证:DSH `0.1.5-rc.2`**(本机实测:装配成功 + 冷加载探针 PASS + `npm test` ALL TESTS PASSED + `npm run verify` 13 层全绿;兼容声明面见 `package.json` 的 `dshCompat`:`>=0.1.0-rc.3 <0.2.0`,覆盖 0.1.5 线)。本版三项修复:**D2**(待决 ask 提升为会话级——修复"用户消息与 ask 答复并发"致授权全部未登记)+ **B1/C1**(规则 24 官方 bundle 豁免兑现;装配不一致的收敛豁免——守卫不再拦下唯一能收敛的动作)+ **A2**(ask 授权登记改复数路径、`authMatches` 支持多路径匹配——修复"多路径只取一个、取最长取到次要路径")。
9
11
 
10
12
  ## 项目背景
11
13
 
@@ -23,7 +25,7 @@ DSH 规则执行引擎 v3 的插件实现。它把 `~/.dsh/AGENTS.md` 当作唯
23
25
  本插件定位:**通用规则执行引擎**——任何用户的 AGENTS.md 规则集均可使用;本机工作流偏好仅作默认兜底(配置层),不含任何强制绑定。
24
26
 
25
27
  - **声明式绑定**:规则正文可写 `<!-- handler: xxx -->`(或经插件配置 `handlerOverrides: {规则编号: 执行器名}`)显式绑定引擎执行器;未声明的规则按"纯自证"处理(参与匹配/自证提示,不参与机器硬拦);
26
- - **执行器语义名**:声明/配置可写语义名(`approval`/`backup`/`inline-command`…),自动映射到内部执行器;直接写内部名(`rule12a-approval` 等)同样兼容;
28
+ - **措施类型(kind)**:声明/配置写 kind 名(`approval`/`backup`/`inline-command`…)即绑定对应执行器;历史内部名(`rule12a-approval` 等)经兼容别名表归一为同一 kind,**旧配置与旧规则正文无需改动**;
27
29
  - **禁用语义**:管理器禁用的规则以"禁用占位"存在——`/guard rules` 显示"(已禁用)",不参与硬拦/纠察;恢复启用后自动重新生效(禁用 ≠ 删除,不再"消失");
28
30
  - **无 AGENTS.md 也可用**:无规则文件时引擎零错加载、零规则、零误拦(发布门禁验证);
29
31
  - **本机偏好表下沉配置**:内置默认偏好表已从代码移除——通用部署=空表(代码零本机编号);本机偏好经 `rule-engine.json` 的 `handlerDefaultMap` 提供(可整体替换/清空,引擎升级不丢);
@@ -241,7 +243,9 @@ console.log(qualityTrend(sig).summary); // 方向:rework 改善 / 持平 /
241
243
  > 更早版本(0.1.0-0.5.5)与本机历史要点见 git 历史;各版本内部"用户定稿"等决策细节不再随发布物携带。
242
244
 
243
245
  | 版本 | 日期 | 要点 |
244
- | **0.6.3** | 2026-09-09 | docs(readme): 词表配置章节补齐 dualtrack(markers/whitelist/加载优先级/空表 REFUSED) |
246
+ |---|---|---|
247
+ | **0.6.4** | 2026-09-10 | **本版**:**D2**(待决 ask 提升为会话级——修复"用户消息与 ask 答复并发"致授权全部未登记)+ **B1/C1**(规则 24 官方 bundle 豁免兑现;装配不一致的收敛豁免——守卫不再拦下唯一能收敛的动作)+ **A2**(ask 授权登记改复数路径、`authMatches` 支持多路径匹配)。**适用环境验证 = DSH `0.1.5-rc.2`**(装配 + 冷加载探针 + `npm test` + `verify` 13 层全绿) |
248
+ | **0.6.3** | 2026-09-09 | docs(readme): 词表配置章节补齐 dualtrack(markers/whitelist/加载优先级/空表 REFUSED) |
245
249
  | **0.6.2** | 2026-09-08 | fix(0.6.2): whitelist 3-round + 0.1.2 adaptation + peer-anchor 0.1.2 + E7 pre-plug wiring |
246
250
  |---|---|---|
247
251
  | **0.6.1+(本地增强,未发版)** | 2026-09-07 | 只读豁免清单扩充(用户定调"纯只读顺畅"):`gh api` 只读态(无 -X/--method/graphql/-F/-f 的段)、`gh release/issue/pr/run view`、`cmdkey /list`、`git ls-remote` 显式;写形态(gh api -F/graphql、git fetch、curl 下载、重定向落盘)保持拦截;26 用例全绿 + 语法体检 + 热重载生效(发版需 bump 0.6.2) |
@@ -260,11 +264,11 @@ console.log(qualityTrend(sig).summary); // 方向:rework 改善 / 持平 /
260
264
  | **0.5.7** | 2026-08-26 | 注入噪音治理(词表只产嫌疑 + LLM 裁决 + fail-closed + 投递资格闸 + 审计完整性)、注入通道重入修复(宏任务投递)、语义层(awaitingJudge/judge-pass/false/unavailable) |
261
265
  | **0.5.6** | 2026-08-26 | 同回复聚合注入、已自证规则不重复触发、C2 规则统计(detected/suppressed/injected) |
262
266
 
263
- ## 发行固定源
264
-
265
- <!-- fixed-source: 待发布回填 -->
267
+ ## 发行固定源
268
+
269
+ > **0.6.4(当前)** —— 本版三项修复:**D2**(待决 ask 提升为会话级)/ **B1/C1**(规则 24 官方 bundle 豁免兑现 + 装配不一致的收敛豁免)/ **A2**(ask 授权登记改复数路径 + `authMatches` 多路径匹配)。**适用环境验证 = DSH `0.1.5-rc.2`**(装配 + 冷加载探针 + `npm test` + `verify` 13 层全绿)。**发布 commit 待三通道完成后回填**(届时 `git checkout <hash>` 可复现 npm `dsh-rule-engine@0.6.4` 与 GitHub Release v0.6.4 同源代码)。
266
270
 
267
- - **0.6.3(当前)** 固定于 main Commit `68c1e7d`(`git checkout 68c1e7d` + 发布 bump 可复现 npm `dsh-rule-engine@0.6.3` 与 GitHub Release v0.6.3 同源代码——0.6.3 = 分层残留闸 `dualtrack-check`(判据 A:中文≠个人化)+ 词表全量配置化(lexicons / patterns / criticismPersonal / dualtrack 走 `rule-engine.json`)+ 第三批第 1 批文案层 + 三项门禁修复(`--init` 覆盖保护 / loader-smoke 中文夹具 / 中文目录顿号兼容)。**注:发布后回填真实 release 提交 hash。**
271
+ - **0.6.3(当前)** 固定于 main Commit `3e87f9d`(`git checkout 3e87f9d` 可复现 npm `dsh-rule-engine@0.6.3` 与 GitHub Release v0.6.3 同源代码——0.6.3 = 分层残留闸 `dualtrack-check`(判据 A:中文≠个人化)+ 词表全量配置化(lexicons / patterns / criticismPersonal / dualtrack 走 `rule-engine.json`)+ 第三批第 1 批文案层 + 三项门禁修复(`--init` 覆盖保护 / loader-smoke 中文夹具 / 中文目录顿号兼容)。
268
272
  - **0.6.2** 固定于 main Commit `92da194538fce2f56fa7c6712c70711865772686`(`git checkout 92da194538fce2f56fa7c6712c70711865772686` 可复现 npm `dsh-rule-engine@0.6.2` 与 GitHub Release v0.6.2 同源代码——0.6.2 = 0.1.2-rc.1 适配(Remote 新合同 `Remote(undefined, …)` / peer 锚扩 `>=0.1.2-rc.0`)+ 白名单 3 轮扩充 + Do- 结构词回归修复 + E7 豁免预插机制上线)。
269
273
  - **0.6.1** 固定于 main Commit `051e2da`(`git checkout 051e2da` 可复现 npm `dsh-rule-engine@0.6.1` 与 GitHub Release v0.6.1 同源代码——0.6.1 = 豁免预插(release-plugin bump 后自动追加 pnpm minimumReleaseAgeExclude,⑬ 绝对口径防发布后红灯窗口——踩坑 18 镜像)+ 豁免判定单源化(scripts/lib/pnpm-exempt.mjs 与 verify-all ⑬ 共享,4 单测锁定)+ viewFails 发布语境 STRICT 计 ❌ + ⑬ 块头注释绝对口径(E1/E2/E3 收尾批)。
270
274
  - **0.6.0** 固定于 main Commit `be5b8c93`(可复现 `dsh-rule-engine@0.6.0` 与 Release v0.6.0——0.6.0 = 本机集成层(localIntegrations 四键)+ 本机痕迹消号 + li-skipped/entry-script-missing 启动审计 + 发布门禁 B1/B2(readme-version-check / local-residue-scan,挂 verify-all/release-plugin/check:meta)+ check-tool-coverage 素材 fail-closed;词表文件 `scripts/local-residue-markers.txt` 为本机门禁工具,不入库、不进发布物(见 .gitignore / package.json files 排除)。固定源之后的提交仅限 README 指针文本)。
@@ -397,10 +401,22 @@ dsh plugin --profile web add dsh-rule-engine
397
401
  - dsh-visualize(Nagi-ovo)
398
402
  - dsh-rules-manager(jilian-dsh)
399
403
  - dsh-vision-router、dsh-example-injector 等未列出的作者
404
+ - **设计思想与机制来源(本项目直接内化/借鉴)**:
405
+ - stop-that-shit(lennney):任务边界、反过度工程、预算与四类越界——任务契约模块的设计源头。
406
+ - dsh-agi-harness(yjh051108):闸=最小决策单元、Wilson 下界、拒因即指路、自由面/盲区显式声明、任务签名+质量趋势(quality-ledger)、变异测试+棘轮。
407
+ - Claude Code 权限范式(Anthropic 官方文档):默认询问、deny 永远赢、通配符规则只加不放。
408
+ - mattpocock/skills「writing-for-agents」:写给 agent 的文档方法论。
409
+ - dsh-zvec-grep(sugarforever):后台任务型插件写法。
410
+ - **升级与审查工具链**:
411
+ - oh-my-dsh / dsh-plugin-upgrade-skill(社区):0.1.2 升级卡库与对策集。
412
+ - build-dsh-plugin(AI-Scarlett):插件完备性审计。
413
+ - cordis-plugin-thinking-loop-guard(argszero):纯思考空转的源码级判读。
400
414
  - **学习参考的社区文档/库作者**:
401
415
  - dsh-handbook(Electricitysheep)
402
416
  - SandBase deepseek-harness-handbook(sandbaseai)
417
+ - awesome-dsh-plugin / Oh! dsh(生态目录)
403
418
  - 以及 DSH 官方文档镜像与源码维护者
419
+ - **贡献建议与实证的个人**:TheBuilderJR、Reximmortal1021、AI-Scarlett、ckcfcc、goatliamia 等。
404
420
 
405
421
  ## 免责声明
406
422
 
@@ -374,13 +374,17 @@ function pathUnderAuth(p, authPath) {
374
374
  export function authMatches(auth, op) {
375
375
  if (!auth || !op) return false;
376
376
  if (auth.type !== "any" && op.type !== "any" && auth.type !== op.type) return false;
377
- if (auth.pathPrefix) {
377
+ // A2(2026-09-10):授权侧支持多路径——ask 多路径问题文本经 index.js L1029 记入 pathPrefixes。
378
+ // 语义:任一授权路径覆盖任一操作候选路径即匹配(此前只认单一 auth.pathPrefix,ask 侧只记一条 → 其余路径的授权形同虚设)。
379
+ const authPaths = Array.isArray(auth.pathPrefixes) && auth.pathPrefixes.length > 0
380
+ ? auth.pathPrefixes.map(normalizePath).filter(Boolean)
381
+ : (auth.pathPrefix ? [normalizePath(auth.pathPrefix)] : []);
382
+ if (authPaths.length > 0) {
378
383
  const candidates = Array.isArray(op.pathPrefixes) && op.pathPrefixes.length > 0
379
384
  ? op.pathPrefixes
380
385
  : (op.pathPrefix ? [op.pathPrefix] : []);
381
386
  if (candidates.length === 0) return false;
382
- const authPath = normalizePath(auth.pathPrefix);
383
- if (!candidates.some((p) => pathUnderAuth(normalizePath(p), authPath))) return false;
387
+ if (!candidates.some((p) => authPaths.some((a) => pathUnderAuth(normalizePath(p), a)))) return false;
384
388
  }
385
389
  // 柱子 B(2026-08-31):对象锚定——授权声明了显式命名对象 → 操作命令文本必须命中该对象
386
390
  //("推送 X 仓库"不覆盖"推送 Y 仓库";未锚定对象=不收紧)
@@ -402,7 +406,11 @@ export function findMatchingAuth(auths, op, now = Date.now()) {
402
406
  export function describeAuth(auth) {
403
407
  if (!auth) return "无";
404
408
  const type = auth.type || "any";
405
- const path = auth.pathPrefix ? `路径 ${auth.pathPrefix}` : "全局";
409
+ // A2(2026-09-10):多路径授权逐条显示(ask 多路径问题文本);单路径/全局维持原措辞。
410
+ const paths = Array.isArray(auth.pathPrefixes) && auth.pathPrefixes.length > 0
411
+ ? auth.pathPrefixes
412
+ : (auth.pathPrefix ? [auth.pathPrefix] : []);
413
+ const path = paths.length === 0 ? "全局" : paths.length === 1 ? `路径 ${paths[0]}` : `路径 ${paths.join(" / ")}`;
406
414
  const expires = auth.expiresAt ? `|到期 ${new Date(auth.expiresAt).toISOString()}` : "";
407
415
  return `${type}|${path}|${new Date(auth.at || 0).toISOString()}${expires}|仅本次运行有效`;
408
416
  }
@@ -37,6 +37,7 @@ import { computeMountSignature, profileNameFromArgs } from "./mount-signature.js
37
37
  import { findBackupForPath, getSessionState, maybeReloadIfChanged } from "./state.js";
38
38
  import { isVersionedFile, validateEditedFile } from "./version-guard.js";
39
39
  import { decideContractAction, defaultContract, isArmed } from "./contract.js";
40
+ import { kindOf } from "./measure-kinds.js";
40
41
  import { classifyAction } from "./overengineering.js";
41
42
  import { labelFingerprint, labelsAllowFingerprint } from "./label-fingerprint.js";
42
43
 
@@ -286,23 +287,26 @@ function resolveLocalDependencyPkgPath(bundleName, profilePkgPath, parsed) {
286
287
  return existsSync(pkgPath) ? pkgPath : null;
287
288
  }
288
289
 
289
- /** 若本次文件变更会写入 profile package.json dsh.profile.bundles,返回其中非 bundle/无法确认的项 */
290
- export function nonBundleInProfileBundles(name, args) {
291
- const p = pathTarget(args);
292
- if (!isProfilePackageJson(p)) return null;
293
- const content = resultingFileContent(name, args);
294
- if (!content) return null;
290
+ /** 官方 bundle 白名单(B1 修复,2026-09-10):规则 24 自己的豁免条款原文即「官方 bundle 由官方机制管理,
291
+ * 模型不主动修改」——引擎此前未在代码里兑现。这两个包**永远**位于 DSH 本体或 profiles 共享层,
292
+ * 独立 DSH_HOME 的新 profile 两者都可能尚未建立 → 校验必然失败并**卡死 profile 声明编辑**(试点主要阻塞点)。 */
293
+ const OFFICIAL_BUNDLES = new Set(["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"]);
294
+
295
+ /** 解析给定 profile package.json **内容**中非 bundle / 无法确认的项(内容级纯函数,供 B1 与 C1 复用) */
296
+ export function nonBundleInContent(content, profilePkgPath) {
295
297
  let parsed;
296
298
  try { parsed = JSON.parse(content); } catch { return null; }
297
299
  const bundles = parsed?.dsh?.profile?.bundles;
298
300
  if (!Array.isArray(bundles)) return null;
299
301
  const bad = [];
300
302
  for (const b of bundles) {
301
- const pkgPath = resolveBundlePkgPath(b, p) || resolveLocalDependencyPkgPath(b, p, parsed);
303
+ if (OFFICIAL_BUNDLES.has(b)) continue; // B1:官方 bundle 直接放行(规则 24 豁免条款)
304
+ const pkgPath = resolveBundlePkgPath(b, profilePkgPath) || resolveLocalDependencyPkgPath(b, profilePkgPath, parsed);
302
305
  if (!pkgPath) {
303
306
  const dep = parsed?.dependencies?.[b] ?? parsed?.devDependencies?.[b] ?? parsed?.optionalDependencies?.[b];
304
307
  const depDesc = typeof dep === "string" ? `dependencies 为 ${dep}` : "dependencies 中无此包";
305
- bad.push(`${b}(找不到 package.json,无法确认类型;${depDesc}。请先用 dev_install_package 或先安装依赖再写 bundles)`);
308
+ // B1 建议 3:给出可执行的排查方向(独立 DSH_HOME 场景最常见的成因)
309
+ bad.push(`${b}(找不到 package.json,无法确认类型;${depDesc}。请先用 dev_install_package 或先安装依赖再写 bundles;若为独立 DSH_HOME,请确认 profiles/node_modules 共享层存在——该层由 DSH 真实启动时的 healProfilesModuleFallback 自动建立)`);
306
310
  continue;
307
311
  }
308
312
  try {
@@ -315,6 +319,31 @@ export function nonBundleInProfileBundles(name, args) {
315
319
  return bad.length ? bad : null;
316
320
  }
317
321
 
322
+ /** 若本次文件变更会写入 profile package.json 的 dsh.profile.bundles,返回其中非 bundle/无法确认的项 */
323
+ export function nonBundleInProfileBundles(name, args) {
324
+ const p = pathTarget(args);
325
+ if (!isProfilePackageJson(p)) return null;
326
+ const content = resultingFileContent(name, args);
327
+ if (!content) return null;
328
+ return nonBundleInContent(content, p);
329
+ }
330
+
331
+ /** C1 修复(2026-09-10):判定本次变更是否为「使装配声明趋于自洽」的修复动作。
332
+ * 判据:变更**前** profile package.json 的 bundles 不自洽(存在解析不到的项),变更**后**自洽。
333
+ * 用途:mount-audit 守卫的**收敛豁免**——解除「不一致态只能靠被拦动作修复」的死循环。
334
+ * 保守边界:读不到原文件、或变更后仍不自洽 → 不豁免。 */
335
+ export function isConvergingBundleFix(name, args) {
336
+ const p = pathTarget(args);
337
+ if (!isProfilePackageJson(p)) return false;
338
+ const after = resultingFileContent(name, args);
339
+ if (!after) return false;
340
+ let before = null;
341
+ try { before = readFileSync(p, "utf8"); } catch { return false; }
342
+ const beforeBad = nonBundleInContent(before, p);
343
+ const afterBad = nonBundleInContent(after, p);
344
+ return Array.isArray(beforeBad) && beforeBad.length > 0 && (!afterBad || afterBad.length === 0);
345
+ }
346
+
318
347
  /** 任务契约守卫:仅在总开关开启且会话 armed 时硬拦;ask 场景交给 tools/pre-execute */
319
348
  function taskContractGuardDecision(state, exec, session) {
320
349
  if (!state.taskContract?.taskContractEnabled) return null;
@@ -499,7 +528,7 @@ function matchRule(cfg, ctx) {
499
528
 
500
529
  // 规则 22 机器化:无执行分点/存在歧义 → 本回合禁止变更类工具调用
501
530
  //("你的执行方案难道没问题吗?"虽含"执行"仍是疑问句——同形词不构成指令,不豁免)
502
- if (cfg.handler === "rule22-7-direct") {
531
+ if (kindOf(cfg.handler) === "intent-direct") {
503
532
  // B2(2026-08-28 阶段二):委派/子代理回合不做"无执行分点"判定(规则 22④ 的识别层落地)——
504
533
  // 子代理任务书以 source.kind="user" 注入,会被当用户消息判"无执行分点"而误拦(ERR-L8QAXS 实弹)。
505
534
  if (isDelegatedSession(exec)) {
@@ -588,7 +617,7 @@ function matchRule(cfg, ctx) {
588
617
  }
589
618
 
590
619
  // 规则 1:同工具同参数连续失败 ≥2 次后拦第 3 次(失败计数由 tool/result 更新)
591
- if (cfg.handler === "rule1-retry") {
620
+ if (kindOf(cfg.handler) === "retry") {
592
621
  const key = `${name}:${JSON.stringify(args || {})}`;
593
622
  const count = state.retryCounts.get(key) || 0;
594
623
  const userText = session.turn.userText || session.lastUserText || "";
@@ -602,7 +631,7 @@ function matchRule(cfg, ctx) {
602
631
  }
603
632
 
604
633
  // 规则 9:内联命令 / BOM 写配置(PS7 语义:仅拦显式 utf8BOM)
605
- if (cfg.handler === "rule9-inline-bom") {
634
+ if (kindOf(cfg.handler) === "inline-command") {
606
635
  if ((name === "pwsh" || name === "bash") && cmd) {
607
636
  if (INLINE_CMD.test(cmd)) {
608
637
  return makeHit(cfg, "【硬拦截】禁止内联命令(node -e / pwsh -c / node -p 等),请先写脚本文件再执行");
@@ -615,7 +644,7 @@ function matchRule(cfg, ctx) {
615
644
  }
616
645
 
617
646
  // 规则 18:DSH 任务首次工具调用前必须已读手册
618
- if (cfg.handler === "rule18-manual-first") {
647
+ if (kindOf(cfg.handler) === "manual-first") {
619
648
  const userText = session.turn.userText || session.lastUserText || "";
620
649
  const firstTool = session.turn.toolCount === 0;
621
650
  if (firstTool && !session.manualReadSeen && dshKeywordsRe().test(userText) && !isManualReadTool(name, args, state.localIntegrations?.manualExempt?.paths || [])) {
@@ -625,7 +654,7 @@ function matchRule(cfg, ctx) {
625
654
  }
626
655
 
627
656
  // 规则 13A:删除/覆盖/高风险写前需有“目标路径对应备份”证据
628
- if (cfg.handler === "rule13a-backup") {
657
+ if (kindOf(cfg.handler) === "backup") {
629
658
  // 统一入口命令豁免:入口脚本每次写入前自身执行备份(backup() 保留 5 份),
630
659
  // 引擎静态扫描看不到脚本内部动作(已知盲区);入口命令也已被 __self-protect 限定为唯一写通道。
631
660
  if ((name === "pwsh" || name === "bash") && isEntryChannelCommand(cmd, state.localIntegrations?.entryScript)) return null;
@@ -668,7 +697,7 @@ function matchRule(cfg, ctx) {
668
697
  // hints 含 "skill" 的其它规则 cfg(如 12A:hints=["ask","skill","sensitive",...])截胡——
669
698
  // 非 skill 工具时 return null,导致 12A 敏感授权检查静默失效(C-2 实测 Move-Item 放行)。
670
699
  // 现在:hints 兜底仅在「name === 'skill'」时进入;handler 明确为 rule12b-skill 时维持原语义。
671
- if (cfg.handler === "rule12b-skill") {
700
+ if (kindOf(cfg.handler) === "skill-auth") {
672
701
  if (name === "skill") {
673
702
  const skillName = typeof args?.name === "string" ? args.name : "";
674
703
  if ((state.localIntegrations?.manualExempt?.skills || []).includes(skillName)) return null;
@@ -703,7 +732,7 @@ function matchRule(cfg, ctx) {
703
732
  }
704
733
 
705
734
  // 规则 12A:敏感操作需要匹配授权证据
706
- if (cfg.handler === "rule12a-approval") {
735
+ if (kindOf(cfg.handler) === "approval") {
707
736
  if (isSensitiveToolCall(name, args, sessionIdOf(exec))) {
708
737
  // 0.5.11(用户定稿):12A 与 22-7 判据同源——分析通道/低风险新建豁免在两条分支
709
738
  // 使用同一组判据(isAnalysisOp/isLowRiskWorkspaceNew),不再各写一套(此前 22-7 放行、
@@ -742,7 +771,7 @@ function matchRule(cfg, ctx) {
742
771
  }
743
772
 
744
773
  // 规则 21:规则/配置文件变更需 unlock(元规则)
745
- if (cfg.handler === "rule21-meta") {
774
+ if (kindOf(cfg.handler) === "meta") {
746
775
  if ((name === "edit" || name === "write" || (name === "str_replace_editor" && args?.command !== "view")) && isProtectedConfigPath(p) && !unlock) {
747
776
  return makeHit(cfg, "【硬拦截】规则/配置文件受保护:修改需用户先执行 /guard unlock");
748
777
  }
@@ -750,7 +779,7 @@ function matchRule(cfg, ctx) {
750
779
  }
751
780
 
752
781
  // 规则 24:插件装配类型确认(A 硬拦)+ 变更类工具统一覆盖(原规则 25 语义,检查④)
753
- if (cfg.handler === "rule24-assembly-type") {
782
+ if (kindOf(cfg.handler) === "assembly") {
754
783
  if (name === "dev_install_package") {
755
784
  const dir = args?.dir;
756
785
  if (dir) {
@@ -782,7 +811,7 @@ function matchRule(cfg, ctx) {
782
811
  }
783
812
 
784
813
  // 规则 27:装配变更后必须先通过全量审计,才能继续装配(C 时序;全局变更 + 本会话审计证据)
785
- if (cfg.handler === "rule27-mount-audit") {
814
+ if (kindOf(cfg.handler) === "mount-audit") {
786
815
  if (isAssemblyMutationTool(name, args)) {
787
816
  const currentSig = computeMountSignature(profileNameFromArgs(args));
788
817
  state.mountSignature = currentSig;
@@ -791,6 +820,14 @@ function matchRule(cfg, ctx) {
791
820
  ? currentSig !== auditedSig
792
821
  : (state.mountRevision > (session.mountAuditRevision || 0));
793
822
  if (needsAudit) {
823
+ // C1 修复(2026-09-10):**收敛豁免**——允许"使装配趋于自洽"的变更,解除不一致态的死循环。
824
+ // 背景(独立 DSH_HOME 试点):改 dependencies → 通过;改 bundles 补齐 → 被拦;
825
+ // 试图回滚 dependencies → **同样被拦** → 审计失败项(bundles-missing)恰恰只能靠被拦的动作修复
826
+ // → 无法收敛(试点靠用户手工写文件才解开)。
827
+ // ① 回滚豁免:变更后签名回到"审计通过时的签名"
828
+ // ② 修复豁免:变更前声明不自洽(bundles 有解析不到的项)而变更后自洽
829
+ if (auditedSig && currentSig === auditedSig) return null; // ① 回到已审计状态
830
+ if (isConvergingBundleFix(name, args)) return null; // ② 修复审计点名的缺失/非法项
794
831
  const why = auditedSig
795
832
  ? `装配内容已变化(装配状态哈希 ${currentSig.slice(0, 8)} ≠ 审计通过时 ${auditedSig.slice(0, 8)})`
796
833
  : `插件装配已变更(mountRevision=${state.mountRevision})且本会话未通过全量审计`;
@@ -10,19 +10,21 @@ import {
10
10
  activateTimeRe,
11
11
  dshKeywordsRe
12
12
  } from "./patterns.js";
13
+ import { kindOf } from "./measure-kinds.js";
13
14
 
14
15
  export function activateForUserMessage(configs, userText) {
15
16
  const text = userText || "";
16
17
  return configs.filter((cfg) => {
17
- if (cfg.handler === "rule18-manual-first" && dshKeywordsRe().test(text)) return true;
18
- if (cfg.handler === "rule12b-skill" && activateSkillRe().test(text)) return true;
19
- if (cfg.handler === "rule12a-approval" && activateApprovalRe().test(text)) return true;
20
- if (cfg.handler === "rule12c-network" && activateNetworkRe().test(text)) return true;
21
- if (cfg.handler === "rule13a-backup" && activateBackupRe().test(text)) return true;
22
- if (cfg.handler === "rule2-time" && activateTimeRe().test(text)) return true;
23
- if (cfg.handler === "rule7-promise" && activatePromiseRe().test(text)) return true;
24
- if (cfg.handler === "rule5-source" && activateSourceRe().test(text)) return true;
25
- if (cfg.handler === "rule11-language" && CJK_PRESENT.test(text)) return true;
18
+ const kind = kindOf(cfg.handler);
19
+ if (kind === "manual-first" && dshKeywordsRe().test(text)) return true;
20
+ if (kind === "skill-auth" && activateSkillRe().test(text)) return true;
21
+ if (kind === "approval" && activateApprovalRe().test(text)) return true;
22
+ if (kind === "network" && activateNetworkRe().test(text)) return true;
23
+ if (kind === "backup" && activateBackupRe().test(text)) return true;
24
+ if (kind === "time" && activateTimeRe().test(text)) return true;
25
+ if (kind === "promise" && activatePromiseRe().test(text)) return true;
26
+ if (kind === "source" && activateSourceRe().test(text)) return true;
27
+ if (kind === "language" && CJK_PRESENT.test(text)) return true;
26
28
  return false;
27
29
  });
28
30
  }
@@ -33,15 +35,16 @@ export function activateForToolCall(configs, toolName, args) {
33
35
  const name = String(toolName || "");
34
36
  const argText = JSON.stringify(args || {});
35
37
  return configs.filter((cfg) => {
36
- if (cfg.handler === "rule9-inline-bom" && (name === "pwsh" || name === "bash")) return true;
37
- if (cfg.handler === "rule1-retry") return true;
38
- if (cfg.handler === "rule18-manual-first") return true;
39
- if (cfg.handler === "rule13a-backup" && (name === "pwsh" || name === "bash" || name === "edit" || name === "write")) return true;
40
- if (cfg.handler === "rule12b-skill" && name === "skill") return true;
41
- if (cfg.handler === "rule12a-approval" || cfg.handler === "rule12d-sensitive") {
38
+ const kind = kindOf(cfg.handler);
39
+ if (kind === "inline-command" && (name === "pwsh" || name === "bash")) return true;
40
+ if (kind === "retry") return true;
41
+ if (kind === "manual-first") return true;
42
+ if (kind === "backup" && (name === "pwsh" || name === "bash" || name === "edit" || name === "write")) return true;
43
+ if (kind === "skill-auth" && name === "skill") return true;
44
+ if (kind === "approval" || kind === "sensitive") {
42
45
  if (name === "pwsh" || name === "bash" || name === "edit" || name === "write" || name === "ask_user_question") return true;
43
46
  }
44
- if (cfg.handler === "rule21-meta" && (name === "edit" || name === "write")) return true;
47
+ if (kind === "meta" && (name === "edit" || name === "write")) return true;
45
48
  if (argText) {
46
49
  const low = argText.toLowerCase();
47
50
  if (cfg.triggerKeywords.some((k) => low.includes(k.toLowerCase()))) return true;
@@ -0,0 +1,127 @@
1
+ // measure-kinds.js - 措施类型注册表(架构 v3 §三 L1:通用层,零规则号)
2
+ //
3
+ // 依据:reports/2026-09-10-引擎分层架构-通用层零规则内容-v1.md
4
+ // §三 L1「措施类型注册表(measure kinds):每型一个通用解释器,命名去规则号」;
5
+ // §八 P1「措施类型注册表 + 解释器骨架;guard-core/matcher 改 kind → interpreter;内部名去规则号」。
6
+ //
7
+ // 三层分工(P1):
8
+ // ① MEASURE_KINDS —— 通用层的 kind 清单(**零规则号**,随包发布);
9
+ // ② LEGACY_HANDLER_ALIASES —— 历史内部名(带规则号)→ kind 的**兼容映射**
10
+ // (P4 清淤时迁个人层配置;保留在此是为了不破坏既有配置与规则正文声明);
11
+ // ③ kindOf() —— 唯一归一入口,接受 旧名 / kind 名 / 未知名 三种输入。
12
+ //
13
+ // 兼容面(P1 行为等价的前提):本机 rule-engine.json 的 handlerDefaultMap/handlerOverrides、
14
+ // 规则正文 `<!-- handler: … -->`、以及测试夹具,**仍可写旧内部名**——经 kindOf 归一后
15
+ // 与 kind 名完全等价。新用户/新规则写 kind 名即可,无需知道任何规则号。
16
+ export const MEASURE_KINDS = {
17
+ retry: "连续失败重试闸(第 3 次拒绝)",
18
+ time: "时间表述取证",
19
+ source: "引用出处标注",
20
+ promise: "承诺保守",
21
+ "inline-command": "禁止内联命令",
22
+ language: "语言与表达",
23
+ approval: "敏感操作授权确认",
24
+ "skill-auth": "技能调用授权",
25
+ network: "下载与网络",
26
+ backup: "写入前备份",
27
+ "manual-first": "先查手册再动手",
28
+ meta: "元规则管理",
29
+ "intent-direct": "无执行分点直拦",
30
+ "runtime-verify": "交付验证证据",
31
+ assembly: "插件装配类型守卫",
32
+ "release-asset": "发布资产规范",
33
+ "mount-audit": "挂载唯一性审计",
34
+ sensitive: "敏感操作(注入面)"
35
+ };
36
+
37
+ /** 历史内部名 → kind(兼容层;键含规则号,P4 迁个人层配置) */
38
+ export const LEGACY_HANDLER_ALIASES = {
39
+ "rule1-retry": "retry",
40
+ "rule2-time": "time",
41
+ "rule5-source": "source",
42
+ "rule7-promise": "promise",
43
+ "rule9-inline-bom": "inline-command",
44
+ "rule11-language": "language",
45
+ "rule12a-approval": "approval",
46
+ "rule12b-skill": "skill-auth",
47
+ "rule12c-network": "network",
48
+ "rule12d-sensitive": "sensitive",
49
+ "rule13a-backup": "backup",
50
+ "rule18-manual-first": "manual-first",
51
+ "rule21-meta": "meta",
52
+ "rule22-7-direct": "intent-direct",
53
+ "rule23-runtime-verify": "runtime-verify",
54
+ "rule24-assembly-type": "assembly",
55
+ "rule26-release-asset": "release-asset",
56
+ "rule27-mount-audit": "mount-audit"
57
+ };
58
+
59
+ /**
60
+ * 归一为 kind 名。
61
+ * 旧内部名 → kind;kind 名 → 原样(幂等);未知名 → 原样(由覆盖自省提示未覆盖)。
62
+ * @param {string} name
63
+ * @returns {string}
64
+ */
65
+ export function kindOf(name) {
66
+ const s = String(name || "").trim();
67
+ if (!s) return "";
68
+ return LEGACY_HANDLER_ALIASES[s] || s;
69
+ }
70
+
71
+ /** 兼容旧调用名(understander 对外导出同义函数) */
72
+ export function normalizeHandlerName(name) {
73
+ return kindOf(name);
74
+ }
75
+
76
+ // ── P2(2026-09-10):个人层「措施声明」解析 + 校验(架构正本 §三 L2/L3)──
77
+
78
+ /** 规则正文内联声明:`<!-- handler: <kind> -->` 或 `<!-- handler: <kind> {json} -->`
79
+ * 设计(方案 A,用户 2026-09-10 拍板):**扩展既有 handler 声明**而非新增独立块——
80
+ * 向后兼容(旧声明零影响)、单点解析入口。正则捕获 `-->` 之前的**全部内容**,之后再拆分,
81
+ * 避免用嵌套正则直接匹配 JSON(`}` 截断风险)。 */
82
+ const HANDLER_DECL_RE = /<!--\s*handler\s*:\s*([\s\S]*?)-->/i;
83
+
84
+ /**
85
+ * 解析个人层措施声明。
86
+ * @param {string} body 规则正文
87
+ * @returns {null | {kind: string, params: object} | {error: string}}
88
+ * null = 无声明;{kind, params} = 解析成功;{error} = 声明非法(调用方须 fail-closed)
89
+ */
90
+ export function parseHandlerDecl(body) {
91
+ const m = String(body || "").match(HANDLER_DECL_RE);
92
+ if (!m) return null;
93
+ const raw = String(m[1] || "").trim();
94
+ if (!raw) return { error: "声明为空(`<!-- handler: -->`)" };
95
+ const km = /^([A-Za-z0-9][A-Za-z0-9-]*)\s*([\s\S]*)$/.exec(raw);
96
+ if (!km) return { error: `声明格式非法:${raw.slice(0, 60)}` };
97
+ const kind = kindOf(km[1]);
98
+ const rest = String(km[2] || "").trim();
99
+ let params = {};
100
+ if (rest) {
101
+ let parsed;
102
+ try {
103
+ parsed = JSON.parse(rest);
104
+ } catch {
105
+ return { error: `参数不是合法 JSON:${rest.slice(0, 60)}` };
106
+ }
107
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
108
+ return { error: `参数必须是 JSON 对象:${rest.slice(0, 60)}` };
109
+ }
110
+ params = parsed;
111
+ }
112
+ return { kind, params };
113
+ }
114
+
115
+ /**
116
+ * 校验措施声明(架构正本 §三 L3:**kind 未注册即拒绝并明确报错,fail-closed**)。
117
+ * @param {null | {kind: string} | {error: string}} decl parseHandlerDecl 的输出
118
+ * @returns {null | string} null = 通过(或无声明);字符串 = 明确错误原因
119
+ */
120
+ export function validateMeasure(decl) {
121
+ if (!decl) return null;
122
+ if (decl.error) return decl.error;
123
+ if (!Object.prototype.hasOwnProperty.call(MEASURE_KINDS, decl.kind)) {
124
+ return `kind 未注册:${decl.kind}(已注册:${Object.keys(MEASURE_KINDS).join(" / ")})`;
125
+ }
126
+ return null;
127
+ }
@@ -303,6 +303,12 @@ const ALLOW_SEG_RE =
303
303
  // 0.5.12(F4):纯字符串/纯输出段(整段是引号包裹字符串或 $() 子表达式)——不是命令,放行。
304
304
  // 保守边界:只匹配"整段即字符串"(^...$ 锚定),不让 node -e "..." / curl -o "..." 借道。
305
305
  const PURE_STRING_SEG_RE = /^(?:"[^"\r\n]*"|'[^'\r\n]*'|`[^`\r\n]*`|\$\(\s*[^()\r\n]*\s*\))+$/;
306
+ // F5(2026-09-10 修复,第三方实测):`Remove-Item Env:NAME` 的**操作对象是进程环境变量,不是文件**,
307
+ // 此前被 MUTATING_CMD_RE 的 Remove-Item 命中判写(ERR-JCSE3O 的类判定来源)。
308
+ // 边界(保守):仅当整段目标都是 Env:/$env: 形式时豁免;`Remove-Item <路径>`、混合目标、
309
+ // 选项在目标之前(如 `Remove-Item -Force Env:X`)一律不豁免——宁可误拦不可放行真删除。
310
+ const ENV_SCOPE_REMOVE_SEG_RE =
311
+ /^\s*Remove-Item\s+(?:\$env:|Env:)[\w()]+\s*(?:-[\w]+\s+(?:"[^"]*"|'[^']*'|[^\s;|]+)\s*)*$/i;
306
312
  // 剥离 try/catch/finally 控制块:块内语句与主语句同样接受只读判定(Try-* 不是 cmdlet)。
307
313
  // 注意:不做全量 [{}] 替换——if/else 子表达式($(if(...){...}else{...}))会被切碎误判。
308
314
  function stripControlBlocks(command) {
@@ -319,12 +325,38 @@ export function isMutationCommand(command) {
319
325
  return typeof command === "string" && MUTATING_CMD_RE.test(command);
320
326
  }
321
327
 
322
- /** 只读命令链按分隔符折段(机制 B,2026-08-24):`; 换行 && || |`(管道前后是命令链;`2>&1`/`2>$null` 的 stderr 重定向不拆分) */
328
+ /** 只读命令链按分隔符折段(机制 B,2026-08-24):`; 换行 && || |`(管道前后是命令链;`2>&1`/`2>$null` 的 stderr 重定向不拆分)
329
+ * F5(2026-09-10 修复,第三方实测):**引号感知**——此前裸拆 `|` 会把**引号内的 `|`** 当分隔符
330
+ * (正则 alternation `-Pattern 'a|b'`、格式串 `"{0} | x"`、`-notmatch 'x|y'`)→ 切出无法识别的残段
331
+ * → L354 保守判非只读 → 整条只读命令被规则 22 按 write 拦(ERR-4WOAKG / 9T886X / Z8M7YC / IKXLU8)。
332
+ * 现:`'…'` / `"…"` / `` `…` `` 内的分隔符不拆。转义语义按 PS:`'` 内不转义(`''` 表一个单引号);
333
+ * `"` 与 `` ` `` 内 `\` / `` ` `` 转义下一字符(兼容跨语言写法)。 */
323
334
  function splitCommandSegments(command) {
324
- return String(command)
325
- .split(/\r?\n|;|&&|\|\||(?<!\d)\|/)
326
- .map((s) => s.trim())
327
- .filter(Boolean);
335
+ const s = String(command);
336
+ const out = [];
337
+ let buf = "";
338
+ let quote = null;
339
+ for (let i = 0; i < s.length; i++) {
340
+ const c = s[i];
341
+ if (quote) {
342
+ buf += c;
343
+ if (quote !== "'" && (c === "\\" || c === "`")) { buf += s[i + 1] ?? ""; i++; continue; }
344
+ if (c === quote) quote = null;
345
+ continue;
346
+ }
347
+ if (c === "'" || c === '"' || c === "`") { quote = c; buf += c; continue; }
348
+ if (c === "\r") continue;
349
+ if (c === "\n" || c === ";") { out.push(buf); buf = ""; continue; }
350
+ if (c === "&" && s[i + 1] === "&") { out.push(buf); buf = ""; i++; continue; }
351
+ if (c === "|") {
352
+ if (s[i + 1] === "|") { out.push(buf); buf = ""; i++; continue; }
353
+ if (i > 0 && /\d/.test(s[i - 1])) { buf += c; continue; } // 保留原 (?<!\d) 语义
354
+ out.push(buf); buf = ""; continue;
355
+ }
356
+ buf += c;
357
+ }
358
+ out.push(buf);
359
+ return out.map((x) => x.trim()).filter(Boolean);
328
360
  }
329
361
 
330
362
  /** 判断命令文本是否只读(读文件/查询类,无写入/删除/提交副作用)。
@@ -345,6 +377,7 @@ export function isReadOnlyCommand(command) {
345
377
  const s = String(seg).trim();
346
378
  if (!s) return true; // 剥离残留的空段放行
347
379
  if (s.startsWith("#")) return true;
380
+ if (ENV_SCOPE_REMOVE_SEG_RE.test(s)) return true; // F5:环境变量操作非文件删除(须在 MUTATING 之前)
348
381
  if (MUTATING_CMD_RE.test(s)) return false;
349
382
  if (MUTATING_METHOD_RE.test(s)) return false;
350
383
  if (COM_CONTENT_ASSIGN_RE.test(s)) return false; // $doc.Text=... 是写文档
package/lib/core/state.js CHANGED
@@ -156,6 +156,12 @@ export function getSessionState(state, sessionId) {
156
156
  selfCertCount: new Map(),
157
157
  authorizations: [],
158
158
  backups: [],
159
+ // D2 修复(2026-09-10,独立 DSH_HOME 试点反馈):待决 ask 提升为**会话级**。
160
+ // 此前放在 turn 级:用户先发新消息 → turn/start → resetTurn 整体替换 s.turn
161
+ // → pendingAsk 被刷成 null → 随后到达的 ask 答复走不进登记分支,
162
+ // 实测「用户消息与 ask 答复并发」时一次 ask 的授权**全部未登记**(ERR-M9Z47H)。
163
+ // 语义依据:pendingAsk 描述的是「一次工具调用的待决状态」,跨回合有效,不属回合文本状态。
164
+ pendingAsk: null,
159
165
  mountAuditRevision: 0,
160
166
  mountAuditSignature: "",
161
167
  lastCommandOutput: "",
@@ -252,7 +258,8 @@ export function freshTurn() {
252
258
  intents: null,
253
259
  intentState: "lexicon", // lexicon | llm-pending | llm-ready(LLM 意图兜底状态)
254
260
  llmIntent: null, // { raw, verdict, reason, confidence, at }(LLM 裁决,非高置信时词表兜底)
255
- pendingAsk: null,
261
+ // pendingAsk 已迁至**会话级**(见 getSessionState)——D2 修复,2026-09-10。
262
+ // 此处不再定义回合级 pendingAsk:避免两处同名状态互相遮蔽。
256
263
  pendingToolCalls: new Map(),
257
264
  getDateSeen: false,
258
265
  backupSeen: false,
@@ -36,7 +36,10 @@ const ANALYSIS_TOOLS = new Set([
36
36
  "cordis_inspect_list", "cordis_inspect_query", "cordis_inspect_self",
37
37
  "ask_user_question",
38
38
  // ESR 状态查询(读工作区记忆状态,无持久副作用)
39
- "esr_status", "esr_ready", "esr_model"
39
+ "esr_status", "esr_ready", "esr_model",
40
+ // univer-office 只读族(2026-09-09 补录:规则 24④ 实测 univer_execute 被硬拦——该族未分类;
41
+ // 逐工具分类而非前缀一刀切:只读/查询类 → analysis)
42
+ "univer_status", "univer_inspect", "univer_lint", "univer_api", "univer_resources"
40
43
  ]);
41
44
 
42
45
  // 产物类:工具自然行为会写文件(artifact/工作区/会话存储),低风险——放行 + 审计。
@@ -59,7 +62,9 @@ const ARTIFACT_TOOLS = new Set([
59
62
  // (12A isSensitiveToolCall / 13A / 24 装配判定)链上覆盖,不再各自归类。
60
63
  "dev_stage_add", "dev_stage_call", "dev_stage_promote", "dev_stage_demote",
61
64
  // ESR 工程状态写操作(写工作区记忆存储,engram_store 同族)
62
- "esr_task", "esr_node", "esr_link", "esr_claim", "esr_close", "esr_unclaim", "esr_dep", "esr_gc"
65
+ "esr_task", "esr_node", "esr_link", "esr_claim", "esr_close", "esr_unclaim", "esr_dep", "esr_gc",
66
+ // univer-office 产物族(2026-09-09 补录):截图写 artifact;univer_new 只新建不覆盖
67
+ "univer_screenshot", "univer_new"
63
68
  ]);
64
69
 
65
70
  // 变更类:维持现有严格授权(规则 22 粒度比对 / 12A 敏感授权 / 13A 备份)
@@ -77,7 +82,10 @@ const MUTATING_TOOLS = new Set([
77
82
  "followup_task", "spawn_teammate", "team_task_create", "team_task_get",
78
83
  "team_task_list", "team_task_update", "wait_agent",
79
84
  // 本机命名空间兜底(dev_ 前缀 = 开发侧工具,行为可写可管理;具体已枚举,前缀兜底未来新增)
80
- "dev_router_status"
85
+ "dev_router_status",
86
+ // univer-office 变更族(2026-09-09 补录,规则 24④:未分类 + 疑似写 → 硬拦;逐工具分类纳入守卫链)
87
+ "univer_execute", "univer_import", "univer_unit", "univer_worktree",
88
+ "univer_compile_svg", "univer_export", "univer_print_pdf"
81
89
  ]);
82
90
 
83
91
  // 前缀规则:生态命名空间的工具自动归类(0.5.9 新)——将来的新插件遵循惯例即被覆盖。
@@ -13,6 +13,7 @@ import {
13
13
  URL_RE,
14
14
  sourceMarkRe
15
15
  } from "./patterns.js";
16
+ import { normalizeHandlerName, parseHandlerDecl, validateMeasure } from "./measure-kinds.js";
16
17
 
17
18
  // 2026-08-31(残余1 剥离,用户定稿):本机默认偏好表从代码下沉到本机配置——
18
19
  // rule-engine.json 的 handlerDefaultMap(可选键,可整体替换/清空)。代码层默认表为空
@@ -87,40 +88,21 @@ function hintPatterns(checkText) {
87
88
  return [...new Set(hints)];
88
89
  }
89
90
 
90
- /** 规则正文内联 handler 声明(T3):`<!-- handler: rule22-7-direct -->` */
91
- const HANDLER_DECL_RE = /<!--\s*handler\s*:\s*([a-z0-9][a-z0-9-]*)\s*-->/i;
92
-
93
91
  /**
94
- * 执行器语义别名(残余3 通用化,2026-08-31):
95
- * 内部执行器名(rule12a-approval 等)带规则编号影子——陌生用户声明时不应见到。
96
- * 语义名 内部名映射:声明/配置写语义名即归一;直接写内部名不受影响(别名表无该键)。
97
- * 归一发生在 resolveHandler 末端;guard-core 分支(按内部名)零改动。
92
+ * 措施类型归一 + 声明解析(架构 v3 P1/P2,2026-09-10):
93
+ * 注册表(MEASURE_KINDS)+ 兼容别名表(LEGACY_HANDLER_ALIASES)+ 归一入口(kindOf)
94
+ * + 声明解析(parseHandlerDecl)+ 校验(validateMeasure)均在 ./measure-kinds.js
95
+ * —— 本文件不含任何规则号字面量。
96
+ * 归一语义:历史内部名 → kind;kind 名 → 原样(幂等);未知名 → 原样(覆盖自省提示未覆盖)。
98
97
  */
99
- const HANDLER_ALIASES = {
100
- retry: "rule1-retry",
101
- time: "rule2-time",
102
- source: "rule5-source",
103
- promise: "rule7-promise",
104
- "inline-command": "rule9-inline-bom",
105
- language: "rule11-language",
106
- approval: "rule12a-approval",
107
- "skill-auth": "rule12b-skill",
108
- network: "rule12c-network",
109
- backup: "rule13a-backup",
110
- "manual-first": "rule18-manual-first",
111
- meta: "rule21-meta",
112
- "intent-direct": "rule22-7-direct",
113
- "runtime-verify": "rule23-runtime-verify",
114
- assembly: "rule24-assembly-type",
115
- "release-asset": "rule26-release-asset",
116
- "mount-audit": "rule27-mount-audit"
117
- };
118
-
119
- /** 语义名归一(内部名原样返回;未知名保持原样——由覆盖自省提示未覆盖) */
120
- export function normalizeHandlerName(name) {
121
- const s = String(name || "");
122
- return HANDLER_ALIASES[s] || s;
123
- }
98
+ export {
99
+ normalizeHandlerName,
100
+ kindOf,
101
+ MEASURE_KINDS,
102
+ LEGACY_HANDLER_ALIASES,
103
+ parseHandlerDecl,
104
+ validateMeasure
105
+ } from "./measure-kinds.js";
124
106
 
125
107
  /**
126
108
  * handler 绑定解析(T3 通用化,2026-08-31)——优先级:
@@ -135,8 +117,10 @@ export function resolveHandler(rule, opts = {}) {
135
117
  const overrides = opts?.handlerOverrides;
136
118
  if (overrides && typeof overrides === "object" && overrides[id] !== undefined) return normalizeHandlerName(overrides[id]);
137
119
  const body = String(rule?.body || "");
138
- const decl = body.match(HANDLER_DECL_RE);
139
- if (decl) return normalizeHandlerName(decl[1]);
120
+ const decl = parseHandlerDecl(body);
121
+ // P2(架构正本 §三 L3):声明非法(JSON 坏 / 空声明)**或 kind 未注册** → fail-closed(不执行);
122
+ // 错误详情由 understandRule 写入 config.measureError(此处只保证不产出可执行的 handler)。
123
+ if (decl) return validateMeasure(decl) ? "" : normalizeHandlerName(decl.kind);
140
124
  // 剥离后(2026-08-31):代码层无默认表;defaultMap 由调用方注入(本机=配置 handlerDefaultMap)
141
125
  const map = opts?.defaultMap || {};
142
126
  return normalizeHandlerName(map[rule?.index] || "");
@@ -156,6 +140,9 @@ export function understandRule(rule, opts = {}) {
156
140
  const triggerKeywords = splitKeywords(elems.trigger);
157
141
  const confidence = level && elems.trigger && elems.check && (elems.action || level.toUpperCase().includes("D")) ? "high" : level ? "medium" : "low";
158
142
  const handler = resolveHandler(rule, opts);
143
+ // P2(2026-09-10):个人层措施声明 → 措施实例 {kind, params};kind 未注册 / 声明非法 → fail-closed
144
+ const decl = parseHandlerDecl(rule.body || "");
145
+ const measureError = validateMeasure(decl);
159
146
  const config = {
160
147
  ruleId: rule.index,
161
148
  title: rule.title,
@@ -164,6 +151,8 @@ export function understandRule(rule, opts = {}) {
164
151
  actions,
165
152
  confidence,
166
153
  handler,
154
+ measure: decl && !measureError ? decl : null,
155
+ measureError: measureError || "",
167
156
  triggerKeywords,
168
157
  hints,
169
158
  elements: elems,
package/lib/index.js CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  askResultSelectedText,
40
40
  classifyAskScopeType,
41
41
  inferPathPrefixFromText,
42
+ inferPathPrefixesFromText,
42
43
  inferTypeFromText,
43
44
  isAuthMessage,
44
45
  isDirectiveMessage,
@@ -710,7 +711,8 @@ export function handleSessionEvent(ctx, session, event) {
710
711
  if (s.turn.toolCount === 1) s.turn.firstToolName = toolName;
711
712
  if (toolName === "ask_user_question") {
712
713
  s.turn.askSeen = true;
713
- s.turn.pendingAsk = { callId: d.callId, questions: args?.questions || [] };
714
+ // D2 修复(2026-09-10):写**会话级**(不随 turn/start 清空);at 供陈旧值保护
715
+ s.pendingAsk = { callId: d.callId, questions: args?.questions || [], at: Date.now() };
714
716
  }
715
717
  const pendingCall = { name: toolName, args };
716
718
  const targetPath = args?.file_path ?? args?.path;
@@ -1013,8 +1015,9 @@ export function handleSessionEvent(ctx, session, event) {
1013
1015
  if (output) s.lastCommandOutput = output;
1014
1016
  }
1015
1017
 
1016
- const pending = s.turn.pendingAsk;
1017
- if (pending) {
1018
+ const pending = s.pendingAsk;
1019
+ // D2 修复(2026-09-10):会话级待决 ask + 陈旧值保护(30 分钟未答视为作废,防跨回合误登记)
1020
+ if (pending && (!pending.at || Date.now() - pending.at <= 30 * 60 * 1000)) {
1018
1021
  const result = d.result ?? d.value ?? d;
1019
1022
  if (askResultApproved(result)) {
1020
1023
  s.turn.realUserSeen = true; // v0.5.7:ask 弹窗答复也算"真实用户在场"
@@ -1024,7 +1027,12 @@ export function handleSessionEvent(ctx, session, event) {
1024
1027
  // 授权范围只取“问题核心 + 用户实际选择”,不再把全部选项描述纳入;
1025
1028
  // 避免“仅会话内说明/不跨会话保留”等选项说明把一次小授权放大成全局 12h(2026-08-24 修复)
1026
1029
  const scopeText = `${askQuestionCoreText(pending.questions)} ${selectedText}`.trim();
1027
- const pathPrefix = inferPathPrefixFromText(scopeText || qText);
1030
+ // A2 修复(2026-09-10):原用单数版 inferPathPrefixFromText——只记一个路径且取"最长"。
1031
+ // 探针实测:多路径问题文本下"最长"往往是次要路径(如"去向 .backups/trash-x")而非主路径,
1032
+ // 主操作路径丢失 → 授权匹配必然失败。改为复数版:pathPrefix 取首个(按文本出现顺序=主路径,
1033
+ // 与用户消息侧 scopesFromIntents 的复数口径对齐),全部路径记入 pathPrefixes 供 authMatches 任一匹配。
1034
+ const pathPrefixes = inferPathPrefixesFromText(scopeText || qText);
1035
+ const pathPrefix = pathPrefixes[0] || "";
1028
1036
  // 0.5.10 建议1①:ask 答复结构化——操作类型从答复文本推断(write/command/any),不再一律 any
1029
1037
  // (修复"弹窗答复接不住真想操作"的粒度问题——V476AL 同族)
1030
1038
  const authRecord = {
@@ -1032,6 +1040,7 @@ export function handleSessionEvent(ctx, session, event) {
1032
1040
  pathPrefix,
1033
1041
  source: "ask"
1034
1042
  };
1043
+ if (pathPrefixes.length > 1) authRecord.pathPrefixes = pathPrefixes;
1035
1044
  const sessionWide = isSessionWideAskText(scopeText) || isSessionWideAskText(selectedText);
1036
1045
  if (sessionWide) authRecord.expiresAt = Date.now() + 12 * 60 * 60 * 1000;
1037
1046
  else if (!pathPrefix) authRecord.expiresAt = Date.now() + 2 * 60 * 1000;
@@ -1098,7 +1107,7 @@ export function handleSessionEvent(ctx, session, event) {
1098
1107
  session: sid
1099
1108
  });
1100
1109
  }
1101
- s.turn.pendingAsk = null;
1110
+ s.pendingAsk = null; // D2 修复(2026-09-10):清理**会话级**待决 ask
1102
1111
  }
1103
1112
  return;
1104
1113
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rule-engine",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "DSH 规则执行引擎 v3:容器解析 AGENTS.md + 理解器 + 匹配机 + 执行框架",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -11,11 +11,20 @@
11
11
  // —— 通用中文文案(字符串字面量内的 CJK)**不计入**:第三方判据明示「中文≠个人化,
12
12
  // 通用功能词/提示语是产品能力」。runtime 计数仍在 --report 里显示,供参考。
13
13
  //
14
+ // 扫描判据 D(2026-09-10 分层架构 v3 · P0 追加):规则号字面量
15
+ // 依据:reports/2026-09-10-引擎分层架构-通用层零规则内容-v1.md §七。
16
+ // 模式:rule\d+[A-Za-z]?- / byId\.get\("\d+"\) / \[guardian:rule\d+\] / rule-hint\.\d+
17
+ // / ruleId\s*[=!]==?\s*"<N>"(2026-09-10 用户拍板纳入;§七 原列四条,此条据 §二 现状证据补入)
18
+ // 为何单列一判据:「机制以规则号为骨架」比「文案含编号」更根本,且与判据 A 不同维度
19
+ // (A 抓本机私有词/映射键;D 抓通用层住着规则体系),故各自独立棘轮。
20
+ // 口径:去注释后扫描(注释属开发溯源,非机制骨架);不设白名单——代码层零规则号。
21
+ //
14
22
  // 棘轮(ratchet):基线记录各文件计数,只许降不许升。
15
23
  // node scripts/dualtrack-check.mjs # 比对基线(CI/发布门禁用)
16
24
  // node scripts/dualtrack-check.mjs --init # 首次生成基线
17
25
  // node scripts/dualtrack-check.mjs --update # 手动更新基线(须在提交说明里写清改了什么)
18
26
  // node scripts/dualtrack-check.mjs --report # 只打印各文件计数
27
+ // node scripts/dualtrack-check.mjs --root <dir> # 改扫描根(仅测试隔离用;非默认根会显著提示)
19
28
  //
20
29
  // 白名单(scripts/dualtrack-whitelist.json):
21
30
  // files —— 整文件豁免(如 lib/lang/**,第 3 批语言包)
@@ -26,7 +35,14 @@ import path from "node:path";
26
35
  import { fileURLToPath } from "node:url";
27
36
  import { loadMarkers } from "../lib/core/dualtrack-markers.js";
28
37
 
29
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
38
+ // ── 命令行参数(提前解析:--root 必须在下面的路径常量之前生效)──
39
+ const ARGV = process.argv.slice(2);
40
+ const args = new Set(ARGV);
41
+ const rootIdx = ARGV.indexOf("--root");
42
+ const IS_CUSTOM_ROOT = rootIdx >= 0;
43
+ const ROOT = IS_CUSTOM_ROOT && ARGV[rootIdx + 1]
44
+ ? path.resolve(ARGV[rootIdx + 1])
45
+ : path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
30
46
  const LIB = path.join(ROOT, "lib");
31
47
  const BASELINE_FILE = path.join(ROOT, "scripts", "dualtrack-baseline.json");
32
48
  const WHITELIST_FILE = path.join(ROOT, "scripts", "dualtrack-whitelist.json");
@@ -35,6 +51,15 @@ const RESIDUE_FILE = path.join(ROOT, "scripts", "local-residue-markers.txt");
35
51
  const CJK = /[\u4e00-\u9fff]/;
36
52
  const MAP_KEY_RE = /^\d+[A-Z]?$/;
37
53
 
54
+ // ── 判据 D:规则号字面量(§七;口径=去注释后扫描,无白名单)──
55
+ const RULE_LITERAL_PATTERNS = [
56
+ ["executor-name", /rule\d+[A-Za-z]?-/g, "内部执行器名 rule<N>[x]-"],
57
+ ["byid-get", /byId\.get\(\s*["'`]\d+["'`]\s*\)/g, '按规则号取规则 byId.get("<N>")'],
58
+ ["guardian-tag", /\[guardian:rule\d+\]/g, "对外文案 [guardian:rule<N>]"],
59
+ ["rule-hint", /rule-hint\.\d+/g, "rule-hint.<N>"],
60
+ ["ruleid-eq", /ruleId\s*[=!]==?\s*["'`]\d+[A-Za-z]?["'`]/g, '硬编码规则号比较 ruleId === "<N>"']
61
+ ];
62
+
38
63
  // ── 白名单 ──
39
64
  /** 白名单两层(2026-09-09):包内通用白名单 + 本机 rule-engine.json 的 dualtrack.whitelist 合并。
40
65
  * 包内那份随包发布(如 lib/lang/**);本机那份记录「我豁免我自己的某条」,不进包。 */
@@ -77,6 +102,73 @@ function fileExempt(relPath, patterns) {
77
102
  return false;
78
103
  }
79
104
 
105
+ // ── 去注释(判据 D 用):保留字符串/模板/正则原文,注释替换为空白(保持行结构) ──
106
+ function stripComments(src) {
107
+ let out = "";
108
+ let i = 0;
109
+ const n = src.length;
110
+ let prevSig = "";
111
+ const push = (s) => { out += s; };
112
+ while (i < n) {
113
+ const c = src[i];
114
+ const c2 = src[i + 1];
115
+ if (c === "/" && c2 === "/") {
116
+ while (i < n && src[i] !== "\n") { push(" "); i++; }
117
+ continue;
118
+ }
119
+ if (c === "/" && c2 === "*") {
120
+ push(" "); i += 2;
121
+ while (i < n && !(src[i] === "*" && src[i + 1] === "/")) {
122
+ push(src[i] === "\n" ? "\n" : " ");
123
+ i++;
124
+ }
125
+ if (i < n) { push(" "); i += 2; }
126
+ continue;
127
+ }
128
+ if (c === '"' || c === "'" || c === "`") {
129
+ const q = c;
130
+ push(c); i++;
131
+ while (i < n) {
132
+ if (src[i] === "\\") { push(src[i] + (src[i + 1] || "")); i += 2; continue; }
133
+ push(src[i]);
134
+ if (src[i] === q) { i++; break; }
135
+ i++;
136
+ }
137
+ prevSig = "str";
138
+ continue;
139
+ }
140
+ // 正则字面量:仅在可能的正则位置整体复制(否则 / 是除号)
141
+ if (c === "/" && prevSig !== "ident" && prevSig !== ")" && prevSig !== "]" && prevSig !== "str") {
142
+ let j = i + 1;
143
+ let inClass = false;
144
+ let closed = false;
145
+ while (j < n) {
146
+ const ch = src[j];
147
+ if (ch === "\\") { j += 2; continue; }
148
+ if (ch === "\n") break;
149
+ if (ch === "[") inClass = true;
150
+ else if (ch === "]") inClass = false;
151
+ else if (ch === "/" && !inClass) { closed = true; break; }
152
+ j++;
153
+ }
154
+ if (closed) {
155
+ push(src.slice(i, j + 1));
156
+ i = j + 1;
157
+ while (i < n && /[a-z]/i.test(src[i])) { push(src[i]); i++; }
158
+ prevSig = "regex";
159
+ continue;
160
+ }
161
+ }
162
+ push(c);
163
+ if (/[A-Za-z0-9_$]/.test(c)) prevSig = "ident";
164
+ else if (c === ")") prevSig = ")";
165
+ else if (c === "]") prevSig = "]";
166
+ else if (!/\s/.test(c)) prevSig = "other";
167
+ i++;
168
+ }
169
+ return out;
170
+ }
171
+
80
172
  // ── 词法扫描:剔除注释,收集字符串字面量与对象键 ──
81
173
  /**
82
174
  * @returns {{strings: string[], mapKeys: string[]}}
@@ -203,6 +295,16 @@ function tokenize(src) {
203
295
  function scanFile(absPath, relPath, whitelist, residueMarks) {
204
296
  const src = fs.readFileSync(absPath, "utf8");
205
297
  const { strings, mapKeys } = tokenize(src);
298
+ // 判据 D:去注释后扫规则号字面量
299
+ const code = stripComments(src);
300
+ let ruleLiterals = 0;
301
+ const ruleLiteralHits = [];
302
+ for (const [id, re, desc] of RULE_LITERAL_PATTERNS) {
303
+ const m = code.match(re);
304
+ if (!m) continue;
305
+ ruleLiterals += m.length;
306
+ if (ruleLiteralHits.length < 3) ruleLiteralHits.push(`${id}×${m.length}(${desc})如 ${m[0]}`);
307
+ }
206
308
  let runtime = 0;
207
309
  const runtimeHits = [];
208
310
  for (const s of strings) {
@@ -228,11 +330,13 @@ function scanFile(absPath, relPath, whitelist, residueMarks) {
228
330
  runtime,
229
331
  mapKeys: mapHits.length,
230
332
  local,
333
+ ruleLiterals,
231
334
  // 判据 A:total 只计「本机性」两类;通用中文文案(runtime)不计入闸
232
335
  total: mapHits.length + local,
233
336
  runtimeHits,
234
337
  mapHits: mapHits.slice(0, 5),
235
- localHits
338
+ localHits,
339
+ ruleLiteralHits
236
340
  };
237
341
  }
238
342
 
@@ -247,7 +351,7 @@ function walk(dir, base = dir, out = []) {
247
351
  }
248
352
 
249
353
  // ── 主流程 ──
250
- const args = new Set(process.argv.slice(2));
354
+ if (IS_CUSTOM_ROOT) console.log(`SCAN ROOT(非默认,仅测试隔离用):${ROOT}\n`);
251
355
  const whitelist = loadWhitelist();
252
356
  // 本机标识词表:与 B2 扫描器共用唯一加载器(本机配置优先 → 环境变量 → 包内示例)
253
357
  const { markers: residueMarks, source: markersSource } = loadMarkers({ root: ROOT });
@@ -259,16 +363,22 @@ if (residueMarks.length === 0) {
259
363
  const files = walk(LIB).filter((rel) => !fileExempt(rel, whitelist.files));
260
364
  const results = files.map((rel) => scanFile(path.join(LIB, rel), rel, whitelist, residueMarks));
261
365
  const counts = {};
262
- for (const r of results) counts[`lib/${r.relPath}`] = r.total;
366
+ const ruleCounts = {};
367
+ for (const r of results) {
368
+ counts[`lib/${r.relPath}`] = r.total;
369
+ ruleCounts[`lib/${r.relPath}`] = r.ruleLiterals;
370
+ }
263
371
  const grandTotal = results.reduce((a, r) => a + r.total, 0);
372
+ const ruleGrandTotal = results.reduce((a, r) => a + r.ruleLiterals, 0);
264
373
 
265
374
  if (args.has("--report")) {
266
- for (const r of results.sort((a, b) => b.total - a.total)) {
267
- if (r.total === 0) continue;
268
- console.log(` ${String(r.total).padStart(4)} lib/${r.relPath} [映射键 ${r.mapKeys} / 本机标识 ${r.local} / 通用中文文案 ${r.runtime}(不计入闸)]`);
375
+ for (const r of results.sort((a, b) => (b.total + b.ruleLiterals) - (a.total + a.ruleLiterals))) {
376
+ if (r.total === 0 && r.ruleLiterals === 0) continue;
377
+ console.log(` ${String(r.total).padStart(4)} lib/${r.relPath} [映射键 ${r.mapKeys} / 本机标识 ${r.local} / 通用中文文案 ${r.runtime}(不计入闸)] [规则号字面量 ${r.ruleLiterals}]`);
269
378
  if (r.mapHits.length) console.log(` 命中键:${r.mapHits.join(" / ")}`);
379
+ if (r.ruleLiteralHits.length) console.log(` 规则号字面量:${r.ruleLiteralHits.join(";")}`);
270
380
  }
271
- console.log(`\nDUALTRACK REPORT:${results.length} 文件,合计 ${grandTotal}`);
381
+ console.log(`\nDUALTRACK REPORT:${results.length} 文件,判据 A(分层残留)合计 ${grandTotal},判据 D(规则号字面量)合计 ${ruleGrandTotal}`);
272
382
  process.exit(0);
273
383
  }
274
384
 
@@ -287,13 +397,15 @@ if (args.has("--init") || args.has("--update")) {
287
397
  }
288
398
  const payload = {
289
399
  generatedAt: new Date().toISOString(),
290
- note: "dualtrack 棘轮基线:各文件「分层残留」计数,只许降不许升。--init 首次生成,--update 手动更新(须在提交说明里写清改了什么)。",
400
+ note: "dualtrack 棘轮基线:各文件计数,只许降不许升。files/total=判据 A(分层残留);ruleLiteralFiles/ruleLiteralsTotal=判据 D(规则号字面量)。--init 首次生成,--update 手动更新(须在提交说明里写清改了什么)。",
291
401
  total: grandTotal,
292
- files: counts
402
+ files: counts,
403
+ ruleLiteralsTotal: ruleGrandTotal,
404
+ ruleLiteralFiles: ruleCounts
293
405
  };
294
406
  fs.writeFileSync(BASELINE_FILE, JSON.stringify(payload, null, 2) + "\n", "utf8");
295
407
  console.log(`${isUpdate ? "UPDATED" : "INITIALIZED"} ${BASELINE_FILE}`);
296
- console.log(`基线合计:${grandTotal}(${Object.keys(counts).length} 个文件)`);
408
+ console.log(`基线:判据 A 合计 ${grandTotal}(${Object.keys(counts).length} 文件)/判据 D 合计 ${ruleGrandTotal}`);
297
409
  process.exit(0);
298
410
  }
299
411
 
@@ -324,12 +436,46 @@ for (const [file, b] of Object.entries(base)) {
324
436
  if (counts[file] === undefined && b > 0) improvements.push(`${file}: 文件已删除(基线 ${b})`);
325
437
  }
326
438
 
327
- if (regressions.length > 0) {
328
- console.error(`DUALTRACK FAIL:${regressions.length} 项超出基线(只许降不许升)`);
329
- for (const r of regressions) console.error(` ✗ ${r}`);
330
- console.error("\n 处置:把新增内容迁个人层/配置层,或(确属通用功能词)登记 scripts/dualtrack-whitelist.json");
439
+ // 判据 D 比对(独立棘轮;基线缺该维度 → fail-closed,提示 --update)
440
+ const ruleBase = baseline.ruleLiteralFiles;
441
+ const ruleRegressions = [];
442
+ const ruleImprovements = [];
443
+ if (!ruleBase || typeof ruleBase !== "object") {
444
+ ruleRegressions.push("基线缺「规则号字面量」维度(ruleLiteralFiles)——判据 D 于 2026-09-10 新增,请运行 --update 记录基线");
445
+ } else {
446
+ for (const [file, count] of Object.entries(ruleCounts)) {
447
+ const b = ruleBase[file];
448
+ if (b === undefined) {
449
+ if (count > 0) ruleRegressions.push(`${file}: 新增文件含规则号字面量 ${count} 处(基线无此文件)`);
450
+ continue;
451
+ }
452
+ if (count > b) {
453
+ const r = results.find((x) => `lib/${x.relPath}` === file);
454
+ const detail = r ? r.ruleLiteralHits.slice(0, 2).join(";") : "";
455
+ ruleRegressions.push(`${file}: ${count} > 基线 ${b}${detail ? `(如:${detail})` : ""}`);
456
+ } else if (count < b) {
457
+ ruleImprovements.push(`${file}: ${count} < 基线 ${b}`);
458
+ }
459
+ }
460
+ for (const [file, b] of Object.entries(ruleBase)) {
461
+ if (ruleCounts[file] === undefined && b > 0) ruleImprovements.push(`${file}: 文件已删除(基线 ${b})`);
462
+ }
463
+ }
464
+
465
+ if (regressions.length > 0 || ruleRegressions.length > 0) {
466
+ if (regressions.length > 0) {
467
+ console.error(`DUALTRACK FAIL(判据 A 分层残留):${regressions.length} 项超出基线(只许降不许升)`);
468
+ for (const r of regressions) console.error(` ✗ ${r}`);
469
+ }
470
+ if (ruleRegressions.length > 0) {
471
+ console.error(`DUALTRACK FAIL(判据 D 规则号字面量):${ruleRegressions.length} 项超出基线(只许降不许升)`);
472
+ for (const r of ruleRegressions) console.error(` ✗ ${r}`);
473
+ }
474
+ console.error("\n 处置:分层残留 → 迁个人层/配置层,或(确属通用功能词)登记 scripts/dualtrack-whitelist.json");
475
+ console.error(" 规则号字面量 → 改为通用 kind 名(架构 v3 §三 L1);判据 D 不设白名单。");
331
476
  process.exit(1);
332
477
  }
333
- console.log(`DUALTRACK OK:合计 ${grandTotal}(基线 ${baseline.total ?? "?"})${improvements.length ? `,${improvements.length} 个文件下降可 --update` : ""}`);
334
- for (const im of improvements.slice(0, 5)) console.log(` ↓ ${im}`);
478
+ const downCount = improvements.length + ruleImprovements.length;
479
+ console.log(`DUALTRACK OK:判据 A 合计 ${grandTotal}(基线 ${baseline.total ?? "?"})/判据 D 合计 ${ruleGrandTotal}(基线 ${baseline.ruleLiteralsTotal ?? "?"})${downCount ? `,${downCount} 个文件下降可 --update` : ""}`);
480
+ for (const im of [...improvements, ...ruleImprovements].slice(0, 5)) console.log(` ↓ ${im}`);
335
481
  process.exit(0);
@@ -64,8 +64,11 @@ process.env.DSH_WORKSPACE = root;
64
64
  const plain = understandRule(arbitrary);
65
65
  check(plain.handler === "", "③ 任意编号 + 无声明:handler 为空(纯自证分流,不误绑定本机执行器)");
66
66
  check(plain.actions.includes("deny") && plain.actions.includes("self-certify"), "③ 任意编号:A+D 动作解析正确(deny+self-certify)");
67
- const declared = understandRule({ ...arbitrary, body: arbitrary.body + "\n<!-- handler: rule12a-approval -->" });
68
- check(declared.handler === "rule12a-approval", " 任意编号 + 声明:绑定指定执行器");
67
+ // 架构 v3 P1:声明值统一归一为 kind 名——kind 名(陌生用户视角)与历史内部名(兼容)都须绑定
68
+ const declaredKind = understandRule({ ...arbitrary, body: arbitrary.body + "\n<!-- handler: approval -->" });
69
+ check(declaredKind.handler === "approval", "③ 任意编号 + 声明(kind 名):绑定指定执行器");
70
+ const declaredLegacy = understandRule({ ...arbitrary, body: arbitrary.body + "\n<!-- handler: rule12a-approval -->" });
71
+ check(declaredLegacy.handler === "approval", "③ 任意编号 + 声明(历史内部名):归一为同一 kind");
69
72
  const covered = analyzeCoverage(understandAll([arbitrary]));
70
73
  check(covered.uncovered.some((u) => u.ruleId === "X-77"), "③ 任意编号 A 级无 handler:被标记未覆盖(提示正确)");
71
74
  // 通用化闭环:defaultMap 清空 → 陌生规则无兜底绑定(纯声明式世界,不借道本机偏好表);
@@ -166,7 +166,8 @@ if (dryRun) {
166
166
  // §1.2(2026-09-08)dry-run 可观测项(单测锚点)
167
167
  console.log(`[DRY-RUN] plugins.json 条目: ${entry ? `${entry.name} (dir=${entry.dir})` : "(未命中——将回退目录路径参数)"}`);
168
168
  console.log(`[DRY-RUN] dist-tags 轮询: ${Math.max(30, Number(process.env.RELEASE_DIST_TAG_POLL_SEC) || 180)}s(RELEASE_DIST_TAG_POLL_SEC 可配)`);
169
- console.log(`[DRY-RUN] README 四点同步: 徽章 / 正文当前版本 / 历史表新行 / 固定源占位注释`);
169
+ console.log(`[DRY-RUN] README 四点同步: 徽章 / 正文当前版本 / 历史表新行 / 固定源自动维护`);
170
+ console.log(`[DRY-RUN] 固定源将更新为: **${nextVer}(当前)** 锚点 ${quiet(`cd /d "${dir}" && git rev-parse HEAD`).trim().slice(0, 7)}(发布后自动回填 release 提交号)`);
170
171
  process.exit(0);
171
172
  }
172
173
  writeFileSync(pkgPath, readFileSync(pkgPath, "utf8").replace(`"version": "${oldVer}"`, `"version": "${nextVer}"`));
@@ -186,19 +187,32 @@ for (const readme of ["README.md", "README.en.md"]) {
186
187
  if (replaced !== text) writeFileSync(rp, replaced);
187
188
  }
188
189
  }
189
- // §1.2-②(2026-09-08 第二批):README 正文"当前版本"行同步 + 固定源占位注释(发布后回填锚点,可 grep)
190
+ // §1.2-②(2026-09-08 第二批):README 正文"当前版本"行同步
191
+ // 方案 A(2026-09-09,踩坑 140 根治):固定源行**自动维护**——bump 时插入「(当前)」行(锚点=改号前 HEAD),
192
+ // 发布提交后由 §5.5 回填真实 release 提交号;旧「(当前)」标记与遗留占位注释一并清理。
193
+ // 旧做法只插占位注释,而 B1 门禁要求「固定源=package 版本」→ 每次发布必撞(0.6.3 首发实证)。
194
+ const headBeforeShort = quiet(`cd /d "${dir}" && git rev-parse HEAD`).trim().slice(0, 7);
195
+ let fixedSourceBumpLine = "";
190
196
  for (const readme of ["README.md", "README.en.md"]) {
191
197
  const rp = join(dir, readme);
192
198
  if (!existsSync(rp)) continue;
193
199
  let rtxt = readFileSync(rp, "utf8");
194
200
  const before = rtxt;
195
201
  rtxt = rtxt.replace(/(> 当前版本 \*\*)\d+\.\d+\.\d+(\*\*)/, `$1${nextVer}$2`);
196
- if (readme === "README.md" && rtxt.includes("## 发行固定源") && !rtxt.includes("<!-- fixed-source:")) {
197
- rtxt = rtxt.replace("## 发行固定源", "## 发行固定源\n\n<!-- fixed-source: 待发布回填 -->");
202
+ if (readme === "README.md" && rtxt.includes("## 发行固定源")) {
203
+ // 旧「(当前)」标记降级(只保留版本号加粗)
204
+ rtxt = rtxt.replace(/\*\*(\d+\.\d+\.\d+)(当前)\*\*/g, "**$1**");
205
+ // ② 清理遗留占位注释(方案 A 起不再产生)
206
+ rtxt = rtxt.replace(/\n<!-- fixed-source: [^>]*-->\n/g, "\n");
207
+ // ③ 插入新版本行(幂等:已存在则跳过)
208
+ if (!rtxt.includes(`- **${nextVer}(当前)**`)) {
209
+ fixedSourceBumpLine = `- **${nextVer}(当前)** 固定于 main Commit \`${headBeforeShort}\`(\`git checkout ${headBeforeShort}\` + 发布 bump 可复现 npm \`${name}@${nextVer}\` 与 GitHub Release v${nextVer} 同源代码)。**注:release 提交号由发布脚本自动回填。**`;
210
+ rtxt = rtxt.replace("## 发行固定源\n", `## 发行固定源\n\n${fixedSourceBumpLine}\n`);
211
+ }
198
212
  }
199
213
  if (rtxt !== before) writeFileSync(rp, rtxt);
200
214
  }
201
- console.log("版本已 bump(package.json + README 徽章 + 正文当前版本 + 固定源占位)");
215
+ console.log(`版本已 bump(package.json + README 徽章 + 正文当前版本 + 固定源 ${nextVer}(锚点 ${headBeforeShort}))`);
202
216
 
203
217
  // B1(2026-09-03):README 版本表行自动插入(阶段 C 人脑核对已失效的自动化;幂等:已存在 nextVer 行则跳过)
204
218
  // 位置:版本表(| 版本 | 日期 | 要点 |)表头后第一数据行之前(最新在上);内容用调用方 notes 参数或 git log 最近提交信息
@@ -344,6 +358,23 @@ run(`cd /d "${repoRoot}" && ${proxyPrefix()}git add -A -- ${stageSpec}`);
344
358
  run(`cd /d "${repoRoot}" && git -c core.autocrlf=false commit -m "release: ${name} v${nextVer}" || exit 0`);
345
359
  run(`cd /d "${repoRoot}" && ${proxyPrefix()}git push "${pushUrl}" HEAD`);
346
360
 
361
+ // ── 5.5 固定源回填(方案 A,2026-09-09):用 release 提交号替换 bump 时的锚点 ──
362
+ if (fixedSourceBumpLine) {
363
+ const relShort = quiet(`cd /d "${repoRoot}" && git rev-parse HEAD`).trim().slice(0, 7);
364
+ const rd = join(dir, "README.md");
365
+ const rtxt = existsSync(rd) ? readFileSync(rd, "utf8") : "";
366
+ const finalLine = `- **${nextVer}(当前)** 固定于 main Commit \`${relShort}\`(\`git checkout ${relShort}\` 可复现 npm \`${name}@${nextVer}\` 与 GitHub Release v${nextVer} 同源代码)。`;
367
+ if (rtxt.includes(fixedSourceBumpLine)) {
368
+ writeFileSync(rd, rtxt.replace(fixedSourceBumpLine, finalLine), "utf8");
369
+ console.log(`\n=== 固定源回填 ===\n${nextVer}:锚点 ${headBeforeShort} → release 提交 ${relShort}`);
370
+ run(`cd /d "${repoRoot}" && ${proxyPrefix()}git add -A -- ${stageSpec}`);
371
+ run(`cd /d "${repoRoot}" && git -c core.autocrlf=false commit -m "docs(readme): ${nextVer} 固定源回填 ${relShort}(发布后自动同步)" || exit 0`);
372
+ run(`cd /d "${repoRoot}" && ${proxyPrefix()}git push "${pushUrl}" HEAD`);
373
+ } else {
374
+ console.log("(固定源回填跳过:未匹配到 bump 行——可能已手工回填)");
375
+ }
376
+ }
377
+
347
378
  // ── 6. GitHub Release(带正式 tgz asset,规则 26;清单标记 skipRelease 的包不建)─────
348
379
  if (entry?.skipRelease) {
349
380
  console.log("(清单标记 skipRelease,跳过 GitHub Release)");