dsh-rule-engine 0.6.3 → 0.6.5

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.
@@ -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 的 ask 授权登记写入 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})且本会话未通过全量审计`;
@@ -87,7 +87,7 @@ export function isEngineInjectedMessage(text) {
87
87
  // source.kind 主判之外,对"标记为 user 但实为注入"的消息做模板兜底——
88
88
  // 命中即整体跳过:不覆盖回合状态、不产生 scopes/授权。
89
89
  // 本机实证来源:runtime context 快照(无 system-reminder 标签)、子代理完成通知(B3)、
90
- // vision-router 自动挂载提醒(index.js:3494-3518)、引擎自身注入([规则引擎])。
90
+ // vision-router 自动挂载提醒(该包 client 侧注入)、引擎自身注入([规则引擎])。
91
91
  // example-injector 的近距离引导模板待实测后收窄补充。
92
92
  const SYSTEM_INJECTION_PREFIXES = [
93
93
  /^Current runtime context\./,
@@ -50,7 +50,9 @@ export function verdictForDeny(turn, cfg = {}, lexiconDenied = true) {
50
50
  if (conf >= high) return { mutationDenied: false, source: "llm-high", reason: `LLM 高置信(${conf})` };
51
51
  if (conf >= low) return { mutationDenied: false, source: "llm-low", reason: `LLM 低置信(${conf})` };
52
52
  }
53
- return { mutationDenied: true, source: "lexicon", reason: "" };
53
+ // 2026-09-13:把「兜底失败原因」透出——此前失败降级与「词表缺词」在返回值上完全同形,无法区分。
54
+ const llmError = (llm && llm.raw === turn?.userText && llm.error) ? String(llm.error) : "";
55
+ return { mutationDenied: true, source: "lexicon", reason: "", llmError };
54
56
  }
55
57
 
56
58
  /**
@@ -120,18 +122,24 @@ export async function enrichIntentWithLlm(ctx, state, sid, text, cfg) {
120
122
  state.llmIntentCache.delete(firstKey);
121
123
  }
122
124
  auditIntent(state, sid, text, verdict, ms, false);
123
- } catch {
125
+ } catch (e) {
124
126
  session.turn.intentState = "lexicon";
125
- auditIntent(state, sid, text, null, Date.now() - started, false);
127
+ // 2026-09-13:失败也要留痕——此前 catch 路径不写 turn.llmIntent,
128
+ // 导致 96.7% 的失败率长期不可归因(审计只有「失败降级」,无原因)。
129
+ const errMsg = String((e && e.message) || e);
130
+ session.turn.llmIntent = { raw: text, verdict: null, error: errMsg, at: Date.now() };
131
+ auditIntent(state, sid, text, null, Date.now() - started, false, errMsg);
126
132
  }
127
133
  }
128
134
 
129
- function auditIntent(state, sid, text, verdict, ms, limitHit) {
135
+ function auditIntent(state, sid, text, verdict, ms, limitHit, llmError) {
130
136
  try {
131
137
  state.llmIntentLast = {
132
138
  text: String(text).slice(0, 60),
133
139
  verdict: verdict ? { ...verdict, confidence: Number(verdict.confidence) } : null,
134
140
  source: verdict ? "llm" : "fail",
141
+ // 2026-09-13:失败原因(供 /guard status 与排障读取;此前只记「fail」)
142
+ error: llmError || null,
135
143
  at: Date.now()
136
144
  };
137
145
  audit({
@@ -141,7 +149,7 @@ function auditIntent(state, sid, text, verdict, ms, limitHit) {
141
149
  event: "user/message",
142
150
  reason: verdict
143
151
  ? `词表低置信 → LLM:${JSON.stringify(verdict)}(延迟 ${ms}ms${limitHit ? " 限额" : ""})`
144
- : `词表低置信 → LLM 失败降级(延迟 ${ms}ms)`,
152
+ : `词表低置信 → LLM 失败降级(延迟 ${ms}ms${llmError ? `:${llmError}` : "(原因未捕获)"})`,
145
153
  session: sid,
146
154
  verdictSource: verdict ? "llm-intent" : "lexicon",
147
155
  text: String(text).slice(0, 120)
@@ -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
+ * → 保守判非只读(本文件的引号感知分支)→ 整条只读命令被规则 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=... 是写文档
@@ -436,9 +469,11 @@ export function isAnalysisOp(toolName, args) {
436
469
  // ═══════════ 0.5.10 已知环境坑特征表(建议5——错误码自动召回,K-01/K-05 依据)═══════════
437
470
  // 工具/命令错误文本命中特征 → 审计 error-hint + 注入指向知识库的提示(低频:仅命中才提示;语义=指路不打扰)。
438
471
  // hint 文案经 messages 层取(第 1 批 1b:去本机标识——原 hint 引用本机手册踩坑编号/代理端口/发布变量)
472
+ // 2026-09-11 去本机残留(分层 P4):第二条原写死本机代理端口号(key 亦含该端口号)——
473
+ // 通用层不应含任何本机标识;判据放宽为「连接被拒」本身(提示仅是"先查代理/服务是否启动",泛指无害)。
439
474
  const KNOWN_PITFALLS = [
440
475
  { key: "SEC_E_NO_CREDENTIALS", re: /SEC_E_NO_CREDENTIALS/i, hintKey: "pitfall.tls" },
441
- { key: "proxy-7890-refused", re: /ECONNREFUSED[^\n]*7890/i, hintKey: "pitfall.proxy" },
476
+ { key: "conn-refused", re: /ECONNREFUSED/i, hintKey: "pitfall.proxy" },
442
477
  { key: "sandbox-pipe-EPERM", re: /EPERM/i, hintKey: "pitfall.sandbox-pipe" },
443
478
  { key: "ERR_MODULE_NOT_FOUND", re: /ERR_MODULE_NOT_FOUND/i, hintKey: "pitfall.module-missing" }
444
479
  ];
@@ -617,12 +652,15 @@ export function isAuditCommand(command) {
617
652
  * 2026-08-31(验证通道放行面,用户定稿):清单扩展 + 由 isAnalysisOp 独立调用——
618
653
  * ① 单测通配(任意 *.test.mjs)、② 验证/门禁脚本(check-tool-coverage / publish-aptitude-check)、
619
654
  * ③ --dry-run(发布脚本 dry-run=只读推导,发布链无 dry-run 不匹配)、④ gh api 只读 GET、npm whoami。
655
+ * 2026-09-11(手册条款 16「重复劳动升级律」落地):新增 ⑤ 收尾脚本 session-wrapup.mjs——清理 .staging-* 入回收站
656
+ * + 列待提交清单 + 沉淀自检。放行依据=规则 13A 豁免原文「移动文件到 .backups/ 或 .backups/trash-<时间戳>/
657
+ * 的保护性备份操作,免询问」;**不含** commit / publish / push 等执行类动作(仍走严格授权,脚本只"列出"待提交文件)。
620
658
  * 安全边界:执行类脚本(release-plugin 无 --dry-run / inject / uninject / install / publish / commit /
621
659
  * push / Remove-Item 等)一律不匹配——执行类仍走授权语义严格版(0.5.11 防借道回归不放松)。
622
660
  */
623
661
  export function isVerificationCommand(command) {
624
662
  if (typeof command !== "string") return false;
625
- if (/\b(?:node|npm|npx)\s+[^\n]*(?:run-all\.mjs|audit-mount-consistency\.mjs|loader-smoke\.e2e\.mjs|verify-all\.mjs|health-audit\.mjs|check-tool-coverage\.mjs|publish-aptitude-check\.mjs|\.test\.mjs\b|--check|--test|--dry-run)\b/i.test(command)) return true;
663
+ if (/\b(?:node|npm|npx)\s+[^\n]*(?:run-all\.mjs|audit-mount-consistency\.mjs|loader-smoke\.e2e\.mjs|verify-all\.mjs|health-audit\.mjs|check-tool-coverage\.mjs|publish-aptitude-check\.mjs|session-wrapup\.mjs|\.test\.mjs\b|--check|--test|--dry-run)\b/i.test(command)) return true;
626
664
  if (/\b(?:npm|pnpm)\s+(?:test|run\s+test)\b/i.test(command)) return true;
627
665
  if (/\bgh\s+api\b/i.test(command)) {
628
666
  // 只读 GET 形态;写形态(--method/-X 显式方法、-F/--field 表单)不豁免(gg api 写=执行类)
@@ -853,7 +891,12 @@ export const PATTERN_MAP_KEYS = {
853
891
 
854
892
  /** 数值型配置键(键 → 内置默认;与正则键共用同一配置层与校验/回退语义) */
855
893
  export const PATTERN_NUM_KEYS = {
856
- criticism_caps_ratio: 0.6 // 英文全大写比率阈值(≥ 该值且字母数 ≥6 视为"喊叫"强形态)
894
+ criticism_caps_ratio: 0.6, // 英文全大写比率阈值(≥ 该值且字母数 ≥6 视为"喊叫"强形态)
895
+ // 2026-09-15(踩坑 144 修法 (b) 收紧档落地):纯比率版把中文消息里的**编号标签**(A1→A3→B5→D)
896
+ // 与**缩写**(ESR/DSH)判成"喊叫"→ A″ 误冻结写类工具(本会话连续两回合实测)。
897
+ // 现要求"词形"证据:≥ min_tokens 个「连续字母 ≥ min_word_len」的 token,再看这些 token 的大写比率。
898
+ criticism_caps_min_tokens: 2,
899
+ criticism_caps_min_word_len: 4
857
900
  };
858
901
 
859
902
  // 内置默认:语言无关最小集(无中文)
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,
@@ -268,7 +275,10 @@ export function freshTurn() {
268
275
  scopes: [],
269
276
  // M8 双通道机制(2026-08-24):统一入口落盘后同轮 engram_store 校验(entryMarker 经配置)
270
277
  manualWriteSeen: false,
271
- engramStoreSeen: false
278
+ engramStoreSeen: false,
279
+ // D(2026-09-13):本回合是否已在「落盘那一刻」提示过——防 turn/end 兜底路径重复投递
280
+ // (maybeInject 的闸是同规则最多 3 次,非一次即止,故需回合级标记)
281
+ m8Hinted: false
272
282
  };
273
283
  }
274
284